将 JWT 令牌存储到 HttpOnly cookie 中

Posted

技术标签:

【中文标题】将 JWT 令牌存储到 HttpOnly cookie 中【英文标题】:Storing JWT token into HttpOnly cookies 【发布时间】:2021-10-16 23:34:36 【问题描述】:

我读了几篇文章,说本地存储不是存储 JWT 令牌的首选方式,因为它不打算用于会话存储,因为您可以通过 javascript 代码轻松访问它,如果有可能导致 XSS 本身易受攻击的第三方库之类的。

从这些文章中总结出来,正确的做法是使用 HttpOnly cookie 而不是本地存储会话/敏感信息。

问题 1

我找到了一项 cookie 服务,就像我目前用于本地存储的服务一样。我不清楚的是expires=Thu, 1 Jan 1990 12:00:00 UTC; path=/;`。它真的必须在某个时候过期吗?我只需要存储我的 JWT 并刷新令牌。全部信息都在里面。

import  Injectable  from '@angular/core';

/**
 * Handles all business logic relating to setting and getting local storage items.
 */
@Injectable(
  providedIn: 'root'
)
export class LocalStorageService 
  setItem(key: string, value: any): void 
    localStorage.setItem(key, JSON.stringify(value));
  

  getItem<T>(key: string): T | null 
    const item: string | null = localStorage.getItem(key);
    return item !== null ? (JSON.parse(item) as T) : null;
  

  removeItem(key: string): void 
    localStorage.removeItem(key);
  

import  Inject, Injectable  from '@angular/core';

@Injectable(
    providedIn: 'root',
  )
export class AppCookieService 
    private cookieStore = ;

    constructor() 
        this.parseCookies(document.cookie);
    

    public parseCookies(cookies = document.cookie) 
        this.cookieStore = ;
        if (!!cookies === false)  return; 
        const cookiesArr = cookies.split(';');
        for (const cookie of cookiesArr) 
            const cookieArr = cookie.split('=');
            this.cookieStore[cookieArr[0].trim()] = cookieArr[1];
        
    

    get(key: string) 
        this.parseCookies();
        return !!this.cookieStore[key] ? this.cookieStore[key] : null;
    

    remove(key: string) 
      document.cookie = `$key = ; expires=Thu, 1 jan 1990 12:00:00 UTC; path=/`;
    

    set(key: string, value: string) 
        document.cookie = key + '=' + (value || '');
    

问题 2

查看注销功能signOut()。在后端撤销 JWT 令牌(额外订阅后端)不是更好的做法吗?

import  Injectable  from '@angular/core';
import  HttpClient  from '@angular/common/http';
import  Router  from '@angular/router';
import  map, Observable, of  from 'rxjs';

import  JwtHelperService  from '@auth0/angular-jwt';
import  environment  from '@env';
import  LocalStorageService  from '@core/services';
import  AuthResponse, User  from '@core/types';

@Injectable(
  providedIn: 'root'
)
export class AuthService 
  private readonly ACTION_URL = `$environment.apiUrl/Accounts/token`;

  private jwtHelperService: JwtHelperService;

  get userInfo(): User | null 
    const accessToken = this.getAccessToken();
    return accessToken ? this.jwtHelperService.decodeToken(accessToken) : null;
  

  constructor(
    private httpClient: HttpClient,
    private router: Router,
    private localStorageService: LocalStorageService
  ) 
    this.jwtHelperService = new JwtHelperService();
  

  signIn(credentials:  username: string; password: string ): Observable<AuthResponse> 
    return this.httpClient.post<AuthResponse>(`$this.ACTION_URL/create`, credentials).pipe(
      map((response: AuthResponse) => 
        this.setUser(response);
        return response;
      )
    );
  

  refreshToken(): Observable<AuthResponse | null> 
    const refreshToken = this.getRefreshToken();
    if (!refreshToken) 
      this.clearUser();
      return of(null);
    

    return this.httpClient.post<AuthResponse>(`$this.ACTION_URL/refresh`,  refreshToken ).pipe(
      map((response) => 
        this.setUser(response);
        return response;
      )
    );
  

  signOut(): void 
    this.clearUser();
    this.router.navigate(['/auth']);
  

  getAccessToken(): string | null 
    return this.localStorageService.getItem('accessToken');
  

  getRefreshToken(): string | null 
    return this.localStorageService.getItem('refreshToken');
  

  hasAccessTokenExpired(token: string): boolean 
    return this.jwtHelperService.isTokenExpired(token);
  

  isSignedIn(): boolean 
    return this.getAccessToken() ? true : false;
  

  private setUser(response: AuthResponse): void 
    this.localStorageService.setItem('accessToken', response.accessToken);
    this.localStorageService.setItem('refreshToken', response.refreshToken);
  

  private clearUser() 
    this.localStorageService.removeItem('accessToken');
    this.localStorageService.removeItem('refreshToken');
  


问题 3

我的后端是 ASP.NET Core 5,我使用的是 IdentityServer4。我不确定是否必须让后端验证 cookie 或它是如何工作的?

services.AddIdentityServer(options =>

    options.Events.RaiseErrorEvents = true;
    options.Events.RaiseInformationEvents = true;
    options.Events.RaiseFailureEvents = true;
    options.Events.RaiseSuccessEvents = true;
    
    options.EmitStaticAudienceClaim = true;
)
    .AddDeveloperSigningCredential()
    .AddInMemoryIdentityResources(Configuration.GetIdentityResources())
    .AddInMemoryApiScopes(Configuration.GetApiScopes(configuration))
    .AddInMemoryApiResources(Configuration.GetApiResources(configuration))
    .AddInMemoryClients(Configuration.GetClients(configuration))
    .AddCustomUserStore();

services.AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
    .AddIdentityServerAuthentication(options =>
    
        options.Authority = configuration["AuthConfiguration:ClientUrl"];
        options.RequireHttpsMetadata = false;
        options.RoleClaimType = "role";
        
        options.ApiName = configuration["AuthConfiguration:ApiName"];
        options.SupportedTokens = SupportedTokens.Jwt;
        options.JwtValidationClockSkew = TimeSpan.FromTicks(TimeSpan.TicksPerMinute);
    );

【问题讨论】:

如果您真的需要帮助,请一次发布一个问题,而不仅仅是展示您的代码。通常人们没有那么多时间来阅读一英里长的问题。 【参考方案1】:

    您希望您的后端使用刷新令牌设置 HttpOnly cookie。因此,您将有一个 POST 端点,您可以在其中发布您的用户凭据,并且此端点在 HttpOnly cookie 中返回刷新令牌,并且 accessToken 可以作为常规 JSON 属性在请求正文中返回。 下面是如何在响应中设置 cookie 的示例:

        var cookieOptions = new CookieOptions
        
            HttpOnly = true,
            Expires = DateTime.UtcNow.AddDays(7),
            SameSite = SameSiteMode.None,
            Secure = true
        ;
        Response.Cookies.Append("refreshToken", token, cookieOptions);
    

    一旦您拥有带有刷新令牌的 HttpCookie,您就可以将其传递给专用 API 端点以轮换访问令牌。这个端点实际上也可以将刷新令牌旋转为security best practice。 以下是检查请求中是否包含 HttpCookie 的方法:

        var refreshToken = Request.Cookies["refreshToken"];
        if (string.IsNullOrEmpty(refreshToken))
        
            return BadRequest(new  Message = "Invalid token" );
        
    

    您的访问令牌应该是短暂的,例如 15-20 分钟。这意味着您希望在到期前不久主动轮换它,以确保经过身份验证的用户不会被注销。您可以在 JavaScript 中使用 setInterval 函数来构建此刷新功能。

    您的刷新令牌可以使用更长的时间,但它不应该不会过期。此外,如第 2 点所述,在访问令牌刷新时轮换刷新令牌确实是个好主意。

    您的访问令牌不需要存储在任何地方,例如本地/会话存储或 cookie。只要不重新加载单个页面,您就可以简单地将其保存在一些 SPA 服务中。如果它由用户重新加载,您只需在初始加载期间轮换令牌(请记住 HttpOnly cookie 被粘在您的域中并且可作为资源供您的浏览器使用),一旦您拥有访问令牌,您就可以将其放入每个后端请求的授权标头.

    您需要在某处(关系数据库或键值存储)保存已发布的刷新令牌,以便能够验证它们、跟踪到期情况并在需要时撤销。

【讨论】:

以上是关于将 JWT 令牌存储到 HttpOnly cookie 中的主要内容,如果未能解决你的问题,请参考以下文章

ReactJS 和 DRF:如何将 JWT 令牌存储在 HTTPonly cookie 中?

如何使用 DRF djangorestframework-simplejwt 包将 JWT 令牌存储在 HttpOnly cookie 中?

我应该将 JWT 令牌存储在 IndexedDB 中吗?

如何在 ngCookies 中设置 httpOnly 标志?

在客户端存储 JWT 令牌的位置以及如何保护它? [复制]

访问令牌和刷新令牌困境 - JWT