将 JWT 令牌存储到 HttpOnly cookie 中
Storing JWT token into HttpOnly cookies
我读了几篇文章,说本地存储不是存储 JWT 令牌的首选方式,因为它不适合用于会话存储,因为您可以通过 JavaScript 代码轻松访问它,这可能会导致如果存在易受攻击的第三方库或其他东西,则 XSS 本身。
从这些文章中总结,正确的方法是使用 HttpOnly cookie 而不是本地存储 session/sensitive 信息。
问题 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 token(后端额外订阅)不是更好的做法吗?
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);
});
您希望后端使用刷新令牌设置 HttpOnly cookie。因此,您将有一个 POST 端点,您可以在其中 post 您的用户凭据和此端点 returns HttpOnly cookie 中的刷新令牌,并且 accessToken 可以在请求 body 中作为常规返回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 函数来构建此刷新功能。
您的刷新令牌可以存活更长时间,但不应该 non-expiring。此外,如第 2 点所述,在访问令牌刷新时轮换刷新令牌是个好主意。
您的访问令牌不需要像 local/session 存储或 cookie 那样存储在任何地方。您可以简单地将它保存在某个 SPA 服务中,只要不重新加载单个页面,该服务就会存在。如果它由用户重新加载,您只需在初始加载期间轮换令牌(记住 HttpOnly cookie 固定在您的域中,并且可以作为资源提供给您的浏览器),一旦您拥有访问令牌,您就可以将其授权 header对于每个后端请求。
您需要在某个地方(关系数据库或 key-value 存储)持久保存已发布的刷新令牌,以便能够验证它们,跟踪到期情况并在需要时撤销。
我读了几篇文章,说本地存储不是存储 JWT 令牌的首选方式,因为它不适合用于会话存储,因为您可以通过 JavaScript 代码轻松访问它,这可能会导致如果存在易受攻击的第三方库或其他东西,则 XSS 本身。
从这些文章中总结,正确的方法是使用 HttpOnly cookie 而不是本地存储 session/sensitive 信息。
问题 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 token(后端额外订阅)不是更好的做法吗?
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);
});
您希望后端使用刷新令牌设置 HttpOnly cookie。因此,您将有一个 POST 端点,您可以在其中 post 您的用户凭据和此端点 returns HttpOnly cookie 中的刷新令牌,并且 accessToken 可以在请求 body 中作为常规返回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 函数来构建此刷新功能。
您的刷新令牌可以存活更长时间,但不应该 non-expiring。此外,如第 2 点所述,在访问令牌刷新时轮换刷新令牌是个好主意。
您的访问令牌不需要像 local/session 存储或 cookie 那样存储在任何地方。您可以简单地将它保存在某个 SPA 服务中,只要不重新加载单个页面,该服务就会存在。如果它由用户重新加载,您只需在初始加载期间轮换令牌(记住 HttpOnly cookie 固定在您的域中,并且可以作为资源提供给您的浏览器),一旦您拥有访问令牌,您就可以将其授权 header对于每个后端请求。
您需要在某个地方(关系数据库或 key-value 存储)持久保存已发布的刷新令牌,以便能够验证它们,跟踪到期情况并在需要时撤销。