Содержание
На данный момент, наше приложение работает с JWT-токеном: формирует токен, отправляет его пользователю в виде куки, считывает и вставляет токен в заголовок запроса. Приложение работает исправно — дает доступ к защищенному ресурсу только тем пользователям, которые соответствуют политике авторизации. В принципе, этого уже достаточно, чтобы можно было собрать небольшое приложение в ASP.NET Core для работы с какими-либо данными. Однако, помимо защиты нашего приложения от несанкционированного доступа к ресурсам следует также предусмотреть и удобство работы пользователя с системой. И сегодня мы рассмотрим один из вариантов того, как обеспечить повторное получение JWT-токена пользователем без непосредственного ввода учётных данных.
Схема работы приложения
Рассмотрим схему работы нашего приложения на данный момент, которая выглядит следующим образом:

- Шаг 1 — пользователь вводит свои учетные данные в систему (логин/пароль)
- Шаг 2 — система аутентификации передает пользователю JWT-токен, необходимый для доступа к защищенным ресурсам приложения
- Шаг 3 — пользователь передает JWT-токен в cookie запроса и компонент middleware нашего приложения вставляет этот токен в заголовок входящего запроса
- Шаг 4 — если токен верный и время его жизни не истекло, то приложение дает доступ пользователю к защищенному ресурсу (авторизует пользователя), используя заданную политику авторизации
- Шаг 5 — пользователь отправляет токен с истекшим сроком жизни
- Шаг 6 — приложение запрещает доступ к приложению и возвращает в заголовках ответа сообщение о том, что время жизни токена истекло
Как только пользователь попадает на шаг 6, то он должен вернуться на шаг 1 — заново ввести логин/пароль и получить токен доступа. Такой подход не является удобным для пользователя, в особенности, когда JWT-токен «живет» непродолжительный промежуток времени (например, как у нас — 2 минуты). При этом, если мы поставим срок жизни токена, скажем, не 2 минуты, а 2 года, то такой подход может привести к тому, что JWT-токен попадет в руки злоумышленнику и тот получит доступ к защищенным данным. Выйти из такой ситуации нам поможет использование второго токена — токена обновления (refresh token), который мы можем отсылать пользователю вместе с токеном доступа и по refresh token обновлять токен доступа тогда, когда это необходимо. При этом, мы можем устанавливать токену обновления больший срок жизни (или вообще делать его бессмертным) и отзывать этот токен, если вдруг учётная запись пользователя будет скомпрометирована.
В итоге, нам необходимо прийти в нашем приложении к следующей схеме работы:
то есть, по сравнению с исходной схемой, здесь добавляются ещё два шага:
- Шаг 7 — пользователь отправляет на сервер refresh token
- Шаг 8 — система возвращает пользователю обновленный токен для доступа к защищенным ресурсам
Реализация схемы работы приложения с токеном обновления
Так как теперь на нашу систему аутентификации ложится, в том числе, и обязанность по выдаче токена обновления, то целесообразно выделить весь этот код в отдельный сервис и, в принципе, провести небольшой рефакторинг приложения.
Создание сервиса по работе с токенами
Для начала, выделим из приложения участки кода, где проводится работа с JWT-токенами и оформим его в отдельный сервис, который будет проводить следующие операции:
- Генерировать JWT-токен доступа
- Генерировать Refresh Token
- Получать из токена с истекшим сроком перечень объектов Claim
Создадим в проекте новый файл TokenService.cs со следующим содержимым:
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
namespace AspAuth
{
public interface ITokenService
{
public string GetAccessToken(IEnumerable<Claim> claims, out DateTime expires);
public string GetRefreshToken();
public ClaimsPrincipal GetPrincipalFromExpiredToken(string token);
}
public class TokenService : ITokenService
{
private readonly AuthOptions _options;
public TokenService(AuthOptions options)
{
_options = options;
}
public string GetAccessToken(IEnumerable<Claim> claims, out DateTime expires)
{
expires = DateTime.Now.AddMinutes(_options.TokenLifetime);
SymmetricSecurityKey key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SecretKey));
JwtSecurityToken token = new(
issuer: _options?.Issuer,
audience: _options?.Audience,
claims: claims,
expires: expires,
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256)
);
JwtSecurityTokenHandler handler = new();
return handler.WriteToken(token);
}
public ClaimsPrincipal GetPrincipalFromExpiredToken(string token)
{
SymmetricSecurityKey key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SecretKey));
TokenValidationParameters validationParameters = new TokenValidationParameters
{
ValidateIssuer = _options.ValidateIssuer,
ValidateAudience = _options.ValidateAudience,
ValidateIssuerSigningKey = _options.ValidateIssuerSigningKey,
ValidIssuer = _options.Issuer,
ValidAudience = _options.Audience,
IssuerSigningKey = key,
};
var tokenHandler = new JwtSecurityTokenHandler();
var principal = tokenHandler.ValidateToken(token, validationParameters, out SecurityToken securityToken);
var jwtSecurityToken = securityToken as JwtSecurityToken;
if (jwtSecurityToken == null || !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase))
throw new SecurityTokenException("Invalid token");
return principal;
}
public string GetRefreshToken()
{
var randomNumber = new byte[32];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(randomNumber);
return Convert.ToHexString(randomNumber);
}
}
public static class ServiceProviderExtensions
{
public static void AddTokenService(this IServiceCollection services, AuthOptions options)
{
services.AddTransient<ITokenService>(t=>new TokenService(options));
}
}
}
О том, как создавать сервисы, мы уже знаем, поэтому стоит обратить внимание только на основные моменты. Для работы сервиса ITokenService необходимо каким-либо образом передать настройки формирования и валидации токена, которые у нас содержатся в appsettings.json, поэтому для класса TokenService определен следующий конструктор:
public TokenService(AuthOptions options)
{
_options = options;
}
Для регистрации сервиса мы создали метод расширения, в который и передаем класс с необходимыми настройками:
public static class ServiceProviderExtensions
{
public static void AddTokenService(this IServiceCollection services, AuthOptions options)
{
services.AddTransient<ITokenService>(t=>new TokenService(options));
}
}
Что касается работы с токенами, то для формирования токена доступа используется метод public string GetAccessToken(IEnumerable<Claim> claims, out DateTime expires). Этот метод практически ни чем не отличается от того кода, который мы использовали ранее. Единственное различие заключается в том, что метод возвращает время истечения жизни токена:
expires = DateTime.Now.AddMinutes(_options.TokenLifetime);
Более интересен метод получения списка Claim из токена с истекшим сроком жизни. Рассмотрим его подробнее:
public ClaimsPrincipal GetPrincipalFromExpiredToken(string token)
{
SymmetricSecurityKey key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SecretKey));
TokenValidationParameters validationParameters = new TokenValidationParameters
{
ValidateIssuer = _options.ValidateIssuer,
ValidateAudience = _options.ValidateAudience,
ValidateIssuerSigningKey = _options.ValidateIssuerSigningKey,
ValidIssuer = _options.Issuer,
ValidAudience = _options.Audience,
IssuerSigningKey = key,
};
var tokenHandler = new JwtSecurityTokenHandler();
var principal = tokenHandler.ValidateToken(token, validationParameters, out SecurityToken securityToken);
var jwtSecurityToken = securityToken as JwtSecurityToken;
if (jwtSecurityToken == null || !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase))
throw new SecurityTokenException("Invalid token");
return principal;
}
Этот метод возвращает объект ClaimsPrincipal. Для того, чтобы получить этот объект мы создаем объект TokenValidationParameters, который содержит настройки валидации токена и, затем, вызываем метод
tokenHandler.ValidateToken(token, validationParameters, out SecurityToken securityToken);
в который передаем настройки валидации и строковое представление JWT-токена. Этот метод и возвращает нам объект ClaimsPrincipal, а также в последнем параметре объект типа SecurityToken, который, в нашем случае является объектом JwtSecurityToken. После того, как токен проверен мы дополнительно проверяем заголовки токена и сравниваем алгоритм шифрования.
Для формирования токена обновления мы, для примера, используем класс генерации случайных чисел
public string GetRefreshToken()
{
var randomNumber = new byte[32];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(randomNumber);
return Convert.ToHexString(randomNumber);
}
и делаем этот токен «бессмертным». Таким образом, этот сервис выполняет всю необходимую на данный момент работу с JWT-токенами. Чтобы можно было сохранять в приложении выданные токены доработаем немного класс User.
Доработка класса User
Класс User также вынесем в отдельный файл и добавим в него следующие свойства:
public class User
{
//...//
public string AccessToken { get; set; }
public string RefreshToken { get; set; }
public DateTime TokenExpires { get; set; }
//...//
}
Весь код класса будет представлен в конце этой части.
Создание сервиса работы с пользователями
Этот сервис создан для удобства. Содержаться он будет в файле UserService.cs:
namespace AspAuth
{
public static class UserRoles
{
public const string USER = "USER";
public const string MANAGER = "MANAGER";
public const string ADMIN = "ADMIN";
}
public class UserService
{
private readonly IEnumerable<User> UserDatabase;
public UserService()
{
UserDatabase = new List<User>()
{
new User("user","12345","Иванов Иван Иванович", "user@mail.ru", 17, new[]{ UserRoles.MANAGER }, "MyCompany", "Administration"),
new User("admin","root","Петров Петр Петрович", "admin@mail.ru", 17, new[]{ UserRoles.MANAGER, UserRoles.ADMIN }, "Microsoft", "Development"),
new User("john","12345","Сноу Джон", "snow@mail.ru", 35, "Cisco", "Security"),
new User("microsoft","root","Билли Гейтс", "admin@mail.ru", 45, new[]{ UserRoles.MANAGER, UserRoles.ADMIN }, "Microsoft", "Development"),
};
}
public User? GetUser(string login, string password)
{
return UserDatabase.FirstOrDefault(f => (f.Login == login) && (f.Password == password));
}
public User? GetUser(string login)
{
return UserDatabase.FirstOrDefault(f => f.Login == login);
}
public User? GetUserByRefreshToken(string refresh_token)
{
return UserDatabase.FirstOrDefault(f => f.RefreshToken == refresh_token);
}
}
public static class ServiceProviderUsersExtensions
{
public static void AddUserService(this IServiceCollection services)
{
services.AddSingleton<UserService>();
}
}
}
здесь стоит только ответить, что сервис регистрируется как синглтон:
services.AddSingleton<UserService>();
Теперь можно приступать к реализации схемы работы с JWT-токенами и токенами обновления.
Доработка метода Main() приложения
Вначале приведем весь код класса Program. С учётом создания новых сервисов, класс Program будет выглядеть следующим образом:
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
var authOptions = builder.Configuration.GetSection("Jwt").Get<AuthOptions>();
builder.Services.AddUserService(); //добавляем условную БД с пользователями
builder.Services.AddTokenService(authOptions);//добавляем сервис работы с токенами
builder.Services.AddTransient<IAuthorizationHandler, MinimumAgeHandler>(); //добавляем свой обработчик авторизации
builder.Services.AddAuthentication(auth =>
{
auth.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
auth.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.RequireHttpsMetadata = true;
options.SaveToken = true;
options.IncludeErrorDetails = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = authOptions.ValidateIssuer,
ValidateAudience = authOptions.ValidateAudience,
ValidateLifetime = authOptions.ValidateLifetime,
ValidateIssuerSigningKey = authOptions.ValidateIssuerSigningKey,
ValidIssuer = authOptions.Issuer,
ValidAudience = authOptions.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(authOptions.SecretKey)),
ClockSkew = TimeSpan.Zero
};
});
builder.Services.AddAuthorization((options) =>
{
options.AddPolicy("OnlyForMicrosoftAdmin",
policy =>
{
policy.RequireClaim("Organization", "Microsoft").//должен работать в Microsoft
RequireClaim("Department", "Development").//долден работать в отделе Development
RequireRole(new[] { "ADMIN" }).//должен быть админом
RequireAuthenticatedUser().//должен быть аутентифицирован
Requirements.Add(new MinimumAgeRequirement(18)); //должен быть не моложе 18 лет
});
});
var app = builder.Build();
app.Use(async (context, next) =>
{
if (context.Request.Cookies.TryGetValue("token", out string? token))
context.Request.Headers.Authorization = $"Bearer {token}";
await next();
});
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/", (HttpContext context) =>
{
string greetings = "Вы не аутентифицированы";
string roleDesc = "Вы НЕ можете управлять приложением";
if ((context.User != null) && (context.User.Identity.IsAuthenticated))
{
var claims = context.User.Claims;
var name = claims.FirstOrDefault(f => f.Type == ClaimTypes.Name);
if (name != null)
greetings = $"Привет, {name.Value}";
//проверяем пользователя на принадлежность роли ADMIN
if (context.User.IsInRole("ADMIN"))
roleDesc = "Вы можете управлять приложением";
}
return Results.Content("<html><body>" +
$"<p>{greetings}</p>" +
$"<p>{roleDesc}</p>" +
"</body></html>", "text/html; charset=utf-8");
});
app.MapGet("/roles/{user}/add", [Authorize(Policy = "OnlyForMicrosoftAdmin")] (string user, string role, [FromServices]UserService users) =>
{
var userData = users.GetUser(user);
if (userData != null)
{
userData.Roles.Add(role);
return $"Для пользователя {user} добавлена роль {role} \n Все роли пользователя: {string.Join(",", userData.Roles)}";
}
return $"Пользователь {user} не найден в БД";
});
app.MapGet("/auth", [Authorize(Roles = "ADMIN")] (HttpContext context) =>
{
return Results.Ok(authOptions);
});
app.MapGet("/login", (string? login, string? password, HttpContext context, [FromServices] ITokenService tokenService, [FromServices] UserService users) =>
{
if (string.IsNullOrEmpty(login) || string.IsNullOrEmpty(password))
return Results.BadRequest(new { Error = "Не задан логин или пароль" });
var user = users.GetUser(login, password);
if (user == null)
return Results.BadRequest(new { Error = "Не верно введен логин или пароль" });
string tokenString = tokenService.GetAccessToken(user.GetUserClaims(), out DateTime expires);
SetUserAuthData(user, tokenString, expires, tokenService, context);
return Results.Ok(new { token = tokenString, refreshToken = user.RefreshToken, tokenExpires = expires, user = login });
});
app.MapGet("/refresh", (string refreshToken, HttpContext context, [FromServices] ITokenService tokenService, [FromServices] UserService users) =>
{
try
{
var user = users.GetUserByRefreshToken(refreshToken);
if (user == null)
return Results.BadRequest(new {error = "Пользователь с таким refresh_token не найден" });
var principal = tokenService.GetPrincipalFromExpiredToken(user.AccessToken);
string tokenString = tokenService.GetAccessToken(principal.Claims, out DateTime expires);
SetUserAuthData(user, tokenString, expires, tokenService, context);
return Results.Ok(new { token = tokenString, refreshToken = user.RefreshToken, tokenExpires = expires, user = user.Login });
}
catch (SecurityTokenException ex)
{
return Results.BadRequest(ex);
}
});
app.Run();
}
public static void SetUserAuthData(User user, string accessToken, DateTime expires, ITokenService tokenService, HttpContext context)
{
user.RefreshToken = tokenService.GetRefreshToken();
user.AccessToken = accessToken;
user.TokenExpires = expires;
context.Response.Cookies.Append("token", accessToken);//отпраляем клиенту куку с токеном
}
}
}
Вначале регистрируем наши сервисы в контейнере DI:
builder.Services.AddUserService(); //добавляем условную БД с пользователями builder.Services.AddTokenService(authOptions);//добавляем сервис работы с токенами
Конечная точка логина пользователя теперь будет выглядеть следующим образом:
app.MapGet("/login", (string? login, string? password, HttpContext context, [FromServices] ITokenService tokenService, [FromServices] UserService users) =>
{
if (string.IsNullOrEmpty(login) || string.IsNullOrEmpty(password))
return Results.BadRequest(new { Error = "Не задан логин или пароль" });
var user = users.GetUser(login, password);
if (user == null)
return Results.BadRequest(new { Error = "Не верно введен логин или пароль" });
string tokenString = tokenService.GetAccessToken(user.GetUserClaims(), out DateTime expires);
SetUserAuthData(user, tokenString, expires, tokenService, context);
return Results.Ok(new { token = tokenString, refreshToken = user.RefreshToken, tokenExpires = expires, user = login });
});
Теперь мы запрашиваем для этой конечной точки два сервиса — ITokenService и UserService. Стоит обратить внимание на то, что мы указываем для двух последних параметров атрибут [FromServices], который указывает на то, что параметры берутся именно из списка сервисов, а не из тела запроса. Внутри обработчика точки мы используем сервис пользователей для поиска пользователя:
var user = users.GetUser(login, password);
и сервис для формирования токена доступа:
string tokenString = tokenService.GetAccessToken(user.GetUserClaims(), out DateTime expires);
во вспомогательном методе SetUserAuthData мы обновляем объект пользователя и устанавливаем cookie с токеном:
public static void SetUserAuthData(User user, string accessToken, DateTime expires, ITokenService tokenService, HttpContext context)
{
user.RefreshToken = tokenService.GetRefreshToken();
user.AccessToken = accessToken;
user.TokenExpires = expires;
context.Response.Cookies.Append("token", accessToken);//отправляем клиенту куку с токеном
}
Для обновления токена с использованием refresh token мы создали новую конечную точку:
app.MapGet("/refresh", (string refreshToken, HttpContext context, [FromServices] ITokenService tokenService, [FromServices] UserService users) =>
{
try
{
var user = users.GetUserByRefreshToken(refreshToken);
if (user == null)
return Results.BadRequest(new {error = "Пользователь с таким refresh_token не найден" });
var principal = tokenService.GetPrincipalFromExpiredToken(user.AccessToken);
string tokenString = tokenService.GetAccessToken(principal.Claims, out DateTime expires);
SetUserAuthData(user, tokenString, expires, tokenService, context);
return Results.Ok(new { token = tokenString, refreshToken = user.RefreshToken, tokenExpires = expires, user = user.Login });
}
catch (SecurityTokenException ex)
{
return Results.BadRequest(ex);
}
});
В принципе, логика работы аналогичная предыдущей за одним только исключением, что новый токен формируется на основе списка Claim, полученного из старого токена:
var principal = tokenService.GetPrincipalFromExpiredToken(user.AccessToken); string tokenString = tokenService.GetAccessToken(principal.Claims, out DateTime expires);
Таким образом, когда пользователь получит сообщение об ошибке в связи с истечением срока жизни токена, ему будет достаточно обратиться к точке «/refresh» и, указав refresh_token получить новый токен доступа. Для демонстрации работы с токенами обновления такой подход вполне допустим.
Тестирование refresh token
Проверим работу нашего приложения. Зарегистрируемся под аккаунтом какого-нибудь пользователя в нашей системе:
Подождем 2 минуты, чтобы срок действия токена истек. Например, пока перейдем на главную страницу приложения:
Пока пользователь остается аутентифицированным, так как токен доступа ещё «жив». Когда время токена закончится, то получим сообщение на главной странице о том, что мы не аутентифицированы:
Обновим токен доступа, используя полученный ранее refresh token
Как можно увидеть по скриншоту, был выдан новый токен доступа и refresh token и теперь мы снова имеем доступ к защищенным ресурсам. При этом, мы не вводили заново логин/пароль пользователя. Следовательно, наша цель достигнута.
В приложении также можно предусмотреть автоматическое обновление токена доступа или, наоборот, отзыв токенов доступа и обновления для пользователя. Но для нашей цели — показать работу с токенами обновления текущей работы приложения будет достаточно. Осталось только привести код всего приложения.
Исходный код проекта
Файл Program.cs
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.Security.Claims;
using System.Text;
namespace AspAuth
{
public class AuthOptions
{
public string Issuer { get; set; } //издатель токена
public string Audience { get; set; } //потребитель токена
public string SecretKey { get; set; } //секретный ключ для подписи
public bool ValidateAudience { get; set; } //проверять потребителя
public bool ValidateIssuer { get; set; } //проверять издателя
public bool ValidateLifetime { get; set; } //проверять время жизни токена
public bool ValidateIssuerSigningKey { get; set; } //проверять подпись
public int TokenLifetime { get; set; } //время жизни токена в минутах
}
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
var authOptions = builder.Configuration.GetSection("Jwt").Get<AuthOptions>();
builder.Services.AddUserService(); //добавляем условную БД с пользователями
builder.Services.AddTokenService(authOptions);//добавляем сервис работы с токенами
builder.Services.AddTransient<IAuthorizationHandler, MinimumAgeHandler>(); //добавляем свой обработчик авторизации
builder.Services.AddAuthentication(auth =>
{
auth.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
auth.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.RequireHttpsMetadata = true;
options.SaveToken = true;
options.IncludeErrorDetails = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = authOptions.ValidateIssuer,
ValidateAudience = authOptions.ValidateAudience,
ValidateLifetime = authOptions.ValidateLifetime,
ValidateIssuerSigningKey = authOptions.ValidateIssuerSigningKey,
ValidIssuer = authOptions.Issuer,
ValidAudience = authOptions.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(authOptions.SecretKey)),
ClockSkew = TimeSpan.Zero
};
});
builder.Services.AddAuthorization((options) =>
{
options.AddPolicy("OnlyForMicrosoftAdmin",
policy =>
{
policy.RequireClaim("Organization", "Microsoft").//должен работать в Microsoft
RequireClaim("Department", "Development").//долден работать в отделе Development
RequireRole(new[] { "ADMIN" }).//должен быть админом
RequireAuthenticatedUser().//должен быть аутентифицирован
Requirements.Add(new MinimumAgeRequirement(18)); //должен быть не моложе 18 лет
});
});
var app = builder.Build();
app.Use(async (context, next) =>
{
if (context.Request.Cookies.TryGetValue("token", out string? token))
context.Request.Headers.Authorization = $"Bearer {token}";
await next();
});
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/", (HttpContext context) =>
{
string greetings = "Вы не аутентифицированы";
string roleDesc = "Вы НЕ можете управлять приложением";
if ((context.User != null) && (context.User.Identity.IsAuthenticated))
{
var claims = context.User.Claims;
var name = claims.FirstOrDefault(f => f.Type == ClaimTypes.Name);
if (name != null)
greetings = $"Привет, {name.Value}";
//проверяем пользователя на принадлежность роли ADMIN
if (context.User.IsInRole("ADMIN"))
roleDesc = "Вы можете управлять приложением";
}
return Results.Content("<html><body>" +
$"<p>{greetings}</p>" +
$"<p>{roleDesc}</p>" +
"</body></html>", "text/html; charset=utf-8");
});
app.MapGet("/roles/{user}/add", [Authorize(Policy = "OnlyForMicrosoftAdmin")] (string user, string role, [FromServices]UserService users) =>
{
var userData = users.GetUser(user);
if (userData != null)
{
userData.Roles.Add(role);
return $"Для пользователя {user} добавлена роль {role} \n Все роли пользователя: {string.Join(",", userData.Roles)}";
}
return $"Пользователь {user} не найден в БД";
});
app.MapGet("/auth", [Authorize(Roles = "ADMIN")] (HttpContext context) =>
{
return Results.Ok(authOptions);
});
app.MapGet("/login", (string? login, string? password, HttpContext context, [FromServices] ITokenService tokenService, [FromServices] UserService users) =>
{
if (string.IsNullOrEmpty(login) || string.IsNullOrEmpty(password))
return Results.BadRequest(new { Error = "Не задан логин или пароль" });
var user = users.GetUser(login, password);
if (user == null)
return Results.BadRequest(new { Error = "Не верно введен логин или пароль" });
string tokenString = tokenService.GetAccessToken(user.GetUserClaims(), out DateTime expires);
SetUserAuthData(user, tokenString, expires, tokenService, context);
return Results.Ok(new { token = tokenString, refreshToken = user.RefreshToken, tokenExpires = expires, user = login });
});
app.MapGet("/refresh", (string refreshToken, HttpContext context, [FromServices] ITokenService tokenService, [FromServices] UserService users) =>
{
try
{
var user = users.GetUserByRefreshToken(refreshToken);
if (user == null)
return Results.BadRequest(new {error = "Пользователь с таким refresh_token не найден" });
var principal = tokenService.GetPrincipalFromExpiredToken(user.AccessToken);
string tokenString = tokenService.GetAccessToken(principal.Claims, out DateTime expires);
SetUserAuthData(user, tokenString, expires, tokenService, context);
return Results.Ok(new { token = tokenString, refreshToken = user.RefreshToken, tokenExpires = expires, user = user.Login });
}
catch (SecurityTokenException ex)
{
return Results.BadRequest(ex);
}
});
app.Run();
}
public static void SetUserAuthData(User user, string accessToken, DateTime expires, ITokenService tokenService, HttpContext context)
{
user.RefreshToken = tokenService.GetRefreshToken();
user.AccessToken = accessToken;
user.TokenExpires = expires;
context.Response.Cookies.Append("token", accessToken);//отпраляем клиенту куку с токеном
}
}
}
Файл MinimumAge.cs
using Microsoft.AspNetCore.Authorization;
namespace AspAuth
{
public class MinimumAgeRequirement : IAuthorizationRequirement
{
protected internal int Age { get; set; }
public MinimumAgeRequirement(int age)
{
Age = age;
}
}
public class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, MinimumAgeRequirement requirement)
{
// получаем claim с типом Age - возраст
var ageClaim = context.User.FindFirst(c => c.Type == "Age");
if (ageClaim is not null)
{
// если claim хранит число
if (int.TryParse(ageClaim.Value, out var age))
{
if (requirement.Age <= age)
context.Succeed(requirement); // сигнализируем, что claim соответствует ограничению
else
context.Fail();
}
}
return Task.CompletedTask;
}
}
}
Файл TokenService.cs
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
namespace AspAuth
{
public interface ITokenService
{
public string GetAccessToken(IEnumerable<Claim> claims, out DateTime expires);
public string GetRefreshToken();
public ClaimsPrincipal GetPrincipalFromExpiredToken(string token);
}
public class TokenService : ITokenService
{
private readonly AuthOptions _options;
public TokenService(AuthOptions options)
{
_options = options;
}
public string GetAccessToken(IEnumerable<Claim> claims, out DateTime expires)
{
expires = DateTime.Now.AddMinutes(_options.TokenLifetime);
SymmetricSecurityKey key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SecretKey));
JwtSecurityToken token = new(
issuer: _options?.Issuer,
audience: _options?.Audience,
claims: claims,
expires: expires,
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256)
);
JwtSecurityTokenHandler handler = new();
return handler.WriteToken(token);
}
public ClaimsPrincipal GetPrincipalFromExpiredToken(string token)
{
SymmetricSecurityKey key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SecretKey));
TokenValidationParameters validationParameters = new TokenValidationParameters
{
ValidateIssuer = _options.ValidateIssuer,
ValidateAudience = _options.ValidateAudience,
ValidateIssuerSigningKey = _options.ValidateIssuerSigningKey,
ValidIssuer = _options.Issuer,
ValidAudience = _options.Audience,
IssuerSigningKey = key,
};
var tokenHandler = new JwtSecurityTokenHandler();
var principal = tokenHandler.ValidateToken(token, validationParameters, out SecurityToken securityToken);
var jwtSecurityToken = securityToken as JwtSecurityToken;
if (jwtSecurityToken == null || !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase))
throw new SecurityTokenException("Invalid token");
return principal;
}
public string GetRefreshToken()
{
var randomNumber = new byte[32];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(randomNumber);
return Convert.ToHexString(randomNumber);
}
}
public static class ServiceProviderExtensions
{
public static void AddTokenService(this IServiceCollection services, AuthOptions options)
{
services.AddTransient<ITokenService>(t=>new TokenService(options));
}
}
}
Файл User.cs
using System.Security.Claims;
namespace AspAuth
{
public class User
{
public string Login { get; set; }
public string Password { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public string Organization { get; set; }
public string Department { get; set; }
public int Age { get; set; }
public string AccessToken { get; set; }
public string RefreshToken { get; set; }
public DateTime TokenExpires { get; set; }
public List<string> Roles { get; set; } = new();
public User(string login, string password, string name, string email, int age, string organization, string department)
{
Login = login;
Password = password;
Name = name;
Email = email;
Age = age;
Roles.Add(UserRoles.USER);//по умолчанию все пользователи имеют роль USER
Organization = organization;
Department = department;
}
public User(string login, string password, string name, string email, int age, string[] roles, string organization, string department) : this(login, password, name, email, age, organization, department)
{
Roles.AddRange(roles);
}
public IEnumerable<Claim> GetUserClaims()
{
var claims = new List<Claim>
{
new Claim(ClaimTypes.Name, Name, ClaimValueTypes.String),
new Claim(ClaimTypes.Email, Email, ClaimValueTypes.Email),
new Claim("Age", Age.ToString(), ClaimValueTypes.Integer),
new Claim("Organization", Organization),
new Claim("Department", Department),
};
//добавляем роли пользователя в список
foreach (var role in Roles)
{
claims.Add(new Claim(ClaimTypes.Role, role));
}
return claims;
}
}
}
Файл UserService.cs
namespace AspAuth
{
public static class UserRoles
{
public const string USER = "USER";
public const string MANAGER = "MANAGER";
public const string ADMIN = "ADMIN";
}
public class UserService
{
private readonly IEnumerable<User> UserDatabase;
public UserService()
{
UserDatabase = new List<User>()
{
new User("user","12345","Иванов Иван Иванович", "user@mail.ru", 17, new[]{ UserRoles.MANAGER }, "MyCompany", "Administration"),
new User("admin","root","Петров Петр Петрович", "admin@mail.ru", 17, new[]{ UserRoles.MANAGER, UserRoles.ADMIN }, "Microsoft", "Development"),
new User("john","12345","Сноу Джон", "snow@mail.ru", 35, "Cisco", "Security"),
new User("microsoft","root","Билли Гейтс", "admin@mail.ru", 45, new[]{ UserRoles.MANAGER, UserRoles.ADMIN }, "Microsoft", "Development"),
};
}
public User? GetUser(string login, string password)
{
return UserDatabase.FirstOrDefault(f => (f.Login == login) && (f.Password == password));
}
public User? GetUser(string login)
{
return UserDatabase.FirstOrDefault(f => f.Login == login);
}
public User? GetUserByRefreshToken(string refresh_token)
{
return UserDatabase.FirstOrDefault(f => f.RefreshToken == refresh_token);
}
}
public static class ServiceProviderUsersExtensions
{
public static void AddUserService(this IServiceCollection services)
{
services.AddSingleton<UserService>();
}
}
}
Итого
Сегодня мы рассмотрели ещё один момент аутентификации пользователей с использованием JWT-токенов — использование токенов обновления (refresh token) для получения нового JWT-токена для пользователя без повторного ввода логина и пароля. Здесь мы представили лишь один из возможных вариантов использования refresh token исключительно в ознакомительных целях.




