双 token 保持登录机制前端拦截实践

双 token 机制

关于**“双 token 机制”**指的是使用户的登录状态在活跃期间保持有效的一种安全机制。

一般需要用户登录的系统为了校验用户的身份或登陆状态,会在用户登录时由服务器生成一个存储了或指向用户信息的 token,返回给客户端存储,这个 token 称为 access_token。

在请求其它接口的时候将 access_token 发送给服务器(通过 Header 或 Cookie)。

服务器根据 access_token 获取到用户信息,作为鉴权的依据。

而为了避免 token 被用户以外的人获取恶意使用,一般会预估一个用户最大的活跃时间,作为 access_token 的有效期,一旦过期,请求就鉴权失败,客户端有义务通知用户(例如强制登出、提示重新登录等)。

但是当 access_token 过期时,用户仍在活跃,突然提示登录这样的体验实在不是很友好。

在不取消 access_token 有效期的前提下,开发者们想到了 双 token 的方法。

就是服务器再生成一个 token,专门用于刷新(或更新) access_token,刷新的方式可以是延长 access_token 的有效期,也可以是生成并返回一个新的 access_token。这个用于刷新的 token 称为 refresh_token。

客户端需要改善的是在需要的时机,主动向服务器请求刷新 access_token,实现用户在活跃期间登录过期时自动重新登录的效果,大大提高用户体验。

通过 refresh_token 可以获取的内容可以包括:

  • 客户端来源
  • access_token
  • 用户信息
  • 其它

服务器可以将 refresh_token 与 access_token 一起返回给客户端客户端,作为客户端发送刷新请求的参数,实现更多安全机制,也可以不提供给客户端,它的最终目的是刷新 access_token。

现在实现了用户活跃期间保持登录状态,但是为了安全还是要为 refresh_token 设置一个有效期,当 refresh_token 也过期时,就无法刷新 access_token,从而真的登录过期,客户端通知用户。

设置 refresh_token 有效期的目的是保证用户不再活跃一定时间后,重置用户的登陆状态,实现单个 token 有效期的相同安全机制。

至于刷新 access_token 时是否同步刷新 refresh_token 就依实际需求为主了。

refresh_token 的有效期必须大于 access_token 的有效期,一般是后者的两倍。

axios 封装

基于 axios 的双 token 机制前端请求封装处理:

import axios from 'axios'

// API 状态码
const SUCCESS = '200' // 成功
const FORBIDDEN = '403' // token 过期

// axios实例
const service = axios.create()

// 请求队列
const requestQueue = {
  // 请求列表,存储刷新 token 期间发起的请求
  list: [],
  // 添加请求
  add(config, resolve) {
    // failed = true 表示 token 获取失败,用于清理队列时决议请求的 Promise,避免内存堆积
    this.list.push((failed) => {
      if (failed) {
        resolve()
      } else {
        // 更新 token
        config.headers.access_token = storage.getItem('access_token')
        // 重新请求
        resolve(service(config))
      }
    })
  },
  // 执行队列
  // failed = true 表示 token 获取失败,用于清理队列时决议请求的 Promise,避免内存堆积
  execute(failed) {
    let fn
    // eslint-disable-next-line
    while (fn = this.list.shift()) {
      fn(failed)
    }
  },
  // 清空队列
  clear() {
    this.execute(true)
  },
}

// 请求拦截
service.interceptors.request.use(
  config => {
    const accessToken = storage.getItem('access_token')

    // 如果未登录,直接请求
    if (!accessToken) {
      return config
    }

    // 首部添加 token
    config.headers.access_token = accessToken

    // 判断是否是刷新 token 操作
    if (config.url.includes('refreshToken')) {
      return config
    }

    return handleByRefreshStatus(config, 'request')
  },
  error => {
    return Promise.reject(error)
  },
)

// 响应拦截
service.interceptors.response.use(
  response => {
    // 判断是否是刷新 token 操作
    if (response.config.url.includes(refreshToken)) {
      return response
    }

    const res = response.data

    // token 过期
    if (res.code === FORBIDDEN) {
      return handleByRefreshStatus(response.config)
    }

    // 请求失败(服务器响应级别)
    if (res.code !== SUCCESS) {
      return Promise.reject(res)
    } else {
      return res
    }
  },
  error => {
    // 请求失败(异常级别)
    return Promise.reject(error)
  },
)

// 根据刷新状态处理请求
function handleByRefreshStatus(config, type = 'response') {
  return new Promise((resolve) => {
    const status = localStorage.getItem('refresh_token_status')
    if (type === 'request') {
      // 判断是否正在刷新 token
      if (!status) {
        resolve(config)
      } else {
        requestQueue.add(config, resolve)
      }
    } else {
      // 将请求添加到队列中,待刷新完成后执行队列
      requestQueue.add(config, resolve)

      // 判断 token 刷新状态
      if (!status) {
        // 设置刷新状态
        localStorage.setItem('refresh_token_status', 1)

        // 获取新 token
        refreshTokenFetch(localStorage.getItem('access_token'))
          .then(({ data: res }) => {
            if (!(res && res.data && res.data.access_token)) {
              throw res
            }
            localStorage.setItem('access_token', res.data.access_token)

            localStorage.removeItem('refresh_token_status')

            // 执行队列
            requestQueue.execute()
          })
          .catch(err => {
            console.error('refresh token failed', err)
            localStorage.removeItem('refresh_token_status')
            // 清空队列
            requestQueue.clear()
            // 登录过期处理
            loginExpired()
          })
      }
    }
  })
}

// 登录过期处理
function loginExpired() {
  // 客户端提示用户登录过期的处理方案
}

// 刷新 token 请求
function refreshTokenFetch(refresh_token) {
  return service.post('/refreshToken', {
      refresh_token,
  })
}

export default service

说明补充

主要逻辑

  • 当请求返回 403(token 过期),根据当前请求重新创建一个相同的请求追加到队列中,发送刷新 token 请求,如果获取成功执行队列中的请求。
  • 当客户端发送请求时,如果如果正在刷新 token,则将请求追加到队列中,等待执行。

localStorage 存储

将 token 等信息存储在 localStorage 是为了在多 Tab 打开应用时能够共享 token 和 刷新状态。避免相互间分别触发刷新请求导致的冲突。

具体以实际需求为准。

清空队列

清空队列的目的是清理内存,而不是仅仅清空队列数组。

axios 发起请求实际是创建了一个 Promise,当触发了刷新 token,当前请求的处理就被存入队列中,等待执行,而请求的处理就是 Promise 的决议。

如果用户登录过期的处理未重置页面状态(例如 location 跳转),队列中的请求也没有被执行完成,Promise 就一直是 pending 状态,这样请求过程产生的闭包就一直存储在内存中。

所以必须将队列中的每个请求进行处理(决议 Promise)。

推荐文章

《前端鉴权的兄弟们:cookie、session、token、jwt、单点登录 - by HenryLulu_几木》 作者详细简洁的介绍了不同鉴权方式的逻辑,关于鉴权,我觉得读完这篇就够了。

个人补充:

  • 双 token 机制目的是保持一定时间(refresh_token 有效期)内的持续登录状态,至于 token 中存的是加密数据或仅仅是一个 sessionID 取决于开发者。
  • 关于单点登录,主要就是通过向 SSO 获取访问目标系统的 ticket,并在访问目标系统的时候通过 URL 参数的方式将 ticket 传递过去进行登录校验。至于如何在获取 ticket 后继续访问目标系统,取决于开发者(如使用 location.href 跳转、或 jsonp)

你可能感兴趣的:(前端基础,http,vue.js,restful)