В предыдущей статье мы рассмотрели структуру JWT и схему работы ASP.NET Core Identity и JWT вместе. В этой — на живом примере реализуем авторизацию в ASP.NET Core 3.1 с использованием этих технологий.
Архитектура приложения и необходимые nugget пакеты
Чтобы продемонстрировать на практике использование ASP.NET Core Identity и JWT, я создала пять отдельных проектов, настроила связи между ними и установила необходимые nugget-пакеты. Дополнительно добавила Entity Framework для взаимодействия MS SQL Server.
Исходный код проекта доступен на GitHub
Моё приложение — максимально упрощенное. Но я все равно придерживалась Onion Architecture. Так пример получается максимально приближенным к реальному проекту: его можно брать и расширять до своего рабочего простенького веб-приложения.
Настраиваем конфигурацию
Добавим конфигурацию для использования DataContext EntityFramework.
services.AddDbContext<DataContext>(opt =>
opt.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));
И строку подключения к MS SQL Server в appsettings.json.е.
"ConnectionStrings": {
"DefaultConnection": "Server=.\\SQL_2016;Database=AspNetCoreIdentity;user id=testUser;
password='testUser'"}
Добавим MediatR, чтобы реализовать в нашем приложении подход CQRS. Он позволяет отделить код, изменяющий состояние, от кода, читающего это состояние. С подходом CQRS мы уменьшаем количество зависимостей в каждом классе и соблюдаем SRP — один из принципов SOLID. Проект, построенный по принципам CQRS, легче расширять и тестировать.
services.AddMediatR(typeof(LoginHandler).Assembly);
Добавляем ASP.NET Core Identity в проект
Первым делом добавим в Domain пользовательский класс AppUser.cs с необходимыми под наши задачи полями. Унаследуем его от IdentityUser.
public class AppUser : IdentityUser
{
public string DisplayName { get; set; }
}
Теперь зарегистрируем созданный нами класс пользователя в Startup.cs.
var builder = services.AddIdentityCore<AppUser>();
var identityBuilder = new IdentityBuilder(builder.UserType, builder.Services);
Добавим в проект EFData DataContext, унаследованный от IdentityDbContext.
public class DataContext: IdentityDbContext<AppUser>
{
public DataContext(DbContextOptions options) : base(options) { }
}
Установим тип хранилища (класс контекста данных) в Startup.cs, которое Identity будет использовать для хранения данных.
identityBuilder.AddEntityFrameworkStores<DataContext> ();
identityBuilder.AddSignInManager<SignInManager<AppUser>>();
Подключим аутентификацию в методе Configure() класса Startup.cs.
app.UseAuthentication();
Теперь мы можем создать начальную миграцию. Для этого стартовым проектом выбираем API, открываем Package Manager Console и в Default project выбираем проект EFData. Запускаем создание начальной миграции командой:
> Add-migration initial
Результатом будет автоматически созданная миграция в папке Migrations проекта EFData. Прежде чем накатить её на нашу базу, в том же проекте создадим DataSeed для автозаполнения начальными данными.
public class DataSeed
{
public static async Task SeedDataAsync(DataContext context, UserManager userManager)
{
if (!userManager.Users.Any())
{
var users = new List<AppUser>
{
new AppUser
{
DisplayName = "TestUserFirst",
UserName = "TestUserFirst",
Email = "testuserfirst@test.com"
},
new AppUser
{
DisplayName = "TestUserSecond",
UserName = "TestUserSecond",
Email = "testusersecond@test.com"
}
};
foreach (var user in users)
{
await userManager.CreateAsync(user, "qazwsX123@");
}
}
}
}
Теперь можно создавать базу. Она заполнится данными при первом запуске веб-приложения.
update-database
Всё необходимое для работы с Identity добавлено. Теперь реализуем метод для авторизации пользователя в приложении.
В UserController проекта API добавим метод LoginAsync.
[HttpPost("login")]
public async Task<ActionResult<UserModel>> LoginAsync(LoginQuery query)
{
return await Mediator.Send(query);
}
В проект Application добавим три класса:
LoginHandler.cs
public class LoginHandler : IRequestHandler<LoginQuery, UserModel>
{
private readonly UserManager<AppUser> _userManager;
private readonly SignInManager<AppUser> _signInManager;
public LoginHandler(UserManager userManager<AppUser>,
SignInManager<AppUser> signInManager)
{
_userManager = userManager;
_signInManager = signInManager;
}
public async Task<UserModel> Handle(LoginQuery request, CancellationToken cancellationToken)
{
var user = await _userManager.FindByEmailAsync(request.Email);
if (user == null)
{
throw new RestException(HttpStatusCode.Unauthorized);
}
var result = await _signInManager.CheckPasswordSignInAsync(user, request.Password, false);
if (result.Succeeded)
{
return new UserModel
{
DisplayName = user.DisplayName,
Token = "test", (Далее здесь будет вызов метода сервиса, генерирующий Token)
UserName = user.UserName,
Image = null
};
}
throw new RestException(HttpStatusCode.Unauthorized);
}
}
LoginQuery.cs
public class LoginQuery : IRequest<UserModel>
{
public string Email { get; set; }
public string Password { get; set; }
}
LoginQueryValidation.cs
public class LoginQueryValidation : AbstractValidator<LoginQuery>
{
public LoginQueryValidation()
{
RuleFor(x => x.Email).EmailAddress().NotEmpty();
RuleFor(x => x.Password).NotEmpty();
}
}
Протестируем метод с помощью любого API клиента. В нашем случае это будет Postman. Делаем запрос к веб-приложению и получаем результат:
Добавим набор ограничений в Startup.cs. Теперь каждый запрос к нашему API должен быть авторизован. Единственный метод, который будет являться исключением (в дальнейшем их может быть больше) — это Login. Для этого на UserController навесим атрибут [AllowAnonymous].
services.AddMvc(option =>
{
// Отключаем маршрутизацию конечных точек на основе endpoint-based logic из EndpointMiddleware
// и продолжаем использование маршрутизации на основе IRouter.
option.EnableEndpointRouting = false;
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser().RequireAuthenticatedUser().Build();
option.Filters.Add(new AuthorizeFilter(policy));
}).SetCompatibilityVersion(CompatibilityVersion.Latest);
Добавляем JWT в проект
Конфигурация взаимодействия нашего приложения с JWT производится достаточно просто. В проекте Application определим отдельный интерфейс IJwtGenerator с единственным методом CreateToken.
public interface IJwtGenerator
{
string CreateToken(AppUser user);
}
В проект Infrastructure добавим класс JwtGenerator и унаследуем его от IJwtGenerator со следующей реализацией CreateToken:
public class JwtGenerator : IJwtGenerator
{
private readonly SymmetricSecurityKey _key;
public JwtGenerator(IConfiguration config)
{
_key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"]));
}
public string CreateToken(AppUser user)
{
var claims = new List { new Claim(JwtRegisteredClaimNames.NameId, user.UserName) };
var credentials = new SigningCredentials(_key, SecurityAlgorithms.HmacSha512Signature);
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.Now.AddDays(7),
SigningCredentials = credentials
};
var tokenHandler = new JwtSecurityTokenHandler();
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
}
Зарегистрируем реализацию в контейнере приложения в Startup.cs.
services.AddScoped<IJwtGenerator, JwtGenerator>();
Настроим проверку JWT в Startup.cs.
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("super secret key"));
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(
opt =>
{
opt.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = key,
ValidateAudience = false,
ValidateIssuer = false,
};
});
Защита секретных ключей JWT. Сейчас значение строки указано в открытом виде и берется даже не из конфигов. Чтобы безопасно хранить секретные данные, предлагаю воспользоваться .NET user secret. Добавлю лишь, что итоговый файл хранит незашифрованные данные. Поэтому мы будем использовать его только во время разработки. Чтобы включить секретное хранилище воспользуемся командой в Package Manager Console.
> dotnet user-secrets init
В API.csproj добавим UserSecretsId.
Теперь в наше секретное хранилище можем поместить значение следующей командой:
> dotnet user-secrets set "TokenKey" "super secret key" –p API/
Чтобы просмотреть все, что сейчас находится в хранилище, воспользуемся командой:
> dotnet user-secrets list –p API/
После этого мы можем использовать наши секретные данные следующим образом:
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["TokenKey"]));
Теперь мы можем использовать JwtGenerator в LoginHandler:
return new User
{
DisplayName = user.DisplayName,
Token = _jwtGenerator.CreateToken(user),
UserName = user.UserName,
Image = null
};
Так мы связали авторизацию с использованием ASP.NET Core Identity и генерацию токена из JwtGenerator. Наш ответ при регистрации изменится и будет возвращать токен, с которым мы можем идти на сервер за данными.
Проверим работу веб-приложения. Чтобы запрос выполнился успешно, в параметрах Headers необходимо добавить заголовок Authorization со значением "Bearer {токен}":
Вот и всё! Мы создали проект и добавили в него ASP.NET Core Identity и JWT.
Преимущество JWT в его самодостаточности. Все необходимые для аутентификации данные хранятся в самом токене. JWT не обеспечивает защиту данных, но гарантирует их подлинность при помощи цифровой подписи.
Полезные ссылки:
Джеффри Палермо о «Луковой архитектуре» (на англ.)
A naive introduction to CQRS in C#
Safe storage of app secrets in development in ASP.NET Core