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 整体流程:一张图说清楚
我把整个认证与会话管理的流程画了出来,你看一眼就明白了:
3.6 实战中的几个关键细节
最后分享几个我踩过的坑,你写代码时注意一下:
- Token 刷新策略:不要用长期有效的 Token。我习惯用 2 小时过期 + 7 天刷新 Token。刷新 Token 单独一个接口,且必须校验旧 Token 是否已被吊销。
- 上下文泄漏:用
AsyncLocalStorage时,一定要确保每个请求都调用了run()。我曾经漏了一个中间件,结果某个接口拿到的 userId 是上一个请求的,排查了一整天。 - 日志脱敏:打印用户上下文时,把密码、Token 本身都抹掉。只记录 userId、角色和操作时间就够了。
- 性能监控:JWT 验证本身很快,但 Session 检查如果每次都查 Redis,高并发下还是有压力。我建议加一层本地缓存,缓存时间设为 1-2 秒,能扛住大部分重复请求。
我的习惯:在开发环境把 JWT 过期时间设成 10 分钟,强迫自己频繁测试刷新流程。很多团队上线后才发现刷新逻辑有 bug,就是因为开发时 Token 设得太长了。
认证与会话管理这块,说白了就是「信任传递」。客户端信任 Token,服务端信任上下文,中间件负责把这种信任关系串起来。地基打牢了,后面的配方修改保护才能站得住。