[Часть 5] ASP.NET: Identity Core и JWT

Введение

Аутентификация с помощью токенов на предъявителя является актуальной темой, и хотя я уже говорил об этом в своем блоге (ASP.NET Core 6: Аутентификация JWT и Identity Core), я решил вернуться к этой теме.

Цель разговора о JWT — придать непрерывность серии постов, которые мы делаем об ASP.NET Core и CQRS с MediatR, поскольку в последующих темах нам понадобится аутентификация и авторизация.

В конечном итоге мы получим готовое решение, созданное полностью нами, концепция за концепцией.

Исходный код для этого поста можно найти в этой ветке моего github.

Аутентификация с помощью JWT-носителя

Когда мы говорим о JWT, мы обычно также говорим об OpenID Connect, и это становится более сложным при создании сервера идентификации. Но, как вы, возможно, уже знаете, JWT — это механизм, который используется в OpenID Connect, и мы можем использовать его независимо от того, как мы выполняем аутентификацию в нашем приложении.

Примечание 👀: Если вы хотите подробно узнать, что такое JWT, посетите мой предыдущий пост -> ASP.NET Core 6: JWT и Identity Core Authentication

Установка ASP.NET Identity Core и JWT Bearer

Чтобы приступить к кодированию аутентификации, нам сначала понадобятся три пакета NuGet:

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package Microsoft.AspNetCore.Identity
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
Войдите в полноэкранный режим Выход из полноэкранного режима

Identity Core — это система членства, которая помогает нам управлять пользователями, аутентификацией и авторизацией. Он очень полезен и рекомендуется к использованию на 100%, чтобы не изобретать колесо.

Обновление DbContext

Identity Core работает в основном через Entity Framework. В проекте у нас уже есть DbContext, и мы продолжим его использовать, но нам нужно обновить его так, чтобы он теперь знал сущности, которые предлагает Identity.

using MediatrValidationExample.Domain;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace MediatrValidationExample.Infrastructure.Persistence;
public class MyAppDbContext : IdentityDbContext<IdentityUser> // <-----
{
    public MyAppDbContext(DbContextOptions<MyAppDbContext> options) : base(options)
    { }

    public DbSet<Product> Products => Set<Product>();
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Самое важное здесь то, что теперь мы наследуем не от DbContext, а от IdentityDbContext<TUser>.

Общий тип TUser представляет пользователя, а класс IdentityUser является реализацией по умолчанию, которую предоставляет Identity. В другом посте, который я упоминал, мы расширяем IdentityUser, чтобы добавить свойства по мере необходимости, но в этом случае для простоты мы оставим его с реализацией по умолчанию.

Обновление БД

Мы используем базу данных SQLite, но без проблем это может быть SQL Server или любой другой, поддерживаемый EF Core.

Чтобы обновить БД, мы должны добавить соответствующую миграцию и таким образом обновить ее:

dotnet ef migrations add AddedIdentityCore -o Infrastructure/Persistence/Migrations
dotnet ef database update
Войдите в полноэкранный режим Выход из полноэкранного режима

Генерация JWT

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

Примечание 👀: Помните, что целью этой серии уроков является продолжение использования CQRS.
Примечание 2: Традиционный путь был бы Features/Auth/Command/TokenCommand.cs, но я съел команду 🤣.

Аутентификация: Функции -> Auth -> TokenCommand

Мы создадим эту команду для аутентификации пользователей с помощью имени пользователя и пароля.

using MediatR;
using MediatrValidationExample.Exceptions;
using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;

namespace MediatrValidationExample.Features.Auth;
public class TokenCommand : IRequest<TokenCommandResponse>
{
    public string UserName { get; set; } = default!;
    public string Password { get; set; } = default!;
}

public class TokenCommandHandler : IRequestHandler<TokenCommand, TokenCommandResponse>
{
    private readonly UserManager<IdentityUser> _userManager;
    private readonly IConfiguration _config;

    public TokenCommandHandler(UserManager<IdentityUser> userManager, IConfiguration config)
    {
        _userManager = userManager;
        _config = config;
    }

    public async Task<TokenCommandResponse> Handle(TokenCommand request, CancellationToken cancellationToken)
    {
        // Verificamos credenciales con Identity
        var user = await _userManager.FindByNameAsync(request.UserName);

        if (user is null || !await _userManager.CheckPasswordAsync(user, request.Password))
        {
            throw new ForbiddenAccessException();
        }

        var roles = await _userManager.GetRolesAsync(user);

        // Generamos un token según los claims
        var claims = new List<Claim>
        {
            new Claim(ClaimTypes.Sid, user.Id),
            new Claim(ClaimTypes.Name, user.UserName)
        };

        foreach (var role in roles)
        {
            claims.Add(new Claim(ClaimTypes.Role, role));
        }

        var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"]));
        var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256Signature);
        var tokenDescriptor = new JwtSecurityToken(
            issuer: _config["Jwt:Issuer"],
            audience: _config["Jwt:Audience"],
            claims: claims,
            expires: DateTime.Now.AddMinutes(720),
            signingCredentials: credentials);

        var jwt = new JwtSecurityTokenHandler().WriteToken(tokenDescriptor);

        return new TokenCommandResponse
        {
            AccessToken = jwt
        };
    }
}

public class TokenCommandResponse
{
    public string AccessToken { get; set; } = default!;
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Цитируя мой предыдущий пост:

  • Проверка учетных данных: Мы используем ASP.NET Identity для хранения пользователей (у него больше функций, но пока мы будем использовать только эту часть) и ролей. UserManager уже имеет множество методов для управления пользователями, их паролями и ролями.
  • Генерация JWT: Согласно списку утверждений, которые были сгенерированы в соответствии с аутентифицированным пользователем, мы генерируем JWT. Это шаблон, это всегда будет один и тот же код. Важно видеть, что мы используем конфигурацию appsettings, ту самую, которая будет использоваться для проверки JWT при выполнении запросов.

Примечание 👀: ForbiddAccessException — это пользовательское исключение, которое мы сделали из первых постов, но мы просто используем его.

Необходима дополнительная настройка:

  "Jwt": {
    "Issuer": "WebApiJwt.com",
    "Audience": "localhost",
    "Key": "S3cr3t_K3y!.123_S3cr3t_K3y!.123"
  }
Войдите в полноэкранный режим Выход из полноэкранного режима

Issuer и Audience здесь не очень важны, когда мы будем использовать OpenID Connect в форме, это будет очень важно, но пока это просто требование.

Ключ очень важен, это наш секрет для симметричного шифрования.

Такие решения, как Identity Server или OpenIddict, используют асимметричное шифрование с помощью RSA и сертификатов — еще одна очень хорошая тема, которой я займусь позже.

AuthController

Чтобы раскрыть нашу команду и разрешить ее использование, мы будем использовать этот Api Controller:

using MediatR;
using MediatrValidationExample.Features.Auth;
using Microsoft.AspNetCore.Mvc;

namespace MediatrValidationExample.Controllers;

[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
    private readonly IMediator _mediator;

    public AuthController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpPost]
    public Task<TokenCommandResponse> Token([FromBody] TokenCommand command) =>
        _mediator.Send(command);
}
Войдите в полноэкранный режим Выход из полноэкранного режима

В общем, мы вызываем нашу команду, как мы это делали в предыдущих постах.

Окончательная конфигурация

Теперь мы можем генерировать JWT с помощью написанного нами кода, но нам нужно завершить настройку зависимостей и указать Web API на использование схемы аутентификации (в данном случае Bearer Tokens).

// código omitido...

