Обновление JWT-токена

Многие современные приложения Web API, использующие для аутентификации пользователя JWT-токены, во-первых, ограничивают время жизни токена, а, во-вторых, позволяют пользователю обновить токен без повторного ввода учётных данных. На данный момент наше приложение обладает только одной из этих функций – мы ограничиваем время жизни токена, однако не даем пользователю возможности обновить его.

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

Схема работа приложения

Схема работы нашего приложения на данный момент выглядит следующим образом:

  • Шаг 1 — пользователь передает на сервер свои учетные данные (логин/пароль)
  • Шаг 2 — система аутентификации передает пользователю JWT-токен, необходимый для доступа к защищенным ресурсам приложения
  • Шаг 3 — пользователь передает JWT-токен в заголовке каждого запроса
  • Шаг 4 — если токен верный и время его жизни не истекло, то приложение дает доступ пользователю к защищенному ресурсу (авторизует пользователя)
  • Шаг 5 — пользователь отправляет токен с истекшим сроком жизни
  • Шаг 6 — приложение запрещает доступ к приложению

Как только пользователь попадает на шаг 6, то он должен вернуться на шаг 1 — заново ввести логин/пароль и получить токен доступа. Такой подход не является удобным для пользователя, в особенности, когда JWT-токен «живет» непродолжительный промежуток времени (например, как у нас — 1 минуту). При этом, если мы поставим срок жизни токена, скажем, не 2 минуты, а 2 года, то такой подход может привести к тому, что JWT-токен попадет в руки злоумышленнику и тот получит доступ к защищенным данным. Выйти из такой ситуации нам поможет использование второго токена — токена обновления (refresh token), который мы можем отсылать пользователю вместе с токеном доступа и по refresh token обновлять токен доступа тогда, когда это необходимо. При этом мы можем устанавливать токену обновления больший срок жизни (или вообще делать его бессмертным) и отзывать этот токен, если вдруг учётная запись пользователя будет скомпрометирована.

В итоге нам необходимо прийти в нашем приложении к следующей схеме работы

то есть, по сравнению с исходной схемой, здесь добавляются ещё два шага:

  • Шаг 7 — пользователь отправляет на сервер refresh token
  • Шаг 8 — система возвращает пользователю обновленный токен для доступа к защищенным ресурсам.

Реализация механизма обновления JWT-токена

Чтобы реализовать такую схему работы приложения нам необходимо внести изменения в наш проект. Добавим в папку Models/Identity новый класс Token

public class Token
{
    public string AccessToken { get; set; }
    public string RefreshToken { get; set; }
    public DateTime ExpiresIn { get; set; }
}

Чтобы в дальнейшем мы могли корректно обновлять ключ доступа, и чтобы пользователь не мог обновить ключ доступа, пересылая в приложение произвольный токен обновления, внесем изменения в класс User:

public class User : IdentityUser<int>
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public DateTime BeginningOfWork { get; set; }
    public string RefreshToken { get; set; }
}

Здесь мы добавили свойство RefreshToken, которое будет хранить текущий токен обновления для конкретного пользователя. Это свойство будет обновляться каждый раз, как только пользователь сделает запрос на обновление ключа доступа. Соответственно, так как мы внесли изменение в модель, нам необходимо сделать новую миграцию. Выполним к консоли диспетчера пакетов команду:

PM> Add-Migration AddUserWithRefreshToken

Теперь применим новый класс в нашем приложении. Для этого изменим метод GenerateToken() в контроллере UsersController следующим образом:

public async Task<Token> GenerateToken()
{
    var claims = await GetUserClaims();
    var options = GetTokenOptions(claims);
    var token = new JwtSecurityTokenHandler().WriteToken(options);

    DateTime exp = options.Payload.ValidTo;
    var refreshToken = Convert.ToBase64String(Encoding.UTF8.GetBytes(Guid.NewGuid().ToString()));

    _user.RefreshToken = refreshToken;

    await _userManager.UpdateAsync(_user);

    return new Token()
    {
        AccessToken = token,
        ExpiresIn = exp,
        RefreshToken = refreshToken
    };
}

