原文:
封装 axios 拦截器实现用户无感刷新 access_token - 掘金

前言

最近做项目的时候,涉及到一个单点登录,即是项目的登录页面,用的是公司共用的一个登录页面,在该页面统一处理逻辑。最终实现用户只需登录一次,就可以以登录状态访问公司旗下的所有网站。

单点登录( Single Sign On,简称SSO),是目前比较流行的企业业务整合的解决方案之一,用于多个应用系统间,用户只需要登录一次就可以访问所有相互信任的应用系统。

其中本文讲的是在登录后如何管理access_tokenrefresh_token,主要就是封装axios拦截器,在此记录。

需求

  • 前置场景

image.png

  1. 进入该项目某个页面http://xxxx.project.com/profile需要登录,未登录就跳转至SSO登录平台,此时的登录网址urlhttp://xxxxx.com/login?app_id=project_name_id&redirect_url=http://xxxx.project.com/profile,其中app_id是后台那边约定定义好的,redirect_url是成功授权后指定的回调地址。
  2. 输入账号密码且正确后,就会重定向回刚开始进入的页面,并在地址栏带一个参数?code=XXXXX,即是http://xxxx.project.com/profile?code=XXXXXXcode的值是使用一次后即无效,且10分钟内过期
  3. 立马获取这个code值再去请求一个api/access_token/authenticate,携带参数{ verify_code: code },并且该api已经自带app_idapp_secret两个固定值参数,通过它去请求授权的api,请求成功后得到返回值{ access_token: "xxxxxxx", refresh_token: "xxxxxxxx", expires_in: xxxxxxxx },存下access_tokenrefresh_tokencookie中(localStorage也可以),此时用户就算登录成功了。
  4. access_token为标准JWT格式,是授权令牌,可以理解就是验证用户身份的,是应用在调用api访问和修改用户数据必须传入的参数(放在请求头headers里),2小时后过期。也就是说,做完前三步后,你可以调用需要用户登录才能使用的api;但是假如你什么都不操作,静静过去两个小时后,再去请求这些api,就会报access_token过期,调用失败。
  5. 那么总不能 2 小时后就让用户退出登录吧,解决方法就是两小时后拿着过期的access_tokenrefresh_tokenrefresh_token过期时间一般长一些,比如一个月或更长)去请求/refresh api,返回结果为{ access_token: "xxxxx", expires_in: xxxxx },换取新的access_token,新的access_token过期时间也是 2 小时,并重新存到cookie,循环往复继续保持登录调用用户api了。refresh_token在限定过期时间内(比如一周或一个月等),下次就可以继续换取新的access_token,但过了限定时间,就算真正意义过期了,也就要重新输入账号密码来登录了。

公司网站登录过期时间都只有两小时(token过期时间),但又想让一个月内经常活跃的用户不再次登录,于是才有这样需求,避免了用户再次输入账号密码登录。

为什么要专门用一个refresh_token去更新access_token呢?首先access_token会关联一定的用户权限,如果用户授权更改了,这个access_token也是需要被刷新以关联新的权限的,如果没有 refresh_token,也可以刷新access_token,但每次刷新都要用户输入登录用户名与密码,多麻烦。有了refresh_token,可以减少这个麻烦,客户端直接用refresh_token去更新access_token,无需用户进行额外的操作。

说了这么多,或许有人会吐槽,一个登录用access_token就行了还要加个refresh_token搞得这么麻烦,或者有的公司refresh_token是后台包办的并不需要前端处理。但是,前置场景在那了,需求都是基于该场景下的。

  • 需求
  1. access_token过期的时候,要用refresh_token去请求获取新的access_token,前端需要做到用户无感知的刷新access_token。比如用户发起一个请求时,如果判断access_token已经过期,那么就先要去调用刷新token接口拿到新的access_token,再重新发起用户请求。
  2. 如果同时发起多个用户请求,第一个用户请求去调用刷新token接口,当接口还没返回时,其余的用户请求也依旧发起了刷新token接口请求,就会导致多个请求,这些请求如何处理,就是我们本文的内容了。

思路

方案一

写在请求拦截器里,在请求前,先利用最初请求返回的字段expires_in字段来判断access_token是否已经过期,若已过期,则将请求挂起,先刷新access_token后再继续请求。

  • 优点:能节省http请求
  • 缺点:因为使用了本地时间判断,若本地时间被篡改,有校验失败的风险

方案二

写在响应拦截器里,拦截返回后的数据。先发起用户请求,如果接口返回access_token过期,先刷新access_token,再进行一次重试。

  • 优点:无需判断时间
  • 缺点:会消耗多一次http请求

在此我选择的是方案二。

实现

这里使用axios,其中做的是请求后拦截,所以用到的是axios的响应拦截器axios.interceptors.response.use()方法

