程沛权
程沛权

基于OAuth2.0的refreshToken前端刷新方案与演示demo

作者:程沛权2021-01-30
Star on GitHub

本文最后更新于 1162 天前,部分内容可能不适合当前所有情况,仅供参考。

如今在涉及到用户登录的系统设计里面,基本上都是通过 OAuth 2.0 来设计授权,当你在调用登录接口的时候,可以看到在返回来的数据里面会有 2 个 Token:一个 accessToken 和一个 refreshToken

为什么会有两个 Token,之间有什么区别?这其实是 OAuth 2.0 的四种方式 之一的 “凭证式”,一个是平时请求接口时的用户凭证,一个是用来刷新用户凭证的刷新凭证。

这也是我最近在业务上涉及到的一处开发需求点,之前的老业务,服务端都没有按照这样的模式去做,单纯的过期就让用户重新登录,所以自己也没有实际去处理过 Token 续期的场景。

一波处理下来,刚开始下手觉得有点繁琐,但实现起来还是蛮简单的,过程颇觉有趣,把第一次的开发经验记录起来。

需求背景

通常来说下发的 accessToken 都有一个比较短暂的有效期,大部分情况下可能只有大半天,短的话更可能只有 2 ~ 3 小时(对,我处理的这个业务就是……),意味着用户在一天之内可能需要频繁进行重新登录。

关于为什么 accessToken 的有效期要那么短,可以参考 OAuth 2.0 的一个简单解释

传统的登录都是到期了跳回登录页面,让用户重新走一遍登录流程就可以了,但如今 accessToken 的超短有效期带来的用户体验是非常糟糕的,为了安全而牺牲用户体验,就是产品和开发打架的常见原因之一。

那么有没有办法既保证安全,又能够减少用户重复登录的操作呢? refreshToken 就是因此产生。

它可以用来请求重新颁发一个 accessToken,当请求被告知过期时,通过刷新令牌的方式,用新的令牌来完成之前还没完成的请求,让用户可以不重新登录,达到无感知刷新的目的,直到 refreshToken 也过期了,才需要回去走登录流程。

这一篇来讲一讲如何无感知的帮助用户执行 accessToken 的刷新。

需求目的

搞清楚目的才能好好搞事情哈哈哈,于是拆解了一下需求,分为三个小点:

  1. accessToken 过期的时候,在发起下一次请求之前,前端先帮用户主动刷新 Token,拿到新的 Token 完成后续的请求

  2. 在刷新 Token 成功之前,不允许重复刷新(因为一个页面可能有多个请求),多个未完成的请求需要挂起

  3. refreshToken 也过期时(也就是刷新失败),停止重复刷新,引导用户重新登录

Btw: 后面的 Token 统一都是指 accessToken

实现思路

理清楚需求目的之后,还需要先跟服务端同学约定一下判断规则,先确认我们在前端能够拿到哪些数据,按照上一次对接的业务情况,服务端的登录接口提供了以下三个字段返回:

字段含义
accessToken请求接口的时候,需要在请求头里带上的 Token
refreshToken用来请求刷新 Token 的凭证
expiresTimeToken 的过期时间

其中登录接口和刷新接口是免 Token 验证的,登录接口只需要校验默认的请求头以及账号密码,刷新接口只需要校验刷新凭证。

实现过程

以 Vue + Axios 来搭一个演示项目为例,核心代码相关的文件是这几个:

src └─libs ├─axios │ ├─config.ts │ ├─index.ts │ └─instance.ts ├─refreshToken.ts
└─setLoginInfoToLocal.ts

虽然文件比较多,但代码其实不多,习惯把一些可能复用的代码抽离出来独立成模块了。

文件作用
axios/config.tsaxios 的一些基础配置,可以配置接口路径、超时时间等
axios/instance.ts一个 axios 实例,在这里配置了一些全局都会用到的请求拦截、返回拦截
axios/index.ts组件里用到的 axios 入口文件,会在这里再添加一些专属业务侧的拦截
refreshToken.ts用来刷新 Token 的一些业务代码,返回一个 Promise
setLoginInfoToLocal.ts存储登录信息到本地,在调用登录接口和刷新接口之后需要用到

点击查看: libs - refresh-token

下面把几个主要文件里面,主要的代码部分讲一下:

config.ts

之所以要抽离出 config ,是因为之前遇到一个坑,axios 如果先 create 再 export,那么用到的地方其实都是同一个实例,不同的模块里引用了同一个实例然后还要再做一些拦截,会相互覆盖。

所以如果你在其他地方,可能要用到一个干净的新实例的时候,抽离出 config 可以单独 create ,可以减少你重复编写代码的情况。

你在这里可以动态指定接口路径、默认的请求头、超时时间等等。