Рассмотрим внесенные изменения. Так как теперь мы пользователю время жизни ключа доступа в свойстве ExpiresIn, то мы считываем это значение из свойства ValidTo:

DateTime exp = options.Payload.ValidTo;

Далее мы генерируем непосредственно токен обновления. Здесь мы используем обычный GUID, который кодируем в Base64:

var refreshToken = Convert.ToBase64String(Encoding.UTF8.GetBytes(Guid.NewGuid().ToString()));

Можно предусмотреть и более сложные механизмы генерации RefreshToken, вынести генерацию этого значения в отдельный метод и так далее. Но, для упрощения, мы не будем это делать. Более того, наш токен обновления будет бессрочным. Полученный токен обновления записывается в базу данных:

_user.RefreshToken = refreshToken;
await _userManager.UpdateAsync(_user);

После этого объект класса Token возвращается пользователю:

return new Token()
{
     AccessToken = token,
     ExpiresIn = exp,
     RefreshToken = refreshToken
};

Протестируем новую версию этого метода, выполнив запрос на аутентификацию пользователя из http-файла нашего проекта:

Как видно по рисунку, объект Token возвращается как полагается. Теперь нам необходимо решить следующим момент — как понять, какой пользователь отправляет нам токен на обновление JWT-токена?  Добавим в контроллер следующий метод:

public ClaimsPrincipal GetClaimsPrincipal(string expiredToken)
{
    var tokenSettings = _configuration.GetSection("tokenSettings");
    var key = Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("SECRET"));
    var tokenValidationParameters = new TokenValidationParameters
    {
        ValidateAudience = true,
        ValidateIssuer = true,
        ValidateIssuerSigningKey = true,
        IssuerSigningKey = new SymmetricSecurityKey(key),
        ValidateLifetime = false,
        ValidIssuer = tokenSettings["Issuer"],
        ValidAudience = tokenSettings["Audience"],
        ClockSkew = TimeSpan.Zero
    };
    var tokenHandler = new JwtSecurityTokenHandler();
    var principal = tokenHandler.ValidateToken(expiredToken,
                       tokenValidationParameters,
                       out SecurityToken securityToken);

    if (securityToken is not JwtSecurityToken jwtSecurityToken || jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase) == false)
    {
        throw new SecurityTokenException("Invalid token");
    }

    return principal;
}

Метод GetClaimsPrincipal() предназначен для получения информации о владельце ключа доступа срок действия которого истёк. Метод возвращает объект типа ClaimsPrincipal, который содержит основную информацию о владельце токена. Первая часть этого метода соответствует части метода GetTokenOptions() – мы получаем настройки JWT-токенов из конфигурации приложения и создаем объект ключа подписи токена. Единственное отличие в этой части заключается в том, что при определении параметров валидации токена мы устанавливаем свойство ValidateLifetime в значение false:

ValidateLifetime = false,

Здесь, опять же, значение этой настройки зависит от того, как вы планируете организовать процесс обновления токена доступа. Так, если вы хотите, чтобы пользователь имел возможность обновить токен доступа уже после того, как время жизни токена доступа истекло, то значение ValidateLifetime должно быть равно false. В этом случае пользователь может получить токен доступа, а, затем, обновить его, например, через год, используя значение RefreshToken (этот ключ у нас бессрочный).

Если же вы хотите, чтобы обновление токена доступа было возможно только для «живого» токена, то значение ValidateLifetime следует установить в значение true. В этом случае пользователь должен будет следить за временем жизни токена доступа и обновить его, например, за одну секунду до того, как текущее время в системе станет равным значение ExpiresIn. Обычно, используется именно этот вариант обновления токена доступа.

