需求
当前前后端普遍使用token进行鉴权,当token过期后,用户需要重新登录,输入用户名和密码以获取新的token。一般token过期时间设置很短,对于比较活跃的用户体验感非常差。那么如何解决这个问题呢?
这里引入两个新的名词access_token和refresh_token。access_token为授权令牌,用于验证用户身份,是在调用API时需要传入的header请求参数。当access_token过期后,需要刷新,但是每次刷新都需要填写用户名密码,非常繁琐。而refresh_token可以解决这个问题,顾名思义,refresh_token为刷新token,用来刷新access_token,无需用户进行附加操作。
- 当access_token过期,前端拿着refresh_token去获取新的access_token,再重新发起请求,此时需要做到用户无感知。
- 当用户同时发起多个请求时,第一个请求会去调用刷新token接口,当该接口还没返回时,其他的请求也去调用了刷新token接口,此时会产生多个请求,前端需要避免这种情况出现。
方案
利用axios的响应拦截器对返回的数据做处理。不需要解析access_token和refresh_token拿到各自的过期时间去做判断,但是需要多发送一次http请求。
实现
- @/utils/cookies
import Cookies from 'js-cookie'
export default Cookies
// Token
const tokenKey = 'access_token'
export const getToken = (): string | undefined => Cookies.get(tokenKey)
export const setToken = (token: string): unknown => Cookies.set(tokenKey, token)
export const removeToken = (): unknown => Cookies.remove(tokenKey)
// refreshToken
const refreshTokenKey = 'refresh_token'
export const getRefreshToken = (): unknown => Cookies.get(refreshTokenKey)
export const setRefreshToken = (token: string): unknown => Cookies.set(refreshTokenKey, token)
export const removeRefreshToken = (): unknown => Cookies.remove(refreshTokenKey)
const usernameKey = 'username'
export const getUsername = (): unknown => Cookies.get(usernameKey)
export const setUsername = (token: string): unknown => Cookies.set(usernameKey, token)
export const removeUsername = (): unknown => Cookies.remove(usernameKey)
export const clearToken = (): void => {
removeToken()
removeRefreshToken()
removeUsername()
}
- @/utils/request.js
/** 创建axios实例 */
const service = axios.create({
baseURL: settings.apiBaseUrl,
timeout: 5 * 3600 * 1000,
})
/** 响应拦截器拦截器 */
service.interceptors.response.use(
(response: BaseAxiosResponse) => {
const { config, data } = response
if (data.code === 0) {
return Promise.resolve(response)
} else if (data.code === 400005) {
// token 过期处理逻辑
} else {
return Promise.reject(response?.data)
}
},
error => {
return Promise.reject(error)
}
)
响应拦截器改造。当返回的code值为400005时,表示access_token无效。此时调用刷新接口重新获取access_token,并重新发起原请求。
/** 创建axios实例 */
const service = axios.create({
baseURL: settings.apiBaseUrl,
timeout: 5 * 3600 * 1000,
})
/** 响应拦截器拦截器 */
service.interceptors.response.use(
(response: BaseAxiosResponse) => {
const { config, data } = response
if (data.code === 0) {
return Promise.resolve(response)
} else if (data.code === 400005) {
// token 过期处理逻辑
const token = getRefreshToken()
return refreshToken({ oldRefreshToken: token })
.then(res => {
const { accessToken, refreshToken } = res.data
setToken(accessToken)
setRefreshToken(refreshToken)
config.headers.Authorization = accessToken
return service(config)
})
.catch(err => {
console.log('抱歉,您的登录状态已失效,请重新登录!')
return Promise.reject(err)
})
} else {
// refreshToken 失效
if (config.url.includes(REFRESH_URL)) {
clearToken()
router.push('/login').catch(err => {
console.log(err)
})
}
return Promise.reject(response?.data)
}
},
error => {
return Promise.reject(error)
}
)
当用户同时发起多个请求时,可能存在多次调用刷新token的接口,因此需要定义一个标记来判断当前是否处于刷新状态,如果处于刷新状态,则禁止其他请求调用刷新接口。
/** 创建axios实例 */
const service = axios.create({
baseURL: settings.apiBaseUrl,
timeout: 5 * 3600 * 1000,
})
let isRefreshing = false // 标记是否正在刷新 token
/** 响应拦截器拦截器 */
service.interceptors.response.use(
(response: BaseAxiosResponse) => {
const { config, data } = response
if (data.code === 0) {
return Promise.resolve(response)
} else if (data.code === 400005) {
// token 过期处理逻辑
if (!isRefreshing) {
isRefreshing = true
const token = getRefreshToken()
return refreshToken({ oldRefreshToken: token })
.then(res => {
const { accessToken, refreshToken } = res.data
setToken(accessToken)
setRefreshToken(refreshToken)
config.headers.Authorization = accessToken
return service(config)
})
.catch(err => {
router.push('/login').catch(err => {
console.log(err)
})
return Promise.reject(err)
})
.finally(() => {
isRefreshing = false
})
} else {
// refreshToken 失效
if (config.url.includes(REFRESH_URL)) {
clearToken()
router.push('/login').catch(err => {
console.log(err)
})
}
return Promise.reject(response?.data)
}
},
error => {
return Promise.reject(error)
}
)
上述同时发起多个请求的方法可以进一步优化。
当发起多个请求,第一个请求进入刷新token的流程,需要将其他请求挂起,当token更新之后在重新发起请求。这里定义一个requests数组,暂存挂起的请求。
/** 创建axios实例 */
const service = axios.create({
baseURL: settings.apiBaseUrl,
timeout: 5 * 3600 * 1000,
})
let isRefreshing = false // 标记是否正在刷新 token
const requests = [] // 存储待重发请求的数组
/** 响应拦截器拦截器 */
service.interceptors.response.use(
(response: BaseAxiosResponse) => {
const { config, data } = response
if (data.code === 0) {
return Promise.resolve(response)
} else if (data.code === 400005) {
// token 过期处理逻辑
if (!isRefreshing) {
isRefreshing = true
const token = getRefreshToken()
return refreshToken({ oldRefreshToken: token })
.then(res => {
const { accessToken, refreshToken } = res.data
setToken(accessToken)
setRefreshToken(refreshToken)
config.headers.Authorization = accessToken
// token 刷新后将数组的方法重新执行
requests.forEach(cb => cb(accessToken))
return service(config)
})
.catch(err => {
router.push('/login').catch(err => {
console.log(err)
})
return Promise.reject(err)
})
.finally(() => {
isRefreshing = false
})
}else {
// 返回未执行 resolve 的 Promise
return new Promise(resolve => {
// 用函数形式将 resolve 存入,刷新token之后回调执行
requests.push(token => {
config.headers.Authorization = token
resolve(service(config))
})
})
}
} else {
// refreshToken 失效
if (config.url.includes(REFRESH_URL)) {
clearToken()
router.push('/login').catch(err => {
console.log(err)
})
}
return Promise.reject(response?.data)
}
},
error => {
return Promise.reject(error)
}
)