前言
最近在搭建一个新的项目中,对于 用户身份验证
这块, 后端说用常规的方案:AccessToken + RefreshToken
的无感刷新,引发了一顿思考。
老场景:
经常在项目中遇到,用户正在操作,突然跳转到登录页面,提示需要重新登录,这是因为AccessToken
过期了。
对应的解决方案:
试想:如果为了安全设置 AccessToken
每一小时刷新一次,那么也就意味着用户每小时就需要登录一次,体验感很差;如果将 AccessToken
过期时间设置为一个月,则安全性又大大降低。
所以,除了有 AccessToken
,还需要加入 RefreshToken
。RefreshToken 的存在就是为了降低 AccessToken
的有效期,避免 AccessToken
有效期太长导致泄漏造成更大的危害。它只是一种相对的安全的设置,并不保证完全的安全。同时也是为了让用户能够保持登录的状态,不需要去重新登录的一种用户体验解决方案。
原理
- AccessToken 设置有效期为一天,RefreshToken 设置有效期为一个月。
- 用户首次登录的时候,服务端返回 AccessToken + RefreshToken,前端把这两个token保存在本地。
- 客户端每次发起请求时,HTTP请求头携带
Authorization=Bearer+ AccessToken
。 - 如果请求401了,说明 AccessToken 过期了。
- 客户端把401的这些请求暂存起来,放在待请求队列里。
- 客户端需要重新发起新的请求去获取最新的 AccessToken,这个新的请求携带
Authorization=Bearer+ RefreshToken
。- 请求成功: 获取到新的 AccessToken 后,再将待请求队列里的任务依次发起请求,携带最新的 AccessToken。
- 请求401:说明 RefreshToken 也过期了,这时候才需要用户重新登录。
本文可供借鉴的点
- 方法的封装:各司其职,单一原则。
- 流程完整性:包含待请求队列的管理。
- 无感刷新的思路。
实现
基于 axios@1.4.0
进行封装,篇幅有限,只能摘取部分代码讲解一下,完整的封装放在 GitHub 上。
封装 request
请发方法
- 初步创建一个
OriginAxios
类,并封装一个request
请发方法:
ts
export default class OriginAxios {
private axiosInstance: AxiosInstance // axios实例
private readonly options: CreateAxiosOptions // 请求选项
private isRefreshing = false // 是否正在刷新token,开启请求队列
private requestQueue: RequestQueueItem[] // 待请求队列
constructor(options: CreateAxiosOptions) {
this.options = options
this.axiosInstance = axios.create(options)
this.isRefreshing = false
this.requestQueue = []
}
request<T = any>(config: AxiosRequestConfig, options?: RequestOptions): Promise<T> {
let conf: CreateAxiosOptions = config
const { requestOptions } = this.options
// 将默认配置和用户配置组合
const opt: RequestOptions = Object.assign({}, requestOptions, options)
return new Promise((resolve, reject) => {
this.requestPipeline(this.axiosInstance, conf, opt, resolve, reject)
})
}
}
封装请求拦截器
- 封装请求拦截器
interceptors
:主要处理请求头Authorization
。
ts
interceptors(instance: AxiosInstance): void {
const { requestInterceptors } = this.getTransform() || {}
// 请求拦截
instance.interceptors.request.use(
(config) => {
// 请求头设置Authorization
if (requestInterceptors && isFunction(requestInterceptors)) {
config = requestInterceptors(config, this.options)
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截
instance.interceptors.response.use(
(res: AxiosResponse<any>) => {
return res
},
(error: AxiosError) => {
throw error
}
)
}
/**
* @description: 添加请求头Authorization
*/
requestInterceptors: (config, options) => {
const token = getToken()
if (token && (config as Recordable)?.requestOptions?.withToken !== false) {
// jwt token
;(config as Recordable).headers.Authorization = options.authenticationScheme
? `${options.authenticationScheme} ${token}`
: token
}
return config
},
封装请求管道处理方法
-
封装请求管道处理方法
requestPipeline
:封装起来的原因是:
requestQueue
待请求队列里的任务,执行的时候也直接调用这个方法即可;并且这样也可以把回调的resolve和reject
,流转到请求拦截器中,便于处理响应。当状态码
401
时,判断当前是否正在刷新token
中 (isRefreshing)- 是:将请求放到待请求队列里,等待刷新token之后,重新执行。
- 否:发起请求,刷新token。
ts
// 请求管道处理具体细节
requestPipeline<T = unknown>(
instance: AxiosInstance,
conf: CreateAxiosOptions,
options: RequestOptions,
resolve: (data: Promise<T>) => void,
reject: (data: unknown) => void
): void {
this.interceptors(instance)
instance<any, AxiosResponse<Result>>(conf)
.then((res: AxiosResponse<Result>) => {
resolve(res.data as unknown as Promise<T>)
})
.catch((error) => {
const code = error?.response?.status
if (code === 401) {
if (!this.isRefreshing) {
this.isRefreshing = true
this.refreshToken()
} else {
this.addRequestQueueForRefreshToken<T>(instance, conf, options, resolve, reject)
}
} else {
//...异常状态码响应处理
}
})
}
封装refreshToken方法
- 发起请求,刷新
AccessToken
,并携带Authorization=Bearer+ RefreshToken
- 请求结果:
成功
:执行待请求队列的任务失败
:提示重新登录
ts
// 刷新token
refreshToken() {
const loginStore = useLoginStoreWithOut()
this.postRefreshTokenFunc()
.then((res) => {
if (res.data) {
const data = res.data
loginStore.updateToken(data.data)
this.requestQueueStartAfterRefreshToken()
}
})
.catch(() => {
loginStore.logout()
ElMessage.error('登录已失效,需要重新登录')
})
.finally(() => {
this.isRefreshing = false
})
}
postRefreshTokenFunc() {
const params = {
// 设备Canvas指纹
clientId: getCanvasFingerprint(),
}
const token = getRefreshToken()
return axios.post(import.meta.env.VITE__APP_BASE_URL + '/auth/refresh', params, {
headers: {
Authorization: `Bearer ${token}`,
},
})
}
封装添加任务到待请求队列方法
- 添加请求到等待队列
ts
addRequestQueueForRefreshToken<T = unknown>(
instance: AxiosInstance,
conf: CreateAxiosOptions,
options: RequestOptions,
resolve: (data: Promise<T>) => void,
reject: (data: unknown) => void
): void {
this.requestQueue.push({
instance,
conf,
options,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
resolve,
reject,
})
}
封装 执行待请求队列 方法
- 刷新
token
成功,等待队列开始请求
ts
requestQueueStartAfterRefreshToken(): void {
let requestQueueItem = this.requestQueue.pop()
while (requestQueueItem) {
const { options, instance, conf, resolve, reject } = requestQueueItem
this.requestPipeline(instance, conf, options, resolve, reject)
requestQueueItem = this.requestQueue.pop()
}
}
至此,整个封装工作也已经结束了。
结语
完整的封装放在 GitHub 上
只是提供一种思路,技术上没什么难点,有思考就写下来,共勉。