前端无感刷新token

1,795 阅读4分钟

没有记笔记的习惯,摸鱼无聊,随便写写吧。o( ̄︶ ̄)o

前提

很多项目都有前端页面长时间展示不自动退出的需求。比如大屏可视化项目

问题

目前很多项目都使用token鉴权,为了不自动退出系统,需要保持token可持续性

解决方案

我想到的解决方案有以下几种:

  1. 后端服务设置token永无过期,由于从安全考虑,token长期存在是有很大隐患,不可取的
  2. 后端服务设置,当每次请求接口时,更新token的过期时间
  3. 前端无感刷新token,即前端在请求过程中碰到后端返回token过期的标识,则请求刷新token接口,更新token后重新请求失败的接口。

第2种方案有后端实现,需要把token与过期时间分开,与前端无关

下面来讲讲前端怎么实现第3种方案

无感刷新token

当登录或刷新token时,后端都会返回两个值:token 和 refreshToken

实现流程

  1. 在请求开始时把请求条件(URL,入参,请求方式等)临时存储
  2. 请求返回:正确,则正常返回,释放请求条件临时存储
  3. 请求返回:错误且错误码是token过期,则把这个请求执行函数放入订阅中,然后请求refreshToken API 刷新token的请求
  4. 请求request时,当前如果是正在刷新token,则请求执行函数放入订阅中
  5. 刷新token的请求返回后,替换新的token,触发发布所有订阅中的请求函数,达到无感刷新token

token的发布订阅:tokenDep.js

let isTokenRefreshing = false // token 刷新中 
let subscriberList = [] // 需要回调的函数列表 
let options = {} // 每次请求时都记录下参数,在请求返回时释放,如果token过期,则使用它进行在请求 

function setOptions(key, value) {   
    options[key] = value 
}

function getOptions(key) {
    return options[key]
} 

function removeOptions(key) {
    delete options[key]
} 

function setTokenRefreshing(flag) {   
    isTokenRefreshing = flag 
} 

function getTokenRefreshing() {   
    return isTokenRefreshing 
} 

// 添加订阅
function addSubcriber(cb) {
    subscriberList.push(cb) 
} 

// 发布订阅
function tiggerSubcriber() {
    while(subscriberList.length) {     
        const cb = subscriberList.shift()     
        cb()   
    }   
    clearSubcriber() 
}

// 清空
function clearSubcriber() {   
    isTokenRefreshing = false   
    subscriberList = []   
    options = {} 
} 

export default {   
    setTokenRefreshing,   
    getTokenRefreshing,   
    addSubcriber,   
    tiggerSubcriber,   
    clearSubcriber,   
    setOptions,   
    getOptions,   
    removeOptions
}

http请求: request.js

import axios from 'axios'
import { Message } from 'element-ui'
import store from '@/store'
import { getToken, getRefreshToken } from './auth'
import settings from '@/settings'
import TokenDep from './tokenDep'
import { refreshTokenApi } from '@/api/auth'  

// 以下接口不进行刷新token
const noRefreshTokenAPI = ['/api/refreshToken', '/api/logout', '/api/login']

// create an axios instance
const service = axios.create({
  baseURL: '/api', // url = base url + request url
  // withCredentials: true, // send cookies when cross-domain requests
  timeout: 50000 // request timeout
})

// request interceptor
service.interceptors.request.use(
  config => {
    // 当在刷新token时,进行阻塞
    if (TokenDep.getTokenRefreshing() && !noRefreshTokenAPI.includes(config.url)) {
      let retry = new Promise((resolve) => {
        TokenDep.addSubcriber(() => {
          config.headers['token'] = getToken()
          resolve(config)
        })
      })
      return retry
    }
    config.headers['token'] = getToken()
    return config
  },
  error => {
    // do something with request error
    console.log('Error on request', error) // for debug
    Message({
      message: error.message,
      type: 'error',
      duration: 5 * 1000
    })
    return Promise.reject(error)
  }
)

const toRefreshToken = (request_unique) => {
  // 当token过期且不在刷新token时,进行刷新token
  if (!TokenDep.getTokenRefreshing()) {
    TokenDep.setTokenRefreshing(true)
    console.log('刷新token-----begin')
    refreshTokenApi({ refreshToken: getRefreshToken() }).then(res => {
      store.dispatch('user/refreshToken', res.data).then(() => {
        TokenDep.setTokenRefreshing(false)
        console.log('刷新token后,触发回调-----end')
        TokenDep.tiggerSubcriber()
      })
    }).catch(() => {
      TokenDep.setTokenRefreshing(false)
    })
  }
  let retry = new Promise((resolve) => {
    TokenDep.addSubcriber(() => {
      const options = TokenDep.getOptions(request_unique)
      TokenDep.removeOptions(request_unique)
      // options 是从开始传进来的
      resolve(request(options))
    })
  })
  return retry
}

const toReLogin = code => {
  /*
    40001: 会话超时,请重新登录(进行了刷新token)
    40002: "不合法的token,请认真比对 token 的签名
    40003, "缺少token参数"
    40005, "解析用户身份错误,请重新登录!
    40008, "您已在另一个设备登录
    40009, "登录超时,请重新登录
  */
    if ([40002, 40003, 40005, 40008, 40009].includes(code)) {
      TokenDep.clearSubcriber()
      // 返回登录页面
      store.dispatch('user/resetToken').then(() => {
        setTimeout(() => {
          if (location.href.indexOf('/login') === -1) {
            localStorage.removeItem('haveTips');
            location.reload()
          }
        }, 1500)
      })
    }
}

// response interceptor
service.interceptors.response.use(
  response => {
    const config = response.config
    const request_unique = config.headers['request_unique']
    const res = response.data

    if (res.code !== 0) {
      // code === 40001 为会话超时,进行刷新token
      if (!noRefreshTokenAPI.includes(config.url) && (TokenDep.getTokenRefreshing() || res.code === 40001)) {
        return toRefreshToken(request_unique)
      }
      Message({
        message: res.msg || '服务器出错,请稍后重试',
        type: 'error',
        duration: 5 * 1000
      })
      
      // 返回登录页面
      toReLogin(res.code)

      return Promise.reject(new Error(res.msg || '服务器出错,请稍后重试'))
    } else {
      TokenDep.removeOptions(request_unique)
      return res
    }
  },
  error => {
    console.log('Error on response', error.response) // for debug
    if (error.response) {
      const { data: res, config } = error.response
      const request_unique = config.headers['request_unique']
      // code === 40001 为会话超时,进行刷新token
      if (!noRefreshTokenAPI.includes(config.url) && (TokenDep.getTokenRefreshing() || res.code === 40001)) {
        return toRefreshToken(request_unique)
      }
      // 返回登录页面
      toReLogin(res.code)
      
      Message({
        message: res.msg || '服务器出错,请稍后重试',
        type: 'error',
        duration: 5 * 1000
      })
    } else {
      Message({
        message: error.message,
        type: 'error',
        duration: 5 * 1000
      })
    }
    return Promise.reject(error)
  }
)

const request = (options) => {
  // 唯一值为key存储请求前传入的参数
  const request_unique = Symbol()
  TokenDep.setOptions(request_unique, options)
  if (!options.headers) {
    options.headers = {}
  }
  options.headers['request_unique'] = request_unique
  return service(options)
}

export default request

总结

无感刷新token其实就是当请求后端返回token过期,前端不报错,会把当前请求存储起来,先去调用刷新token接口,返回token后更新本地token,再从存储队列里取出请求重新发起请求