Перетяжка, Премия ТПрогер, 13.11
Перетяжка, Премия ТПрогер, 13.11
Перетяжка, Премия ТПрогер, 13.11

JWT-токен в cookie в ASP.Net Core: максимальная безопасность и контроль

В этом материале мы подробно рассмотрим механизм передачи JWT-токенов (JSON Web Token) через HTTP-Only cookie, который выводит безопасность и контроль над пользовательской сессией на новый уровень

325 открытий2К показов
JWT-токен в cookie в ASP.Net Core: максимальная безопасность и контроль

JWT-токены распространены, чтобы обеспечивать процессы авторизации и аутентификации между сервером и клиентами. Стандартная схема их использования включает в себя передачу токена через заголовок Authorization и обновление с использованием рефреш-токена и рефреш-эндпоинта.

Во время аутентификации клиент получает от сервера токен и должен его где-то сохранить, чтобы потом передавать серверу с каждым запросом. Хранится токен в месте, доступном из JS кода, что порождает уязвимость к XSS атакам. Кроме того, клиенту требуется реализовать логику рефреша.

Иногда в проекте может возникнуть потребность в полном контроле над токеном со стороны сервера: он должен иметь возможность не только проверить, но и обновить или отозвать токен. В этом случае можно применять передачу JWT-токенов через HTTP-Only cookie. Об этом — техлид IT_ONE Сергей Белоусов.

Настройка контейнера ServiceCollection

Рассмотрим на примере. Для добавления работы с JWT-токенами через cookie в фреймфорке ASP.NET Core необходимо в Program.cs добавить следующие строки

			services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options =>  
{  
	// выставляем настройки под нужды своего проекта, здесь просто пример
   options.TokenValidationParameters = new TokenValidationParameters()  
   {  
      ValidateIssuer = true,  
      ValidIssuer = issuer,  
      ValidateAudience = true,  
      ValidAudience = audience,  
      ValidateLifetime = true,  
      ValidateIssuerSigningKey = true,  
      IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(issuerSigningKey))  
   };
   
   // здесь и происходит перевод токена из cookie в контекст
   options.Events = new JwtBearerEvents  
   {  
      OnMessageReceived = context =>  
      {  
         if(context.Request.Cookies.ContainsKey(Constants.TokenCookieName))  
            context.Token = context.Request.Cookies[Constants.TokenCookieName];  
         return Task.CompletedTask;  
      }   
   };
});

		

Код контроллера для запуска и защиты сессии

			/// <summary>  
///       Вход в систему  
/// </summary>  
[HttpPost("login")]  
[AllowAnonymous]  
public async Task<ActionResult<LoginResponse>> Login([FromBody] LoginRequest request)  
{  
   var authResult = _authService.Authenticate(request.Login, request.Password);  
   if (!authResult.Success)  
   {      
	  ClearTokenCookie();  // стираем авторизационный куки
      return Unauthorized(new ProblemDetails  
      {  
         Title = "Ошибка",  
         Detail = "Неправильные логин или пароль",  
      });  
   }   
  
   var sessionId = Guid.NewGuid().ToString();  
   var token = _jwtService.CreateToken(sessionId, authResult.Login, authResult.Roles);  
   Response.Cookies.Append(Constants.TokenCookieName, token, new CookieOptions  
   {  
      Expires = DateTimeOffset.Now.AddMinutes(_options.ExpirationTime),  
      SameSite = SameSiteMode.Strict,  
      HttpOnly = true,  
	  Secure = true
   });  
  
   var antiForgeryToken = _antiForgery.CreateToken(sessionId, authResult.Login, authResult.Roles);  
  
   return Ok(new LoginResponse { Token = antiForgeryToken });  
}

/// <summary>  
///       Выход из системы  
/// </summary>  
/// <returns></returns>  
[HttpPost("logout")]  
[AllowAnonymous]  
public ActionResult Logout()  
{  
   ClearTokenCookie();  
   return Ok();  
}  
  
private void ClearTokenCookie()  
{  
   if (Request.Cookies.ContainsKey(Constants.TokenCookieName))  
   {   
      Response.Cookies.Delete(Constants.TokenCookieName);  
   }
}

		

Разберём подробнее, что происходит при запуске аутентифицированной сессии. Клиент передаёт в запросе логин и пароль пользователя. Если условный сервис аутентификации _authService (реализацию оставляю за вами) подтверждает правильность введенных данных, то мы создаем JWT-токен и сохраняем его в cookie со следующими параметрами:

1) HttpOnly = true

Ключевая настройка. Разрешает доступ к cookie только со стороны сервера, JS код не будет иметь к ней доступ.