[Authorize] // <---
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{

// ...código omitido
Войдите в полноэкранный режим Выход из полноэкранного режима

Мы используем атрибут [Authorize], чтобы заставить драйвер запросить схему аутентификации. ASP.NET Core поддерживает одну или несколько различных схем аутентификации. То есть, мы можем комбинировать JWT с аутентификацией Cookie или любым другим способом. Обычно используется только один, но вы легко можете иметь два или более (хотя я не знаю почему, но вы можете).

Конфигурация разделена на две части:

// Identity Core
builder.Services
    .AddIdentityCore<IdentityUser>()
    .AddRoles<IdentityRole>()
    .AddEntityFrameworkStores<MyAppDbContext>();
Войдите в полноэкранный режим Выход из полноэкранного режима

Здесь мы настраиваем все зависимости Identity, как то, какую реализацию TUser использовать, так и TRoles, а также контекст для использования.

Примечание 👀: ранее я упоминал, что Identity Core — это структура аутентификации и авторизации (Claims, Roles, Policies, etc).

// Autenticación y autorización
builder.Services
    .AddHttpContextAccessor()
    .AddAuthorization()
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer"],
            ValidAudience = builder.Configuration["Jwt:Audience"],
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]))
        };
    });
Войдите в полноэкранный режим Выход из полноэкранного режима

Здесь мы настраиваем аутентификацию и авторизацию с помощью токенов Bearer Tokens.

Примечание 👀: Посты, которые могут вас заинтересовать по данной теме: JWT и OpenID

Обновление Swagger

Шаблон Web API по умолчанию добавляет базовую конфигурацию Swagger. Чтобы протестировать аутентификацию с помощью Bearer Tokens, нам нужно сообщить Swagger, что нам нужно ввести JWT в заголовок Authorization.

builder.Services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new OpenApiInfo
    {
        Title = "My API",
        Version = "v1"
    });
    c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
    {
        In = ParameterLocation.Header,
        Description = "Please insert JWT with Bearer into field",
        Name = "Authorization",
        Type = SecuritySchemeType.ApiKey
    });
    c.AddSecurityRequirement(new OpenApiSecurityRequirement {
   {
     new OpenApiSecurityScheme
     {
       Reference = new OpenApiReference
       {
         Type = ReferenceType.SecurityScheme,
         Id = "Bearer"
       }
      },
      new string[] { }
    }
  });
});
Войдите в полноэкранный режим Выход из полноэкранного режима

Это рецепт, у Swashbuckle гораздо больше настроек, но эту тему я оставлю для вас.

Пользователи семян

Ранее у нас уже был метод Seed для тестовых данных, мы обновили этот метод следующим образом:

