Session与JWT:选择合适的Node.js认证模型
了解在 Node.js 认证中会话与 JWT 实际上的区别、各自的运作原理,以及如何选择二者而不会日后后悔。
两种方法背后的共同问题
HTTP 本身并不具备在多次请求之间保留任何信息的机制。除非请求附带身份验证信息,否则每个传入的请求看起来都像是来自完全陌生的人。会话和 JWT 都以相同的基本方式解决这个问题:它们会在后续请求中让客户端返回一段数据。实际上,两者的区别在于这段数据的性质,更重要的是,“谁已登录”的权威记录存储在何处。
方案一:会话
在基于会话的架构中,服务器掌握着权威性。用户登录后,服务器会生成一个随机会话标识符,将真实用户信息存储在 Redis 或数据库等地方与该标识符关联,而仅将标识符本身返回给客户端,通常以 Cookie 的形式存储。
app.post("/login", async (req, res) => {
const user = await authenticate(req.body.email, req.body.password);
const sessionId = generateSecureId();
await redis.set(`session:${sessionId}`, JSON.stringify({ userId: user.id }), "EX", 86400);
res.cookie("sessionId", sessionId, { httpOnly: true, secure: true });
res.json({ success: true });
});
app.use(async (req, res, next) => {
const sessionId = req.cookies.sessionId;
const session = await redis.get(`session:${sessionId}`);
req.user = session ? JSON.parse(session).userId : null;
next();
});
从那时起,每一个传入的请求都会触发对会话数据存储位置的查找。这一机制既解释了会话为何有用,也说明了其为何会产生额外开销。
优点:你可以立即且完全掌控哪些用户处于登录状态。无论是因为用户主动登出、密码重置,还是管理员关闭了被入侵的账户,终止会话只需在会话存储中执行删除操作即可,无需等待任何自动超时。
缺点:现在每个请求都需要往返于会话存储,这虽然增加的延迟和基础设施负担不大,但确实存在。同时,你的会话存储变成了所有服务器都必须访问的共享状态,一旦规模超过单台服务器,这就成了一个严重的架构问题。
第二种方案:JWT
JSON Web Token(JWT)的工作原理不同:服务器会对已包含用户数据的令牌进行签名,客户端在每次请求时都会重新发送该令牌。服务器只需验证签名即可确认令牌内容,无需到其他地方查询。
app.post("/login", async (req, res) => {
const user = await authenticate(req.body.email, req.body.password);
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, { expiresIn: "1h" });
res.cookie("token", token, { httpOnly: true, secure: true });
res.json({ success: true });
});
app.use((req, res, next) => {
try {
const decoded = jwt.verify(req.cookies.token, process.env.JWT_SECRET);
req.user = decoded.userId;
} catch {
req.user = null;
}
next();
});
该方案不需要进行数据库操作或Redis调用。加密签名本身就足以证明令牌自生成以来未被篡改。
优势:无需在每次请求时都查询数据库或缓存,从而显著提升速度;同时不存在所有服务器都需要访问的共享会话存储,这简化了水平扩展过程,也让无状态微服务架构更易于设计。
权衡之处:一旦 JWT 被发放,服务器在它到期之前没有内置方式来使其失效。如果该令牌被盗,或者需要立即切断用户的访问权限,服务器却无能为力,因为从一开始就没有任何集中存储的内容。这正是最常让人们措手不及的权衡点,通常是在他们已经按照 JWT 只是更快捷、更优的会话机制来设计系统之后。
引发实际问题的误解
“JWT是无状态的”这一说法经常被当作未经验证的卖点随意使用,以至于人们忽视了它真正的含义:无状态意味着默认情况下也是无需撤销的。如果会话cookie被攻破,只需将其删除即可。而被盗的JWT在其有效期内的整个时段内仍会保持完全有效,并继续受到服务器的信任,除非你特意设计了额外的机制来阻止这种情况。
人们通常采用的解决办法是维护一个已撤销令牌的黑名单:
app.use(async (req, res, next) => {
try {
const decoded = jwt.verify(req.cookies.token, process.env.JWT_SECRET);
const isRevoked = await redis.get(`revoked:${decoded.jti}`);
if (isRevoked) throw new Error("Token revoked");
req.user = decoded.userId;
} catch {
req.user = null;
}
next();
});
这种方法确实可行,但仔细看看你刚才的做法:你又引入了针对共享数据存储的每次请求检查,而这恰恰是JWT本应消除的开销。此时你已不再拥有真正的无状态系统。实际上你拥有的是一个伪装成无状态系统的会话型系统,其底层故障处理机制更为复杂。
那么到底该使用哪种方式呢
在以下情况下应选择会话机制:你需要即时且可靠的令牌撤销功能(比如银行应用、管理控制台或任何安全要求极高的场景);你在负载均衡器后运行应用程序,且该负载均衡器能够轻松指向共享会话存储;或者你更愿意维护单一的真实数据源,而非处理令牌有效期和黑名单逻辑。
何时应使用 JWT:当你在处理完全独立的多个服务之间的认证时,而这些服务并不都需要直接访问同一个共享会话存储;当你构建的系统中令牌的有效期较短(几分钟而非几天),这样就能接受撤销令牌带来的时间间隔;或者你追求无状态设计的真正原因是需要为众多独立的 API 使用者提供服务,而不仅仅是因为它听起来更简洁。
一个值得注意的细节:在实际应用中,大多数生产系统并不会直接选择其中一种方案。它们通常采用的做法是使用寿命较短的 JWT,并结合保存在服务器端的刷新令牌。这是一种混合方案而非非此即彼的选择:会话机制负责处理长期的信任关系及令牌撤销问题,而 JWT 则用于实现其间短暂的无状态验证。如果你将“会话与 JWT”视为非此即彼的选择,那往往意味着你尚未达到需要采用这种混合架构的程度,此时使用单纯的会话机制可能是更直接、更简单的起点。
实际决策
在这一切背后,问题从来都不是纯粹的技术层面,而是关于成本应由谁来承担的决策。Session会在每次请求时收取相应费用,但作为交换,你能获得始终实时、绝不会过时的控制能力。JWT则消除了每次请求的收费,但其代价是服务器所认为的情况与实际情况之间可能会逐渐出现偏差。无论人们如何讨论这两种技术,它们都不应被贴上“现代”或“过时”的标签。它们只不过是针对同一个永远存在的权衡而选择的两种不同方案罢了。
相关阅读
- Node.js认证系统中的刷新令牌策略 — 了解如何在Node.js中设计、轮换、撤销以及安全存储刷新令牌,从而确保令牌被盗或用户登出时能按预期发挥作用。
- Node.js流详解:解决内存不足导致的文件处理崩溃问题 — 了解为何将整个文件加载到内存中会导致Node.js服务器崩溃,以及如何通过可读流、可写流、双向流和转换流结合背压机制来解决这一问题。