Введение
В современном мире веб-приложений безопасность играет ключевую роль. С ростом количества кибератак и утечек данных разработчикам необходимо уделять особое внимание защите информации и обеспечению безопасности своих приложений. Один из важнейших аспектов безопасности — это аутентификация, то есть проверка подлинности тех, кто обращается к ресурсам приложения, будь то пользователь или другой сервис.
В этой статье мы разберем, как настроить аутентификацию в ASP.NET Core с использованием JWT (JSON Web Token) и асимметричной криптографии на основе RSA. JWT предоставляет удобный и безопасный способ передачи информации между участниками, а использование асимметричной криптографии позволяет улучшить безопасность за счет разделения ключей на публичные и приватные. Мы обсудим основные концепции асимметричной криптографии, преимущества использования RSA в сравнении с симметричными методами и подробно рассмотрим процесс настройки вашего приложения для использования этих технологий.
Что такое JWT и зачем он нужен
Перед определением JSON Web Token давайте определимся, что такое клейм (claim, утверждение).
Claims — это информация о пользователе, на основе которой можно принимать решения по авторизации. Это набор утверждений, обычно в формате key-value, которые содержат информацию о пользователе: идентификатор, роль, логин.
JSON Web Token используется для передачи информации о пользователе. Полезной нагрузкой как раз является набор клеймов.
Итак, JSON Web Token — это формат для передачи данных между приложениями, который содержит набор утверждений (клеймов) о пользователе и подписан специальным ключом.
Из чего состоит JSON Web Token?
1. Заголовок (Header) содержит метаданные о токене. В них входит алгоритм подписи (поле alg) и опционально тип контента (typ). Стандарт рекомендует в поле typ указывать значение JWT, если не используется каких-либо кастомных типов в токене.
2. Полезная нагрузка (Payload) — набор клеймов. Стандарт определяет клеймы по умолчанию, например:
- Issuer (iss) — некоторый идентификатор того, кто выпустил токен.
- Subject (sub) — идентификатор того, кто запрашивает токен.
- Expiration Time (exp) — время жизни токена.
3. Signature — Header и Payload в формате base64, подписанные с помощью специального алгоритма и секретного ключа
JWT — это просто строка в формате header.payload.signature. Аутентифицировать пользователя можно с помощью верификации signature, а авторизовывать — с помощью информации из клеймов.
![](https://vaiti.io/wp-content/uploads/2024/06/1-1-1024x800.png)
«Поиграться» с JWT, а также почитать о нем подробнее можно на сайте JSON Web Tokens — jwt.io .
Симметричная vs асимметричная криптография
Обычно в JWT используется один ключ для подписи и проверки токена. Это симметричный ключ, и вот какой у него принцип работы:
- Сервис аутентификации выпускает токен и подписывает его.
- Клиент передает токен в запросах к сервису.
- Сервис проверяет подпись токена с помощью того же ключа.
В случае монолитной архитектуры, когда у нас есть один бэкенд и один фронтенд, никаких проблем нет. Бэкенд проверяет токены, которые он же и выписывает, и секретный ключ хранится только в одном месте.
![](https://vaiti.io/wp-content/uploads/2024/06/2-1.png)
Но в случае с микросервисной архитектурой все становится сложнее: у нас есть множество маленьких бэкендов, каждый из которых должен проверять валидность пришедшего JWT, а значит, ключ хранится в каждом из сервисов. Вектор атаки увеличивается, и если злоумышленник получит этот ключ, то сможет создавать свои валидные токены.
Асимметричная аутентификация предлагает разделение ролей: с помощью приватного ключа мы подписываем токены, а с помощью публичного проверяем их валидность. Только сервис аутентификации имеет у себя приватный ключ, а все остальные сервисы и, например, Gateway имеют только публичный ключ. Таким образом мы повышаем безопасность: только один сервис знает приватный ключ.
![](https://vaiti.io/wp-content/uploads/2024/06/3-1-1024x582.png)
В таком случае мы имеем:
- Сервис, который выпускает токены, — например, AuthService. Он имеет приватный и публичный ключи и выпускает токены, подписывая их приватным ключом.
- Сервис, к которому мы обращаемся. Для каждого запроса он получает JWT из заголовков и проверяет подпись с помощью публичного ключа сервиса из п. 1.
Создаем проект на ASP.NET Core
Попробуем создать проект на ASP.NET Core, который выписывает и проверяет токены с помощью пары «приватный — публичный ключ». Я добавил проект по шаблону Web API. Его можно создать через IDE или с помощью консольной команды:
dotnet new webapi
Теперь нам необходимо сгенерировать ключи. Добавим папку Keys. Далее, если у вас Windows, можно использовать утилиту ssh-keygen. Просто вызываем ssh-keygen из командной строки, отказываемся от passphrase и вводим путь к папке с проектом.
![](https://vaiti.io/wp-content/uploads/2024/06/4-1.png)
Приватный ключ находится в формате .pem, а публичный нет. Сконвертируем его — это нужно для удобства использования ключей в коде:
ssh-keygen -f ./Keys/key.pub -m pem -e > ./Keys/key.pub.pem
В папке Keys должно появиться три файла:
- key — приватный ключ в формате .pem;
- key.pub — публичный ключ в формате ssh-rsa;
- key.pub.pem — публичный ключ в формате .pem.
Теперь добавим настройки к этим файлам для того, чтобы они копировались в директорию проекта при сборке. Это можно сделать с помощью свойств файла:
![](https://vaiti.io/wp-content/uploads/2024/06/5-1.png)
Или вручную в файле .csproj добавить строки
<ItemGroup>
<None Update="Keyskey">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Keyskey.pub.pem">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
Должно получиться вот так:
![](https://vaiti.io/wp-content/uploads/2024/06/6-1.png)
Теперь подключим необходимые библиотеки. Их также можно установить с помощью командной строки или в IDE:
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer;
dotnet add package System.IdentityModel.Tokens.Jwt.
Наш проект готов к дальнейшей разработке.
Стандартные механизмы
Сначала разберем аутентификацию с помощью стандартных механизмов ASP.NET Core.
В файле Program.cs указываем параметры аутентификации — схему по умолчанию и параметры валидации JWT:
builder.Services.AddAuthentication(options =>
{
// устанавливаем дефолтную схему как JWT
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
// создаем объект RSA
var publicKey = RSA.Create();
// импортируем публичный ключ для проверки подписи
publicKey.ImportFromPem(File.ReadAllText("Keys/key.pub"));
// устанавливаем параметры токена
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = "yourIssuer",
ValidAudience = "yourAudience",
// указываем ключ для проверки подписи
IssuerSigningKey = new RsaSecurityKey(publicKey),
CryptoProviderFactory = new CryptoProviderFactory
{
// отключаем кеширование ключа. Объект RSA — Disposable,
// и если его закешировать, возможны ObjectDisposedException
CacheSignatureProviders = false
}
};
});
Далее вызываем метод AddAuthorization, в котором обязываем приложение проверять факт успешной аутентификации:
builder.Services.AddAuthorization(x =>
{
x.AddPolicy("Default", o => o.RequireAuthenticatedUser());
});
И ниже, на этапе настройки middleware указываем использование аутентификации и авторизации:
app.UseAuthentication();
app.UseAuthorization();
Теперь добавим два эндпоинта: первый будет выписывать токены, а второй — проверять факт авторизации пользователя (как раз по токену). Мы воспользуемся подходом Minimal API, который подразумевает добавление эндпоинтов сразу в Program.cs без создания контроллеров.
В этом методе должна быть реализована проверка логина и пароля, например, из БД. Для упрощения примера я упустил этот момент, но в реальном приложении токены нужно выписывать после проверки логина и пароля 🙂
app.MapPost("api/v1/token", ([FromBody] Credentials credentials) =>
{
using var privateKey = RSA.Create();
privateKey.ImportFromPem(File.ReadAllText("Keys/key"));
// указываем клеймы. Тут могут быть также кастомные параметры,
// например телефон или почта пользователя
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, credentials.Login),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
// создаем объект токена с параметрами
var token = new JwtSecurityToken(
issuer: "yourIssuer",
audience: "yourAudience",
claims: claims,
expires: DateTime.Now.AddHours(1),
signingCredentials: new SigningCredentials(new RsaSecurityKey(privateKey), SecurityAlgorithms.RsaSha256)
);
// конвертируем токен в строку
var stringToken = new JwtSecurityTokenHandler().WriteToken(token);
return new { Token = stringToken };
});
И добавим метод получения информации о пользователе:
app.MapGet("api/v1/check", (HttpContext context) =>
{
var claims = context.User.Claims.ToDictionary(x => x.Type, x => x.Value);
return claims;
}).RequireAuthorization("Default");
Запустим приложение для проверки. У нас откроется Swagger, где мы сможем попробовать выполнить HTTP-запросы:
![](https://vaiti.io/wp-content/uploads/2024/06/7-1024x561.png)
При выполнении запроса мы получили токен:
![](https://vaiti.io/wp-content/uploads/2024/06/8-1024x461.png)
Теперь скопируем этот ключ, чтобы добавить его в заголовок на следующий запрос. Для отправки данных с заголовками можно воспользоваться утилитой Postman. Во вкладке Authorization выберем тип Bearer, добавим туда наш токен и выполним запрос:
![](https://vaiti.io/wp-content/uploads/2024/06/9-1024x745.png)
Свой обработчик аутентификации
Что, если нам необходимо кастомизировать обработку аутентификации? Например, получать ключ динамически из БД или выполнить дополнительную логику. В таком случае нужно написать свой обработчик аутентификации и реализовать логику самостоятельно. Разберем, как это сделать.
Добавим в проект классы CustomAuthenticationOptions и CustomAuthenticationHandler. CustomAuthenticationHandler унаследуем от AuthenticationHandler и реализуем в нем следующую логику:
public class CustomAuthenticationOptions : AuthenticationSchemeOptions
{
// тут могут быть дополнительные параметры
}
public class CustomAuthenticationHandler : AuthenticationHandler
{
public CustomAuthenticationHandler(
IOptionsMonitor options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock)
: base(options, logger, encoder, clock)
{
}
protected override async Task HandleAuthenticateAsync()
{
// получаем токен
var token = Request.Headers.Authorization.ToString().Replace("Bearer ", string.Empty);
if (string.IsNullOrEmpty(token))
{
return AuthenticateResult.Fail("No token");
}
var securityTokenHandler = new JwtSecurityTokenHandler();
using var rsa = RSA.Create();
var publicKey = File.ReadAllText("./Keys/key.pub.pem");
rsa.ImportFromPem(publicKey);
var rsaSecurityKey = new RsaSecurityKey(rsa);
try
{
// описываем параметры валидации
var validationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = rsaSecurityKey,
ValidateIssuer = true,
ValidateLifetime = true,
ValidateAudience = true,
ClockSkew = TimeSpan.Zero,
CryptoProviderFactory = new CryptoProviderFactory()
{
CacheSignatureProviders = false
},
};
// выполняем валидацию. Если прошла неуспешно, будет исключение
var claims = securityTokenHandler.ValidateToken(token, validationParameters, out _);
// создаем объект Identity и наполняем его клеймами
// Тут может быть кастомная логика получения информации о пользователе
var ticket = new AuthenticationTicket(claims, "custom");
return AuthenticateResult.Success(ticket);
}
catch (Exception ex)
{
return AuthenticateResult.Fail(ex);
}
}
}
Созданный кастомный обработчик аутентификации нам надо подключить в файле Program.cs вместо AddJwtBearer:
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = "custom";
options.DefaultChallengeScheme = "custom";
})
.AddScheme<CustomAuthenticationOptions, CustomAuthenticationHandler>("custom", "custom",
x => { /* тут можно настроить параметры */ }
);
Готово! Теперь снова запускаем и тестируем приложение. Получаем токен и используем его:
![](https://vaiti.io/wp-content/uploads/2024/06/10-1024x655.png)
Теперь мы знаем, как использовать JWT-токены и асиметричную подпись в ASP.NET Core. Разберем еще один важный вопрос.
Как и где хранить ключи?
Это сложная и немного «холиварная» тема. В реальных проектах на production-среде хранить ключи в файловой системе проекта точно не стоит. Но какие есть варианты?
Менеджеры секретов
Можно воспользоваться решением HashiCorp Vault или аналогами от AWS или Azure. Они удобны, безопасны, и их легко интегрировать в приложение на ASP.NET Core.
CI/CD и переменные окружения
В системе CI/CD, например в GitLab, можно генерировать ключи в рамках пайплайнов и добавлять их в переменные среды приложения. В конфигурацию ASP.NET Core их можно добавить при настройке конфигурации с помощью метода Configuration.AddEnvironmentVariables — Configuration in ASP.NET Core | Microsoft Learn.
Заключение
В рамках этой статьи мы разобрали работу с JWT и парой «публичный — приватный ключ». Мы использовали объект RSA для генерации ключа, который использовался для подписи токена.
Также я веду телеграм-канал, где пишу про .NET и не только.