Skip to content
On this page

无感刷新

token结构

token 是保存在 request header 里的一段字符串(比如用 header 名可以叫 authorization),它分为三部分:

token结构

如图 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: 检验refresh_token

access_token 设置 30 分钟过期,而 refresh_token 设置 7 天过期。

这样 7 天内,如果 access_token 过期了,那就可以用 refresh_token 刷新下,拿到新 token。

只要不超过 7 天未访问系统,就可以一直是登录状态,可以无限续签,不需要登录。

如果超过 7 天未访问系统,那 refresh_token 也就过期了,这时候就需要重新登录了。

下面我们也来实现下这种双token机制

接口文档

Api文档

一共有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_tokenrefresh_token有什么区别呢?

可不可以把access_tokenrefresh_token混用呢?

显然是不可以的,前面我们说过 token是由header、payload、signature 三部分组成的:我们观察下后台返回的access_tokenrefresh_token有什么区别

可以看到,header这一块 access_tokenrefresh_token都是一样的,也就是说两者采用的加密算法是相同的(header部分保存当前的加密算法)

payload 这一块,access_token明显比refresh_token要长得多, 前面说过 payload是用来存放用户信息的,因此,access_token存放的用户信息明显比refresh_token多。因此 access_tokenrefresh_token不能混用。refresh_token相比access_token除了少存放一些用户信息外,最重要的是refresh_token的过期时间要比access_token长得多。那refresh_tokenpayload存放了什么? 如果说access_token的payload存放了userId,username以及相关的权限信息,那么refresh_token的payload我们可以仅仅存放userId。然后请求后台的时候,如果refresh_token没过期,我取出payload部分的userId, 再根据userId把用户相关信息查询出来。然后再根据查出来的信息拼接成新的access_tokenrefresh_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>

Released under the MIT License.