阅读 464

前端自动刷新令牌

前端自动刷新令牌

前言

1. 技术选型

我们在实际项目中选用了JWT这种认证方式.

  1. 简单了解JWT

    JWTJson Web Token, 用户登陆后, 将非私密用户信息放置在token中携带给前端并加密, 之后每一笔请求携带token, 后端解密token即可取得用户信息

    最大好处: 后端无状态, 可以平滑横向扩张, 且token较难解密

    最大弊端: 后端较难控制token失效

    更多详情可以在掘金进行搜索: JWT

  2. 是否需要前端刷新令牌?

    其实并不是一定的

    • 当前端后端同域下, 选用Cookie+HttpOnly进行令牌传递, 可以让前端无需操作任何令牌, 当令牌过期后主动由后端刷新并放回Cookie即可
    • (我们项目)当跨域情况下, (实际测试中)后端在Set-Cookie时, Chrome浏览器会发生无法正常处理Cookie的问题, 因此放弃Cookie传递, 使用请求头携带token

      第一张图是Chrome, 第二张图是FireFox, 相同请求

    Chrome

    FireFox

    • 因此选用localStorage存储token, 由前端处理放在请求头中进行认证

    • 最后一个问题: 由于token有效期短, 需要有人刷新token

    小插曲: 其实可以交给后端刷新令牌, 当token过期后, 后端刷新然后放回请求头, 前端主动根据返回请求头进行更新即可. 问题在后端遇到并发时token会混乱.

    最终原因由前端刷新: 吵不过后端, 只能接下需求_(:з」∠)_

  3. http请求插件 不过多介绍, 选用了axios

  4. axios使用介绍 主要使用了拦截器interceptors, 由于使用了Promise, 可以随意在请求前后进行各种延时操作.

功能点介绍

  1. 登陆后将登陆token储存

  2. axios请求前将所有token放入请求头

  3. 退出登录时清空token存储

  4. 当任意接口返回401时, 尝试刷新token, 若成功, 则更新token储存, 若失败则跳转登陆

  5. 由于并发的存在, 需要考虑以下情况:

    • 发起一笔请求时, 若已经在尝试刷新token, 将此请求拦截, 并在成功刷新后, 更新请求头并重新发送
    • 当收到一笔401请求时, 若已经在尝试刷新token, 将此请求拦截, 并在成功刷新后, 更新请求头并重新发送
  6. 加载页面时, 尝试加载token

转化为代码逻辑

  1. 登录后,将token存储进localStorage, 并更新所有axios实例的默认请求头

  2. null

  3. 退出登录时, 将localStoragetoken清除, 并清除所有axios实例的默认请求头

  4. axios请求失败且状态码为401

    • 拦截该笔请求, 并注册一个刷新Token完成事件
      • 当触发token刷新成功时, 将该笔请求更新token, 然后重新发送
      • 当触发token刷新失败时, 将该笔请求返回失败, 交由Promise:reject进一步处理
    • 在没有其他刷新token请求时, 尝试刷新token, 并在刷新后触发刷新Token完成事件, 若刷新成功, 则将token存储进localStorage, 并更新所有axios实例的默认请求头
  5. axios请求前, 检查是否正在刷新token, 若正在刷新token, 注册一个刷新Token完成事件

    • 当触发token刷新成功时, 将该笔请求更新token, 然后继续发送
    • 当触发token刷新失败时, 将该笔请求返回失败, 抛弃请求
  6. 加载页面时, 从localStorage中获取token

实际代码

  1. 私有全局变量

    • isRefreshing: Boolean 是否正在刷新token
    • RefreshEvent: EventEmitter 刷新事件分发器
    • instances: AxiosInstance[] 所有封装好的axios实例,

      注意最好默认包含Axios即默认实例

    • REFRESH_URL: String 刷新URL
  2. 封装方法

    • 设置并返回token
    function setRefreshToken(refreshToken) {
      if (refreshToken !== undefined) {
        // 若携带参数, 则塞入localStorage中更新
        localStorage.setItem('ompJwtRefreshToken', refreshToken)
      } else {
        // 若没有携带参数, 则从localStorage中加载, 注意防范XSS攻击
        refreshToken = (
          localStorage.getItem('ompJwtRefreshToken', refreshToken) || ''
        ).replace(/[^.\-_a-zA-Z0-9]/g, '')
      }
      // 这里instances包含所有axios实例
      instances.forEach(instance => {
        // 设置默认请求头
        instance.defaults.headers['x-client-refresh-token'] = refreshToken
      })
      return refreshToken
    }
    复制代码
    • 刷新token

    插播小广告: 我的掘金主页

    function tryToRefreshToken() {
      // 约全局变量isRefreshing: 是否正在刷新token
      isRefreshing = true
      let refreshToken = localStorage.getItem('ompJwtRefreshToken') || ''
      refreshToken = refreshToken.replace(/[^.\-_a-zA-Z0-9]/g, '')
      // 没有refreshToken是无法刷新token的, 直接失败
      // 这里使用事件分发机制处理, 返回false表示刷新失败
      if (!refreshToken) return RefreshEvent.emit('refreshEnd', false)
      // ^_^ 友好提示, 防止用户以为刷新中点击按钮没有反应
      Notice.open({
        title: '正在主动刷新, 尝试继续登陆中……',
        duration: 0,
        name: 'refresh'
      })
      axios
        .get(REFRESH_URL)
        .then(res => {
          // 刷新成功
          Notice.close('refresh')
          Notice.open({
            title: '刷新成功, 将自动继续您之前的操作~'
          })
          // 注意触发事件
          RefreshEvent.emit('refreshEnd', true)
        })
        .catch(e => {
          // 刷新失败
          RefreshEvent.emit('refreshEnd', false)
          Notice.close('refresh')
          Notice.open({
            title: '刷新失败, 将自动为您跳转登录页'
          })
          router.push({name: LOGIN_PAGE})
          return Promise.reject(e)
        })
    }
    复制代码
    • 请求前拦截器
    function preRequestInterceptor(config) {
      // 当正在刷新token时, 延时请求, 直到刷新完成
      if (isRefreshing && config.url !== REFRESH_URL) {
        // 通过返回Promise进行延迟操作
        return new Promise((resolve, reject) => {
          // 注册事件
          RefreshEvent.once('refreshEnd', result => {
            // 注意resolve(config)才能继续请求
            // 注意config中已经包含旧的token了, 并且不会自动刷新, 需要手动重新设置下
            if (result) {
                config.headers['x-client-refresh-token'] = setRefreshToken()
                resolve(config)
            }
            // 这里建议reject封装后的东西, 否则会出现reject形式不一致
            else reject(config)
          })
        })
      }
      return config
    }
    复制代码
    • 请求后拦截器
    function errorDeal(error) {
      if (error && error.response) {
        switch (error.response.status) {
          // 一般会有其他处理吧
          case 401:
            // 绑定事件
            // 重发事件避免重复处理(其实不会出现这种情况)
            if (error.config._retry) return Promise.reject(error)
            // 先注册事件!!! 再触发重试, 否则可能会注册失败哦~
            const re = new Promise((resolve, reject) => {
              // 同样注册事件, 用于延时请求
              RefreshEvent.once('refreshEnd', result => {
                if (result) resolve(error.config)
                else reject(error)
              })
            }).then(config =>
              // 两个注意点:
              // 1. 刷新token
              // 2. 请使用Axios.create({})出的实例, 避免此请求重复一次错误处理, 那样的话就会有两次错误处理
              config.headers['x-client-refresh-token'] = setRefreshToken()
              axiosRetry.request({
                ...config,
                // 绑定一些私有属性方便你们使用
                _retry: true
              })
            )
            if (!isRefreshing) tryToRefreshToken()
            // 注意返回Promise
            return re
        }
      }
      return Promise.reject(error)
    }
    复制代码
  3. 在各个地方触发各种方法:

    • 私有全局
    // 定义变量哟~
    // 注册刷新结束时间, 解除刷新态, 路由处理
    RefreshEvent.on('refreshEnd', result => {
      isRefreshing = false
      if (!result) {
        route.push({
          name: 'login'
        })
      }
    })
    // 绑定拦截器
    instances.forEach(instance => {
      instance.interceptors.request.use(preRequestInterceptor)
      instance.interceptors.response.use(undefined, errorDeal)
    })
    // 注册重试实例(即不注册拦截器)
    const axiosRetry = axios.create({})
    // 先触发一下, 以便从localStorage中进行加载
    setAccessToken()
    setRefreshToken()
    复制代码

写在最后

分享一下工作时缥缈的想法, 没有然后了.

如果文中出现错误, 还请提出哟, 我尽量改~

插播小广告: 我的掘金主页

P.S. 坐标: 南京, 性别: ♂, 联系方式: ‭41620F678‬

此文在掘金原创, 其他网站请勿转载.

关注下面的标签,发现更多相似文章
评论