Appearance
无感刷新
token结构
token 是保存在 request header 里的一段字符串(比如用 header 名可以叫 authorization),它分为三部分:
如图 token 是由 header、payload、signature 三部分组成的:
header 部分保存当前的加密算法,payload 部分是具体存储的数据,signature 部分是把 header 和 payload 还有 salt 做一次加密之后生成的。(salt,盐,就是一段任意的字符串,增加随机性)
这三部分会分别做 Base64,然后连在一起就是 token,放到某个 header 比如 Authorization 中:
js
authorization: Bearer xxxxx.xxxxx.xxxx
请求的时候把这个 header 带上,服务端就可以解析出对应的 header、payload、 signature 这三部分,然后根据 header 里的算法也对 header、payload 加上 salt 做一次加密,如果得出的结果和 signature 一样,就接受这个 token。
TIP
对于其它概念不懂没关系,我们目前只需要知道 payload 也就是第二部分,放的是识别用户身份的关键数据,比如(username, userId, orgId)等等。其它两部分则跟加密算法有关系,暂时先不做了解
为什么需要无感刷新(场景)
jwt 是有有效期的,我们设置的是 7 天,实际上为了安全考虑会设置的很短,比如 30 分钟。
这时候用户可能还在访问系统的某个页面,结果访问某个接口返回 token 失效了,让重新登录。
体验是不是就很差?
为了解决这个问题,服务端一般会返回两个 token:access_token 和 refresh_token
access_token 就是用来认证用户身份的,之前我们返回的就是这个 token。
而 refresh_token 是用来刷新 token 的,服务端会返回新的 access_token 和 refresh_token
也就是这样的流程:
登录成功之后,返回两个 token:
access_token 用来做登录鉴权:
而 refresh_token 用来刷新,拿到新 token:
access_token 设置 30 分钟过期,而 refresh_token 设置 7 天过期。
这样 7 天内,如果 access_token 过期了,那就可以用 refresh_token 刷新下,拿到新 token。
只要不超过 7 天未访问系统,就可以一直是登录状态,可以无限续签,不需要登录。
如果超过 7 天未访问系统,那 refresh_token 也就过期了,这时候就需要重新登录了。
下面我们也来实现下这种双token机制
接口文档
一共有4个接口
aaa 接口模拟后端返回数据的接口 不需要登录
bbb 接口模拟后端返回数据的接口 需要登录
user/login 登录接口
user/refresh 刷新token的接口
前端实现
新建项目 可自行把 pnpm 换成 npm 不解释
js
pnpm create vue@latest
安装 axios
js
pnpm add axios
修改 src/main.js
js
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
修改src/App.vue
vue
<script setup>
import { ref } from 'vue'
import axios from 'axios'
const aaa = ref('')
const bbb = ref('')
const query = async () => {
const {data: aaaData} = await axios.get('http://8.134.71.29:3001/aaa')
const {data: bbbData} = await axios.get('http://8.134.71.29:3001/bbb')
aaa.value = aaaData
bbb.value = bbbData
}
query()
</script>
<template>
<div>
<p>{{ aaa }}</p>
<p>{{ bbb }}</p>
</div>
</template>
<style scoped>
</style>
跑项目
js
pnpm dev
可以看到 aaa 正常请求 bbb 请求出错 401
现在我们模拟登录
配置axios请求拦截器
到了现在一切正常, 但是当token过期后(自行修改localStorage), bbb依旧是401, 这样在真实开发场景下,我们是直接让用户去重新登录的,体验极差
现在我们添加 axios
的响应拦截器,当请求401
的时候, 我们去后台请求换token
的操作
src/App.vue
js
axios.interceptors.response.use(response => {
return response
}, async error => {
const {data, config} = error.response
// 当请求 401 并且 该请求路径不是 /user/refresh的时候,我们去后台换token
if(data.statusCode === 401 && !config.url.includes('/user/refresh')) {
const res = await refreshToken()
if(res.status === 200) {
// 换取到了 token 后, 重新发起上次请求
return axios(config)
}else {
// token 换取 失败,说明 refresh_token 也过期了
alert('登录过期,请重新登录')
return Promise.reject(res.data)
}
}else {
return error.response
}
})
可以看到,当 token
失效后,会执行换取 token
的请求,然后重新发起 bbb 请求
现在看起来很完美,很正常,但是一切都结束了吗?我们再来看这种情况,当有多个请求
请求完都返回 401
的情况
我们改造下 query 方法
js
const query = async () => {
if(!localStorage.getItem('access_token')) {
await login()
}
await Promise.all([
axios.get('http://8.134.71.29:3001/bbb'),
axios.get('http://8.134.71.29:3001/bbb'),
axios.get('http://8.134.71.29:3001/bbb'),
])
const {data: aaaData} = await axios.get('http://8.134.71.29:3001/aaa')
const {data: bbbData} = await axios.get('http://8.134.71.29:3001/bbb')
aaa.value = aaaData
bbb.value = bbbData
}
当token
失效后,现在的情况是这样的
会重复发 user/refresh
请求,实际上我们只需要发一次换token
的操作 改造下axios响应拦截器
,这个类似防抖的思想,搞个变量,当第一次进入的时候,把该变量变为true
,后续只要该变量是true
就把失败的请求缓存起来,当换完token
后,依次把缓存的请求执行
js
let refreshing = false
const queue = []
axios.interceptors.response.use(response => {
return response
}, async error => {
const {data, config} = error.response
if(data.statusCode === 401 && !config.url.includes('/user/refresh')) {
if(refreshing) {
// 这里为什么要整个 promise 并且 缓存了 resolve ?
// 因为axios的拦截器返回的都是promise, 该请求此时还不知道最后是成功还是失败的
// 我们要缓存它的resolve,用来后续改变此次请求的状态
return new Promise(resolve => {
queue.push({
config,
resolve
})
})
}
refreshing = true
const res = await refreshToken()
refreshing = false
if(res.status === 200) {
queue.forEach(({ config, resolve }) => {
// 依次resolve来改变之前请求的状态,这里用了promise状态透传的特性
// 简单说就是我重新发起一次请求,如果这次请求成功了,那么之前失败的请求也是成功的
resolve(axios(config))
})
return axios(config)
}else {
alert('登录过期,请重新登录')
return Promise.reject(res.data)
}
}else {
return error.response
}
})
现在只发起了一次请求
refresh_token如何换取新token?
access_token
和 refresh_token
有什么区别呢?
可不可以把access_token
和refresh_token
混用呢?
显然是不可以的,前面我们说过 token
是由header、payload、signature 三部分组成的:我们观察下后台返回的access_token
和refresh_token
有什么区别
可以看到,header这一块 access_token
和refresh_token
都是一样的,也就是说两者采用的加密算法是相同的(header部分保存当前的加密算法)
payload 这一块,access_token
明显比refresh_token
要长得多, 前面说过 payload
是用来存放用户信息的,因此,access_token
存放的用户信息明显比refresh_token
多。因此 access_token
和refresh_token
不能混用。refresh_token
相比access_token
除了少存放一些用户信息外,最重要的是refresh_token
的过期时间要比access_token
长得多。那refresh_token
的payload
存放了什么? 如果说access_token
的payload存放了userId,username以及相关的权限信息,那么refresh_token
的payload我们可以仅仅存放userId。然后请求后台的时候,如果refresh_token
没过期,我取出payload
部分的userId, 再根据userId把用户相关信息查询出来。然后再根据查出来的信息拼接成新的access_token
和refresh_token
。我们看看后端是如何实现这一部分的。
ts
@Get('refresh')
// @Query解析出 refresh_token参数
async refresh(@Query('refresh_token') refreshToken: string) {
try {
// 校验refresh_token是否过期,如果过期,走 catch
// 没过期 verify 返回值就是 payload存放的用户信息
const data = this.jwtService.verify(refreshToken)
// 根据 payload的userId查询出 用户信息
const user = await this.userService.findUserById(data.userId)
// 根据查询出的信息生成新的 access_token 和 refresh_token
// access_token的payload部分存放了userId和username,
const access_token = this.jwtService.sign({
userId: user.id,
username: user.username
}, {
expiresIn: '30m' // access_token过期时间设置为30分钟
})
// refresh_token的payload部分仅存放了userId,
const refresh_token = this.jwtService.sign({
userId: user.id
}, {
expiresIn: '7d' // refresh_token过期时间设置为7天
})
return {
access_token,
refresh_token,
}
}catch(e) {
// 如果校验失败,说明 refresh_token 也过期了
throw new UnauthorizedException('token已失效,请重新登录')
}
}
}
完整代码
vue
<script setup>
import { ref } from 'vue'
import axios from 'axios'
axios.interceptors.request.use((config) => {
const accessToken = localStorage.getItem('access_token')
if(accessToken) {
config.headers.Authorization = 'Bearer ' + accessToken
}
return config
})
const aaa = ref('')
const bbb = ref('')
const refreshToken = async () => {
const res = await axios.get('http://8.134.71.29:3001/user/refresh', {
params: {
refresh_token: localStorage.getItem('refresh_token')
}
})
localStorage.setItem('access_token', res.data.access_token || '')
localStorage.setItem('refresh_token', res.data.refresh_token || '')
return res
}
let refreshing = false
const queue = []
axios.interceptors.response.use(response => {
return response
}, async error => {
const {data, config} = error.response
if(data.statusCode === 401 && !config.url.includes('/user/refresh')) {
if(refreshing) {
// 这里为什么要整个 promise 并且 缓存了 resolve ?
// 因为axios的拦截器返回的都是promise, 该请求此时还不知道最后是成功还是失败的
// 我们要缓存它的resolve,用来后续改变此次请求的状态
return new Promise(resolve => {
queue.push({
config,
resolve
})
})
}
refreshing = true
const res = await refreshToken()
refreshing = false
if(res.status === 200) {
queue.forEach(({ config, resolve }) => {
// 依次resolve来改变之前请求的状态,这里用了promise状态透传的特性
// 简单说就是我重新发起一次请求,如果这次请求成功了,那么之前失败的请求也是成功的
resolve(axios(config))
})
return axios(config)
}else {
alert('登录过期,请重新登录')
return Promise.reject(res.data)
}
}else {
return error.response
}
})
const login = async () => {
const res = await axios.post('http://8.134.71.29:3001/user/login', {
username: 'admin',
password: '1234'
})
localStorage.setItem('access_token', res.data.access_token)
localStorage.setItem('refresh_token', res.data.refresh_token)
}
const query = async () => {
if(!localStorage.getItem('access_token')) {
await login()
}
await Promise.all([
axios.get('http://8.134.71.29:3001/bbb'),
axios.get('http://8.134.71.29:3001/bbb'),
axios.get('http://8.134.71.29:3001/bbb'),
])
const {data: aaaData} = await axios.get('http://8.134.71.29:3001/aaa')
const {data: bbbData} = await axios.get('http://8.134.71.29:3001/bbb')
aaa.value = aaaData
bbb.value = bbbData
}
query()
</script>
<template>
<div>
<p>{{ aaa }}</p>
<p>{{ bbb }}</p>
</div>
</template>
<style scoped>
</style>