Аутентификация и авторизация в ASP.NET Core. Обновление JWT-токена (refresh token)

На данный момент, наше приложение работает с 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-токенами и оформим его в отдельный сервис, который будет проводить следующие операции:

  1. Генерировать JWT-токен доступа
  2. Генерировать Refresh Token
  3. Получать из токена с истекшим сроком перечень объектов 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 исключительно в ознакомительных целях.

Подписаться
Уведомить о
guest
2 Комментарий
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии