写一个双 token demo 含前后端实现

token 是什么

本文所说的 token 其实应该叫 JSON Web Token 简称 JWT 是一种开放标准(RFC 7519),用于在网络应用环境间安全地将信息作为 JSON 对象传输。

这里说的 token === jwt

在前后端分离的项目中 token 通常用于用户的身份识别和校验

token 的本质是一个加密后的字符串,可以通过密钥进行解密

常见的 token 格式大概如下图

JWT 由三个部分组成,每个部分用点(.)分隔:

  1. Header(头部) :包含令牌的类型(即 JWT)和所使用的签名算法(如 HMAC SHA256 或 RSA
  2. Payload(负载) :通常是一个 json 对象
  3. Signature(签名) :用于验证消息在传输过程中没有被更改,并且对于使用私钥签名的令牌,还可以验证发送者的身份。

jwt.io 这个网站可以帮助你调试 jwt

什么是双 token 机制

双 tokne 机制从字面意思上理解需要两个 token 事实上呢也确实是这样,不过两者有一些差别

一般会把两个 token 分别命名为 accessToken refreshToken 也称为长短 token

accessToken: 有效期短,一般设置的请求的 header Authorization 字段

refreshToken: 有效期长,当 accessToken 过期时重新签发一个新的 accessToken

下边画个图

大概就是 accessToken 的有效期一般设置的很短 ,refreshToken 的有效期会长一些比如一个周

如果 accessToken 过期了,就去请求接口刷新 accessToken 这时需要检查 refreshToken 是否过期

没有过期就返回新的 accessToken 然后客户端再次发起请求

refreshToken 过期了就需要重新进行登录

解决了什么问题

使用单个 token 的时候一旦被拦截就可以在 token 有效期内一直使用

而长 token 加短 token 的方式可以一定程度的上避免这种情况,同时长 token 也可以保证活跃的用户不用重复登录,假如你每天都访问掘金 每天都要登录的话 会不会🐎人呢?

上边流程图里可以看到每次刷新 accessToken 时会同时刷新 refreshToken,为什么不直接增加 refreshToken 的过期时间呢?

增加过期时间当然是可以做到的,但这也会导致 refreshToken 和上边的单 token 一样的问题

而每次都刷新的话即使 refreshToken 被截获,重新请求后也会生成新的导致被拦截的 refreshToken 失效

但是你要说万无一失那也不可能,只是相对安全一些,接下来让我们看看 gpt 怎么说!

如何实现

我们使用 express 来模拟服务端实现一个双 token 的 demo 实现

服务端

我们先来实现下服务端

使用 express 启动过一个服务并设置静态文件夹用来展示我们的前端 index.html 页面

使用 jsonwebtoken 生成 accessToken refreshToken

使用 cookie-parserrefreshToken 设置到 cookie 中并设置 js 无法读取等安全限制

js 复制代码
const express = require('express');
const jwt = require('jsonwebtoken');
const cookieParser = require('cookie-parser');
const path = require('path');

const app = express();

// 设置静态文件目录
app.use(express.static(path.join(__dirname, 'public')));

// 定义根路由返回 index.html
app.get('/', (req, res) => {
  res.sendFile(path.join(__dirname, 'public', 'index.html'));
});

app.use(express.json());
app.use(cookieParser());

// 密钥,用于签名和验证 JWT
const ACCESS_TOKEN_SECRET = '516AE41C-9658-220A-C2EE-698EFB7546CC';
const REFRESH_TOKEN_SECRET = 'FB3C5606-A097-B63F-ED27-113985FB5905';

// 模拟用户数据库
const users = [
  { id: 1, username: 'user1', password: 'pw1' },
  { id: 2, username: 'user2', password: 'pw2' }
];

// 生成访问令牌和刷新令牌
function generateToken(userId) {
  const accessToken = jwt.sign({ userId }, ACCESS_TOKEN_SECRET, { expiresIn: '10s' });
  const refreshToken = jwt.sign({ userId }, REFRESH_TOKEN_SECRET, { expiresIn: '30s' });
  return { accessToken, refreshToken };
}

// 获取存储刷新令牌的 cookie 键
function getCookieRefreshTokenKey(userId) {
  return `refreshToken-${userId}`;
}

// 存储令牌到用户 id 的映射(这里使用一个简单的对象模拟)
const accessTokens = {};

// 设置 token 信息
function setTokenInfo(res, userId) {
  const { accessToken, refreshToken } = generateToken(userId);
  accessTokens[accessToken] = userId;

  // 将刷新令牌存储在 HTTP-only cookie 中
  res.cookie(getCookieRefreshTokenKey(userId), refreshToken, { httpOnly: true, secure: true, sameSite: 'strict' });
  res.json({ accessToken });
}

// 登录接口
app.post('/login', (req, res) => {
  const { username, password } = req.body;
  const user = users.find(u => u.username === username && u.password === password);

  if (!user) {
    return res.status(400).json({ message: '用户不存在!' });
  }

  setTokenInfo(res, user.id);
});

// 获取 header 中的 token
function getHeaderToken(req) {
  const fullToken = req.headers.authorization;

  if (!fullToken) {
    return { code: -1, message: 'token 不存在!' };
  }

  const token = fullToken.split(' ')[1];
  if (!token) {
    return { code: -1, message: '未知的 token!' };
  }

  return { code: 0, message: token, userId: accessTokens[token] };
}

// 刷新访问令牌接口
app.post('/refresh-token', (req, res) => {
  const accessTokenInfo = getHeaderToken(req);
  if (accessTokenInfo.code !== 0) {
    return res.status(403).json({ message: accessTokenInfo.message });
  }

  try {
    jwt.verify(accessTokenInfo.message, ACCESS_TOKEN_SECRET);
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      const refreshToken = req.cookies[getCookieRefreshTokenKey(accessTokenInfo.userId)];
      if (!refreshToken) {
        return res.status(403).json({ message: 'refreshToken 不存在!' });
      }

      try {
        // 刷新 token 验证成功返回新的 token
        jwt.verify(refreshToken, REFRESH_TOKEN_SECRET);
        return setTokenInfo(res, accessTokenInfo.userId);
      } catch (err) {
        return res.status(403).json({ message: '未知的 refreshToken' });
      }
    }

    return res.status(403).json({ message: '未知的 token!' });
  }

  // 上边无异常返回新的 token
  return setTokenInfo(res, accessTokenInfo.userId);
});

// 退出登录成功
function logoutSuccess(res, accessTokenInfo) {
  res.clearCookie(getCookieRefreshTokenKey(accessTokenInfo.userId));
  delete accessTokens[accessTokenInfo.message];
  res.json({ message: 'Logged out successfully' });
}

// 登出接口
app.post('/logout', (req, res) => {
  const accessTokenInfo = getHeaderToken(req);
  if (accessTokenInfo.code !== 0) {
    return res.status(403).json({ message: accessTokenInfo.message });
  }

  // 获取到用户才可以正常退出
  try {
    jwt.verify(accessTokenInfo.message, ACCESS_TOKEN_SECRET);
    return logoutSuccess(res, accessTokenInfo);
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return logoutSuccess(res, accessTokenInfo);
    }
    return res.status(403).json({ message: '未知的 token!' });
  }
});

// 模拟获取数据
app.get('/data/:id', (req, res) => {
  const accessTokenInfo = getHeaderToken(req);
  if (accessTokenInfo.code !== 0) {
    return res.status(403).json({ message: accessTokenInfo.message });
  }

  try {
    jwt.verify(accessTokenInfo.message, ACCESS_TOKEN_SECRET);
    return res.json({ message: '获取成功!', data: new Date() });
  } catch (err) {
    return res.status(401).json({ message: 'token 已过期' });
  }
});

// 启动服务器
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server successfully started`);
  console.log(`Server: http://127.0.0.1:${PORT}`);
});

存储在 cookie

  • 优点 :存储在 cookie 中不需要手动进行传递,浏览器会自动在每个请求中包含 cookie。可以设置 HttpOnly 标志,使 cookie 无法被 JavaScript 读取防止 XSS(跨站脚本攻击)。
  • 限制cookie 的大小有限制(通常为 4KB),使用 HttpOnly 标志,前端 js 无法读取和操作 cookiecookie 是与域名绑定的,不支持分布式后端服务(除非使用共享 cookie 的机制)。

存储在 localStorage

  • 优点localStorage 可以存储更多的数据(5m+),并且支持分布式应用,因为数据存储在客户端,可以手动传递给不同的后端服务。
  • 限制localStorage 中的数据可以被 JavaScript 读取和操作,因此存在 XSS 攻击的风险。localStorage 中的数据需要手动在请求中传递。

客户端

客户端实现主要是对请求库的封装这里拿 axios 举例

js 复制代码
const axiosInstance = axios.create({
  baseURL: 'http://127.0.0.1:3000',
  withCredentials: true
});

let refreshing = false;
let queue = []

async function refreshToken(originalRequest) {
  // 请求刷新 token 接口
  return axiosInstance.post('/refresh-token').then(response => {
    // 获取新的 access token
    const accessToken = response.data.accessToken;

    // 存储 access token
    localStorage.setItem('accessToken', accessToken);

    // 设置 Authorization header
    axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;

     // 重新执行 401 函数
     queue.forEach(cb => cb());
   
    // // 执行完毕后清空队列
    queue = []

    return axiosInstance(originalRequest);
  }).catch((err) => {
    // 获取新的 access token 失败,清除 access token 并重新登录
    localStorage.removeItem('accessToken');
    delete axiosInstance.defaults.headers.common['Authorization'];
    queue = []
    return Promise.reject(err)
  }).finally(() => {
    refreshing = false;
  })
}

axiosInstance.interceptors.response.use(
  response => response,
  async error => {
    const originalRequest = error.config;

    if (error.response.status === 401) {
      // 将 401 的错误的请求加入队列
      if (refreshing) {
        return new Promise((resolve) => {
          queue.push(() => resolve(axiosInstance(originalRequest)));
        });
      }

      if (!refreshing) {
        refreshing = true;
        return await refreshToken(originalRequest)
      }
    }

    return Promise.reject(error);
  }
);

axiosInstance.interceptors.request.use(
  config => {
    const accessToken = localStorage.getItem('accessToken');
    if (accessToken) {
      config.headers['Authorization'] = `Bearer ${accessToken}`;
    }
    return config;
  },
  error => Promise.reject(error)
);

window.axiosInstance = axiosInstance;

主要逻辑是在接口请求 401 时,将请求缓存到一个数组中,然后发起 refresh-token 请求

axiosInstance(originalRequest) originalRequest 是本次请求的配置直接传给 axios 实例就可以再次发起同样的请求

同时将 refreshing 变量设置为 true 防止多次调用 refresh-token 接口

当 token 刷新完成后将 accessToken 存储到 localStorage, 同步将 accessToken 设置请求 headers 的 Authorization 的值(Bearer 固定写法)

执行 queue 数组中缓存的数据,执行完成后清空数组

refreshToken 函数为什么要执行 axiosInstance(originalRequest) 并返回 ? 因为第一个发生 401 的请求没有被添加到 queue 数组中

todo

请求去重

具体效果

accessToken 10s 过期, refreshToken 30s 过期

点击登录

单个请求 401

多个请求 401

refreshToken 403 过期重新登录

总结

本文介绍了 token 是什么,全称是 JSON Web Token 简称 JWT 是一种开放标准,本质上是一个加密后的字符串可以使用密钥解密

然后通过一张图介绍了双 token 的执行流程以及双 token 有什么好处

然后对双 token 机制进行了实践包含前端后端实现

代码已经上传 github 猛击访问

相关推荐
前进别停留9 分钟前
javaWeb三剑客:html,css
前端·css·html
Mzp风可名喜欢1 小时前
JS手写实现深拷贝
前端·javascript
阳爱铭1 小时前
机器内存使用率突然激增的原因是什么?
linux·windows·分布式·后端·程序人生·中间件·架构
yiyiy111yiy1 小时前
关于Harmony的学习
前端·javascript·学习·原型模式
DYS_000011 小时前
哨兵机制Sentinel
linux·运维·前端·redis·缓存
ZachOn1y1 小时前
Java 入门指南:JVM(Java虚拟机)—— Java 类文件结构
java·开发语言·jvm·后端·java-ee·个人开发
时光匆匆岁月荏苒,转眼我们已不是当年2 小时前
【前端echarts】echarts双饼图与easyui列表联动
前端·echarts·easyui
lv1级小菜鸟2 小时前
【网络安全】-rce漏洞-pikachu
前端·安全·web安全
自己给自己创造机会2 小时前
css 实现一个卡片
前端·javascript·css
灰啦啦2 小时前
HTML的块级元素与行内元素
前端·html