Содержание
Одним из наиболее популярных способов аутентификации пользователей в приложениях Web API является использование JWT-токенов.
В самом простом случае процесс аутентификации происходит следующим образом
- Клиент отправляет по определенному пути (например,
api/login
) свои учётные данные – логин и пароль. Данные могут посылаться на сервер как в теле запроса, так и в параметрах запроса или в заголовках, в зашифрованном или открытом виде и т.д. Главное, чтобы сервер знал как получить учётные данные из запроса клиента. - Сервер проверяет учётные данные пользователя
- Если данные верны, то в ответ сервер отправляет клиенту специальный токен – JWT (JSON Web Token), который содержит необходимую информацию о пользователе.
При последующих запросах пользователь включает полученный JWT-токен в каждый запрос, подтверждая, тем самым, что он (пользователь) аутентифицирован. В более сложных сценариях сервер может обновлять токен доступа без запроса логина/пароля пользователя, отзывать токен доступа, указывать срок жизни токена и так далее. Но в целом, аутентификация происходит по сценарию, указанному на рисунке выше. Именно такой сценарий мы и будем реализовывать далее, используя приложение, которое мы разработали в предыдущей части.
Что из себя представляет JWT-токен
JWT-токен (Json Web-Token) — это открытый стандарт (RFC 7519), который определяет компактный и автономный способ безопасной передачи информации между сторонами в виде объекта JSON.
JWT-токены состоят из трех частей:
- Header (заголовок). Заголовок обычно состоит из двух частей: типа токена и используемого алгоритма подписи, такого, например, как HMAC SHA256 или RSA.
- Payload (полезная нагрузка). Здесь обычно содержатся некие утверждения (claims), то есть информация о пользователе, которая может использоваться для авторизации. Например, в этой части токена мы можем передать имя, фамилию, возраст пользователя, место работы и так далее. При этом не рекомендуется включать в полезную нагрузку токена конфиденциальную информацию так как токен может быть декодирован.
- Signature (подпись) — это строка, которая создается на основании первых двух элементов с использованием алгоритма, указанного в заголовке токена.
Для обычного пользователя JWT-токен выглядит как три строки, закодированные в Base64 и разделенные точками. Может возникнуть вопрос: в чем интерес использования JWT-токенов, если токен может быть легко декодирован? Интерес заключается в том, что даже, если токен попадет в руки к злоумышленникам, то они не смогут подменить в этом токене полезную нагрузку. Посмотреть – могут, подменить – нет. «Секрет» кроется в третьей части токена – подписи.
Если злоумышленник попытается поменять в полезной нагрузке хотя бы один символ, то ему также придется изменить и подпись токена, которая, в свою очередь создается с использованием ключа, который известен только серверу. Таким образом, сервер может очень легко проверить подлинность токена, просто сравнив подписи отправленного токена и той подписи, которая должна быть при использовании переданной полезной нагрузки. Вот, в том числе и поэтому JWT-токены и нашли широкое применение при аутентификации пользователей в приложениях Web API.
Установка необходимых nuget-пакетов и настройка JWT
Чтобы использовать JWT-токены в ASP.NET Core там потребуется установить в проект nuget-пакет под названием Microsoft.AspNetCore.Authentication.JwtBearer и настроить сервис аутентификации пользователей на работу с JWT, а также указать необходимые настройки JWT-токенов. В общем случае, такая настройка выглядит следующим образом
builder.Services.AddAuthentication() .AddJwtBearer(options => { });
Здесь AddJwtBearer()
– метод расширения для AuthenticationBuilder
, который добавляет аутентификацию пользователей на основе JWT-токенов, значения которых извлекаются из заголовка Authorization
. В этот метод мы можем передать делегат типа Action<JwtBearerOptions>
, где JwtBearerOptions
– класс, определяющий настройки JWT-токенов. Этот класс содержит довольно много различных свойств, однако для нас, на данный момент, наиболее важным свойством этого класса является свойство TokenValidationParameters
, определяющее параметры проверки токена. TokenValidationParameters
представляет собой одноименный класс, у которого определены следующие свойства
Название | Тип | Описание |
ValidateAudience |
Boolean |
Определяет, будет ли проверяться аудитория во время проверки токена. Проверка аудитории уменьшает атаки типа Open Redirect. Например, если сайт, получивший токен, не смог воспроизвести его на другом сайте, то перенаправленный токен будет содержать аудиторию исходного сайта. |
ValidateIssuer |
Boolean |
Определяет, будет ли проверяться эмитент (производитель) токена во время его проверки |
ValidateIssuerSigningKey |
Boolean |
Определяет, будет ли проверяться ключ подписи токена во время его проверки. |
ValidateLifetime |
Boolean |
Определяет, будет ли проверяться время жизни токена во время его проверки |
ValidAudience |
String |
Аудитория токена |
ValidIssuer |
String |
Эмитент токена |
Это далеко не все имеющиеся у класса свойства, однако, перечисленных выше свойств нам будет достаточно для дальнейшей работы. Для того, чтобы мы могли воспользоваться различными параметрами проверки токена, необходимо каким-либо образом указать приложению какие значения свойств ValidAudience
и ValidIssuer
считать верными, а также определить значение ключа подписи токена. Эти значения мы можем определить, например, используя конфигурацию приложения, следующим образом — добавим в файл appsettings.json
следующие настройки
"tokenSettings": { "Issuer": "CSharpAPI", "Audience": "https://localhost:7007" }
Здесь, в файле appsettings.json добавлен новый объект tokenSettings
содержащий поля Audience
и Issuer
, которые будут использоваться в дальнейшем как допустимые значения для токена, т.е. значения этих полей будут присвоены свойствам ValidAudience
и ValidIssuer
. Что касается секретного ключа, который будет использоваться для подписи токена, то такое значение лучше хранить отдельно, например, в переменных среды. Поэтому добавим новую настройку в файл launchSettings.json
"https": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, "applicationUrl": "https://localhost:7007;http://localhost:5158", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "SECRET": " MyVeryTopSecretKey256bitMinimalSizeForCorrectlyWork" } }
В дальнейшем вы можете самостоятельно записать это значение в переменные среды, используя, например, инструменты ОС Windows. Для тестирования же работы приложения с JWT-токенами нам достаточно использовать launchSettings.json. Итак, в этом файле мы сохранили в переменной среды с именем SECRET
секретный ключ для подписи токена. Теперь перейдем непосредственно к настройке сервиса для работы с JWT-токенами. Добавим в файл Program.cs сразу после строки
var builder = WebApplication.CreateBuilder(args);
следующие строки (см. листинг):
var tokenSettings = builder.Configuration.GetSection("tokenSettings"); var secretKey = Environment.GetEnvironmentVariable("SECRET");
По сути, здесь для нас нет ничего нового – мы просто прочитали из конфигурации приложения секцию tokenSettings
со значениями для ValidAudience
и ValidIssuer
, а также считали из переменных среды значение секретного ключа. Теперь применим эти значения. Для этого подключим сервис для работы с JWT-токенами и передадим необходимые для него параметры:
builder.Services.AddAuthentication().AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters() { ValidateAudience = true, ValidateIssuer = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = tokenSettings["Issuer"], ValidAudience = tokenSettings["Audience"], IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)) }; });
Здесь для JWT-токена мы указали необходимые параметры валидации и передали значения, полученные из конфигурации приложения. Теперь наше приложение готово к работе с JWT-токенами – мы можем зарегистрировать нового пользователя в системе (или воспользоваться учетной записью пользователя из прошлой части), а также воспользоваться новым сервисом для создания и валидации JWT-токенов.
Аутентификация пользователя
Для начала определимся с тем, каким образом пользователь будет передавать свои учётные данные на сервер. В зависимости от наших возможностей и требований к приложению, мы можем организовать процесс передачи данных самыми различными способами, например, передавать учётные данные в параметрах запроса, в теле запроса как JSON-объект, в полях формы и так далее. Здесь мы рассмотрим вариант передачи учётных данных пользователя в теле запроса. Для этого нам необходимо создать новый класс, объект которого будет содержать необходимые для аутентификации сведения
namespace WebApplication6.Models.Identity { public class LoginUserDto { [Required(ErrorMessage = "Поле UserName является обязательным")] public string UserName { get; set; } [Required(ErrorMessage = "Поле Password является обязательным")] public string Password { get; set; } } }
Класс LoginUserDto
содержит всего два свойства, которые необходимы для аутентификации пользователя – его логин (свойство UserName
) и пароль (свойство Password
).
Второй момент, который нам необходимо реализовать – это, непосредственно, передача учётных данных пользователя и проверка их корректности. Для этого воспользуемся уже имеющимся у нас контроллером UsersController
. Прежде всего, добавим в контроллер метод проверки корректности учётных данных пользователя
private User? _user; public async Task<bool> ValidUser(LoginUserDto loginUser) { _user = await _userManager.FindByNameAsync(loginUser.UserName); return (_user != null) && (await _userManager.CheckPasswordAsync(_user, loginUser.Password)); }
В этом методе мы воспользовались сервисом UserManager
, который запрашиваем в конструкторе контроллера. Вначале мы, используя метод FindByNameAsync()
пытаемся найти пользователя по его логину в хранилище. На втором шаге, если такой пользователь будет найден, мы проверяем пароль пользователя, используя метод CheckPasswordAsync()
. В этот метод мы передаем объект пользователя и пароль, который передается непосредственно пользователем при аутентификации.
Если проверка учетных данных проходит успешно, то объект пользователя сохраняется в поле _user
, а метод возвращает значение true
. Теперь, если метод ValidUser()
вернет значение true
, то мы должны сформировать и направить пользователю JWT-токен. Чтобы это сделать, мы должны получить настройки токена, которые мы определили ранее, а также записать в токен необходимые для дальнейшей работы параметры.
Реализуем эти два момента также в виде отдельных методов контроллера, чтобы в дальнейшем мы могли их, при необходимости, легко изменять. Для того, чтобы получить настройки токена из конфигурации приложения мы должны запросить соответствующий сервис. Изменим конструктор контроллера следующим образом:
public UsersController(UserManager<User> userManager, IConfiguration configuration) { _userManager = userManager; _configuration = configuration; }
Получив доступ к сервису конфигурации приложения, мы можем определять настройки JWT-токена. Для этого добавим в контроллер новые методы:
public List<Claim> GetUserClaims() { return [new(ClaimTypes.Name, _user.UserName)]; } public JwtSecurityToken GetTokenOptions(List<Claim> claims) { var key = Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("SECRET")); var secret = new SymmetricSecurityKey(key); SigningCredentials signingCredentials = new SigningCredentials(secret, SecurityAlgorithms.HmacSha256); var tokenSettings = _configuration.GetSection("tokenSettings"); var tokenOptions = new JwtSecurityToken( signingCredentials: signingCredentials, issuer: tokenSettings["Issuer"], audience: tokenSettings["Audience"], claims: claims, expires: DateTime.Now.AddMinutes(1)); return tokenOptions; } public string GenerateToken() { var claims = GetUserClaims(); var options = GetTokenOptions(claims); return new JwtSecurityTokenHandler().WriteToken(options); }
Рассмотрим представленные выше методы по порядку. Первый метод — GetUserClaims()
. Вся информация, которая может быть нам полезна для работы с объектом пользователя в ASP.NET Core Identity, аккумулируется в специальных объектах класса Claim
(утверждение). В объекты Claim
можно записать имя пользователя, где он работает, сколько пользователю лет и так далее. Наш метод GetUserClaims()
формирует список таких объектов. Для примера, мы создаем список всего с одним элементом – именем пользователя. В дальнейшем мы можем добавить в этот список другую полезную для работы информацию о пользователе.
Второй метод GetTokenOptions()
формирует объект класса JwtSecurityToken
в котором содержится вся информация токена. Код этого метода, условно, можно разделить на три части. В первой части мы, по сути, воспроизводим те же действия, которые проводили для чтения настроек токена в файле Program.cs:
var key = Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("SECRET")); var tokenSettings = _configuration.GetSection("tokenSettings");
Вторая часть метода – создание объекта класса SigningCredentials
, который представляет ключ шифрования и алгоритм, используемый для создания цифровой подписи токена.
SigningCredentials signingCredentials = new SigningCredentials(secret, SecurityAlgorithms.HmacSha256);
В данном случае мы будем использования алгоритм HMAC-SHA256. И, наконец, третья часть метода – непосредственно создание объекта JwtSecurityToken
:
var tokenOptions = new JwtSecurityToken( signingCredentials: signingCredentials, issuer: tokenSettings["Issuer"], audience: tokenSettings["Audience"], claims: claims, expires: DateTime.Now.AddMinutes(1));
Здесь стоит обратить внимание на последний используемый в конструкторе параметр – expires
с помощью которого мы указываем время жизни токена. В нашем случае JWT-токен будет действителен в течение одной минуты.
Третий метод – GenerateToken()
создает строковое представление JWT-токена. Для этого в методе создается объект типа JwtSecurityTokenHandler
и вызывается его метод WriteToken()
в который передаем созданный в предыдущем методе объект типа JwtSecurityToken
.
Теперь у нас всё готово для того, чтобы написать новое действие контроллера для аутентификации пользователя в системе. Добавим в контроллер новое действие:
[HttpPost("login")] public async Task<IActionResult> LoginUser(LoginUserDto loginUser) { if (await ValidUser(loginUser)) { return Ok(GenerateToken()); } return Unauthorized(); }
С учётом разработанных ранее методов, действие LoginUser()
достаточно простое – если переданные пользователем учётные данные проходят проверку, то пользователю возвращается JWT-токен с кодом статуса HTTP 200 Ok, иначе – возвращается код 401 Unauthorized. Осталось проверить работу этого действия. Добавим в http-файл проекта следующий запрос
POST {{WebApplication6_HostAddress}}/users/login/ Content-Type: application/json { "UserName": "Vlad", "Password": "_12345q67890" }
Этот запрос содержит корректные данные ранее зарегистрированного пользователя. Результат выполнения запроса будет выглядеть следующим образом
Если полностью скопировать тело ответа, то мы здесь как раз и увидим JWT-токен, состоящий из трех частей.
- eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
- eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoiVmxhZCIsImV4cCI6MTczNTA0NzQ5OSwiaXNzIjoiQ1NoYXJwQVBJIiwiYXVkIjoiaHR0cHM6Ly9sb2NhbGhvc3Q6NzAwNyJ9.
- VnWcDeipWcbJLLv21OBs_bDCfa7LP4tUo6ZHlvKqky4
Если декодировать эту строку из Base64, то получим вот такое содержимое:
{ "alg":"HS256", "typ":"JWT" } { "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name":"Vlad", "exp":1735047499, "iss":"CSharpAPI", "aud":"https://localhost:7007" } *подпись токена*
Вместо строки *подпись токена*
располагается строка символов. В случае, если будет отправлен неверный логин или пароль, сервер вернет ответ как показано рисунке ниже
Итак, мы аутентифицировали пользователя, выдали ему JWT-токен и теперь можем приступить к следующей части – авторизации.
Итого
В этой части мы научились проводить аутентификацию пользователя с использованием JWT-токенов. JWT-токен состоит из трех частей — заголовка, полезной нагрузки и подписи и представляет собой компактный и автономный способ безопасной передачи информации между сторонами в виде объекта JSON .