最近作项目的时候,涉及到一个单点登陆,便是项目的登陆页面,用的是公司共用的一个登陆页面,在该页面统一处理逻辑。最终实现用户只需登陆一次,就能够以登陆状态访问公司旗下的全部网站。javascript
单点登陆( Single Sign On ,简称 SSO),是目前比较流行的企业业务整合的解决方案之一,用于多个应用系统间,用户只须要登陆一次就能够访问全部相互信任的应用系统。
其中本文讲的是在登陆后如何管理access_token
和refresh_token
,主要就是封装 axios拦截器,在此记录。前端
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
是成功受权后指定的回调地址。?code=XXXXX
,便是http://xxxx.project.com/profile?code=XXXXXX
,code的值是使用一次后即无效,且10分钟内过时/access_token/authenticate
,携带参数{ verify_code: code }
,而且该api已经自带app_id
和app_secret
两个固定值参数,经过它去请求受权的api,请求成功后获得返回值{ access_token: "xxxxxxx", refresh_token: "xxxxxxxx", expires_in: xxxxxxxx }
,存下access_token
和refresh_token
到cookie中(localStorage也能够),此时用户就算登陆成功了。access_token
为标准JWT格式,是受权令牌,能够理解就是验证用户身份的,是应用在调用api访问和修改用户数据必须传入的参数(放在请求头headers里),2小时后过时。也就是说,作完前三步后,你能够调用须要用户登陆才能使用的api;可是假如你什么都不操做,静静过去两个小时后,再去请求这些api,就会报access_token
过时,调用失败。access_token
和refresh_token
(refresh_token
过时时间通常长一些,好比一个月或更长)去请求/refresh
api,返回结果为{ access_token: "xxxxx", expires_in: xxxxx }
,换取新的access_token
,新的access_token
过时时间也是2小时,并从新存到cookie,循环往复继续保持登陆调用用户api了。refresh_token
在限定过时时间内(好比一周或一个月等),下次就能够继续换取新的access_token
,但过了限定时间,就算真正意义过时了,也就要从新输入帐号密码来登陆了。公司网站登陆过时时间都只有两小时(token过时时间),但又想让一个月内常常活跃的用户再也不次登陆,因而才有这样需求,避免了用户再次输入帐号密码登陆。java
为何要专门用一个 refresh_token
去更新 access_token
呢?首先access_token
会关联必定的用户权限,若是用户受权更改了,这个access_token
也是须要被刷新以关联新的权限的,若是没有 refresh_token
,也能够刷新 access_token
,但每次刷新都要用户输入登陆用户名与密码,多麻烦。有了 refresh_ token
,能够减小这个麻烦,客户端直接用 refresh_token
去更新 access_token
,无需用户进行额外的操做。ios
说了这么多,或许有人会吐槽,一个登陆用access_token
就好了还要加个refresh_token
搞得这么麻烦,或者有的公司refresh_token
是后台包办的并不须要前端处理。可是,前置场景在那了,需求都是基于该场景下的。git
access_token
过时的时候,要用refresh_token
去请求获取新的access_token
,前端须要作到用户无感知的刷新access_token
。好比用户发起一个请求时,若是判断access_token
已通过期,那么就先要去调用刷新token接口拿到新的access_token
,再从新发起用户请求。写在请求拦截器里,在请求前,先利用最初请求返回的字段expires_in
字段来判断access_token
是否已通过期,若已过时,则将请求挂起,先刷新access_token
后再继续请求。github
写在响应拦截器里,拦截返回后的数据。先发起用户请求,若是接口返回access_token
过时,先刷新access_token
,再进行一次重试。json
在此我选择的是方案二。axios
这里使用axios,其中作的是请求后拦截,因此用到的是axios的响应拦截器axios.interceptors.response.use()
方法api
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) }
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是过时的,那请求刷新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 && !error.config.url.includes('/auth/refresh')) { 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 && !error.config.url.includes('/auth/refresh')) { 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 && !error.config.url.includes('/auth/refresh')) { 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, }) }