После того, как мы определили настройки валидации токена, мы создаем объект типа JwtSecurityTokenHandler и, используя его метод ValidateToken() производим валидацию токена, строковое значение которого передается в параметре expiredToken.

var tokenHandler = new JwtSecurityTokenHandler();
var principal = tokenHandler.ValidateToken(expiredToken,      
                tokenValidationParameters,
                out SecurityToken securityToken);

Если токен доступа верный, то метод ValidateToken() возвращает объект типа ClaimsPrincipal, содержащий т.н. удостоверение (Identity) владельца токена, а в параметре validatedToken возвращается объект securityToken, представляющий, непосредственно, токен доступа.

Далее мы проверяем является ли полученный токен JWT-токеном и соответствие алгоритма шифрования тому, который используется нами для формирования JWT-токенов:

if (securityToken is not JwtSecurityToken jwtSecurityToken ||
    jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256,   
    StringComparison.InvariantCultureIgnoreCase) == false)
{
       throw new SecurityTokenException("Invalid token");
}

Если последняя проверка прошла успешно, то метод возвращает полученный объект типа ClaimsPrincipal. Этот объект мы будем использовать в следующем методе:

public async Task<Token> RefreshToken(Token token)
{
    var principal = GetClaimsPrincipal(token.AccessToken);
    var user = await _userManager.FindByNameAsync(
    principal.Identity.Name);
    if (user == null || user.RefreshToken != token.RefreshToken)
        throw new SecurityTokenException("Ошибка обновления токена");
    _user = user;
    return await GenerateToken();
}

Метод RefreshToken() осуществляет обновление токена доступа. После того, как мы получаем объект типа ClaimsPrincipal, мы используем его свойство Identity для того, чтобы получить имя пользователя. Используя имя пользователя, мы пытаемся получить запись пользователя из хранилища:  

var user = await _userManager.FindByNameAsync(
                       principal.Identity.Name);

Если объект пользователя получен, в переданное в параметре token значение RefreshToken совпадает с тем, которое было получено из хранилища, то происходит формирование нового токена, который и возвращается методом:

if (user == null || user.RefreshToken != token.RefreshToken)
           throw new SecurityTokenException("Ошибка обновления токена");
_user = user;
return await GenerateToken();

Всё, что нам теперь остается — это добавить новое действие контроллера для обновления токена доступа:

[HttpPost("refresh_token")]
public async Task<IActionResult> Refresh(Token token)
{
    try
    {
        var refreshedToken = await RefreshToken(token);
        return Ok(refreshedToken);
    }
    catch (Exception ex)
    {
        ModelState.AddModelError("Token", ex.Message);
        return ValidationProblem(statusCode: 400, modelStateDictionary: ModelState);
    }
}

Осталось только протестировать новое действие контроллера. Для этого добавим в http-файл проекта следующий запрос:

POST {{WebApplication6_HostAddress}}/users/refresh_token/
Content-Type: application/json

{
  "accessToken": "сюда_вставить_jwt-токен",
  "refreshToken": "сюда_вставить_токен_обновления",
  "expiresIn": "сюда_вставить_время_жизни_токена"
}

Теперь аутентифицируем пользователя в системе и вставим в новый запрос полученные данные токена, а затем выполним запрос на обновление токена дважды. Первый вызов метода вернет обновленный токен:

Если же мы попытаемся обновить токен доступа, используя те же данные, что и в первом запросе, то получим ошибку:

Так как RefreshToken обновляется вместе с токеном, то вторая попытка использовать старый токен обновления вызывает ошибку, что мы и видим выше на рисунке. Таким образом мы реализовали все основные моменты работ по аутентификации и авторизации пользователей в приложении ASP.NET Core Web API.

Итого

В этой части мы реализовали самую простую схему обновления JWT-токена для пользователя. Для обновления используется специальный RefreshToken, который пользователь «обменивает» на товый токен доступа.

Подписаться
Уведомить о
guest
0 Комментарий
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии