乐趣区

关于javascript:封装-axios-拦截器实现用户无感刷新-accesstoken

前言

最近做我的项目的时候,波及到一个单点登录,即是我的项目的登录页面,用的是公司共用的一个登录页面,在该页面对立解决逻辑。最终实现用户只需登录一次,就能够以登录状态拜访公司旗下的所有网站。

单点登录(Single Sign On,简称 SSO),是目前比拟风行的企业业务整合的解决方案之一,用于多个利用零碎间,用户只须要登录一次就能够拜访所有相互信任的利用零碎。

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

需要

  • 前置场景
  1. 进入该我的项目某个页面 http://xxxx.project.com/profile 须要登录,未登录就跳转至 SSO 登录平台,此时的登录网址 url 为 http://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=XXXXXX,code 的值是应用一次后即有效,且 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_token到 cookie 中(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() 办法

办法介绍

  • @utils/auth.js
import Cookies from 'js-cookie'

const TOKEN_KEY = 'access_token'
const REGRESH_TOKEN_KEY = 'refresh_token'

export const getToken = () => Cookies.get(TOKEN_KEY)

export const setToken = (token, params = {}) => {Cookies.set(TOKEN_KEY, token, params)
}

export const setRefreshToken = (token) => {Cookies.set(REGRESH_TOKEN_KEY, token)
}
  • request.js
import axios from 'axios'
import {getToken, setToken, getRefreshToken} from '@utils/auth'

// 刷新 access_token 的接口
const refreshToken = () => {return instance.post('/auth/refresh', { refresh_token: getRefreshToken() }, true)
}

// 创立 axios 实例
const instance = axios.create({
  baseURL:  process.env.GATSBY_API_URL,
  timeout: 30000,
  headers: {'Content-Type': 'application/json',}
})

instance.interceptors.response.use(response => {return response}, error => {if (!error.response) {return Promise.reject(error)
    }
    // token 过期或有效,返回 401 状态码,在此解决逻辑
    return Promise.reject(error)
})

// 给申请头增加 access_token
const setHeaderToken = (isNeedToken) => {const accessToken = isNeedToken ? getToken() : null
  if (isNeedToken) { // api 申请须要携带 access_token 
    if (!accessToken) {console.log('不存在 access_token 则跳转回登录页')
    }
    instance.defaults.headers.common.Authorization = `Bearer ${accessToken}`
  }
}

// 有些 api 并不需要用户受权应用,则不携带 access_token;默认不携带,须要传则设置第三个参数为 true
export const get = (url, params = {}, isNeedToken = false) => {setHeaderToken(isNeedToken)
  return instance({
    method: 'get',
    url,
    params,
  })
}

export const post = (url, params = {}, isNeedToken = false) => {setHeaderToken(isNeedToken)
  return instance({
    method: 'post',
    url,
    data: params,
  })
}

接下来革新 request.js 中 axios 的响应拦截器

instance.interceptors.response.use(response => {return response}, error => {if (!error.response) {return Promise.reject(error)
    }
    if (error.response.status === 401) {const { config} = error
        return refreshToken().then(res=> {const { access_token} = res.data
            setToken(access_token)
            config.headers.Authorization = `Bearer ${access_token}`
            return instance(config)
        }).catch(err => {console.log('道歉,您的登录状态已生效,请从新登录!')
            return Promise.reject(err)
        })
    }
    return Promise.reject(error)
})

约定返回 401 状态码示意 access_token 过期或者有效,如果用户发动一个申请后返回后果是 access_token 过期,则申请刷新 access_token 的接口。申请胜利则进入 then 外面,重置配置,并刷新 access_token 并从新发动原来的申请。

但如果 refresh_token 也过期了,则申请也是返回 401。此时调试会发现函数进不到 refreshToken()catch外面,那是因为 refreshToken() 办法外部是也是用了同个 instance 实例,反复响应拦截器 401 的解决逻辑,但该函数自身就是刷新access_token,故须要把该接口排除掉,即:

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 的状态,如果处在刷新状态则不再容许其余申请调用该接口。

let isRefreshing = false // 标记是否正在刷新 token
instance.interceptors.response.use(response => {return response}, error => {if (!error.response) {return Promise.reject(error)
    }
    if (error.response.status === 401) {const { config} = error
        if (!isRefreshing) {
            isRefreshing = true
            return refreshToken().then(res=> {const { access_token} = res.data
                setToken(access_token)
                config.headers.Authorization = `Bearer ${access_token}`
                return instance(config)
            }).catch(err => {console.log('道歉,您的登录状态已生效,请从新登录!')
                return Promise.reject(err)
            }).finally(() => {isRefreshing = false})
        }
    }
    return Promise.reject(error)
})