const config: any = {
  // 接口路径
  baseURL: IS_DEV
    ? 'http://127.0.0.1:12321/api'
    : 'https://www.fastmock.site/mock/1c85c0d436ae044cf22849549ef471b8/api',

  // 公共请求头
  headers: {
    'Content-Type': 'application/json; charset=UTF-8',
    Authorization: 'Basic KJytrqad8765Fia',
  },

  // 默认的响应方式
  responseType: 'json',

  // 超时时间
  timeout: 30000,

  // 跨域的情况下不需要带上cookie
  withCredentials: false,

  // 调整响应范围,范围内的可以进入then流程,否则会走catch
  validateStatus: (status: number) => {
    return status >= 200 && status < 500
  },
}

完整代码:config.ts - refresh-token

官方文档:请求配置 - axios

instance.ts

单独封装的 instance,是一个 “干净” 的实例,它里面包含的只是全局都会用到的一些请求拦截和返回拦截。

请求拦截可以在开始请求之前,添加上一些特殊数据,比如给每个请求头都带上 Token 等等。

instance.interceptors.request.use(
  // 正常拦截
  (config) => {
    // 添加token
    const LOCAL_TOKEN: string = ls.get('token') || ''
    if (LOCAL_TOKEN) {
      config.headers['Authorization'] = LOCAL_TOKEN
    }

    // 返回处理后的配置
    return Promise.resolve(config)
  },

  // 拦截失败
  (err) => Promise.reject(err)
)

返回拦截可以拦截掉一些特殊的返回情况,还可以简化接口返回的数据等等。

instance.interceptors.response.use(
  // 正常响应
  (res) => {
    // 处理axios在IE 8-9下的坑爹问题
    if (
      res.data === null &&
      res.config.responseType === 'json' &&
      res.request.responseText !== null
    ) {
      try {
        res.data = JSON.parse(res.request.responseText)
      } catch (e) {
        console.log(e)
      }
    }

    // 登录失效拦截(主要针对refreshToken也失效的情况)
    if (res.data.code === 1 && res.data.msg === '用户凭证已过期') {
      // 告知用户
      message.error(res.data.msg)

      // 切去登录
      try {
        router.push({
          name: 'login',
        })
      } catch (e) {
        console.log(e)
      }
    }

    // 提取接口的返回结果,简化接口调用的编码操作
    return Promise.resolve(res.data)
  },

  // 异常响应(统一返回一个msg提示即可)
  (err) => Promise.reject('网络异常')
)

export default instance

完整代码:instance.ts - refresh-token

index.ts

其实和 instance.ts 的性质差不多,本质上也是要在这里做一些拦截,但是不同于 instance 的地方在于,入口文件更多的是侧重于业务侧的拦截。

比如前面有说到,登录接口和刷新接口是不需要校验用户凭证的,也就是不必每个接口都需要进行 Token 刷新,那么这些只针对部分业务接口的拦截,就统一放到 index 这边。

我们的刷新操作也是在这里完成的。

我们前面说到,在拦截的时候,要做到不允许重复刷新,同时多个未完成的请求需要挂起,所以我们需要定义两个全局变量。

// 防止重复刷新的状态开关
let isRefreshing: boolean = false

// 被拦截的请求列表
let requests: any[] = []

前端主动发起刷新的判断标准,就是看本地记录的时间是否到期,所以要先检测本地是否存在时间记录,计算时间差:

// 读取Token的过期时间戳
const OLD_TOKEN_EXP: number = ls.get('token_expired_timestamp') || 0

// 获取当前的时间戳
const NOW_TIMESTAMP: number = Date.now()

// 计算剩余时间
const TIME_DIFF: number = OLD_TOKEN_EXP - NOW_TIMESTAMP

同时还要检查是否具备主动发起刷新的条件,必须本地存在旧的记录,才会去帮用户刷新。

// 是否有Token存储记录
const HAS_LOCAL_TOKEN: boolean = ls.get('token') ? true : false

// 是否有Token过期时间记录
const HAS_LOCAL_TOKEN_EXP: boolean = OLD_TOKEN_EXP ? true : false

然后因为像刷新请求这个请求不应该触发刷新,所以再获取一下接口的 URL:

// 获取接口url
const API_URL: string = config.url || ''

最后,我们要把刷新操作都放到综合条件里面去,满足所有条件的,才去执行刷新。

if (
  API_URL !== '/refreshToken' &&
  HAS_LOCAL_TOKEN &&
  HAS_LOCAL_TOKEN_EXP &&
  TIME_DIFF <= 0
) {
  // 这里面是刷新的操作...
}

开始刷新的时候,为了避免重复刷新,只有未刷新时,才会进入刷新流程,同时进入后需要先把状态打开。

