单 token:普通的单 token 方案,有效期设置过长不安全,过短需要频繁重新登录,用户体验差。
双 token:服务端返回两个 token,一个有效期短(例如:30分钟过期)的 access-token 用来认证用户身份,一个有效期长(例如:7天)的 refresh-token 用来刷新 token。
优点:1.用户只需要7天内登录过一次,即可刷新登录状态,不用频繁重新登录;2.access-token 有效期短,被盗损失小,而 refresh-token 只在第一次获取和刷新 token 时才在网络中传输,被盗风险小,万一被盗,刷新 token 也需要同时提供两个 token。
1. 项目背景
最近开发项目时,碰到这个需求,刚开始初步实现了一版。后面 review 时,发现与 axios 拦截器中其他业务逻辑耦合度过高,就想着能不能把相关逻辑单独抽离出来,使用时主动注册到拦截器即可,也方便复用。
2. 功能展望
- 身份认证失败时,自动刷新身份凭证,刷新成功后,再重新发起请求并返回结果;
- 刷新身份凭证期间发起的请求先暂时中断,待刷新成功后,再重新请求;
- 抽离成一个插件,可以无缝注册到 axios 中,并支持自定义刷新身份凭证方法与刷新时机。
3. 功能开发
3.1 实现自动刷新身份凭证
首先,需要知道什么情况下,才需要刷新 token。一般来说接口返回的 http 状态码为401时,就代表需要身份认证。也可以先和后端约定好相关状态码。代码如下:
import axios, { AxiosError } from 'axios'
export const instance = axios.create({
baseURL: '/api',
timeout: 5000,
withCredentials: true
})
instance.interceptors.request.use(
(config) => {
// 获取 token 并设置请求头
const token = localStorage.getItem('token')
config.headers.Authorization = `Bearer ${token}`
return config
},
(error: AxiosError) => Promise.reject(error)
)
instance.interceptors.response.use(
(res) => {
const data = res.data
if (data instanceof Blob || data.statusCode === 200) {
return res
}
// 1. 与后端约定的身份验证失败状态码
if (data.statusCode === 401) {
// 401 代表 token 过期,自动刷新 token
}
return Promise.reject(new AxiosError(data.message))
},
(error: AxiosError) => {
// 2. http 状态码为 401 时,自动刷新 token
if (error.response?.status === 401) {
// 401 代表 token 过期,自动刷新 token
}
return Promise.reject(error)
}
)
接下来,我们需要实现一个刷新方法,该方法在刷新成功时,可以接收一个回调函数并返回该回调函数执行后的 Promise 结果。刷新失败时,返回一个 Promise.reject 错误。代码如下:
import axios, { AxiosError, type AxiosResponse } from 'axios'
export const instance = axios.create({
baseURL: '/api',
timeout: 5000,
withCredentials: true
})
// 通过接口获取新 token,伪代码,实际项目中需要根据实际情况修改
const getNewToken = async () => {
const res = await instance.post('/refreshToken')
if (res.data.statusCode === 200) {
// 缓存新 token
localStorage.setItem('token', res.data.data.token)
localStorage.setItem('refreshToken', res.data.data.refreshToken)
return true
}
return false
}
// 刷新方法,成功时执行回调,失败时抛出 reject(AxiosError)
const refreshToken = (
callback: (
resolve: (value: AxiosResponse | PromiseLike<AxiosResponse>) => void,
reject: (reason?: any) => void
) => void
): Promise<AxiosResponse> => {
return new Promise((resolve, reject) => {
getNewToken().then((res) => {
// 刷新 token 成功,执行回调
if (res) {
callback(resolve, reject)
return
}
// 刷新 token 失败,抛出错误
reject(new AxiosError('Refresh token failed'))
})
})
}
instance.interceptors.request.use(
(config) => {
// 获取 token 并设置请求头
const token = localStorage.getItem('token')
config.headers.Authorization = `Bearer ${token}`
return config
},
(error: AxiosError) => Promise.reject(error)
)
instance.interceptors.response.use(
(res) => {
const data = res.data
if (data instanceof Blob || data.statusCode === 200) {
return res
}
// 与后端约定的身份验证失败状态码
if (data.statusCode === 401) {
// 刷新 token
return refreshToken((resolve, reject) => {
// 刷新成功后,重新请求
instance.request(res.config).then(resolve).catch(reject)
})
}
return Promise.reject(new AxiosError(data.message))
},
(error: AxiosError) => {
// http 状态码为 401 时,自动刷新 token
if (error.response?.status === 401) {
// 刷新 token
return refreshToken((resolve, reject) => {
// 刷新成功后,重新请求
instance.request(error.config!).then(resolve).catch(reject)
})
}
return Promise.reject(error)
}
)
至此,我们就初步实现了一个无感刷新登录状态的功能了。
3.2 请求等待队列
上面的代码存在一个问题,那就是在刷新期间还会发送新请求到服务器。那能不能先中断在刷新期间发起的请求,等待刷新完成后再重新发起呢?
这是可以实现的。
可以添加一个等待队列,将刷新 token 期间发送的请求中断后,放入该队列中,等到刷新成功后,再重新请求。
那要如何判断哪些请求是在刷新 token 期间发送的呢?
可以通过增加一个变量来标记,修改刷新方法的代码如下:
// 新增变量标记刷新状态
let isRefreshing = false
const refreshToken = (
callback: (
resolve: (value: AxiosResponse | PromiseLike<AxiosResponse>) => void,
reject: (reason?: any) => void
) => void
): Promise<AxiosResponse> => {
// 开始刷新
isRefreshing = true
return new Promise((resolve, reject) => {
getNewToken().then((res) => {
// 结束刷新
isRefreshing = false
// 刷新 token 成功,执行回调
if (res) {
callback(resolve, reject)
return
}
// 刷新 token 失败,抛出错误
reject(new AxiosError('Refresh token failed'))
})
})
}
那么要如何中断请求呢?
在请求拦截器中抛出错误,然后在响应拦截器的错误回调中处理即可。
import axios, { AxiosError, type AxiosRequestConfig, type AxiosResponse } from 'axios'
export const instance = axios.create({
baseURL: '/api',
timeout: 5000,
withCredentials: true
})
const getNewToken = async () => {
// 伪代码,实际项目中需要根据实际情况修改
const res = await instance.post('/refreshToken')
if (res.data.statusCode === 200) {
localStorage.setItem('token', res.data.data.token)
localStorage.setItem('refreshToken', res.data.data.refreshToken)
return true
}
return false
}
// 等待队列
let queue: Function[] = []
// 添加到等待队列中
const addPending = (config: AxiosRequestConfig): Promise<AxiosResponse> => {
return new Promise((resolve, reject) => {
const delay = config.timeout
let timer: NodeJS.Timeout | null = null
if (delay) {
// 超时处理
timer = setTimeout(() => {
reject(new AxiosError('Request timeout', 'ERR_CANCELED', config as any))
}, delay)
}
const callback = () => {
timer && clearTimeout(timer)
// 重新请求
instance.request(config).then(resolve).catch(reject)
}
queue.push(callback)
})
}
let isRefreshing = false
const refreshToken = (
callback: (
resolve: (value: AxiosResponse | PromiseLike<AxiosResponse>) => void,
reject: (reason?: any) => void
) => void
): Promise<AxiosResponse> => {
isRefreshing = true
return new Promise((resolve, reject) => {
getNewToken().then((res) => {
isRefreshing = false
if (res) {
callback(resolve, reject)
// 执行等待队列中的请求
queue.forEach((fn) => fn())
queue = []
return
}
// 刷新 token 失败,抛出错误
queue = []
reject(new AxiosError('Refresh token failed'))
})
})
}
instance.interceptors.request.use(
(config) => {
// 获取 token 并设置请求头
const token = localStorage.getItem('token')
config.headers.Authorization = `Bearer ${token}`
// 正在刷新 token 时,拦截请求
if (isRefreshing) {
return Promise.reject(new AxiosError('Refreshing token', 'ERR_REFRESHING', config))
}
return config
},
(error: AxiosError) => Promise.reject(error)
)
instance.interceptors.response.use(
(res) => {
const data = res.data
if (data instanceof Blob || data.statusCode === 200) {
return res
}
// 与后端约定的身份验证失败状态码
if (data.statusCode === 401) {
return refreshToken((resolve, reject) => {
// 重新请求
instance.request(res.config).then(resolve).catch(reject)
})
}
return Promise.reject(new AxiosError(data.message))
},
(error: AxiosError) => {
// 处理刷新 token 时拦截的请求
if (error.code === 'ERR_REFRESHING') {
return addPending(error.config!)
}
// http 状态码为 401 时,自动刷新 token
if (error.response?.status === 401) {
return refreshToken((resolve, reject) => {
// 重新请求
instance.request(error.config!).then(resolve).catch(reject)
})
}
return Promise.reject(error)
}
)
但是这样,使用同一个 axios 实例发起的刷新 token 的请求也会被拦截。因为我们是在发起前修改变量的值,当代码走到请求拦截器时,isRefreshing 的值已经为 true 了。要解决这个问题,我们可以再定义一个变量,或判断一下接口地址。
首先修改刷新方法的代码为:
let isRefreshing = false
// 新增标记变量
let isStartedRefresh = false
const refreshToken = (
callback: (
resolve: (value: AxiosResponse | PromiseLike<AxiosResponse>) => void,
reject: (reason?: any) => void
) => void
): Promise<AxiosResponse> => {
isRefreshing = true
return new Promise((resolve, reject) => {
getNewToken().then((res) => {
isRefreshing = false
// 新增标记变量
isStartedRefresh = false
// 刷新 token 成功,执行回调
if (res) {
callback(resolve, reject)
// 执行等待队列中的请求
queue.forEach((fn) => fn())
queue = []
return
}
// 刷新 token 失败,抛出错误
queue = []
reject(new AxiosError('Refresh token failed'))
})
})
}
然后修改请求拦截器的代码为:
instance.interceptors.request.use(
(config) => {
// 获取 token 并设置请求头
const token = localStorage.getItem('token')
config.headers.Authorization = `Bearer ${token}`
// 拦截请求
if (isRefreshing && isStartedRefresh) {
return Promise.reject(new AxiosError('Refreshing token', 'ERR_REFRESHING', config))
}
// 发送刷新 token 请求时,标记已经开始刷新 token
if (isRefreshing) {
isStartedRefresh = true
}
return config
},
(error: AxiosError) => Promise.reject(error)
)
至此,我们就完成无感刷新 token 功能的开发啦~
4. 无感刷新插件
直接在 axios 的请求/响应拦截器中写,不是不行,只是其他业务代码多了后,耦合在一起,不好维护,也不好扩展。
最好还是将相关代码抽离出来,封装成一个工具类,提供相应的请求/响应拦截器方法,使用时直接注册到 axios 的请求/响应拦截器中即可。
完整工具类代码如下:
import type {
AxiosError,
AxiosRequestConfig,
AxiosResponse,
InternalAxiosRequestConfig
} from 'axios'
type IRequest = (config: AxiosRequestConfig) => Promise<AxiosResponse>
type IRefreshTokenFn = () => Promise<boolean>
type IsRefresh = (error?: AxiosError, res?: AxiosResponse) => boolean
interface IError extends AxiosError {
config: InternalAxiosRequestConfig
}
export class AxiosRefreshTokenPlugin {
static CODE = 'ERR_REFRESH'
static CODE_FAILED = 'ERR_REFRESH_FAILED'
private isRefreshing = false
private isStartedRefresh = false
pendingQueue: Function[]
request: IRequest
refreshTokenFn: IRefreshTokenFn
constructor(refreshTokenFn: IRefreshTokenFn, request: IRequest, isRefresh?: IsRefresh) {
this.pendingQueue = []
this.request = request
this.refreshTokenFn = refreshTokenFn
// 如果传入了自定义判断是否刷新方法,则使用传入方法
if (isRefresh) {
this.isRefresh = isRefresh
}
}
// 创建错误对象,为了不依赖 axios 的错误对象
static createError(message: string, code: string, config: InternalAxiosRequestConfig) {
const error = new Error(message) as IError
error.code = code
error.config = config
error.isAxiosError = false
error.toJSON = () => ({})
error.name = 'RefreshTokenPluginError'
return error
}
// 默认判断是否需要刷新 token 的方法
isRefresh(error: AxiosError): boolean
isRefresh(error: undefined, res: AxiosResponse): boolean
isRefresh(error?: AxiosError, res?: AxiosResponse) {
if (error) {
return error.response?.status === 401
}
if (res) {
return res.data?.code === 401
}
return false
}
addPending(config: InternalAxiosRequestConfig): Promise<AxiosResponse> {
return new Promise((resolve, reject) => {
// 超时逻辑
const delay = config.timeout
let timer: NodeJS.Timeout | null = null
if (delay) {
timer = setTimeout(() => {
const error = AxiosRefreshTokenPlugin.createError(
'Request timeout',
'ERR_CANCELED',
config
)
reject(error)
}, delay)
}
// 将需要重新请求结果的请求放入等待队列中
const callback = () => {
timer && clearTimeout(timer)
this.request(config).then(resolve).catch(reject)
}
this.pendingQueue.push(callback)
})
}
refresh(error: AxiosError): Promise<AxiosResponse> {
// 标记刷新 token 中
this.isRefreshing = true
return new Promise((resolve, reject) => {
this.refreshTokenFn().then((res) => {
// 标记刷新 token 完成
this.isRefreshing = false
this.isStartedRefresh = false
// 刷新 token 失败,返回错误并清空等待队列
if (!res) {
this.pendingQueue = []
error.message = 'Refresh token failed'
error.code = AxiosRefreshTokenPlugin.CODE_FAILED
reject(error)
return
}
// 将当前触发刷新 token 的请求也添加到等待队列中
this.addPending(error.config!).then(resolve).catch(reject)
// 执行等待队列中的请求方法
this.pendingQueue.forEach((fn) => {
fn()
})
this.pendingQueue = []
})
})
}
requestInterceptor(config: InternalAxiosRequestConfig) {
// 拦截在刷新期间发送的请求
if (this.isRefreshing && this.isStartedRefresh) {
const error = AxiosRefreshTokenPlugin.createError(
'Refreshing token',
AxiosRefreshTokenPlugin.CODE,
config
)
return Promise.reject(error)
}
// 标记开始了刷新 token 请求,后续在刷新期间发送的请求就会走上面那个 if 的逻辑
if (this.isRefreshing) {
this.isStartedRefresh = true
}
return config
}
responseInterceptorFulfilled(response: AxiosResponse) {
if (this.isRefresh(undefined, response) && !this.isRefreshing) {
const error = AxiosRefreshTokenPlugin.createError(
'Unauthorized',
'ERR_UNAUTHORIZED',
response.config
)
return this.refresh(error)
}
return response
}
responseInterceptorRejected(error: AxiosError) {
// 将拦截的请求放入等待队列中,等待成功刷新 token 后,再重新发起请求并返回结果
if (error.code === AxiosRefreshTokenPlugin.CODE) {
return this.addPending(error.config!)
}
if (this.isRefresh(error) && !this.isRefreshing) {
return this.refresh(error)
}
return Promise.reject(error)
}
}
export default function createRefreshTokenPlugin(
refreshToken: IRefreshTokenFn,
request: IRequest,
isRefresh?: IsRefresh
) {
const instance = new AxiosRefreshTokenPlugin(refreshToken, request, isRefresh)
return {
requestInterceptor: instance.requestInterceptor.bind(instance),
responseInterceptorFulfilled: instance.responseInterceptorFulfilled.bind(instance),
responseInterceptorRejected: instance.responseInterceptorRejected.bind(instance)
}
}
为什么使用 bind 绑定 this,因为注册请求/响应拦截器时,axios 会修改 this 指向。
使用示例:
import axios from 'axios'
import createRefreshTokenPlugin from '..'
const instance = axios.create()
const refreshTokenPlugin = createRefreshTokenPlugin(async () => {
// 调用刷新token接口
const res = await refreshToken()
if (失败) {
return false
}
// todo: 保存新token的逻辑...
return true
}, instance.request)
instance.interceptors.request.use(refreshTokenPlugin.requestInterceptor)
// 响应拦截器,先进先出(axios 的规则)
instance.interceptors.response.use(refreshTokenPlugin.responseInterceptorFulfilled, refreshTokenPlugin.responseInterceptorRejected)
// 请求拦截器,后进先出(axios 的规则)
instance.interceptors.request.use(
(config) => {
// 自行添加,token携带相关逻辑
const token = getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
)
js 版本
推荐使用 TS 版本,有更好的类型提示。
export class AxiosRefreshTokenPlugin {
static CODE = 'ERR_REFRESH'
static CODE_FAILED = 'ERR_REFRESH_FAILED'
isRefreshing = false
isStartedRefresh = false
/**
*
* @param {() => Promise<boolean>} refreshTokenFn
* @param {(config: AxiosRequestConfig) => Promise<AxiosResponse>} request
* @param {(error?: AxiosError, res?: AxiosResponse) => boolean} [isRefresh]
*/
constructor(refreshTokenFn, request, isRefresh) {
this.pendingQueue = []
this.request = request
this.refreshTokenFn = refreshTokenFn
if (isRefresh) {
this.isRefresh = isRefresh
}
}
/**
*
* @param {string} message
* @param {string} code
* @param {InternalAxiosRequestConfig} config
* @returns
*/
static createError(message, code, config) {
const error = new Error(message)
error.code = code
error.config = config
error.isAxiosError = false
error.toJSON = () => ({})
error.name = 'RefreshTokenPluginError'
return error
}
/**
*
* @param {AxiosError} [error]
* @param {AxiosResponse} [res]
* @returns {boolean}
*/
isRefresh(error, res) {
if (error) {
return error.response?.status === 401
}
if (res) {
return res.data?.code === 401
}
return false
}
/**
*
* @param {InternalAxiosRequestConfig} config
* @returns {Promise<AxiosResponse>}
*/
addPending(config) {
return new Promise((resolve, reject) => {
const delay = config.timeout
let timer = null
if (delay) {
timer = setTimeout(() => {
const error = AxiosRefreshTokenPlugin.createError(
'Request timeout',
'ERR_CANCELED',
config
)
reject(error)
}, delay)
}
const callback = () => {
timer && clearTimeout(timer)
this.request(config).then(resolve).catch(reject)
}
this.pendingQueue.push(callback)
})
}
/**
*
* @param {AxiosError} error
* @returns {Promise<AxiosResponse>}
*/
refresh(error) {
this.isRefreshing = true
return new Promise((resolve, reject) => {
this.refreshTokenFn().then((res) => {
this.isRefreshing = false
this.isStartedRefresh = false
if (!res) {
this.pendingQueue = []
error.message = 'Refresh token failed'
error.code = AxiosRefreshTokenPlugin.CODE_FAILED
reject(error)
return
}
this.addPending(error.config).then(resolve).catch(reject)
this.pendingQueue.forEach((fn) => {
fn()
})
this.pendingQueue = []
})
})
}
/**
*
* @param {InternalAxiosRequestConfig} config
* @returns
*/
requestInterceptor(config) {
if (this.isRefreshing && this.isStartedRefresh) {
const error = AxiosRefreshTokenPlugin.createError(
'Refreshing token',
AxiosRefreshTokenPlugin.CODE,
config
)
return Promise.reject(error)
}
if (this.isRefreshing) {
this.isStartedRefresh = true
}
return config
}
/**
*
* @param {AxiosResponse} response
* @returns
*/
responseInterceptorFulfilled(response) {
if (this.isRefresh(undefined, response) && !this.isRefreshing) {
const error = AxiosRefreshTokenPlugin.createError(
'Unauthorized',
'ERR_UNAUTHORIZED',
response.config
)
return this.refresh(error)
}
return response
}
/**
*
* @param {AxiosError} error
* @returns
*/
responseInterceptorRejected(error) {
if (error.code === AxiosRefreshTokenPlugin.CODE) {
return this.addPending(error.config)
}
if (this.isRefresh(error) && !this.isRefreshing) {
return this.refresh(error)
}
return Promise.reject(error)
}
}
/**
*
* @param {() => Promise<boolean>} refreshTokenFn
* @param {(config: AxiosRequestConfig) => Promise<AxiosResponse>} request
* @param {(error?: AxiosError, res?: AxiosResponse) => boolean} [isRefresh]
*/
export default function createRefreshTokenPlugin(refreshToken, request, isRefresh) {
const instance = new AxiosRefreshTokenPlugin(refreshToken, request, isRefresh)
return {
requestInterceptor: instance.requestInterceptor.bind(instance),
responseInterceptorFulfilled: instance.responseInterceptorFulfilled.bind(instance),
responseInterceptorRejected: instance.responseInterceptorRejected.bind(instance)
}
}
赶紧去试试吧~