前言
在公司的微服务项目 OAuth2实现微服务的统一认证的背景下,前端调用/oauth/token接口认证,在认证成功会返回两个令牌access_token和refresh_token,出于安全考虑access_token时效相较refresh_token短很多(access_token默认12小时,refresh_token默认30天)。当access_token过期或者将要过期时,需要拿refresh_token去刷新获取新的access_token返回给客户端,但是为了客户良好的体验需要做到无感知刷新。
方案
方案一:
浏览器起一个定时轮询任务,每次在access_token过期之前刷新。
方案二:
请求时返回access_token过期的异常时,浏览器发出一次使用refresh_token换取access_token的请求,获取到新的access_token之后,重试因access_token过期而失败的请求。
方案比较:
第一种方案实现简单,但在access_token过期之前刷新,那些旧access_token依然能够有效访问,如果使用黑名单的方式限制这些就的access_token无疑是在浪费资源。
第二种方案是在access_token已经失效的情况下才去刷新便不会有上面的问题,但是它会多出来一次请求,而且实现起来考虑的问题相较下比较多,例如在token刷新阶段后面来的请求如何处理,等获取到新的access_token之后怎么重新重试这些请求。
总结:第一种方案实现简单;第二种方案更为严谨,过期续期不会造成已被刷掉的access_token还有效;总之两者都是可行方案,推荐第二种方案通过前后端的配合实现无感知刷新token实现JWT续期。
实现
我们是如何通过代码实现在access_token过期时使用refresh_token刷新续期,具体步骤如下:
1. 后端OAuth2设置
为了方便我们测试分别设置access_token和refresh_token的过期时间,因为默认的12小时和30天我们吃不消的;除此之外,还必须满足t(refresh_token) > 60s + t(access_token)的条件, refresh_token的时效大于access_token时效我们可以理解,那这个60s是怎么回事?我们设置access_token时效为1s、refresh_token为120s吧。这里access_token设置为1s,但是时效确是61s,分析如下:
**
1.为什么access_token比设定多了60s时效?
项目使用 nimbus-jose-jwt 这个JWT库,依赖 spring-security-oauth2-jose 这个jar包,找到JWT验证过期的方法JwtTimestampValidator#validate
基本上满足 Instant.now(this.clock).minus(this.clockSkew).isAfter(expiry)
就说明JWT过期了
now – 60s > expiry =转换=> now > expiry + 60s
这里把测试根据时间分为3个阶段:
- 0~61s:双token都没过期,正常请求过程。
- 61s~120s:access_token过期,再次请求会执行一次刷新请求。
- 120s+: refresh_token过期,神仙都救不了,重新登录。
2. 添加刷新令牌方法
设置了支持客户端刷新模式之后,在前端添加一个refreshToken方法,调用的接口和登录认证是同一个接口/oauth/token,只是参数授权方式grant_type的值由password切换到refresh_token,即密码模式切换到刷新模式,这个方法作用是在刷新token之后将新的token写入到localStorage覆盖旧的token。
令牌存储文件:auth.js
// /utils/auth.js
import storage from 'store'
const tokenKey = 'LVJI_TOKEN'
const accessTokenKey = 'LVJI_ACCESS_TOKEN'
const refreshTokenKey = 'LVJI_REFRESH_TOKEN'
export function setToken(token) {
const { accessToken, refreshToken} = token
return storage.set(tokenKey, {accessTokenKey:accessToken,refreshTokenKey:refreshToken})
}
export function getToken() {
return storage.get(tokenKey)
}
export function removeToken() {
return storage.remove(tokenKey)
}
export {
accessTokenKey,
refreshTokenKey
}
用户数据存储模块:user.js
// /src/store/modules/user.js
import {login, logout, getInfo} from '@/api/user'
import {getToken, setToken, removeToken} from '@/utils/auth'
import router, {resetRouter} from '@/router'
const state = {
token: getToken(),
name: '',
avatar: '',
introduction: '',
roles: []
}
const mutations = {
SET_TOKEN: (state, token) => {
state.token = token
},
SET_INTRODUCTION: (state, introduction) => {
state.introduction = introduction
},
SET_NAME: (state, name) => {
state.name = name
},
SET_AVATAR: (state, avatar) => {
state.avatar = avatar
},
SET_ROLES: (state, roles) => {
state.roles = roles
}
}
const actions = {
// 登录
login({commit}, userInfo) {
const {username, password} = userInfo
return new Promise((resolve, reject) => {
login({
username: username,
password: password,
grant_type: 'password',
client_id: 'lvji-admin',
client_secret: '123456'
}).then(response => {
const {accessToken, refreshToken} = response.data
commit('SET_TOKEN', token)
setToken({accessToken,refreshToken})
resolve()
}).catch(error => {
reject(error)
})
})
},
// 刷新token
refreshToken({commit}, refreshToken) {
commit('SET_TOKEN', undefined)
return new Promise((resolve, reject) => {
login({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: 'admin',
client_secret: '123456'
}).then(response => {
const {accessToken, refreshToken} = response.data
commit('SET_TOKEN', token)
setToken({accessToken,refreshToken})
resolve(token)
}).catch(error => {
reject(error)
})
})
},
// 获取用户信息
getInfo({commit, state}) {
return new Promise((resolve, reject) => {
getInfo(state.token).then(response => {
const {data} = response
if (!data) {
reject('Verification failed, please Login again.')
}
const {roles, name, avatar, introduction} = data
// roles must be a non-empty array
if (!roles || roles.length <= 0) {
reject('getInfo: roles must be a non-null array!')
}
commit('SET_ROLES', roles)
commit('SET_NAME', name)
commit('SET_AVATAR', avatar)
commit('SET_INTRODUCTION', introduction)
resolve(data)
}).catch(error => {
reject(error)
})
})
},
// 用户登出-跳转
logout({commit, state, dispatch}) {
return new Promise((resolve, reject) => {
logout(state.token).then(() => {
commit('SET_TOKEN', '')
commit('SET_ROLES', [])
removeToken()
resetRouter()
// reset visited views and cached views
// to fixed https://github.com/PanJiaChen/vue-element-admin/issues/2485
dispatch('tagsView/delAllViews', null, {root: true})
resolve()
}).catch(error => {
reject(error)
})
})
},
// 清除token
resetToken({commit}) {
return new Promise(resolve => {
commit('SET_TOKEN', '')
commit('SET_ROLES', [])
removeToken()
resolve()
})
}
}
export default {
namespaced: true,
state,
mutations,
actions
}
请求库:request.js
// /utils/request.js
import axios from 'axios'
import {MessageBox, Message} from 'element-ui'
import store from '@/store'
import {getToken, accessTokenKey, refreshTokenKey} from '@/utils/auth'
//axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8'
const service = axios.create({
baseURL: process.env.VUE_APP_BASE_API,
timeout: 50000
})
service.interceptors.request.use(
config => {
if (store.getters.token) {
config.headers['Authorization'] = 'Bearer ' + getToken().accessTokenKey
}
return config
},
error => {
return Promise.reject(error)
}
)
let refreshing = false,// 正在刷新标识,避免重复刷新
waitQueue = [] // 请求等待队列
service.interceptors.response.use(
response => {
const {code, msg, data} = response.data
if (code !== '00000') {
if (code === 'A0230') { // access_token过期 使用refresh_token换取access_token
const config = response.config
if (refreshing == false) {
refreshing = true
const refreshToken = getToken().refreshTokenKey
return store.dispatch('user/refreshToken', refreshToken).then((token) => {
config.headers['Authorization'] = 'Bearer ' + token
config.baseURL = '' // 请求重试时,url已包含baseURL
waitQueue.forEach(callback => callback(token)) // 已刷新token,所有队列中的请求重试
waitQueue = []
return service(config)
}).catch(() => { // refresh_token也过期,直接跳转登录页面重新登录
MessageBox.confirm('当前页面已失效,请重新登录', '确认退出', {
confirmButtonText: '重新登录',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
store.dispatch('user/resetToken').then(() => {
location.reload()
})
})
}).finally(() => {
refreshing = false
})
} else {
// 正在刷新token,返回未执行resolve的Promise,刷新token执行回调
return new Promise((resolve => {
waitQueue.push((token) => {
config.headers['Authorization'] = 'Bearer ' + token
config.baseURL = '' // 请求重试时,url已包含baseURL
resolve(service(config))
})
}))
}
} else {
Message({
message: msg || '系统出错',
type: 'error',
duration: 5 * 1000
})
}
}
return {code, msg, data}
},
error => {
return Promise.reject(error)
}
)
export default service