前言

在公司的微服务项目 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
image.png
基本上满足 Instant.now(this.clock).minus(this.clockSkew).isAfter(expiry) 就说明JWT过期了
now – 60s > expiry =转换=> now > expiry + 60s

这里把测试根据时间分为3个阶段:

  1. 0~61s:双token都没过期,正常请求过程。
  2. 61s~120s:access_token过期,再次请求会执行一次刷新请求。
  3. 120s+: refresh_token过期,神仙都救不了,重新登录。

**

2. 添加刷新令牌方法

设置了支持客户端刷新模式之后,在前端添加一个refreshToken方法,调用的接口和登录认证是同一个接口/oauth/token,只是参数授权方式grant_type的值由password切换到refresh_token,即密码模式切换到刷新模式,这个方法作用是在刷新token之后将新的token写入到localStorage覆盖旧的token。

令牌存储文件:auth.js

  1. // /utils/auth.js
  2. import storage from 'store'
  3. const tokenKey = 'LVJI_TOKEN'
  4. const accessTokenKey = 'LVJI_ACCESS_TOKEN'
  5. const refreshTokenKey = 'LVJI_REFRESH_TOKEN'
  6. export function setToken(token) {
  7. const { accessToken, refreshToken} = token
  8. return storage.set(tokenKey, {accessTokenKey:accessToken,refreshTokenKey:refreshToken})
  9. }
  10. export function getToken() {
  11. return storage.get(tokenKey)
  12. }
  13. export function removeToken() {
  14. return storage.remove(tokenKey)
  15. }
  16. export {
  17. accessTokenKey,
  18. refreshTokenKey
  19. }

用户数据存储模块:user.js

  1. // /src/store/modules/user.js
  2. import {login, logout, getInfo} from '@/api/user'
  3. import {getToken, setToken, removeToken} from '@/utils/auth'
  4. import router, {resetRouter} from '@/router'
  5. const state = {
  6. token: getToken(),
  7. name: '',
  8. avatar: '',
  9. introduction: '',
  10. roles: []
  11. }
  12. const mutations = {
  13. SET_TOKEN: (state, token) => {
  14. state.token = token
  15. },
  16. SET_INTRODUCTION: (state, introduction) => {
  17. state.introduction = introduction
  18. },
  19. SET_NAME: (state, name) => {
  20. state.name = name
  21. },
  22. SET_AVATAR: (state, avatar) => {
  23. state.avatar = avatar
  24. },
  25. SET_ROLES: (state, roles) => {
  26. state.roles = roles
  27. }
  28. }
  29. const actions = {
  30. // 登录
  31. login({commit}, userInfo) {
  32. const {username, password} = userInfo
  33. return new Promise((resolve, reject) => {
  34. login({
  35. username: username,
  36. password: password,
  37. grant_type: 'password',
  38. client_id: 'lvji-admin',
  39. client_secret: '123456'
  40. }).then(response => {
  41. const {accessToken, refreshToken} = response.data
  42. commit('SET_TOKEN', token)
  43. setToken({accessToken,refreshToken})
  44. resolve()
  45. }).catch(error => {
  46. reject(error)
  47. })
  48. })
  49. },
  50. // 刷新token
  51. refreshToken({commit}, refreshToken) {
  52. commit('SET_TOKEN', undefined)
  53. return new Promise((resolve, reject) => {
  54. login({
  55. grant_type: 'refresh_token',
  56. refresh_token: refreshToken,
  57. client_id: 'admin',
  58. client_secret: '123456'
  59. }).then(response => {
  60. const {accessToken, refreshToken} = response.data
  61. commit('SET_TOKEN', token)
  62. setToken({accessToken,refreshToken})
  63. resolve(token)
  64. }).catch(error => {
  65. reject(error)
  66. })
  67. })
  68. },
  69. // 获取用户信息
  70. getInfo({commit, state}) {
  71. return new Promise((resolve, reject) => {
  72. getInfo(state.token).then(response => {
  73. const {data} = response
  74. if (!data) {
  75. reject('Verification failed, please Login again.')
  76. }
  77. const {roles, name, avatar, introduction} = data
  78. // roles must be a non-empty array
  79. if (!roles || roles.length <= 0) {
  80. reject('getInfo: roles must be a non-null array!')
  81. }
  82. commit('SET_ROLES', roles)
  83. commit('SET_NAME', name)
  84. commit('SET_AVATAR', avatar)
  85. commit('SET_INTRODUCTION', introduction)
  86. resolve(data)
  87. }).catch(error => {
  88. reject(error)
  89. })
  90. })
  91. },
  92. // 用户登出-跳转
  93. logout({commit, state, dispatch}) {
  94. return new Promise((resolve, reject) => {
  95. logout(state.token).then(() => {
  96. commit('SET_TOKEN', '')
  97. commit('SET_ROLES', [])
  98. removeToken()
  99. resetRouter()
  100. // reset visited views and cached views
  101. // to fixed https://github.com/PanJiaChen/vue-element-admin/issues/2485
  102. dispatch('tagsView/delAllViews', null, {root: true})
  103. resolve()
  104. }).catch(error => {
  105. reject(error)
  106. })
  107. })
  108. },
  109. // 清除token
  110. resetToken({commit}) {
  111. return new Promise(resolve => {
  112. commit('SET_TOKEN', '')
  113. commit('SET_ROLES', [])
  114. removeToken()
  115. resolve()
  116. })
  117. }
  118. }
  119. export default {
  120. namespaced: true,
  121. state,
  122. mutations,
  123. actions
  124. }

