Аутентификация и авторизация в ASP.NET Core. Создание собственных ограничений авторизации

В предыдущей части мы рассмотрели пример авторизации пользователя на основе политик и остановились на том, что, несмотря на то, что встроенные возможности ASP.NET Core позволяют покрыть максимум потребностей разработчиков, всё же иногда необходимо добавить в работу политики авторизации пользователя своё ограничение, например, таковым является ограничение доступа пользователя по его возрасту. И сегодня мы рассмотрим вопрос создания собственных ограничений авторизации в ASP.NET Core.

Интерфейс IAuthorizationRequirement

На данный момент, у класса пользователя в нашем приложении определено свойство Age, которое хранит возраст пользователя:

public class User
  {
      //...//
      public int Age { get; set; }
      //...//

Создадим новое ограничение, которое будет учитывать значение этого свойства при авторизации пользователя. Класс ограничения должен реализовывать интерфейс IAuthorizationRequirement, который выглядит следующим образом:

public interface IAuthorizationRequirement

то есть не содержит ничего — ни свойств, ни методов. Таким образом, создадим следующее ограничение:

public class MinimumAgeRequirement : IAuthorizationRequirement
{
    protected int Age { get; set; }
    public MinimumAgeRequirement(int age) 
    {
        Age = age;
    }
}

Сам по себе, этот класс не выполняет никаких действий, а только хранит некоторое ограничение, как в нашем случае — минимально допустимый возраст пользователя. Чтобы использовать это ограничение при обработке запроса пользователя нам необходимо реализовать обработчик авторизации, который будет использовать созданное ограничение.

Обработчик авторизации AuthorizationHandler<TRequirement>

Обработчик авторизации должен наследовать абстрактный класс AuthorizationHandler<TRequirement> и реализовывать его метод

protected abstract Task HandleRequirementAsync(AuthorizationHandlerContext context, TRequirement requirement);

Этот метод вызывается системой авторизации ASP.NET Core при доступе к ресурсу для которого применяется ограничение, используемое обработчиком. Рассмотрим реализацию обработчика авторизации на примере нашего ограничения:

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 соответствует ограничению
            }
        }
        return Task.CompletedTask;
    }
}

В параметрах метод HandleRequirementAsync() получает объект применяемого ограничения (MinimumAgeRequirement ) и контекст авторизации AuthorizationHandlerContext, в котором содержится информация о запросе и, в частности, свойство User, используя которое, мы можем получить доступ ко всем объектам Claim пользователя.

Методы класса AuthorizationHandlerContext позволяют управлять авторизацией. Так, метод Succeed(requirement) вызывается, если запрос соответствует ограничению requirement и, наоборот, метод Fail(), если запрос не соответствует ограничению.

Таким образом, выше мы сделали следующее:

  • получили из объекта User объект Claim с типом Age
  • проверили, что Claim содержит число (возраст)
  • проверили, что возраст пользователя больше или равен значению в ограничении. При выполнении условия выполнили метод Succeed()

Применение обработчика авторизации в приложении ASP.NET Core

Чтобы применить свой обработчик авторизации, мы должны его зарегистрировать как сервис и добавить ограничение в список Requirements для политики авторизации. Добавим в политику авторизации, которую мы создали в прошлой части, новое ограничение. Ниже представлен только новый код проекта, а весь код будет опубликован в конце этой части:

   public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            builder.Services.AddTransient<IAuthorizationHandler, MinimumAgeHandler>(); //добавляем свой обработчик авторизации

            //настраиванием политику авторизации пользователей
            builder.Services.AddAuthorization((options) =>
            {
                options.AddPolicy("OnlyForMicrosoftAdmin",
                    policy =>
                    {
                        policy.RequireClaim("Organization", "Microsoft").//должен работать в Microsoft
                               RequireClaim("Department", "Development").//долден работать в отделе Development
                               RequireRole(new[] { "ADMIN" })//должен быть админом
                               .Requirements.Add(new MinimumAgeRequirement(18)); //должен быть не моложе 18 лет
                    });
            });
            
            //применяем политику авторизации для конечной точки
            app.MapGet("/roles/{user}/add", [Authorize(Policy = "OnlyForMicrosoftAdmin")] (string user, string role) =>
            {
                var userData = UserDatabase.FirstOrDefault(f => f.Login == user);
                if (userData != null)
                {
                    userData.Roles.Add(role);
                    return $"Для пользователя {user} добавлена роль {role} \n Все роли пользователя: {string.Join(",", userData.Roles)}";
                }
                return $"Пользователь {user} не найден в БД";
            });

            app.Run();
        }
    }
}

