为什么要用双Token无感刷新,它解决了什么问题?
为了保证安全性,后端设置的Token不可能长期有效,过了一段时间Token就会失效。而发送网络请求的过程又是需要携带Token的,一旦Token失效,用户就要重新登陆,这样用户可能需要频繁登录,体验不好。为了解决这个问题,采取双Token(Access_Token,Refresh_Token)无感刷新,用户完全体会不到Token的变化,但实际上,Token已经刷新了。
如何实现token的无感刷新。
在单点登录的环境下,服务器会给用户一个短时token,而另一个长时刷新token用于换取短时token。通过拦截器和配置更改,可以实现自动刷新token的功能。手动刷新token可以通过修改请求头中的token实现。同时,提到了一种通过excel创建基地址并使用拦截器来实现自动刷新token的方案。
单点登录的模式,并以C型加cookie模式为例详细讲解了用户如何完成登录流程。C型加cookie模式具有很强的控制力,但存在着烧钱、认证中心压力大等问题。为了降低成本和减轻认证中心的压力,出现了Token模式,用户登录后生成一个不能被篡改的字符串作为Token,而不再在服务器表格中记录任何东西。Token模式的优势在于成本低,但控制力较弱。
用户登录之后,会返回一个用户的标识,之后带上这个标识请求别的接口,就能识别出该用户。
标识登录状态的方案有两种:session 和 jwt
session 是通过 cookie 返回一个 id,关联服务端内存里保存的 session 对象,请求时服务端取出 cookie 里 id 对应的 session 对象,就可以拿到用户信息。
jwt 不在服务端存储,会直接把用户信息放到 token 里返回,每次请求带上这个 token,服务端就能从中取出用户信息。 token 一般是放在一个叫 authorization 的 header 里。
双 token 验证流程
- 用户登录向服务端发送账号密码,登录失败返回客户端重新登录。登录成功服务端生成 accessToken 和 refreshToken,返回生成的 token 给客户端。
- 在请求拦截器中,请求头中携带 accessToken 请求数据,服务端验证 accessToken 是否过期。token 有效继续请求数据,token 失效返回失效信息到客户端。
- 客户端收到服务端发送的请求信息,在二次封装的 axios 的响应拦截器中判断是否有 accessToken 失效的信息,没有返回响应的数据。有失效的信息,就携带 refreshToken 请求新的 accessToken。
- 服务端验证 refreshToken 是否有效。有效,重新生成 token, 返回新的 token 和提示信息到客户端,无效,返回无效信息给客户端。
- 客户端响应拦截器判断响应信息是否有 refreshToken 有效无效。无效,退出当前登录。有效,重新存储新的 token,继续请求上一次请求的数据。
这两种方案一个服务端存储,通过 cookie 携带标识,一个在客户端存储,通过 header 携带标识。
session 的方案默认不支持分布式,因为是保存在一台服务器的内存的,另一台服务器没有
jwt 的方案天然支持分布式,因为信息保存在 token 里,只要从中取出来就行。
前端部分在axios封装时候加拦截器判断token是否过期,我这里跟别人写的最大的不同点是:我创建了两个axios对象,一个正常数据请求用(server),另一个专门刷新token用(serverRefreshToken),这样写的好处是省去了易错的判断逻辑
refreshToken.js
import request from './request';
import { getRefreshToken } from './token';
// 定义一个全局或模块级的变量来存储当前的刷新token请求的Promise
let promise = null;
// 异步函数,用于刷新token
async function refreshToken() {
// 如果已经有一个刷新token的请求正在进行,直接返回该Promise
if (promise) {
return promise;
}
// 创建一个新的Promise
promise = new Promise((resolve, reject) => {
// 打印日志,表示正在刷新token
console.log('正在刷新token');
// 发起GET请求到'/refresh_token'路径,并携带Authorization头部
request.get('/refresh_token', {
headers: {
// 使用getRefreshToken函数获取的当前刷新token
Authorization: `Bearer ${getRefreshToken()}`,
},
// 假设这不是一个标准的请求选项,如果确实需要这样的属性,请确保你的请求库支持它
// 否则,请移除或替换为正确的请求选项
// __isRefreshToken: true, // 不规范的属性,通常不建议在请求配置中使用双下划线开头的属性
})
.then((resp) => {
// 如果响应的code为0,表示请求成功,resolve Promise并返回true
if (resp.code === 0) {
resolve(true);
} else {
// 如果code不为0,可能表示请求失败或token无效,resolve Promise并返回false
resolve(false);
}
})
.catch((error) => {
// 如果请求发生错误,reject Promise并传递错误对象
reject(error);
});
});
// 无论Promise是resolve还是reject,都会执行finally块中的代码
promise.finally(() => {
// 将promise设置为null,表示刷新token的请求已经完成
promise = null;
});
// 返回Promise,允许调用者使用await等待其完成
return promise;
}
request.js
import axios from 'axios'
import { refreshToken, isRefreshRequest } form './refreshToken.js'
// 创建axios实例
const service = axios.create({
baseURL: '',// 所有的请求地址前缀部分
timeout: 25000, // 请求超时时间(毫秒)
withCredentials: true// 异步请求携带cookie
})
// 请求拦截器
service.interceptors.request.use((config: any) => {
...
}, error => {
...
})
// 响应拦截器
service.interceptors.response.use((response: any) => {
let res = response.data
if (res.code == '401' && !isRefreshRequest(res.config)){ // 如果没有权限且不是刷新token的请求
// 刷新token
try {
const res = await refreshToken()
// 保存新的token
localStorage.setItem('token', res.data.token)
// 有新token后再重新请求
response.config.headers.token = localStorage.getItem('token') // 新token
const resp = await service.request(response.config)
return resp.data
// return service(response.config)
}catch {
localStorage.clear() // 清除token
router.replace('/login') // 跳转到登录页
}
}
}, error => {
...
console.log('error', error)
return Promise.reject(error)
})
问题一:如何防止多次刷新token
为了防止多次刷新token,可以通过一个变量isRefreshing 去控制是否在刷新token的状态
import axios from 'axios'
import { refreshToken, isRefreshRequest } form './refreshToken.js'
// 创建axios实例
const service = axios.create({
baseURL: '',// 所有的请求地址前缀部分
timeout: 25000, // 请求超时时间(毫秒)
withCredentials: true// 异步请求携带cookie
})
// 请求拦截器
service.interceptors.request.use((config: any) => {
...
}, error => {
...
})
// 响应拦截器
service.interceptors.response.use((response: any) => {
let res = response.data
let isRefreshing = false
if (res.code == '401' && ! isRefreshRequest(res.config)){ // 如果没有权限且不是刷新token的请求
if (!isRefreshing) {
isRefreshing = true
// 刷新token
try {
const res = await refreshToken()
// 保存新的token
localStorage.setItem('token', res.data.token)
// 有新token后再重新请求
response.config.headers.token = localStorage.getItem('token') // 新token
const resp = await service.request(response.config)
return resp.data
// return service(response.config)
}catch {
localStorage.clear() // 清除token
router.replace('/login') // 跳转到登录页
}
isRefreshing = false
}
}
}, error => {
...
console.log('error', error)
return Promise.reject(error)
})
问题二:同时发起两个或者两个以上的请求时,怎么刷新token
当第二个过期的请求进来,token正在刷新,我们先将这个请求存到一个数组队列中,想办法让这个请求处于等待中,一直等到刷新token后再逐个重试清空请求队列。
那么如何做到让这个请求处于等待中呢?
为了解决这个问题,我们得借助Promise。将请求存进队列中后,同时返回一个Promise,让这个Promise一直处于Pending状态(即不调用resolve),此时这个请求就会一直等啊等,只要我们不执行resolve,这个请求就会一直在等待。当刷新请求的接口返回来后,我们再调用resolve,逐个重试。
import axios from 'axios'
import { refreshToken, isRefreshRequest } form './refreshToken.js'
// 创建axios实例
const service = axios.create({
baseURL: '',// 所有的请求地址前缀部分
timeout: 25000, // 请求超时时间(毫秒)
withCredentials: true// 异步请求携带cookie
})
// 请求拦截器
service.interceptors.request.use((config: any) => {
...
}, error => {
...
})
// 响应拦截器
service.interceptors.response.use((response: any) => {
let res = response.data
let isRefreshing = false
let requests = [] // 请求队列
if (res.code == '401' && isRefreshRequest(res.config)){ // 如果没有权限且不是刷新token的请求
if (!isRefreshing) {
isRefreshing = true
// 刷新token
try {
const res = await refreshToken()
// 保存新的token
localStorage.setItem('token', res.data.token)
// 有新token后再重新请求
response.config.headers.token = localStorage.getItem('token') // 新token
// token 刷新后将数组的方法重新执行
requests.forEach((cb) => cb(token))
requests = [] // 重新请求完清空
const resp = await service.request(response.config)
return resp.data
// return service(response.config)
}catch {
localStorage.clear() // 清除token
router.replace('/login') // 跳转到登录页
}
isRefreshing = false
} else {
// 返回未执行 resolve 的 Promise
return new Promise(resolve => {
// 用函数形式将 resolve 存入,等待刷新后再执行
request.push(token => {
response.config.headers.token = `${token}`
resolve(service(response.config))
})
})
}
}
}, error => {
...
console.log('error', error)
return Promise.reject(error)
})