前言

本人在开发项目时,在做登录模块时,参考了oauth2,在用户认证成功后会返回给前端一些令牌相关数据。接下来,再用进行接口请求时,前端根据令牌数据进行一系列的判断,然后做出最好的选择。
举个例子:

  • 假如后端生成的令牌的有效期是10min,刷新令牌的有效期是1h。
  • 当用户在10min内没有操作页面,但用户在最后一次操作后的1h内进行操作页面,那么前端就会使用刷新令牌请求后端获取新的令牌,然后携带新的令牌继续请求(底层实现,用户无感知)
  • 当用户操作时,距上一次操作1h后,那么刷新令牌也是无效的,前端会将用户跳转到登录页面进行登录操作。

实现

令牌格式

本人定义的格式如下,其中第一层数据是我封装全局响应,而data内的数据就是我们今天需要说明的令牌数据:

{
	"status":200,
	"code":"0",
	"clientMessage":"执行成功",
	"data":{
		"accessToken":"3aa501cd651945a9b4829ae166fca518",
		"refreshToken":"6d1ceaf3f35d429491b273e7dd7e5daf",
		"accessExpires":"2022-09-24 16:19:53",
		"refreshExpires":"2022-09-26 15:19:53"
		},
	"dataMap":{},
	"timestamp":"2022-09-24 15:19:53"
}

这4个字段都有各自的用途:

  1. accessToken:访问令牌,请求接口时需要携带。
  2. refreshToken:刷新令牌,在访问令牌失效时使用。
  3. accessExpires:访问令牌的有效截止时间。
  4. refreshExpires:刷新令牌的有效截止时间。

后端准备

在整个无感刷新令牌中后端需要准备的东西并不多,只需要准备如下内容即可:

  1. 登录接口,返回认证信息(2个令牌和令牌各自的有效截止时间),格式参考上面的令牌格式
  2. 刷新令牌接口:根据刷新令牌获取新的令牌(2个令牌和令牌各自的有效截止时间),格式参考上面的令牌格式

前端准备

我这里使用的是Axios,在axios的请求拦截器中进行处理逻辑。

  1. 首先定义全局变量:
// 按照axios官方提示需要引入这两步(取消axios的请求)
const CancelToken = axios.CancelToken;
const source = CancelToken.source();

// 全局变量,记录是否正在执行刷新令牌任务
let isRefreshing = false
// 需要取消请求的集合
const cancelTokens = []
  1. 定义Axios请求拦截器
service.interceptors.request.use(async config => {
  /*
    判断当前时间 accessExpires refreshExpires 三个时间
    1. 当前时间小于accessExpires 正常请求
    2. 当前时间大于accessExpires,但小于refreshExpires 先刷新令牌在正常请求
    3. 当前时间大于refreshExpires正常请求(响应拦截器会将其重定向登录页)
   */
  // 自定义的方法,用来获取toke对象,token的格式就是上面令牌的格式(有4个属性)
  const token = LocalStorageUtil.getToken();
  // 判断当前请求接口既不是登录请求,也不是刷新令牌请求时
  if (validateUrlNotAuthentication(config.url)) {
    const now = new Date()
    // 判断是否需要请求刷新令牌接口(当前时间大于accessExpires,但小于refreshExpires 先刷新令牌在正常请求)
    const flag = token && validateDate(token.accessExpires) && validateDate(token.refreshExpires) && now > new Date(token.accessExpires) && now < new Date(token.refreshExpires)
    // 需要请求刷新令牌接口
    if (flag) {
      // 将本次请求放入待取消请求集合
      config.cancelToken = source.token;
      cancelTokens.push(() => source.cancel("令牌无效取消请求"));
      // 调用刷新令牌方法,开始刷新本地令牌
      await refreshingToken(token, config)
    }
  }
  // 在请求头中添加token(这里再次使用方法获取实时的令牌,因为刷新令牌会修改本地存储的token)
  if (LocalStorageUtil.getToken() && LocalStorageUtil.getAccessToken()) {
    // eslint-disable-next-line require-atomic-updates
    config.headers[AUTHORIZATION] = BEARER + LocalStorageUtil.getAccessToken()
  }
  console.log("请求拦截器", config)
  return config
}, error => {
  // do something with request error
  console.log(error) // for debug
  return Promise.reject(error)
})
  1. 定义Axios响应拦截器

