Введение
Аутентификация с помощью токенов на предъявителя является актуальной темой, и хотя я уже говорил об этом в своем блоге (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, и я буду рад помочь вам во всем.