方法介绍

  1. import Cookies from 'js-cookie'
  2. const TOKEN_KEY = 'access_token'
  3. const REGRESH_TOKEN_KEY = 'refresh_token'
  4. export const getToken = () => Cookies.get(TOKEN_KEY)
  5. export const setToken = (token, params = {}) => {
  6. Cookies.set(TOKEN_KEY, token, params)
  7. }
  8. export const setRefreshToken = (token) => {
  9. Cookies.set(REGRESH_TOKEN_KEY, token)
  10. }
  1. import axios from 'axios'
  2. import { getToken, setToken, getRefreshToken } from '@utils/auth'
  3. // 刷新 access_token 的接口
  4. const refreshToken = () => {
  5. return instance.post('/auth/refresh', { refresh_token: getRefreshToken() }, true)
  6. }
  7. // 创建 axios 实例
  8. const instance = axios.create({
  9. baseURL: process.env.GATSBY_API_URL,
  10. timeout: 30000,
  11. headers: {
  12. 'Content-Type': 'application/json',
  13. }
  14. })
  15. instance.interceptors.response.use(response => {
  16. return response
  17. }, error => {
  18. if (!error.response) {
  19. return Promise.reject(error)
  20. }
  21. // token 过期或无效,返回 401 状态码,在此处理逻辑
  22. return Promise.reject(error)
  23. })
  24. // 给请求头添加 access_token
  25. const setHeaderToken = (isNeedToken) => {
  26. const accessToken = isNeedToken ? getToken() : null
  27. if (isNeedToken) { // api 请求需要携带 access_token
  28. if (!accessToken) {
  29. console.log('不存在 access_token 则跳转回登录页')
  30. }
  31. instance.defaults.headers.common.Authorization = `Bearer ${accessToken}`
  32. }
  33. }
  34. // 有些 api 并不需要用户授权使用,则不携带 access_token;默认不携带,需要传则设置第三个参数为 true
  35. export const get = (url, params = {}, isNeedToken = false) => {
  36. setHeaderToken(isNeedToken)
  37. return instance({
  38. method: 'get',
  39. url,
  40. params,
  41. })
  42. }
  43. export const post = (url, params = {}, isNeedToken = false) => {
  44. setHeaderToken(isNeedToken)
  45. return instance({
  46. method: 'post',
  47. url,
  48. data: params,
  49. })
  50. }

接下来改造 request.jsaxios的响应拦截器

  1. instance.interceptors.response.use(response => {
  2. return response
  3. }, error => {
  4. if (!error.response) {
  5. return Promise.reject(error)
  6. }
  7. if (error.response.status === 401) {
  8. const { config } = error
  9. return refreshToken().then(res=> {
  10. const { access_token } = res.data
  11. setToken(access_token)
  12. config.headers.Authorization = `Bearer ${access_token}`
  13. return instance(config)
  14. }).catch(err => {
  15. console.log('抱歉,您的登录状态已失效,请重新登录!')
  16. return Promise.reject(err)
  17. })
  18. }
  19. return Promise.reject(error)
  20. })

约定返回 401 状态码表示access_token过期或者无效,如果用户发起一个请求后返回结果是access_token过期,则请求刷新access_token的接口。请求成功则进入then里面,重置配置,并刷新access_token并重新发起原来的请求。
但如果refresh_token也过期了,则请求也是返回 401。此时调试会发现函数进不到refreshToken()catch里面,那是因为refreshToken()方法内部是也是用了同个instance实例,重复响应拦截器 401 的处理逻辑,但该函数本身就是刷新access_token,故需要把该接口排除掉,即:

  1. if (error.response.status === 401 && !error.config.url.includes('/auth/refresh')) {}

上述代码就已经实现了无感刷新access_token了,当access_token没过期,正常返回;过期时,则axios内部进行了一次刷新token的操作,再重新发起原来的请求。

优化

防止多次刷新 token

如果token是过期的,那请求刷新access_token的接口返回也是有一定时间间隔,如果此时还有其他请求发过来,就会再执行一次刷新access_token的接口,就会导致多次刷新access_token
因此,我们需要做一个判断,定义一个标记判断当前是否处于刷新access_token的状态,如果处在刷新状态则不再允许其他请求调用该接口。

  1. let isRefreshing = false // 标记是否正在刷新 token
  2. instance.interceptors.response.use(response => {
  3. return response
  4. }, error => {
  5. if (!error.response) {
  6. return Promise.reject(error)
  7. }
  8. if (error.response.status === 401 && !error.config.url.includes('/auth/refresh')) {
  9. const { config } = error
  10. if (!isRefreshing) {
  11. isRefreshing = true
  12. return refreshToken().then(res=> {
  13. const { access_token } = res.data
  14. setToken(access_token)
  15. config.headers.Authorization = `Bearer ${access_token}`
  16. return instance(config)
  17. }).catch(err => {
  18. console.log('抱歉,您的登录状态已失效,请重新登录!')
  19. return Promise.reject(err)
  20. }).finally(() => {
  21. isRefreshing = false
  22. })
  23. }
  24. }
  25. return Promise.reject(error)
  26. })

同时发起多个请求的处理