同时发动多个申请的解决

下面做法还不够,因为如果同时发动多个申请,在 token 过期的状况,第一个申请进入刷新 token 办法,则其余申请进去没有做任何逻辑解决,单纯返回失败,最终只执行了第一个申请,这显然不合理。

比方同时发动三个申请,第一个申请进入刷新 token 的流程,第二个和第三个申请须要存起来,等到 token 更新后再从新发动申请。

在此,咱们定义一个数组 requests,用来保留处于期待的申请,之后返回一个Promise,只有不调用resolve 办法,该申请就会处于期待状态,则能够晓得其实数组存的是函数;等到 token 更新结束,则通过数组循环执行函数,即一一执行 resolve 重发申请。

let isRefreshing = false // 标记是否正在刷新 token
let requests = [] // 存储待重发申请的数组

instance.interceptors.response.use(response => {return response}, error => {if (!error.response) {return Promise.reject(error)
    }
    if (error.response.status === 401) {const { config} = error
        if (!isRefreshing) {
            isRefreshing = true
            return refreshToken().then(res=> {const { access_token} = res.data
                setToken(access_token)
                config.headers.Authorization = `Bearer ${access_token}`
                // token 刷新后将数组的办法从新执行
                requests.forEach((cb) => cb(access_token))
                requests = [] // 从新申请完清空
                return instance(config)
            }).catch(err => {console.log('道歉,您的登录状态已生效,请从新登录!')
                return Promise.reject(err)
            }).finally(() => {isRefreshing = false})
        } else {
            // 返回未执行 resolve 的 Promise
            return new Promise(resolve => {
                // 用函数模式将 resolve 存入,期待刷新后再执行
                requests.push(token => {config.headers.Authorization = `Bearer ${token}`
                    resolve(instance(config))
                })  
            })
        }
    }
    return Promise.reject(error)
})

最终 request.js 代码

import axios from 'axios'
import {getToken, setToken, getRefreshToken} from '@utils/auth'

// 刷新 access_token 的接口
const refreshToken = () => {return instance.post('/auth/refresh', { refresh_token: getRefreshToken() }, true)
}

// 创立 axios 实例
const instance = axios.create({
  baseURL:  process.env.GATSBY_API_URL,
  timeout: 30000,
  headers: {'Content-Type': 'application/json',}
})

let isRefreshing = false // 标记是否正在刷新 token
let requests = [] // 存储待重发申请的数组

instance.interceptors.response.use(response => {return response}, error => {if (!error.response) {return Promise.reject(error)
    }
    if (error.response.status === 401) {const { config} = error
        if (!isRefreshing) {
            isRefreshing = true
            return refreshToken().then(res=> {const { access_token} = res.data
                setToken(access_token)
                config.headers.Authorization = `Bearer ${access_token}`
                // token 刷新后将数组的办法从新执行
                requests.forEach((cb) => cb(access_token))
                requests = [] // 从新申请完清空
                return instance(config)
            }).catch(err => {console.log('道歉,您的登录状态已生效,请从新登录!')
                return Promise.reject(err)
            }).finally(() => {isRefreshing = false})
        } else {
            // 返回未执行 resolve 的 Promise
            return new Promise(resolve => {
                // 用函数模式将 resolve 存入,期待刷新后再执行
                requests.push(token => {config.headers.Authorization = `Bearer ${token}`
                    resolve(instance(config))
                })  
            })
        }
    }
    return Promise.reject(error)
})

// 给申请头增加 access_token
const setHeaderToken = (isNeedToken) => {const accessToken = isNeedToken ? getToken() : null
  if (isNeedToken) { // api 申请须要携带 access_token 
    if (!accessToken) {console.log('不存在 access_token 则跳转回登录页')
    }
    instance.defaults.headers.common.Authorization = `Bearer ${accessToken}`
  }
}

// 有些 api 并不需要用户受权应用,则无需携带 access_token;默认不携带,须要传则设置第三个参数为 true
export const get = (url, params = {}, isNeedToken = false) => {setHeaderToken(isNeedToken)
  return instance({
    method: 'get',
    url,
    params,
  })
}

export const post = (url, params = {}, isNeedToken = false) => {setHeaderToken(isNeedToken)
  return instance({
    method: 'post',
    url,
    data: params,
  })
}

  • ps:集体技术博文 Github 仓库,感觉不错的话欢送 star,给我一点激励持续写作吧~
退出移动版