然后获取新的 Token,拿到新的 Token 之后,再把原来挂起的请求执行掉,在这里记得重置队列,避免队列越来越多,下次刷新时造成无畏的重复请求。

// 如果没有在刷新,则执行刷新
if (!isRefreshing) {
  // 打开状态
  isRefreshing = true

  // 获取新的token
  const NEW_TOKEN: string = await refreshToken()

  // 如果新的token存在,用新token继续之前的请求,然后重置队列
  if (NEW_TOKEN) {
    config.headers['Authorization'] = NEW_TOKEN
    requests.forEach((callback: any) => callback(config))
    requests = []
  }
  // 否则直接清空队列,因为需要重新登录了
  else {
    requests = []
  }

  // 关闭状态,允许下次继续刷新
  isRefreshing = false
}

配合上一步,我们需要把刷新 Token 成功之前的请求都挂起来,因为 Promise 只有当 resolve 或者 reject 的时候才会返回结果,所以我们在 Promise 里,把请求都先丢到 requests 数组里存起来,就能达到请求挂起的目的。

// 并把刷新完成之前的请求都存储为请求队列
return new Promise((resolve: any) => {
  requests.push(() => {
    resolve(config)
  })
})

完整代码:index.ts - refresh-token

refreshToken.ts

index 里有一个操作是拿到刷新后的 Token:

// 获取新的token
const NEW_TOKEN: string = await refreshToken()

这里其实是一个接口请求操作,就是通过登录时给的 refreshToken ,去请求刷新凭证的接口签发新的 accessToken 下来。

为了减少代码的回调,方便 index 采用 async / await,所以这里需要返回一个 Promise,当刷新成功时,返回新的 Token 字符串,刷新失败则返回空的字符串。

const refreshToken = (): Promise<any> => {
  return new Promise((resolve) => {
    // 获取本地记录的刷新凭证
    const REFRESH_TOKEN: string = ls.get('refresh_token') || ''

    // 请求刷新
    axios({
      method: 'post',
      url: '/refreshToken',
      data: {
        refreshToken: REFRESH_TOKEN,
      },
    })
      .then((data: any) => {
        // 存储token信息
        const DATA: any = data.data
        setLoginInfoToLocal(DATA)

        // 返回新的token,通知那边搞定了
        const NEW_TOKEN: string = `${DATA.tokenType} ${DATA.accessToken}`
        resolve(NEW_TOKEN)
      })
      .catch((msg: string) => {
        resolve('')
      })
  })
}

在这里,刷新完毕后,记得同时把新的资料存储到本地去,更新上次登录记录的那些数据,所以我才要把 setLoginInfoToLocal 的操作抽离出来,减少重复代码的编写。

完整代码:refreshToken.ts - refresh-token

项目演示

这篇文章对应的仓库就是一个项目源码,这里提供了两种类型的 Mock 接口:

本地 Express Server

推荐用这个方式,可以一边测试效果,一边看代码。

  1. 先通过 git clone https://github.com/chengpeiquan/refresh-token.git 克隆本仓库到本地

  2. 控制台访问项目,输入 npm install 执行依赖安装

  3. 执行 npm run api 启动接口服务

  4. 另外打开一个控制台访问项目,输入 npm run serve 启动项目调试

你可以在 service 文件夹里修改接口的一些返回数据,比如 Token 的有效期(目前默认都是 5s 过期,方便测试),以及 refreshToken 的有效几率(因为无法校验刷新凭证的合法性,所以目前采用的是随机生成一个布尔值,当 false 的时候表示刷新凭证过期,true 则允许继续刷新),等等。

service ├─api │ ├─login.js │ ├─refreshToken.js │ └─test.js ├─createApi.js
└─index.js

这些文件的说明:

文件作用
index.js服务的启动入口文件
createApi.js创建接口的文件,可以把写好的接口导进来生成
api 文件夹里面存放的是接口文件,一个文件对应一个接口

远程 FastMock API

原本是采用这个方式的,但是可能受自己网络或者对方服务器影响,有时候响应很慢,试过 30s 超时了还没响应回来,花费过多时间在等待上了,所以才换成了本地 Server 。

线上访问:Refresh Token Demo

你也可以创建自己的 FastMock 接口,登录官网进行配置后,修改 src/libs/axios/config.ts 里的 baseURL

点击访问:FastMock 官网

点击访问:FastMock 操作文档

参考资料

感谢各位大神总结的相关知识点,收益很多,才有了自己的一番实践和总结,建议大家有兴趣也可以阅读一下!

理解 OAuth 2.0

OAuth 2.0 的四种方式

深入理解 token

请求时 token 过期自动刷新 token