请求库:request.js

  1. // /utils/request.js
  2. import axios from 'axios'
  3. import {MessageBox, Message} from 'element-ui'
  4. import store from '@/store'
  5. import {getToken, accessTokenKey, refreshTokenKey} from '@/utils/auth'
  6. //axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8'
  7. const service = axios.create({
  8. baseURL: process.env.VUE_APP_BASE_API,
  9. timeout: 50000
  10. })
  11. service.interceptors.request.use(
  12. config => {
  13. if (store.getters.token) {
  14. config.headers['Authorization'] = 'Bearer ' + getToken().accessTokenKey
  15. }
  16. return config
  17. },
  18. error => {
  19. return Promise.reject(error)
  20. }
  21. )
  22. let refreshing = false,// 正在刷新标识,避免重复刷新
  23. waitQueue = [] // 请求等待队列
  24. service.interceptors.response.use(
  25. response => {
  26. const {code, msg, data} = response.data
  27. if (code !== '00000') {
  28. if (code === 'A0230') { // access_token过期 使用refresh_token换取access_token
  29. const config = response.config
  30. if (refreshing == false) {
  31. refreshing = true
  32. const refreshToken = getToken().refreshTokenKey
  33. return store.dispatch('user/refreshToken', refreshToken).then((token) => {
  34. config.headers['Authorization'] = 'Bearer ' + token
  35. config.baseURL = '' // 请求重试时,url已包含baseURL
  36. waitQueue.forEach(callback => callback(token)) // 已刷新token,所有队列中的请求重试
  37. waitQueue = []
  38. return service(config)
  39. }).catch(() => { // refresh_token也过期,直接跳转登录页面重新登录
  40. MessageBox.confirm('当前页面已失效,请重新登录', '确认退出', {
  41. confirmButtonText: '重新登录',
  42. cancelButtonText: '取消',
  43. type: 'warning'
  44. }).then(() => {
  45. store.dispatch('user/resetToken').then(() => {
  46. location.reload()
  47. })
  48. })
  49. }).finally(() => {
  50. refreshing = false
  51. })
  52. } else {
  53. // 正在刷新token,返回未执行resolve的Promise,刷新token执行回调
  54. return new Promise((resolve => {
  55. waitQueue.push((token) => {
  56. config.headers['Authorization'] = 'Bearer ' + token
  57. config.baseURL = '' // 请求重试时,url已包含baseURL
  58. resolve(service(config))
  59. })
  60. }))
  61. }
  62. } else {
  63. Message({
  64. message: msg || '系统出错',
  65. type: 'error',
  66. duration: 5 * 1000
  67. })
  68. }
  69. }
  70. return {code, msg, data}
  71. },
  72. error => {
  73. return Promise.reject(error)
  74. }
  75. )
  76. export default service