Здесь мы добавили ограничение MinimumAgeRequirement в политику авторизации с именем OnlyForMicrosoftAdmin. Протестируем наше приложение. Для этого добавим в список пользователей следующего пользователя:

//условная база данных с учетными записями пользователей
List<User> UserDatabase = new()
{
    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"),
};

Таким образом, у нас есть два пользователя с ролью ADMIN, но, при этом, пользователь admin не подходит по возрасту, а пользователь microsoft — соответствует всем требованиям авторизации. Зарегистрируемся с логином admin:

теперь попытаемся получить доступ к конечной точке с авторизацией:

Теперь проверим пользователя с логином microsoft :

Как можно увидеть, наше ограничение по возрасту работает и пользователь с возрастом старше 18 лет получил доступ к конечной точке, что соответствует политике авторизации.

Исходный код проекта

Ниже представлен весь исходный код проекта, реализующего процессы аутентификации и авторизации пользователей с использованием токена JWT

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.IdentityModel.Tokens;

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;

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;
        }
    }

    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 static class UserRoles
    {
        public const string USER = "USER";
        public const string MANAGER = "MANAGER";
        public const string ADMIN = "ADMIN";
    }

    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 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;
        }
    }

    public class Program
    {
        public static void Main(string[] args)
        {

            //условная база данных с учетными записями пользователей
            List<User> UserDatabase = new()
            {
                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"),
            };

            var builder = WebApplication.CreateBuilder(args);
            var authOptions = builder.Configuration.GetSection("Jwt").Get<AuthOptions>();
            SymmetricSecurityKey key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(authOptions.SecretKey));

            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 = key
                    };
                });
            builder.Services.AddAuthorization((options) =>
            {
                options.AddPolicy("OnlyForMicrosoftAdmin",
                    policy =>
                    {
                        policy.RequireClaim("Organization", "Microsoft").//должен работать в Microsoft
                               RequireClaim("Department", "Development").//долден работать в отделе Development
                               RequireRole(new[] { "ADMIN" })//должен быть админом
                               .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) =>
            {
                var userData = UserDatabase.FirstOrDefault(f => f.Login == 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) =>
            {
                if (string.IsNullOrEmpty(login) || string.IsNullOrEmpty(password))
                    return Results.BadRequest(new { Error = "Не задан логин или пароль" });
                var user = UserDatabase.FirstOrDefault(u => (u.Login == login) && (u.Password == password));
                if (user == null)
                    return Results.BadRequest(new { Error = "Не верно введен логин или пароль" });

                JwtSecurityToken token = new(
                    issuer: authOptions?.Issuer,
                    audience: authOptions?.Audience,
                    claims: user.GetUserClaims(), //добавляем список Claim в токен
                    expires: DateTime.UtcNow.Add(TimeSpan.FromMinutes(authOptions.TokenLifetime)),
                    signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256)
                );

                JwtSecurityTokenHandler handler = new JwtSecurityTokenHandler();
                string tokenString = handler.WriteToken(token);

                context.Response.Cookies.Append("token", tokenString);//отпраляем клиенту куку с токеном

                return Results.Ok(new { token = tokenString, user = login });
            });
            app.Run();
        }
    }
}

Итого

Для создания собственных ограничений авторизации мы должны выполнить следующие шаги: 1) создать класс ограничения, который будет реализовывать интерфейс IAuthorizationRequirement и содержать какие-либо значения на которых будет основываться ограничение; 2) создать обработчик авторизации, унаследовав его от абстрактного класса AuthorizationHandler<T> и реализовать метод HandleRequirementAsync в котором необходимо проверить соответствие Claim пользователя ограничению и, в зависимости от результата проверки выполнить метод Succeed() (пользователь соответствует ограничению) или Fail() класса AuthorizationHandlerContext. 3) зарегистрировать обработчик авторизации в качестве сервиса и добавить свое ограничение в список Requirements политики авторизации ASP.NET Core.

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