首页 / 文章

在 Node.js 中同时实现访问令牌和刷新令牌

Node.js中的短有效期访问令牌与轮换式刷新令牌:JWT流程、各令牌的存储位置,以及如何通过轮换机制防范被盗的刷新令牌。

发布者:REACTAPP.TOP阅读约 11 分钟2600 词

来源: Access Tokens vs Refresh Tokens in Node.js: Why Real Applications Need Both

在 Node.js 中构建第一个身份验证流程时,常见的方法是使用单个 JSON Web Token(JWT)。用户提交邮箱和密码,服务器对其进行验证后生成令牌并返回给用户。此后,前端会在每次请求的 Authorization 标头中包含该令牌。

在 localhost 上进行测试时,这种设置似乎没什么问题。但一旦有真实用户开始使用该应用,就会出现结构上的矛盾。

将令牌的有效期设置得较短,比如15分钟,这样用户在处理任务时就会被强制退出——可能正在输入评论或填写表单到一半时。如果将其设置得较长,比如30天,虽然能让用户保持登录状态,但也会带来严重的安全漏洞:一旦该令牌被盗,就无法单独撤销它,而必须使整个系统中所有用户的所有令牌全部失效。

用户便利性与安全性之间的这种矛盾,正是生产级系统不依赖单一令牌的原因——它们使用两种令牌:访问令牌和刷新令牌。

接下来我们将探讨为何需要这种双令牌机制、每种令牌的功能,以及如何在Node.js后端正确实现它。

单一令牌认证的核心问题

在解释为何需要两个令牌之前,先回顾一下JWT的独特之处会有所帮助:它们是无状态的。

当Node.js服务器收到JWT时,无需查询数据库来验证会话是否存在。它只需将令牌的加密签名与密钥进行比对:

Token payload + Secret key = Signature verified?
├── Yes -> Request allowed
└── No  -> 401 Unauthorized

正是这种无状态特性使得基于JWT的API既快速又易于扩展,因为无需在每个请求时都查询数据库。但这一特性也有弊端:一旦令牌被发放,服务器就很难提前使其失效。

