双 Token 刷新机制 是一种用于身份验证和授权的安全机制,通常用于现代 Web 应用和 API 中。它的核心思想是使用两种 Token:Access Token 和 Refresh Token,分别用于短期和长期的权限管理。通过这种机制,可以在保证安全性的同时,提升用户体验。
1. 双 Token 的作用
(1)Access Token(访问令牌)
-
作用:用于访问受保护的资源(如 API 接口)。
-
特点:
- 有效期较短(如 15 分钟到 1 小时)。
- 存储在客户端(如
localStorage
、内存或 Cookie 中)。 - 每次请求时,客户端需要将 Access Token 放在请求头中发送给服务器。
(2)Refresh Token(刷新令牌)
-
作用:用于在 Access Token 过期后,获取新的 Access Token。
-
特点:
- 有效期较长(如 7 天到 30 天)。
- 存储在安全的 HTTP-only Cookie 中,避免被 JavaScript 访问。
- 仅在 Access Token 过期时使用,不会随普通请求发送。
2. 双 Token 刷新机制的工作流程
-
用户登录:
- 用户输入用户名和密码,发送登录请求。
- 服务器验证成功后,返回 Access Token 和 Refresh Token。
-
存储 Token:
- Access Token:存储在客户端(如
localStorage
或内存)。 - Refresh Token:存储在 HTTP-only Cookie 中。
- Access Token:存储在客户端(如
-
访问受保护资源:
- 客户端在每次请求时,将 Access Token 放在请求头中发送给服务器。
- 服务器验证 Access Token 的有效性,如果有效,则返回请求的数据。
-
Access Token 过期:
- 如果 Access Token 过期,服务器会返回 401 状态码(未授权)。
- 客户端检测到 401 错误后,使用 Refresh Token 向服务器请求新的 Access Token。
-
刷新 Access Token:
- 客户端发送 Refresh Token 到服务器的刷新接口。
- 服务器验证 Refresh Token 的有效性,如果有效,则返回新的 Access Token。
- 客户端更新本地的 Access Token,并重试之前的请求。
-
Refresh Token 过期:
- 如果 Refresh Token 也过期,客户端需要重新登录。
3. 双 Token 刷新机制的优势
-
安全性:
- Access Token 有效期短,即使泄露,攻击者也只能在短时间内使用。
- Refresh Token 存储在 HTTP-only Cookie 中,避免被 XSS 攻击窃取。
-
用户体验:
- 用户无需频繁登录,Refresh Token 可以自动刷新 Access Token,保持用户会话。
-
灵活性:
- 可以根据业务需求调整 Access Token 和 Refresh Token 的有效期。
-
无状态性:
- 服务器无需维护会话状态,所有信息都包含在 Token 中。
4.实现过程
const axiosInstance = axios.create({
baseURL: isHttpProxy ? '/proxy-url' : import.meta.env.VITE_BASE_API,
timeout: DEFAULT_OPTIONS.timeout,
})
// 定义一个tokenKey,用于storage的key
const TOKEN_KEY = 'access_token'
// token刷新接口地址
const REFRESH_TOKEN_API = '/api/Auth/RefreshToken'
// 是否正在刷新token
let isRefreshing = false
// 定义一个队列,用于存储失败的请求
let failedQueue: any[] = []
// 刷新token
async function refreshToken() {
try {
// 发送 HTTP-only Cookie 中的 Refresh Token
const res = await api.auth.refreshToken({}, { withCredentials: true, })
const accessToken = `Bearer ${res.data.accessToken}`
localStorage.setItem(TOKEN_KEY, accessToken)
return accessToken
} catch (error: any) {
return error
}
}
// 请求拦截
axiosInstance.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
const accessToken = localStorage.getItem(TOKEN_KEY)
if (accessToken) {
config.headers['Authorization'] = accessToken
}
return config
},
(error: any) => {
return Promise.reject(error)
},
)
// 响应拦截
axiosInstance.interceptors.response.use(
(response: AxiosResponse) => {
// 单独处理下载文件流情况
if (response.config.responseType === 'blob') {
return response
}
// 业务请求成功
if (response.data.isSuccess)
return { ...response, data: handleServiceResult(response.data) }
// 业务请求失败
const errorResult = handleBusinessError(response.data, DEFAULT_BACKEND_OPTIONS)
return { ...response, data: handleServiceResult(errorResult, false) }
},
async (error: AxiosError) => {
// http状态码非200
const errorResult = handleResponseError(error.response)
const originalRequest = error.config as AxiosError['config'] & { _retry: boolean }
// originalRequest._retry是一个自定义属性,用于标记请求是否已经重试过
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
// 刷新token
if (!isRefreshing) {
isRefreshing = true
try {
const newToken = await refreshToken()
axiosInstance.defaults.headers.common['Authorization'] = newToken
// 使用新的token重试所有等待的请求
failedQueue.forEach(promise => promise.resolve(newToken))
// 重试原始请求
return axiosInstance(originalRequest)
} catch (refreshError) {
// 刷新Token失败,跳转登录页
failedQueue.forEach(item => item.reject(refreshError))
localStorage.removeItem(TOKEN_KEY)
window.location.href = window.location.origin + window.location.pathname + '#/login'
return Promise.reject(refreshError)
} finally {
// 重置刷新状态并清空队列
isRefreshing = false
failedQueue = []
}
}
if (originalRequest.url === REFRESH_TOKEN_API) {
return Promise.reject(handleServiceResult(errorResult, false))
}
// 如果正在刷新token,则将请求加入队列
return new Promise((resolve, reject) => failedQueue.push({ resolve, reject }))
.then(newToken => {
originalRequest.headers.Authorization = newToken as string
return axiosInstance(originalRequest)
})
.catch(err => {
return Promise.reject(err)
})
}
return Promise.reject(handleServiceResult(errorResult, false))
},
)
/**
核心逻辑在响应拦截器的error部分:
1、判断http状态码是否为401,并且没有重试标识:_retry,满足条件则认为是Token过期,并且未被重试过。
换句话说,重试过的请求依旧401,则直接报错,防止无限重试。
2、针对并发请求,第一个会触发refreshToken方法更新,第二个到第N个由于被isRefreshing锁住了,
都会进入failedQueue队列当中,等待refreshToken方法执行完毕后,用新的token重试failedQueue队列
当中所有的请求,并且重试原始请求(也就是第一个请求)。至此,便完成了所有401请求的重试。
并发请求时,refreshToken是个异步过程,所以,同一时间会将第2个出现401的请求到后面所有401失败的请求
全部放入队列当中。
return new Promise((resolve, reject) => failedQueue.push({ resolve, reject }))
.then(newToken => {
originalRequest.headers.Authorization = newToken as string
return axiosInstance(originalRequest)
})
.catch(err => {
return Promise.reject(err)
})
流程图
双 Token 刷新机制 是一种安全且高效的身份验证方案,通过 Access Token 和 Refresh Token 的配合,既能保证用户会话的安全性,又能提升用户体验。它的核心思想是:
- Access Token:短期有效,用于访问资源。
- Refresh Token:长期有效,用于刷新 Access Token。
双 Token 刷新机制本质上是延长Token时效的一种策略,如果Refresh Token过期,依然需要重新登录。
如果想进一步提升用户体验,需要进行Token续期。就是在每次请求时,判断Refresh Token是否即将过期,如果即将过期,则需要对其进行续期,避免Refresh Token过期导致跳转到登录页。
这里有两种做法。
一种是客户端登录完成后,服务端返回给客户端一个有效期,由客户端在请求拦截器里面做判断。
第二种是服务端在给客户端下发令牌时,将有效期写入JWT当中,这样返回给客户端的Access Token字符串中就包含了时效信息,然后在客户端每次发起请求的时候,服务端判断Refresh Token是否即将过期,如果是,则更新Refresh Token,从而达到给Refresh Token续期的目的。但第二种做法只支持Http-Only Cookie这种形式,由服务端主动SetCookie。