async Task SeedProducts()
{
    using var scope = app.Services.CreateScope();
    var context = scope.ServiceProvider.GetRequiredService<MyAppDbContext>();
    var userManager = scope.ServiceProvider.GetRequiredService<UserManager<IdentityUser>>();

    // código omitido...

    var testUser = await userManager.FindByNameAsync("test_user");
    if (testUser is null)
    {
        testUser = new IdentityUser
        {
            UserName = "test_user"
        };

        await userManager.CreateAsync(testUser, "Passw0rd.1234");
        await userManager.CreateAsync(new IdentityUser
          {
              UserName = "other_user"
          }, "Passw0rd.1234");
    }
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Мы создаем двух пользователей для тестов авторизации, которые мы проведем позже. А пока мы готовы протестировать практически все 👍🏽.

Тестирование аутентификации

Запустим приложение, и откроется Swagger:

Замок Authorize — это дополнительная конфигурация, которую мы указали в Программе, поэтому swagger позволяет нам присоединять JWT.

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

Используйте конечную точку /api/auth/ для генерации JWT в соответствии с учетными данными, которые мы ввели в метод Seed. Используйте кнопку Authorize, чтобы добавить JWT в заголовок авторизации:


## Добавление авторизации

Авторизация начинается просто, но может стать сложной. Я всегда делаю авторизацию на основе ролей, особенно если я уже использую Identity.

На самом деле у нас все готово, осталось только добавить роли в базу данных и назначить их пользователю для тестирования.

Мы обновим наш метод Seed и добавим в конец следующее:

// Código omitido
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
var adminRole = await roleManager.FindByNameAsync("Admin");
if (adminRole is null)
{
    await roleManager.CreateAsync(new IdentityRole
    {
        Name = "Admin"
    });
    await userManager.AddToRoleAsync(testUser, "Admin");
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Здесь мы создаем роль под названием Admin и назначаем ее нашему тестовому пользователю (test_user), который ранее был запрошен в этом методе.

Роли в Identity должны быть зарегистрированы в базе данных, так как они обычно фиксированы, то нормально иметь их в методе Seed, подобном этому.

Класс IdentityRole является реализацией роли по умолчанию, но я также обычно расширяю их с помощью наследования, чтобы добавить больше свойств, например, описание и категорию роли (ну, здесь все в соответствии с требованиями).

Смысл использования авторизации на основе ролей заключается в том, чтобы позволить создавать продукты только пользователям с ролью Admin. Поэтому мы обновляем метод создания:

  /// <summary>
  /// Crea un producto nuevo
  /// </summary>
  /// <param name="command"></param>
  /// <returns></returns>
  [HttpPost]
  [Authorize(Roles = "Admin")] // <----- Autorización por rol
  public async Task<IActionResult> CreateProduct([FromBody] CreateProductCommand command)
  {
      await _mediator.Send(command);

      return Ok();
  }
Войдите в полноэкранный режим Выход из полноэкранного режима

Мы снова используем атрибут [Authorize], но теперь указывая, что для этого метода нужна определенная роль.

Если мы снова запустим решение, то увидим несколько важных вещей. В методе Seed мы просто создали AspNetRole под названием Admin, а также создали отношение в AspNetUserRoles:


При создании JWT мы проверяем это отношение. То есть, при аутентификации пользователя мы проверяем его роли, чтобы прикрепить их к JWT таким образом, чтобы ASP.NET понял, что это роли.

Когда вы захотите авторизовать пользователей, ASP.NET проверит эти утверждения из JWT, чтобы проверить, есть ли у вас авторизация от этого метода или нет.

Вы можете протестировать с двумя пользователями, которых мы создали: test_user имеет роль Admin, а other_user — нет. Исследуйте с обоими пользователями, чтобы увидеть, как они себя ведут и работает ли авторизация или нет.

При такой настройке аутентификации мы можем делать такие вещи, как User.IsInRole("Admin"), чтобы проверить, имеет ли текущий пользователь определенную роль. Это всегда очень полезно.

Доступ к текущему пользователю.

И мы еще не закончили.

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

Текущий пользователь определяется по JWT, отправляемому в запросе, поэтому нам необходимо иметь доступ к HttpContext.

Доступ к HttpContext осуществляется через IHttpContextAccessor и доступен только при наличии реального HTTP запроса.

Что это значит? Ну, когда мы проводим модульное тестирование, есть вероятность (или, скорее, это факт), что не будет HttpContext, поэтому этот механизм доступа к пользователям должен быть абстракцией, чтобы можно было провести тестирование в данном случае.

В будущих постах мы будем делать интеграционные тесты и, возможно, модульные тесты, поэтому мы должны быть в состоянии адаптироваться к этим требованиям.

Службы -> ICurrentUserService

ICurrentUserService будет абстракцией, которая позволит получить доступ к текущему пользователю.

namespace MediatrValidationExample.Services;
public interface ICurrentUserService
{
    CurrentUser User { get; }

    bool IsInRole(string roleName);
}

public record CurrentUser(string Id, string UserName);
Войдите в полноэкранный режим Выход из полноэкранного режима

Здесь мы определяем контракт, необходимый для доступа к текущему пользователю, пока что нам нужен только объект с ID и его UserName.

Мы также абстрагируемся от того, как определить, есть ли у пользователя роль или нет. Для модульного тестирования может быть важно сделать из этого Mocks, а пока мы оставим это так. Основная идея заключается в том, чтобы не использовать HttpContext напрямую, поскольку это доступно только тогда, когда речь идет о веб-приложении, но если завтра нам понадобится изменить пользовательский интерфейс и создать консольное приложение (со мной такое случалось), например, инструменты для экспорта/импорта данных.

Нам может понадобиться доступ к функциональности (возможностям) Application Core, но уже не из веб-приложения, поэтому мы и создали эту абстракцию.

Примечание 👀: CurrentUser может (или должен) находиться в другом файле, для простоты я поместил его вместе с определением интерфейса.

Его реализация выглядит следующим образом:

using System.Security.Claims;

namespace MediatrValidationExample.Services;
public class CurrentUserService : ICurrentUserService
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public CurrentUserService(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;

        var id = _httpContextAccessor.HttpContext.User.Claims
            .FirstOrDefault(q => q.Type == ClaimTypes.Sid)
            .Value;

        var userName = _httpContextAccessor.HttpContext.User.Identity.Name;

        User = new CurrentUser(id, userName);
    }

    public CurrentUser User { get; }

    public bool IsInRole(string roleName) =>
        _httpContextAccessor.HttpContext!.User.IsInRole(roleName);
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Это то, что будет тесно связано с HttpContext, оно принадлежит непосредственно презентации.

HttpContext.User автоматически инициализируется ASP.NET, поскольку с помощью Bearer Tokens мы указали, что в заголовке Authorization ожидается JWT. Таким же образом, с помощью HttpContext.User.IsInRole мы можем проверить, есть ли у текущего пользователя роль. Все это возможно благодаря тому, что в JWT мы указали с помощью утверждений роли, которые имеет пользователь.

Примечание 👀: Вскоре, возможно, мы разделим это приложение на различные проекты, следуя стилю Vertical Slice Architecture.

Обновление AuthController

Мы обновляем контроллер авторизации, чтобы объяснить использование ICurrentUserService:

    [Authorize]
    [HttpGet("me")]
    public IActionResult Me([FromServices] ICurrentUserService currentUser)
    {
        return Ok(new
        {
            currentUser.User,
            IsAdmin = currentUser.IsInRole("Admin")
        });
    }
Войдите в полноэкранный режим Выход из полноэкранного режима

ICurrentUserService можно использовать только если текущий пользователь аутентифицирован, вы, вероятно, получите ошибки, если попытаетесь получить доступ к /me, если нет JWT 🤭.

Примечание 👀: Перед запуском проекта мы должны зарегистрировать сервис как зависимость builder.Services.AddScoped<ICurrentUserService, CurrentUserService>().

Ответ, который вы получите при вызове с помощью Swagger:

{
  "user": {
    "id": "308e554d-4251-47f9-9617-726dff6562ef",
    "userName": "other_user"
  },
  "isAdmin": false
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Если мы попробуем с пользователем Admin:

{
  "user": {
    "id": "f28cf715-2171-4c0e-9ba5-f2bbbb958f63",
    "userName": "test_user"
  },
  "isAdmin": true
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Мы видим, что метод IsInRole работает без проблем.

С помощью этого мы определяем абстракцию, позволяющую получить доступ к текущему пользователю, делающему запрос. Независимо от того, если мы позже решим перейти с Web на CLI, ядро приложения должно работать без проблем.

Примечание: Эта часть просто объясняет, как можно использовать ICurrentUserService. Свойство IsAdmin также является примером.

Заключение

Мы добавили аутентификацию и авторизацию в наше приложение, которое мы создали в этих 5 постах (пока что), и благодаря использованию ASP.NET Identity Core мы сэкономили много работы над вопросом безопасности пользователей.

Нам не нужно трогать алгоритмы шифрования или хэширования, чтобы надежно хранить пароли пользователей. Identity Core имеет гораздо больше функциональных возможностей, таких как генерация кодов сброса пароля или кодов подтверждения электронной почты, но мы оставим это для другой статьи.

Я надеюсь, что вы найдете его полезным, если у вас есть вопросы, не стесняйтесь связаться со мной в twitter, и я буду рад помочь вам во всем.

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *