在平时的使用 JWT(JSON Web token)的过程中,我们通常会遇到一个问题,那就是在用户使用应用的过程中,access_token 过期了,如何处理这个问题呢?

对于只是纯粹的浏览操作,我们可以让用户重新登录,以刷新 access_token,这既能保证安全性,也不会影响用户体验;但如果发生在用户提交已填写完成的表单时,重新登录将造成表单数据的丢失,带来非常糟糕的用户体验。

这篇文章就来聊一聊如何解决 access_token 过期的问题,即实现无感知刷新 access_token

思路

无感知刷新可以从客户端和服务端分别实现,首先思考一下有哪些方法可以做到无感知刷新 access_token

服务端刷新

服务端刷新是指服务端在判断 token 过期后,依然响应请求,并重新生成 token 返给客户端,客户端被动接收并更新的方式。

方法一:服务端验证 access_token 刷新

由于在生成一个 access_token 时都会为其设置过期时间,因此服务端在接收到一条请求并对 access_token 进行解析时,可以知道该 access_token 是否已经过期(或该 access_token 还有多长时间过期),如果已过期(或已达到过期临界值),仍然响应该请求,并使用解析出来的信息重新生成 access_token 返给客户端,客户端接收到新的 access_token 后替换即可,对原请求不会有任何影响。

这种方法看似解决了无感知刷新问题,实则有很大隐患,即服务器始终会为已过期的 access_token 生成新的 access_token,这肯定是不安全的,因此不推荐使用。

方法二:服务端验证 refresh_token 刷新

此方法实则是对方法一的一点优化,它是指服务端在生成 access_token 时,一并生成一个比 access_token 过期时间长的 refresh_token 返给客户端,客户端在请求时同时带上这两种 token

服务端在接收到请求后,首先验证 access_token 是否过期,如果已过期,则验证 refresh_token 是否已过期,如果未过期,则重新生成 access_tokenrefresh_token 返给客户端,客户端过替换即可,对原请求也不会有任何影响;但如果 refresh_token 也已过期,正常情况下,应当通知客户端重新登录。

客户端刷新

客户端刷新 access_token 不是指由客户端自己生成 access_token,指的是服务端在判断 access_token 过期后,拒绝响应请求,由客户端主动向服务端获取获取新的 access_token 并更新的方式(由于 refresh_token 也需要服务端进行验证,此时已无存在必要)。

虽然客户端也可以实现 access_token 的无感知刷新,但是有很大的弊端,具体是什么后文将做出解答。

方法一:请求前拦截,刷新 access_token

请求前拦截是指,服务端提供一个验证 access_token 的接口,客户端拦截每条请求,在拦截中先调用验证接口,如果未过期,则直接发送请求;如果已过期,则调用获取 access_token 的接口刷新后再发送请求。

这种方法会让每条请求都增加一次验证请求,在 access_token 未过期时是一种严重的资源浪费。而且,在网络状态不佳的时候还会造成用户等待时间过长,影响用户体验,不推荐使用。

方法二:请求后拦截,刷新 access_token

请求后拦截,就是指当客户端发起请求后,服务端正常验证 access_token,当发现过期时,返回一个约定的状态码给客户端,让其重新获取 access_token 后再提交请求。

是否对不同的验证错误类型返回不同的状态码以及如何返回都可以自行约定,与方法一相比,此方法不会造成资源浪费,用户体验也更佳。

实现

有了思路,就可以着手实现了。

服务端刷新

服务端刷新的实现比较简单,本文以 koa2 为例,实现有 refresh_token 的无感知刷新。

token 验证封装为一个中间件:

const jwt = require('jsonwebtoken');

function verifyToken(ctx, next) {
  const {authorization} = ctx.request.headers;
  let accessToken = authorization.split(/\s/)[1];
  try {
    let decoded = jwt.verify(accessToken, 'access_token');
    // 验证通过
    return next();
  } catch (err) {
    // 判断验证错误类型,仅处理过期的 token
    if (err.name === 'TokenExpiredError') {
      const {authorizationRefresh} = ctx.request.headers;
      let refreshToken = authorizationRefresh.split(/\s/)[1];
      try {
        let decoded = jwt.verify(refreshToken, 'refreshToken');
        let accessToken = jwt.sign(decoded, 'access_token', {expiresIn: '2h'});
        let refreshToken = jwt.sign(decoded, 'refresh_token', {expiresIn: '3h'});
        // 将新生成的 token 返给客户端
        ctx.body = {
          token:{
            accessToken,
            refreshToken
          }
        }
      } catch (e) {
        //此时已不需要关心是何种错误
        ctx.status = 401;
        let response = {
          message: '需要登录!'
        };
        ctx.body = JSON.stringify(response);
      }
    } else {
      ctx.status = 400;
      let response = {
        message: '非法请求!'
      };
      ctx.body = JSON.stringify(response);
    }
  }
}

module.exports = verifyToken;

jsonwebtoken 验证错误的类型有三种,但不管是哪一种错误,都无法得到 token 中携带的信息;本例只针对过期错误进行了处理,详细信息可以点击这里open in new window查看。

本例将新生成的 token 放在了响应体中,当然,放在响应头中也可以,两端只要约定好即可。

使用此中间件:

const Koa = require('koa');
const app = new Koa();
const router = require('./routes');
const verifyToken = require('./middleware/verifyToken');

app.use(verifyToken)
   .use(router.routes());

app.listen(3000, () => {
  console.log('This server is running at http://localhost:' + 3000)
});

需要注意的是,koa2 中间件的执行是有顺序的,因此需要保证验证中间件先于路由响应执行,否则,将起不到应有的作用。

至此,一个简单的无感知刷新 token 功能就完成了。

假设有用户将应用一直挂起导致 refresh_token 都过期了,那不还是会有数据丢失的情况发生吗?

是的,这个问题确实存在,但这谁能拦得住呢?😂

客户端刷新

客户端刷新的实现相比服务端刷新要复杂一些,本文以 axios 为例,实现无感知刷新。

假设服务端以 http 状态码的形式返回 access_token 过期错误,且为 401,那么,可以在 axios 的 response 拦截器中对其进行拦截:

import axios from 'axios';

const myAxios = axios.create({
  baseURL: `https://api.example.com`,
  timeout: 30000
});
//请求拦截
myAxios.interceptors.request.use((request) => {
  if (!request.headers.common['Authorization']) {
    request.headers.common['Authorization'] = localStorage.getItem('accessToken');
  }
  return request;
}, (err) => {
  return Promise.reject(err);
});
//响应拦截
myAxios.interceptors.response.use((response) => {
  return response
}, async (err) => {
  if (err.response.status === 401) {
    const {config} = err.response;
    let userName = localStorage.getItem('userName');
    let password = localStorage.getItem('password');
    const {data} = await myAxios.post('/login', {userName, password});// 重新获取 access_token
    myAxios.defaults.headers.common['Authorization'] = data.accessToken;
    return myAxios(config);
  }
  return Promise.reject(err);
});























 
 
 



response 拦截器中增加判断,当服务端返回的状态码为 401 时,重新获取 access_token,然后重新发送请求(重新获取 access_token 和重新发送请求应当是同步的),config 即为原请求的所有配置,直接使用即可。

前文说过,客户端刷新是有弊端的,其中一个弊端就在这里,客户端需要明文保存用户信息,是不安全的。

优化

通过上述方式即可实现无感知刷新 token,但也还有不足的地方需要优化一下,如 token 的自然过期以及并发请求。

如何处理 token 的自然过期

前文的所有内容都仅解决了在用户使用过程中 access_token 过期的问题,如果用户第一次登录后很长时间都没有再使用过应用程序,access_token(甚至包括 refresh_token)都会自然过期,而此时,前文中的所有方法都需要客户端先发送至少一个请求到服务端,才能判定是否需要用户重新登录,这是不必要的资源浪费。

如何避免这种浪费呢?可以通过客户端的存储机制解决。

客户端通常使用的存储方式有三种:cookiesessionStoragelocalStorage,其中,cookie 是可以设置过期时间的,如果将 token 存入 cookie 中,到了过期时间客户端会自动将其删除,就不需要向服务器发送一次请求进行验证了,当然,这需要服务器在用户登录时将过期时间一并返给客户端。

假设登录后的响应数据如下:

{
  "access_token": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiTWVGZWxpeFdhbmciLCJpYXQiOjE1ODIzNTgyNTgsImV4cCI6MTU4MjM2NTQ1OH0.5KRK0dAnBy5T0jJDjAW3Tsly_r0WyciTrEuvyE5YPO0",
  "expiresIn": 7200
}

示例中的 expiresIn 单位为秒,与 cookie 中 max-age 的单位一致,因此可以存储为:

//...
const {access_token, expiresIn} = response;
document.cookie = `access_token=${access_token};max-age=${expiresIn}`;

采用 refresh_token 方案时,则以 refresh_token 的过期时间为准,只要 refresh_token 没有过期,access_token 都相当于是有效的。

那如果服务端和客户端都已经远到有时差了,如何能保证不会因为时差问题造成影响呢?

这个其实并不是什么太大的问题,token 是服务端生成的,所以过期与否都是以服务端时间为准,与客户端没有一点关系,返给客户端的过期时长只是为了让客户端在某个时间点将 token 删除而已,

使用 sessionStorage 进行存储

如果授权仅在一次会话中有效,也可以使用 sessionStorage,当用户关闭应用后删除,在这期间就算用户主动刷新应用也不会有问题:

const {access_token} = response;
sessionStorage.setItem('access_token', access_token);

如何处理并发请求

当发生并发请求时,可能会同时有多条 token 已经过期的请求被发送到服务端,如何处理呢?

服务端刷新

对于服务端来说,token 的验证是统一的,当客户端发起并发请求时,服务端依然是单个异步处理(同步处理会造成阻塞,肯定是不行的)的,因此必然会生成多个 token;而客户端对 token 的更新处理也是统一的,会以最后收到的响应中的 token 为准向前覆盖,因此并没有什么影响,无非就是两端多消耗一点性能而已,不用做特殊处理。

客户端刷新

从功能上来说,客户端刷新也可以不用考虑并发请求,因为并发请求也是一条一条发出去的,后续返回的 access_toekn 会覆盖之前的,同样也就是两端多消耗一点性能而已。

当然,也还是可以做一点优化的。

当客户端发起并发请求时,需要判断是否已经有一条请求失败并正在进行着重新获取 access_token 的操作,如果有,等待 access_token 刷新后重新发送:

import axios from 'axios';

const myAxios = axios.create({
  baseURL: `https://api.example.com`,
  timeout: 30000
});

let isRefreshing = false; // 是否正在刷新 access_token
let requestArr = []; // 待重新发送的请求

//请求拦截
myAxios.interceptors.request.use((request) => {
  if (!request.headers.common['Authorization']) {
    request.headers.common['Authorization'] = localStorage.getItem('accessToken');
  }
  return request;
}, (err) => {
  return Promise.reject(err);
});
//响应拦截
myAxios.interceptors.response.use((response) => {
  return response
}, async (err) => {
  if (err.response.status === 401) {
    let {config} = err.response;
    let userName = localStorage.getItem('userName');
    let password = localStorage.getItem('password');
    requestArr.push(config);
    if (!isRefreshing) {
      isRefreshing = true;
      const {data} = await myAxios.post('/login', {userName, password});// 重新获取 token
      localStorage.setItem('accessToken', data.accessToken);
      myAxios.defaults.headers.common['Authorization'] = data.accessToken;
      requestArr.forEach((config) => {
        config.headers.Authorization = data.data.accessToken; // config 中的 access_token 还是原有的,需要更新
        myAxios(config);
      });
      isRefreshing = false;
      return new Promise(() => {}); // 已不需要再将错误抛出
    }
    return Promise.reject(err);
  }
  return Promise.reject(err);
});

通过将待重新发送的请求配置存入数组中,等待 access_token 刷新后再发出,可以减少重复获取,节约资源,但每条请求第一次的发送仍然避免不了。

那是否可以在请求拦截中取消请求呢?答案是不可以,可以自行尝试。

结语

从文中的对比来看,在服务端实现无感知刷新 token 比在客户端实现要简单得多,而具体选择哪种方式,就仁者见仁,智者见智了。

最近更新:
作者: MeFelixWang