关于**“双 token 机制”**指的是使用户的登录状态在活跃期间保持有效的一种安全机制。
一般需要用户登录的系统为了校验用户的身份或登陆状态,会在用户登录时由服务器生成一个存储了或指向用户信息的 token,返回给客户端存储,这个 token 称为 access_token。
在请求其它接口的时候将 access_token 发送给服务器(通过 Header 或 Cookie)。
服务器根据 access_token 获取到用户信息,作为鉴权的依据。
而为了避免 token 被用户以外的人获取恶意使用,一般会预估一个用户最大的活跃时间,作为 access_token 的有效期,一旦过期,请求就鉴权失败,客户端有义务通知用户(例如强制登出、提示重新登录等)。
但是当 access_token 过期时,用户仍在活跃,突然提示登录这样的体验实在不是很友好。
在不取消 access_token 有效期的前提下,开发者们想到了 双 token 的方法。
就是服务器再生成一个 token,专门用于刷新(或更新) access_token,刷新的方式可以是延长 access_token 的有效期,也可以是生成并返回一个新的 access_token。这个用于刷新的 token 称为 refresh_token。
客户端需要改善的是在需要的时机,主动向服务器请求刷新 access_token,实现用户在活跃期间登录过期时自动重新登录的效果,大大提高用户体验。
通过 refresh_token 可以获取的内容可以包括:
服务器可以将 refresh_token 与 access_token 一起返回给客户端客户端,作为客户端发送刷新请求的参数,实现更多安全机制,也可以不提供给客户端,它的最终目的是刷新 access_token。
现在实现了用户活跃期间保持登录状态,但是为了安全还是要为 refresh_token 设置一个有效期,当 refresh_token 也过期时,就无法刷新 access_token,从而真的登录过期,客户端通知用户。
设置 refresh_token 有效期的目的是保证用户不再活跃一定时间后,重置用户的登陆状态,实现单个 token 有效期的相同安全机制。
至于刷新 access_token 时是否同步刷新 refresh_token 就依实际需求为主了。
refresh_token 的有效期必须大于 access_token 的有效期,一般是后者的两倍。
基于 axios 的双 token 机制前端请求封装处理:
import axios from 'axios'
// API 状态码
const SUCCESS = '200' // 成功
const FORBIDDEN = '403' // token 过期
// axios实例
const service = axios.create()
// 请求队列
const requestQueue = {
// 请求列表,存储刷新 token 期间发起的请求
list: [],
// 添加请求
add(config, resolve) {
// failed = true 表示 token 获取失败,用于清理队列时决议请求的 Promise,避免内存堆积
this.list.push((failed) => {
if (failed) {
resolve()
} else {
// 更新 token
config.headers.access_token = storage.getItem('access_token')
// 重新请求
resolve(service(config))
}
})
},
// 执行队列
// failed = true 表示 token 获取失败,用于清理队列时决议请求的 Promise,避免内存堆积
execute(failed) {
let fn
// eslint-disable-next-line
while (fn = this.list.shift()) {
fn(failed)
}
},
// 清空队列
clear() {
this.execute(true)
},
}
// 请求拦截
service.interceptors.request.use(
config => {
const accessToken = storage.getItem('access_token')
// 如果未登录,直接请求
if (!accessToken) {
return config
}
// 首部添加 token
config.headers.access_token = accessToken
// 判断是否是刷新 token 操作
if (config.url.includes('refreshToken')) {
return config
}
return handleByRefreshStatus(config, 'request')
},
error => {
return Promise.reject(error)
},
)
// 响应拦截
service.interceptors.response.use(
response => {
// 判断是否是刷新 token 操作
if (response.config.url.includes(refreshToken)) {
return response
}
const res = response.data
// token 过期
if (res.code === FORBIDDEN) {
return handleByRefreshStatus(response.config)
}
// 请求失败(服务器响应级别)
if (res.code !== SUCCESS) {
return Promise.reject(res)
} else {
return res
}
},
error => {
// 请求失败(异常级别)
return Promise.reject(error)
},
)
// 根据刷新状态处理请求
function handleByRefreshStatus(config, type = 'response') {
return new Promise((resolve) => {
const status = localStorage.getItem('refresh_token_status')
if (type === 'request') {
// 判断是否正在刷新 token
if (!status) {
resolve(config)
} else {
requestQueue.add(config, resolve)
}
} else {
// 将请求添加到队列中,待刷新完成后执行队列
requestQueue.add(config, resolve)
// 判断 token 刷新状态
if (!status) {
// 设置刷新状态
localStorage.setItem('refresh_token_status', 1)
// 获取新 token
refreshTokenFetch(localStorage.getItem('access_token'))
.then(({ data: res }) => {
if (!(res && res.data && res.data.access_token)) {
throw res
}
localStorage.setItem('access_token', res.data.access_token)
localStorage.removeItem('refresh_token_status')
// 执行队列
requestQueue.execute()
})
.catch(err => {
console.error('refresh token failed', err)
localStorage.removeItem('refresh_token_status')
// 清空队列
requestQueue.clear()
// 登录过期处理
loginExpired()
})
}
}
})
}
// 登录过期处理
function loginExpired() {
// 客户端提示用户登录过期的处理方案
}
// 刷新 token 请求
function refreshTokenFetch(refresh_token) {
return service.post('/refreshToken', {
refresh_token,
})
}
export default service
将 token 等信息存储在 localStorage 是为了在多 Tab 打开应用时能够共享 token 和 刷新状态。避免相互间分别触发刷新请求导致的冲突。
具体以实际需求为准。
清空队列的目的是清理内存,而不是仅仅清空队列数组。
axios 发起请求实际是创建了一个 Promise,当触发了刷新 token,当前请求的处理就被存入队列中,等待执行,而请求的处理就是 Promise 的决议。
如果用户登录过期的处理未重置页面状态(例如 location 跳转),队列中的请求也没有被执行完成,Promise 就一直是 pending 状态,这样请求过程产生的闭包就一直存储在内存中。
所以必须将队列中的每个请求进行处理(决议 Promise)。
《前端鉴权的兄弟们:cookie、session、token、jwt、单点登录 - by HenryLulu_几木》 作者详细简洁的介绍了不同鉴权方式的逻辑,关于鉴权,我觉得读完这篇就够了。
个人补充:
- 双 token 机制目的是保持一定时间(refresh_token 有效期)内的持续登录状态,至于 token 中存的是加密数据或仅仅是一个 sessionID 取决于开发者。
- 关于单点登录,主要就是通过向 SSO 获取访问目标系统的 ticket,并在访问目标系统的时候通过 URL 参数的方式将 ticket 传递过去进行登录校验。至于如何在获取 ticket 后继续访问目标系统,取决于开发者(如使用 location.href 跳转、或 jsonp)