前言
最近做我的项目的时候,波及到一个单点登录,即是我的项目的登录页面,用的是公司共用的一个登录页面,在该页面对立解决逻辑。最终实现用户只需登录一次,就能够以登录状态拜访公司旗下的所有网站。
单点登录(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 分钟内过期 - 立马获取这个 code 值再去申请一个 api
/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
过期,调用失败。 - 那么总不能 2 小时后就让用户退出登录吧,解决办法就是两小时后拿着过期的
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 过期工夫),但又想让一个月内常常沉闷的用户不再次登录,于是才有这样需要,防止了用户再次输出账号密码登录。
为什么要专门用一个 refresh_token
去更新 access_token
呢?首先 access_token
会关联肯定的用户权限,如果用户受权更改了,这个 access_token
也是须要被刷新以关联新的权限的,如果没有 refresh_token
,也能够刷新 access_token
,但每次刷新都要用户输出登录用户名与明码,多麻烦。有了 refresh_ token
,能够缩小这个麻烦,客户端间接用 refresh_token
去更新 access_token
,无需用户进行额定的操作。
说了这么多,或者有人会吐槽,一个登录用 access_token
就行了还要加个 refresh_token
搞得这么麻烦,或者有的公司 refresh_token
是后盾包办的并不需要前端解决。然而,前置场景在那了,需要都是基于该场景下的。
- 需要
- 当
access_token
过期的时候,要用refresh_token
去申请获取新的access_token
,前端须要做到用户无感知的刷新access_token
。比方用户发动一个申请时,如果判断access_token
曾经过期,那么就先要去调用刷新 token 接口拿到新的access_token
,再从新发动用户申请。 - 如果同时发动多个用户申请,第一个用户申请去调用刷新 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,给我一点激励持续写作吧~