现状
项目采用前后端分离开发,前后端使用access_token(即token)进行交互认证,但access_token有一个有效期,在access_token过期后,请求接口将无法成功,现在的处理方式是直接退出跳转至登录入口要求重新登录,但这种方式体验非常不友好,如果当前用户正在录入大量数据时token已经失效,提交数据时直接就退出了,从产品及交互上这种方式是不允许的。
分析
后端采用 IdentityService4 构建认证与授权,在登录成功后除返回access_token之外,增加了expires_in、refresh_token。并增加交换token接口。
expires_in:access_token的过期时间。
refresh_token:刷新token。access_token过期后可以使用refresh_token交换新的access_token。一个refresh_token只能使用一次。
交换token接口:使用refresh_token交换access_token,得到新的access_token、新的expires_in、新的refresh_token。
那么前端刷新token即可有两种方式
1、在request请求之前进行拦截,根据expires_in计算出当前token是否过期,若已过期,则将请求挂起,先调用交换token接口,得到新的access_token后再继续请求。
这里本项目放弃此方式。
2、后端接口在检查到access_token过期后,返回状态码40001(前后端约定值),那么在response中进行拦截,当返回状态码为40001时,调用交换token接口,得到新的access_token后再将原请求重发。
实现
对axios进行封装
import axios from 'axios';
import router from '@/router'
import Vue from 'vue'
import { Loading } from 'element-ui';
import qs from 'qs';
let host = window.g.ApiUrl
let loadingInstance; //loading 实例
let needLoadingRequestCount = 0; //当前正在请求的数量
//是否有请求正在刷新token
let isRefreshing = false
// 重试请求队列 每一项都是一个待执行待函数
let requests= [];
//Loading 封装
/*
* 打开全页loading
* this.$showLoading()
* */
Vue.prototype.$showLoading = function(text='加载中...'){
if (needLoadingRequestCount == 0) {
loadingInstance = Loading.service({text: text});
}
needLoadingRequestCount++;
};
/*
* 关闭全页loading
* this.$closeLoading()
* */
Vue.prototype.$closeLoading = function(type=0){
needLoadingRequestCount--;
if(type == 1){
loadingInstance.close();
return false;
}
if (needLoadingRequestCount <= 0) {
loadingInstance.close();
}
}
/**
* 刷新token
*/
function refreshToken (response,instance) {
const refreshtoken = sessionStorage.getItem('refresh_token');
// 判断 没有refresh_token的处理
if (!refreshtoken) {
sessionStorage.removeItem('access_token')
sessionStorage.removeItem('sso_token')
sessionStorage.removeItem('expires_in')
sessionStorage.removeItem('refresh_token')
window.location.href = window.g.mainSiteUrl;//返回登陆
}
let param = {
client_id: window.g.client_id,
client_secret: window.g.client_secret,
grant_type: 'refresh_token',
refresh_token: refreshtoken
};
// instance是当前已创建的axios实例
return instance.post('/connect/token',qs.stringify(param)).then(res => {
//业务系统token
sessionStorage.setItem('access_token', res.access_token);
//业务系统token过期时间
sessionStorage.setItem('expires_in',res.expires_in);
//业务系统refresh_token
sessionStorage.setItem('refresh_token', res.refresh_token);
// 重新请求接口 前过期的接口
response.config.headers.Authorization ="Bearer "+ sessionStorage.getItem('access_token');
// 已经刷新了token,将所有队列中的请求进行重试,最后再清空队列
requests.forEach(cb => cb( res.access_token))
requests = []
return instance(response.config)
}).catch(res => {
sessionStorage.removeItem('access_token')
sessionStorage.removeItem('sso_token')
sessionStorage.removeItem('expires_in')
sessionStorage.removeItem('refresh_token')
//返回登陆
window.location.href = window.g.mainSiteUrl;
}).finally(() => {
isRefreshing = false
})
}
export default function $axios(options) {
return new Promise((resolve, reject) => {
const instance = axios.create({
baseURL: host,
isEditContentType:true,
isUpload:false
})
// request 拦截器
instance.interceptors.request.use(
config => {
Vue.prototype.$showLoading();
if(config.url!='/connect/token' && config.isEditContentType){
config.headers["Content-Type"]='application/json;charset=UTF-8'
}else if(config.url==='/connect/token'){
config.headers["Content-Type"]='application/x-www-form-urlencoded'
}
let token = sessionStorage.getItem('access_token')
if (token && !config.isUpload) {
config.headers.Authorization = "Bearer "+token
}
// 根据请求方法,序列化传来的参数,根据后端需求是否序列化
if (config.method === 'post') {
// if (config.data.__proto__ === FormData.prototype
// || config.url.endsWith('path')
// || config.url.endsWith('mark')
// || config.url.endsWith('patchs')
// ) {
// } else {
//config.data = JSON.stringify(config.data)
// }
}else if (config.method === 'get') { //get请求增加时间戳
let url = config.url;
url.indexOf('?') === -1 ? config.url = url+'?_='+(new Date().getTime()) : config.url = url+'&_='+(new Date().getTime());
}
return config
},
error => {
Vue.prototype.$closeLoading();
// 请求错误时
// 1. 判断请求超时
if (error.code === 'ECONNABORTED' && error.message.indexOf('timeout') !== -1) {
// return service.request(originalRequest);// 再重复请求一次
}
// 2. 需要重定向到错误页面
const errorInfo = error.response
if (errorInfo) {
error = errorInfo.data // 页面那边catch的时候就能拿到详细的错误信息,看最下边的Promise.reject
const errorStatus = errorInfo.status; // 404 403 500 ...
router.push({
path: `/error/${errorStatus}`
})
}
return Promise.reject(error) // 在调用的那边可以拿到(catch)你想返回的错误信息
}
)
// response 拦截器
instance.interceptors.response.use(response => {
Vue.prototype.$closeLoading();
const code = response.data.code
//接口返回token超时
if (code === "40001") {
var config = response.config;
//当前是否有已经在刷新token,防止多次请求刷新token
if (!isRefreshing) {
//没有则请求刷新token
isRefreshing = true
return refreshToken(response,instance)
} else {
// 正在刷新token,加入队列中,将返回一个未执行resolve的promise
return new Promise((resolve) => {
// 将resolve放进队列,用一个函数形式来保存,等token刷新后直接执行
requests.push((token) => {
config.headers.Authorization ="Bearer "+ token;
resolve(instance(config))
})
})
}
}
let data;
if (response.data == undefined) {
data = JSON.parse(response.request.responseText)
} else {
data = response.data
}
return data
}, error => {
return Promise.reject(error)
})
// 请求处理
instance(options).then(res => {
resolve(res)
return false
}).catch(error => {
reject(error)
})
})
}
注意:
1、在所有需要清除access_token的地方都同时需要将refresh_token一并清除,否则可能将通过refresh_token获取到新的token。
2、从安全考虑,access_token设置有效期时间较短。refresh_token有效期可设置为用户未操作而需要退出的时间。这样可以实现:当用户一直在线的情况下,token不会突然无故失效而退出。同时如果用户间隔一段时间未操作(大于refresh_token的有效期,因为refresh_token过期后获取新access_token将不会成功),那么再次操作将退出让其重新登录。
在项目实现过程中参考了以下博主的文章,博主写的非常详细。
https://segmentfault.com/a/1190000020210980?_ea=153309043
本文仅供自己开发过程留作笔记。