service.interceptors.response.use(response => {
  console.log('响应拦截:', response)
  // 响应码
  const { status, config } = response
  const result = response.data

  // 响应码401,需要重新登录(或使用无感刷新token)
  if (status === 401) {
    // 清空用户登录状态
    store.dispatch('user/resetToken')
    // 跳转到登录页
    Router.push({ path: '/login'})
    return Promise.reject(result)
  }

  if (status < 200 || status >= 400) {
    // 设置响应为错误,
    return Promise.reject(result)
  }

  // 当是blob类型的响应时,返回完整的response,方便获取响应头等信息
  if (config.responseType && config.responseType === 'blob') {
    return Promise.resolve(response)
  }

  // 返回data内的数据(去掉axios和后端的封装外层数据,只保留data)
  return Promise.resolve(result.data)
}, error => {
  /*
    后台返回5xx进入该函数内。
   */
  const data = error.response.data;
  console.log('err', error, data) // error 就是后端接口封装的对象
  // 没有 doNotHandleErrorMessage 属性时,或 doNotHandleErrorMessage=false时 弹出提示信息
  if (data && data.dataMap && !data.dataMap[DO_NOT_HANDLE_ERROR_MESSAGE]) {
    Message({
      message: data.clientMessage,
      type: 'error',
      duration: 5 * 1000
    })
  }
  return Promise.reject(error)
})
  1. 定义刷新令牌方法如下:
/**
 * 刷新令牌
 * @param token 令牌对象
 * @param config axios的配置对象
 */
async function refreshingToken(token, config) {
  if (!isRefreshing) {
    isRefreshing = true
    return new Promise((resolve, reject) => {
      // 请求刷新令牌
      refresh(token.refreshToken).then(data => {
        // 生成token对象
        const newToken = new Token(data.accessToken, data.refreshToken, data.accessExpires, data.refreshExpires)
        // 设置token对象
        LocalStorageUtil.set(TOKEN_LOCAL_STORAGE, newToken)
        resolve(data)
      }).catch(data => {
        // 刷新令牌失败,直接跳转登录界面
        store.dispatch('user/resetToken')
        if (!data.dataMap[DO_NOT_HANDLE_ERROR_MESSAGE]) {
          Message({
            message: '登录已过期,请重新登录',
            type: 'error',
            duration: 5 * 1000
          })
          data.dataMap[DO_NOT_HANDLE_ERROR_MESSAGE] = true
        }
        Router.push({ path: '/login'})
        // 取消队列中的请求
        cancelTokens.forEach(cancel => {
          cancel().catch()
        })
        reject(data)
      }).finally(() => {
        // 恢复变量值
        isRefreshing = false
      })
    })
  } else {
    // 阻塞当前请求
    return new Promise((resolve, reject) => {
      const id = setInterval(() => {
        if (!isRefreshing) {
          clearTimeout(id)
          resolve();
        }
      }, 1000);
    });
  }
}

总结

在这之前,我最开始选择的是在响应拦截器进行无感刷新令牌的逻辑处理,并且也已经完成了工作,但是我发现在响应拦截器处理会有一个问题,就是用户会丢失一次请求的操作(这里是指跟页面数据渲染)
具体举个例子:
比如当前用户已经很长时间没操作了(10min),这个时候访问令牌已经失效了,当他再次请求时,会使用刷新令牌获取新的请求。如果这个逻辑是写在Axios的响应拦截器里,那么即使在补发请求,也不会恢复用户和页面的交互。比如查询表格数据,虽然请求补发了,但是数据不会自动渲染。

这也是本次花费时间将其进行优化的主要原因!

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