2) SameSite = SameSiteMode.Strict

Запрещает браузерам отправлять этот cookie при межсайтовых запросах. При необходимости можно снизить до SameSiteMode.Lax, но это повысит уязвимость к XSRF атакам (о них позже).

3) Secure = true

Cookie будет отправляться только при соединении HTTPS.

4) Expires = DateTimeOffset.Now.AddMinutes(options.ExpirationTime)

Время жизни cookie задаём таким же, как время жизни JWT-токена, потому что нет смысла хранить токен дольше.

При логауте или неправильных данных входа мы просто удаляем токен из cookies – по сути, отзываем токен.

Проверка токена через сервис JwtService

Код JwtService:

			public string CreateToken(string sessionId, string login, params string[] roles)  
{  
   var claims = new List<Claim>  
   {      
      new(ClaimsIdentity.DefaultNameClaimType, login),  
      new(Constants.SessionIdClaimType, sessionId),  
   };  
  
   claims.AddRange(roles.Select(x => new Claim(ClaimsIdentity.DefaultRoleClaimType, x)));  
  
   return CreateToken(claims);  
}  
   
private string CreateToken(IEnumerable<Claim> claims)  
{  
   var notBefore = DateTime.UtcNow;  
   var expires = notBefore.Add(TimeSpan.FromMinutes(_options.ExpirationTime));  
   var securityKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_options.Key));  
   var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);  
   var jwt = new JwtSecurityToken(_options.Issuer, _options.Audience, claims, notBefore, expires, credentials);  
   var tokenHandler = new JwtSecurityTokenHandler();  
  
   return tokenHandler.WriteToken(jwt);  
}
		

В JwtService мы создаем набор клеймов, которые хотим передавать с каждым запросом, и зашиваем их в токен. В моём примере я решил сохранить в токен информацию о логине, сессии и ролях пользователя.

А что за antiForgeryToken?

А вот и отрицательная сторона использования cookie: уязвимости к XSRF-атакам. Если кратко, то это вид атак, при которых злоумышленник заставляет браузер жертвы, уже авторизованной на сайте, отправить нежелательный запрос на этот сайт от её имени. Браузер автоматически добавляет авторизационный cookie к запросу, что позволяет выполнить действия от имени жертвы.

Частично защиту от XSRF атак повышает опция SameSite = SameSiteMode.Strict, но это не панацея. Также рекомендуют настраивать CORS-политики, но я оставлю эту тему за пределами этой статьи (главное, не забыть AllowCredentials() на сервере и WithCredentials() на клиенте).

Ещё один способ защиты от XSRF атак – добавить ко всем запросам antiForgeryToken. В ASP.NET Core MVC можно использовать services.AddAntiforgery(), тогда к каждой форме на странице будет добавлено скрытое поле, которое заполняется сервером при рендеринге страницы и отправляется при запросах на сервер. Но мне кажется, что при использовании связки SPA + ASP.NET Core Backend готовый метод защиты не применим, т.к. рендеринг страницы происходит на клиенте.

В своём проекте я решил, что буду генерировать токен самостоятельно и возвращать его при успешной аутентификации. Т.е. сам antiforgery токен может быть получен только тем, кто знает логин и пароль. После этого токен сохраняется на клиенте и подставляется в header при каждом запросе.

Ремарка 1.

Antiforgery-токен в этом случае уязвим к XSS, т.к. доступен из JS кода, но он сам по себе не дает доступа к сайту, а только подтверждает источник запросов. Тем не менее некая уязвимость остается, хоть и требует одновременной реализации XSS и XSRF атаки.

Ремарка 2.

Не каждому проекту подойдёт такая реализация. В моём antiforgery-токен живет сутки, и это нормально, т.к. пользователь заходит в систему каждый день. Для B2C сайтов с планируемой авторизацией раз 2-3 месяца такой подход может нести больше рисков. Решение за вами.

Проверка antiForgeryToken

Для проверки этого токена используется middleware:

			internal class AntiForgeryMiddleware  
{  
   private readonly RequestDelegate _next;  
   private readonly ILogger<AntiForgeryMiddleware> _logger;  
  
   public AntiForgeryMiddleware(RequestDelegate next, ILogger<AntiForgeryMiddleware> logger)  
   {      
      _next = next ?? throw new ArgumentNullException(nameof(next));  
      _logger = logger;  
   }
     