请看以下事件链:

  • 你的单个JWT被设置为有效期为30天。
  • 攻击者获取了该令牌——可能是通过XSS攻击,或是截获不安全网络中的流量。
  • 账户所有者发现异常后便更改密码。
  • 攻击者则若无其事地继续行动。无论密码是否被更改,被盗的令牌在理论上仍会保持有效,直到30天的有效期结束。
  • 一种解决办法是在Redis等系统中维护已失效令牌的黑名单,但每次调用API时都检查该列表,实际上会将“无状态”的令牌系统变回有状态系统。

    这正是双令牌机制旨在解决的问题。

    什么是访问令牌和刷新令牌?

    为了避免让单个令牌同时承担常规API访问和长期登录维持的功能,工作负载被分配到两个令牌之间:

    +------------------+-----------------------------+-----------------------------+
    | Feature          | Access Token                | Refresh Token               |
    +------------------+-----------------------------+-----------------------------+
    | Purpose          | Authenticate API requests   | Obtain new access tokens    |
    | Lifetime         | Short (5 to 15 minutes)     | Long (7 to 30 days)         |
    | Storage          | Memory or short-lived state | HttpOnly, Secure Cookie     |
    | Database lookup  | No (purely stateless)       | Yes (checked on refresh)    |
    | Exposure risk    | High (sent on every request)| Low (sent only to /refresh) |
    +------------------+-----------------------------+-----------------------------+
    

    1. 访问令牌

    可以将访问令牌视为一种短期有效的通行证。客户端会将其附加在常规 API 请求的请求头中,比如 GET /api/orders 或 POST /api/profile 这类请求。

    由于它的有效期很短(通常为10到15分钟),因此即使发生泄露,造成的损害也有限。服务器只需通过基于 CPU 的签名验证即可确认其有效性,无需访问数据库,从而保证 API 的响应速度。

    2. 刷新令牌

    刷新令牌则相当于一种长期有效的续期凭证。它不会随常规业务 API 调用一起发送——它的唯一作用就是与专门的接口进行通信,比如 POST /api/auth/refresh 这类请求。

    当短暂的访问令牌过期后,客户端会悄悄调用此刷新接口。服务器随后查询数据库以确认用户的账户仍处于活跃状态且具有相应权限,之后会生成新的访问令牌。

    由于这种刷新操作大约每15分钟才会执行一次,而非在每次用户交互时都进行,因此在此处添加数据库查询几乎不会影响性能。

    请求生命周期

    以下是客户端、服务器和数据库三者随时间交互的过程:

    Client                             Server                           Database
      │                                  │                                 │
      ├─────── 1. Login (credentials) ──>│                                 │
      │                                  ├─── Verify credentials ─────────>│
      │                                  │<── User record valid ───────────┤
      │                                  ├─── Store Refresh Token Hash ───>│
      │<────── 2. Return Tokens ─────────┤                                 │
      │       (Access: 15m, Refresh: 7d) │                                 │
      │                                  │                                 │
      │─────── 3. GET /api/dashboard ───>│                                 │
      │       [Bearer Access Token]      ├─── Verify signature (Stateless) │
      │<────── 4. 200 OK + Data ─────────┤                                 │
      │                                  │                                 │
      │    ... 15 minutes pass ...       │                                 │
      │                                  │                                 │
      │─────── 5. GET /api/dashboard ───>│                                 │
      │       [Expired Access Token]     ├─── Token expired!               │
      │<────── 6. 401 Unauthorized ──────┤                                 │
      │                                  │                                 │
      │─────── 7. POST /api/auth/refresh>│                                 │
      │       [Cookie: Refresh Token]    ├─── Check if token in DB ───────>│
      │                                  │<── Token valid & not revoked ───┤
      │                                  ├─── Generate new Access Token    │
      │<────── 8. Return new Access Token┤                                 │
      │                                  │                                 │
      │─────── 9. Retry GET /dashboard ─>│                                 │
      │       [New Access Token]         ├─── Verify signature             │
      │<────── 10. 200 OK + Data ────────┤                                 │
    

    从用户的角度来看,整个续期过程都是隐形的。他们的会话会在后台持续运行,不会有任何突兀的“会话已过期”提示打断当前使用。

    使用 Node.js 实践

    现在让我们构建一个可运行的 Express 示例,展示如何生成、验证和续期令牌。

    首先安装所需的软件包:

    npm install express jsonwebtoken cookie-parser dotenv
    

    1. 创建令牌

    设置一个辅助模块,用于生成两个令牌,每个令牌都用其专属的密钥进行签名:

    // tokenService.js
    const jwt = require('jsonwebtoken');
    
    const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET || 'access_secret_123';
    const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET || 'refresh_secret_456';
    
    function generateAccessToken(userId) {
      // Short lifespan: 15 minutes
      return jwt.sign({ sub: userId }, ACCESS_TOKEN_SECRET, { expiresIn: '15m' });
    }
    
    function generateRefreshToken(userId) {
      // Long lifespan: 7 days
      return jwt.sign({ sub: userId }, REFRESH_TOKEN_SECRET, { expiresIn: '7d' });
    }
    
    module.exports = {
      generateAccessToken,
      generateRefreshToken,
      ACCESS_TOKEN_SECRET,
      REFRESH_TOKEN_SECRET
    };
    

    关键点:务必为访问令牌和刷新令牌分配不同的密钥。如果它们使用相同的密钥,攻击者就可以利用过期的刷新令牌,在需要访问令牌的地方使用它,从而绕过你设定的过期机制。

    2. 处理登录并保存刷新令牌

    登录接口需要将刷新令牌——或者更准确地说,它的SHA-256哈希值——存储在数据库中,并与对应用户关联,这样之后就可以查询到它:

    // server.js
    const express = require('express');
    const cookieParser = require('cookie-parser');
    const jwt = require('jsonwebtoken');
    const {
      generateAccessToken,
      generateRefreshToken,
      ACCESS_TOKEN_SECRET,
      REFRESH_TOKEN_SECRET
    } = require('./tokenService');
    
    const app = express();
    app.use(express.json());
    app.use(cookieParser());
    
    // Simulated database table for refresh tokens
    const refreshTokenDatabase = new Set();
    
    app.post('/api/auth/login', async (req, res) => {
      const { email, password } = req.body;
    
      // In a real app: verify email and hashed password from DB
      const mockUserId = 'usr_987654';
    
      const accessToken = generateAccessToken(mockUserId);
      const refreshToken = generateRefreshToken(mockUserId);
    
      // Save the refresh token to our database so we can revoke it later
      refreshTokenDatabase.add(refreshToken);
    
      // Send refresh token inside an HttpOnly cookie
      res.cookie('refreshToken', refreshToken, {
        httpOnly: true,     // Prevents JavaScript from reading the cookie (protects from XSS)
        secure: process.env.NODE_ENV === 'production', // Requires HTTPS
        sameSite: 'strict', // Protects against CSRF attacks
        maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
      });
    
      // Send access token in JSON body for frontend memory storage
      res.json({ accessToken });
    });
    

    3. 保护业务端点

    在允许请求通过之前,中间件函数会检查从Authorization头部获取的访问令牌:

    function authenticateAccessToken(req, res, next) {
      const authHeader = req.headers['authorization'];
      // The header format is "Bearer <token>"
      const token = authHeader && authHeader.split(' ')[1];
    
      if (!token) {
        return res.status(401).json({ error: 'Access token missing' });
      }
    
      jwt.verify(token, ACCESS_TOKEN_SECRET, (err, decoded) => {
        if (err) {
          // 403 signals to the client that the token is invalid or expired
          return res.status(403).json({ error: 'Invalid or expired access token' });
        }
        req.userId = decoded.sub;
        next();
      });
    }
    
    // Protected route
    app.get('/api/orders', authenticateAccessToken, (req, res) => {
      res.json({
        message: `Orders retrieved for user ${req.userId}`,
        data: [{ id: 1, item: 'Mechanical Keyboard' }]
      });
    });
    

    4. 构建刷新接口

    当客户端在调用如/api/orders这样的接口时收到403响应后,它会访问此端点以获取新的访问令牌:

    app.post('/api/auth/refresh', (req, res) => {
      const refreshToken = req.cookies.refreshToken;
    
      if (!refreshToken) {
        return res.status(401).json({ error: 'Refresh token missing' });
      }
    
      // Check if token exists in the database
      if (!refreshTokenDatabase.has(refreshToken)) {
        return res.status(403).json({ error: 'Refresh token revoked or unknown' });
      }
    
      jwt.verify(refreshToken, REFRESH_TOKEN_SECRET, (err, decoded) => {
        if (err) {
          // Remove invalid token from database
          refreshTokenDatabase.delete(refreshToken);
          return res.status(403).json({ error: 'Refresh token expired' });
        }
    
        // Token is valid! Issue a new access token
        const newAccessToken = generateAccessToken(decoded.sub);
        res.json({ accessToken: newAccessToken });
      });
    });
    

    5. 注销账号与撤销访问权限

    让用户登出非常简单:只需从数据库中删除刷新令牌,并清除存储该令牌的cookie即可。访问令牌则无需特殊处理——它会在几分钟内自动过期。

    app.post('/api/auth/logout', (req, res) => {
      const refreshToken = req.cookies.refreshToken;
    
      if (refreshToken) {
        refreshTokenDatabase.delete(refreshToken);
      }
    
      res.clearCookie('refreshToken');
      res.json({ message: 'Logged out successfully' });
    });
    

    客户端应将令牌存储在何处?

    一个经常被问到的问题是,前端应该把这些令牌保存在哪儿。

    一个常见的错误是将两种令牌都存放在localStorage中。

    问题在于,页面中运行的任何脚本——无论是来自被篡改的npm依赖项还是注入的内联标签——都可以通过localStorage.getItem('token')轻松读取localStorage中的内容。

    更安全的做法如下:

    • 仅将访问令牌存储在内存中。可将其保存在应用的运行时状态中——如 React Context、Pinia 存储或普通变量。由于该令牌有效期很短,且标签页关闭时会立即消失,即便发生成功的脚本注入,也会仅带来短暂的风险。
    • 将刷新令牌保存在 HttpOnly Cookie 中。JavaScript 完全无法访问 HttpOnly Cookie。每当客户端调用 /api/auth/refresh 时,浏览器会自动附加该 Cookie,这样长期有效的凭证就永远不会接触到可能被攻击者控制的代码。

    通过刷新令牌轮换加强安全性

    即便采用了上述方案,普通的刷新令牌机制仍存在一个隐患:如果攻击者设法获取到刷新令牌——无论是通过Cookie存储还是嗅探网络流量——只要该刷新令牌有效,他们就能持续生成新的访问令牌,最长可能达到7天的时间。

    生产环境中的系统通过刷新令牌轮换来弥补这一缺陷。

    在轮换机制下:

    • 每次用刷新令牌兑换新访问令牌时,服务器会销毁旧的刷新令牌并返回一个全新的令牌。
    • 访问令牌和刷新令牌会作为一对在每次请求中一同被替换。
    Request 1: Send Refresh Token A -> Receive Access Token 2 + Refresh Token B (Token A invalidated)
    Request 2: Send Refresh Token B -> Receive Access Token 3 + Refresh Token C (Token B invalidated)
    

    自动检测安全漏洞

    刷新令牌轮换机制还能启用一种名为重复使用检测的强大防护手段。

    设想这样一种情况:攻击者成功复制了Refresh Token A。

    • 合法用户首先兑换Refresh Token A,服务器会返回Refresh Token B。
    • 过了一段时间后,攻击者试图兑换已经过期的Refresh Token A。
    • 服务器查看记录后发现Refresh Token A已被使用,便将其视为安全漏洞的迹象。
    • 服务器会撤销整个令牌链——包括Refresh Token B——迫使攻击者和合法用户都返回登录界面。

    这样一来,攻击者就无法在后台悄悄继续操作,系统会立即作出反应,阻止所有人进入,直到重新进行身份验证。

    应避免的陷阱

    请注意以下四种常见的错误:

    • 在访问令牌中嵌入敏感信息。JWT 的载荷仅经过 Base64 编码,并未加密,因此任何截获它的人都能直接读取内容。务必避免在载荷中包含密码、卡号及其他隐私信息。
    • 让刷新令牌记录无限积累。如果没有过期时间字段和清理机制,你的刷新令牌表将会无限制地膨胀。应添加 expires_at 字段,并安排任务定期删除过期的记录。
  • 忽视客户端端的竞态条件问题。当多个API请求同时发起,而访问令牌早已过期时,它们会同时返回403错误。如果前端为此并行发送多个/refresh请求,轮换机制会将其视为重复使用令牌的行为并终止会话。应通过将所有外出请求排队在单个正在处理的刷新请求之后来避免这种情况。
  • 某些情况下一个令牌可能就足够了

    这种双令牌方案是现代网页和移动客户端的标准选择,但并非在所有场景下都是必需的:

    • 内部服务间通信:机器与机器之间的交互通常依赖客户端凭证流程、短有效期令牌或相互TLS加密,这些方式都不需要交互式的刷新机制。
    • 基础服务器端渲染应用:对于传统的服务器端渲染应用程序——比如使用经典Express模板构建的应用——借助Redis等内存存储支持的简单会话Cookie通常更易于管理,且安全性也相当。

    在构建单页应用(React、Vue或移动客户端)且这些应用需要与无状态REST或GraphQL后端交互时,应采用访问令牌和刷新令牌的模式。

    总结

    将所有功能都依赖同一个JWT会迫使你做出本不必做出的权衡:要么缩短会话时间,导致用户频繁登录而感到困扰;要么延长会话时间,从而面临严重的安全风险。

    将认证功能拆分为两个令牌即可解决这一矛盾:

    • 访问令牌能让每个请求都快速处理且无状态,从而避免数据库频繁查询。
    • 刷新令牌可安全地实现会话的长期持久化,使你能够立即撤销被攻破的会话。

    若能花精力正确配置这两种令牌,就能为用户带来流畅的体验,同时让系统有效抵御此处提到的各种安全问题。