上面做法还不够,因为如果同时发起多个请求,在token过期的情况,第一个请求进入刷新token方法,则其他请求进去没有做任何逻辑处理,单纯返回失败,最终只执行了第一个请求,这显然不合理。

比如同时发起三个请求,第一个请求进入刷新token的流程,第二个和第三个请求需要存起来,等到token更新后再重新发起请求。
在此,我们定义一个数组requests,用来保存处于等待的请求,之后返回一个Promise,只要不调用resolve方法,该请求就会处于等待状态,则可以知道其实数组存的是函数;等到token更新完毕,则通过数组循环执行函数,即逐个执行resolve重发请求。

  1. let isRefreshing = false // 标记是否正在刷新 token
  2. let requests = [] // 存储待重发请求的数组
  3. instance.interceptors.response.use(response => {
  4. return response
  5. }, error => {
  6. if (!error.response) {
  7. return Promise.reject(error)
  8. }
  9. if (error.response.status === 401 && !error.config.url.includes('/auth/refresh')) {
  10. const { config } = error
  11. if (!isRefreshing) {
  12. isRefreshing = true
  13. return refreshToken().then(res=> {
  14. const { access_token } = res.data
  15. setToken(access_token)
  16. config.headers.Authorization = `Bearer ${access_token}`
  17. // token 刷新后将数组的方法重新执行
  18. requests.forEach((cb) => cb(access_token))
  19. requests = [] // 重新请求完清空
  20. return instance(config)
  21. }).catch(err => {
  22. console.log('抱歉,您的登录状态已失效,请重新登录!')
  23. return Promise.reject(err)
  24. }).finally(() => {
  25. isRefreshing = false
  26. })
  27. } else {
  28. // 返回未执行 resolve 的 Promise
  29. return new Promise(resolve => {
  30. // 用函数形式将 resolve 存入,等待刷新后再执行
  31. requests.push(token => {
  32. config.headers.Authorization = `Bearer ${token}`
  33. resolve(instance(config))
  34. })
  35. })
  36. }
  37. }
  38. return Promise.reject(error)
  39. })

最终request.js代码

  1. import axios from 'axios'
  2. import { getToken, setToken, getRefreshToken } from '@utils/auth'
  3. // 刷新 access_token 的接口
  4. const refreshToken = () => {
  5. return instance.post('/auth/refresh', { refresh_token: getRefreshToken() }, true)
  6. }
  7. // 创建 axios 实例
  8. const instance = axios.create({
  9. baseURL: process.env.GATSBY_API_URL,
  10. timeout: 30000,
  11. headers: {
  12. 'Content-Type': 'application/json',
  13. }
  14. })
  15. let isRefreshing = false // 标记是否正在刷新 token
  16. let requests = [] // 存储待重发请求的数组
  17. instance.interceptors.response.use(response => {
  18. return response
  19. }, error => {
  20. if (!error.response) {
  21. return Promise.reject(error)
  22. }
  23. if (error.response.status === 401 && !error.config.url.includes('/auth/refresh')) {
  24. const { config } = error
  25. if (!isRefreshing) {
  26. isRefreshing = true
  27. return refreshToken().then(res=> {
  28. const { access_token } = res.data
  29. setToken(access_token)
  30. config.headers.Authorization = `Bearer ${access_token}`
  31. // token 刷新后将数组的方法重新执行
  32. requests.forEach((cb) => cb(access_token))
  33. requests = [] // 重新请求完清空
  34. return instance(config)
  35. }).catch(err => {
  36. console.log('抱歉,您的登录状态已失效,请重新登录!')
  37. return Promise.reject(err)
  38. }).finally(() => {
  39. isRefreshing = false
  40. })
  41. } else {
  42. // 返回未执行 resolve 的 Promise
  43. return new Promise(resolve => {
  44. // 用函数形式将 resolve 存入,等待刷新后再执行
  45. requests.push(token => {
  46. config.headers.Authorization = `Bearer ${token}`
  47. resolve(instance(config))
  48. })
  49. })
  50. }
  51. }
  52. return Promise.reject(error)
  53. })
  54. // 给请求头添加 access_token
  55. const setHeaderToken = (isNeedToken) => {
  56. const accessToken = isNeedToken ? getToken() : null
  57. if (isNeedToken) { // api 请求需要携带 access_token
  58. if (!accessToken) {
  59. console.log('不存在 access_token 则跳转回登录页')
  60. }
  61. instance.defaults.headers.common.Authorization = `Bearer ${accessToken}`
  62. }
  63. }
  64. // 有些 api 并不需要用户授权使用,则无需携带 access_token;默认不携带,需要传则设置第三个参数为 true
  65. export const get = (url, params = {}, isNeedToken = false) => {
  66. setHeaderToken(isNeedToken)
  67. return instance({
  68. method: 'get',
  69. url,
  70. params,
  71. })
  72. }
  73. export const post = (url, params = {}, isNeedToken = false) => {
  74. setHeaderToken(isNeedToken)
  75. return instance({
  76. method: 'post',
  77. url,
  78. data: params,
  79. })
  80. }