前言

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

单点登录( 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_tokenconst 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;默认不携带,须要传则设置第三个参数为 trueexport 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 // 标记是否正在刷新 tokeninstance.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 // 标记是否正在刷新 tokenlet 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 // 标记是否正在刷新 tokenlet 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_tokenconst 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;默认不携带,须要传则设置第三个参数为 trueexport 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,给我一点激励持续写作吧~