3. 用户认证与会话管理:JWT令牌、Session管理、用户上下文获取

认证和会话管理,说白了就是解决「你是谁」和「你还在不在」这两个问题。我见过不少项目,权限模型设计得挺漂亮,结果在认证这层就漏了洞,配方保护也就成了摆设。这一章我们就把这块地基打扎实。

3.1 两种主流方案:JWT vs Session

先看个对比,心里有个底:

维度 JWT(无状态) Session(有状态)
存储位置 客户端(Token 本身) 服务端(Session 存储)
扩展性 天然支持分布式 需要共享 Session 存储
吊销能力 弱(需黑名单机制) 强(直接删除 Session)
性能开销 验证快(无 DB 查询) 每次需查存储
典型场景 API 服务、移动端 传统 Web 应用

我个人习惯在配方修改这类核心业务上,采用 JWT 做认证,配合一个轻量级的 Session 黑名单做兜底。为什么?因为配方修改的 API 调用频率高,每次去查 Redis 验证身份,延迟上去了,体验就差了。

3.2 JWT 令牌:结构、签发与验证

JWT 由三部分组成,用点号分隔:Header.Payload.Signature。我拆开给你看:

// 一个典型的 JWT 结构
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Header 声明算法和类型。Payload 放用户标识、角色、过期时间等声明。Signature 用密钥对前两部分签名,防止篡改。

关键点:Payload 是 Base64 编码,不是加密。千万别把密码、身份证号这类敏感信息放进去。我在项目中见过有人把用户手机号直接放 Payload,结果前端一解码全暴露了。

签发 Token 的代码,我习惯这么写:

// Node.js 示例:签发 JWT
const jwt = require('jsonwebtoken');

function issueToken(user) {
  const payload = {
    sub: user.id,
    role: user.role,
    // 配方修改权限标识
    permissions: user.permissions
  };

  return jwt.sign(payload, process.env.JWT_SECRET, {
    expiresIn: '2h',        // 过期时间
    issuer: 'recipe-system' // 签发者
  });
}

验证时,中间件会做三件事:解码、验签、检查过期。嗯,这里要注意:验签失败直接返回 401,不要给任何提示信息,防止攻击者猜测密钥。

3.3 Session 管理:有状态方案的必要补充

JWT 虽好,但有个硬伤——无法主动吊销。假设一个操作员的账号被盗,你改了密码,但他手里的 JWT 在过期前依然有效。这时候就需要 Session 管理来兜底。

我的做法是维护一个「活跃 Session 表」,结构很简单:

// Redis 中的 Session 存储结构
// Key: session:{userId}:{tokenId}
// Value: { "issuedAt": 1700000000, "device": "Chrome/120" }
// TTL: 与 JWT 过期时间一致

// 吊销操作
async function revokeUserSessions(userId) {
  const keys = await redis.keys(`session:${userId}:*`);
  if (keys.length > 0) {
    await redis.del(keys);
  }
}

每次请求验证 JWT 时,顺便查一下这个 Token 是否还在活跃 Session 表里。不在的话,说明已被吊销,直接拒绝。

避坑指南:我曾经在 Session 表里只存了 userId,没存 tokenId。结果吊销时把用户所有设备都踢下线了,包括他自己正在用的手机。后来改成 userId + tokenId 的组合键,每个设备独立管理,才解决问题。

3.4 用户上下文获取:贯穿请求生命周期的身份传递

认证通过后,怎么让后续的每个业务逻辑都能拿到当前用户信息?我见过有人每个方法都传 userId 参数,代码里全是 getRecipe(userId, recipeId),又丑又容易漏传。

更好的做法是用「请求上下文」模式。以 Node.js 的 async_hooks 为例:

const { AsyncLocalStorage } = require('async_hooks');
const authStorage = new AsyncLocalStorage();

// 中间件:解析 Token 并注入上下文
function authMiddleware(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    // 注入用户上下文
    authStorage.run({
      userId: decoded.sub,
      role: decoded.role,
      permissions: decoded.permissions
    }, () => next());
  } catch (err) {
    res.status(401).json({ error: 'Unauthorized' });
  }
}

// 业务代码中获取上下文
function getCurrentUser() {
  const store = authStorage.getStore();
  if (!store) throw new Error('No auth context');
  return store;
}

// 配方修改时检查权限
function modifyRecipe(recipeId, changes) {
  const user = getCurrentUser();
  if (!user.permissions.includes('recipe:write')) {
    throw new Error('Permission denied');
  }
  // ... 执行修改逻辑
}

这样做的好处是:业务代码完全不需要关心 Token 怎么来的,只需要调用 getCurrentUser() 就能拿到当前操作员信息。代码干净,测试也方便——测试时直接 mock 上下文就行。

3.5 整体流程:一张图说清楚

我把整个认证与会话管理的流程画了出来,你看一眼就明白了:

用户认证与会话管理流程 客户端 登录请求 认证服务 签发 JWT 返回 Token 存储 Token 客户端 请求 + Token 验证中间件 验证 JWT Session 检查 注入上下文 业务逻辑 getCurrentUser() 用户上下文 返回响应 说明: JWT 负责无状态认证,Session 表负责主动吊销,AsyncLocalStorage 负责上下文传递。 三者配合,既保证了性能,又兼顾了安全性。

3.6 实战中的几个关键细节

最后分享几个我踩过的坑,你写代码时注意一下:

  • Token 刷新策略:不要用长期有效的 Token。我习惯用 2 小时过期 + 7 天刷新 Token。刷新 Token 单独一个接口,且必须校验旧 Token 是否已被吊销。
  • 上下文泄漏:用 AsyncLocalStorage 时,一定要确保每个请求都调用了 run()。我曾经漏了一个中间件,结果某个接口拿到的 userId 是上一个请求的,排查了一整天。
  • 日志脱敏:打印用户上下文时,把密码、Token 本身都抹掉。只记录 userId、角色和操作时间就够了。
  • 性能监控:JWT 验证本身很快,但 Session 检查如果每次都查 Redis,高并发下还是有压力。我建议加一层本地缓存,缓存时间设为 1-2 秒,能扛住大部分重复请求。

我的习惯:在开发环境把 JWT 过期时间设成 10 分钟,强迫自己频繁测试刷新流程。很多团队上线后才发现刷新逻辑有 bug,就是因为开发时 Token 设得太长了。

认证与会话管理这块,说白了就是「信任传递」。客户端信任 Token,服务端信任上下文,中间件负责把这种信任关系串起来。地基打牢了,后面的配方修改保护才能站得住。