   public async Task Invoke(HttpContext context, IAntiForgeryService service)  
   {      
	  var endpoint = context.Features.Get<IEndpointFeature>()?.Endpoint;  
      var attribute = endpoint?.Metadata.GetMetadata<AuthorizeAttribute>();  
      if (attribute is null)  
      {        
         await _next(context);  
         return;  
      }  
      if (!TokenPassed(context, service))  
      {         
         _logger.LogError("Передан недействительный AntiForgery-токен.");  
         context.Response.StatusCode = (int)HttpStatusCode.BadRequest;  
         return;  
      }  
      await _next(context);  
   }  
   
   private static bool TokenPassed(HttpContext context, IAntiForgeryService service)  
   {      
      if (!context.Request.Headers.TryGetValue(Constants.XsrfTokenHeader, out var tokenHeader))  
      {         
         return false;  
      }  

	  // переданный токен берем из заголовков
      var token = tokenHeader.FirstOrDefault() ?? string.Empty;  
	  
	  // необходимые для проверки токена поля берем из Claims
	  var login = context.User.Claims.FirstOrDefault(x => x.Type == ClaimsIdentity.DefaultNameClaimType)?.Value ?? string.Empty;  
	  var roles = context.User.Claims.Where(x => x.Type == ClaimsIdentity.DefaultRoleClaimType).Select(x => x.Value).ToArray();  
	  var sessionId = context.User.Claims.FirstOrDefault(x => x.Type == Constants.SessionIdClaimType)?.Value ?? string.Empty;  
	  
	  return service.CheckToken(token, sessionId, login, roles);
   }
}
		

Сначала определяется наличие AuthorizeAttribute, чтобы не проверять токен для анонимных эндпоинтов. Затем вызывается CheckToken у самописного IAntiForgeryService. Логику проверки можно реализовать любую. Например, сгенерировать новый токен по известным параметрам и сравнить с полученным от клиента. Если токены не соответствуют — возвращаем код ошибки. Я выбрал 400, т.к. antiForgeryToken — это, по-моему, не про авторизацию, поэтому не хотелось использовать 401 и 403.

Абсолютная власть

Главное преимущество cookie — в том, что возможность управления JWT-токеном на стороне сервера ограничена только вашей фантазией. Для себя я реализовал автоматический рефреш JWT-токена при каждом запросе без необходимости отдельного рефреш эндпоинта. Если для примера JWT-токен истекает через 15 минут, то при каждом запросе от авторизованного пользователя я продлеваю токен ещё на 15 минут.

			internal class RefreshTokenMiddleware  
{  
   private readonly RequestDelegate _next;  
   private readonly ILogger<RefreshTokenMiddleware> _logger;  
    
   public RefreshTokenMiddleware(RequestDelegate next, ILogger<RefreshTokenMiddleware> logger)  
   {      
	  _next = next ?? throw new ArgumentNullException(nameof(next));  
      _logger = logger;  
   }
     
   public async Task Invoke(HttpContext context, IJwtService jwtService, IOptions<AuthOptions> options)  
   {      
      if (!context.Request.Cookies.ContainsKey(Constants.TokenCookieName))  
      {         
         await _next(context);  
         return;  
      }      
      var jwtCookie = context.Request.Cookies[Constants.TokenCookieName]!;  
  
      try  
      {  
         // создаем новый токен на основе существующего
         var token = jwtService.RefreshToken(jwtCookie);  

		 // подменяем cookie - теперь там будет лежать обновленный токен
         context.Response.Cookies.Append(Constants.TokenCookieName, token, new CookieOptions  
         {  
            Expires = DateTimeOffset.Now.AddMinutes(options.Value.ExpirationTime),             SameSite = SameSiteMode.Strict,  
            HttpOnly = true,
            Secure = true
         });  
      }      
      catch (Exception ex)  
      {         
         _logger.LogError(ex, "Ошибка обновления токена");  
      }      
      finally  
      {  
         await _next(context);  
      }   
   }
}
		

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

А также можно написать другие middleware, которые в соответствии с бизнес-логикой будут отбирать или выдавать роли или даже отзывать токен «на лету».

Заключение

Использование HTTP-Only cookie для передачи JWT-токенов — это мощный паттерн, который эффективно защищает от XSS-атак и предоставляет серверу беспрецедентный контроль над сессией пользователя. Он позволяет реализовать такие функции, как немедленный отзыв токенов и их прозрачное обновление, что значительно повышает безопасность приложения. Однако этот подход требует тщательной реализации дополнительных мер защиты от XSRF-атак и продуманной политики управления жизненным циклом сессии. При грамотной настройке он становится надёжным фундаментом для системы аутентификации в современных веб-приложениях.

Следите за новыми постами
Следите за новыми постами по любимым темам
325 открытий2К показов