ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Feathers 中实现 JWT 撤销(Revoking JWTs):认证服务的自定义方案与 Redis 实践

2026/9/21 15:28:52 拓冰建站 浏览量
Feathers 中实现 JWT 撤销(Revoking JWTs):认证服务的自定义方案与 Redis 实践 Feathers 中实现 JWT 撤销Revoking JWTs认证服务的自定义方案与 Redis 实践【免费下载链接】feathersThe API and real-time application framework项目地址: https://gitcode.com/gh_mirrors/fe/feathers导读JWTJSON Web Token本质上是一种无状态令牌在签名密钥不泄露、令牌未过期的情况下服务端无法阻止一个仍然有效的 JWT 继续被使用。在 Feathers 框架中默认的登出只是让客户端忘记令牌通常是从 localStorage 中删除这并不能真正让令牌失效。本文基于官方 Cookbook 文档讲解如何通过继承并定制 AuthenticationService 实现真正的 JWT 撤销先给出基于普通对象的入门实现再给出基于 Redis 的、可自动清理过期令牌的生产级方案并配合仓库源码剖析其底层调用链。读完本文你将掌握在 Feathers 应用中实现登出即失效令牌的完整实战能力。为什么默认 JWT 无法被撤销默认情况下一个有效的 JWT 在其有效期expiresIn默认在 packages/authentication/src/options.ts 中为1d内可以一直被使用。正常的登出流程只是客户端丢弃令牌客户端删除本地存储中的 JWT服务端对该令牌没有任何记录因此令牌本身依然有效。要真正撤销一个访问令牌需要引入服务端状态把被撤销的令牌记录下来并在每次验证令牌时先检查它是否在撤销列表中。Feathers 的认证服务为此提供了天然的扩展点。核心扩展点AuthenticationService 的三个方法在动手写代码之前先理解我们要覆写的三个方法在框架源码中的真实行为源码位于 packages/authentication/src/service.tsremove(id, params)service.ts#L151-L163登出入口。当id不为null时必须等于params.authentication.accessToken否则抛出NotAuthenticated(Invalid access token)随后通过this.authenticate(authentication, params, ...authStrategies)重新验证认证信息并返回结果。该方法的返回结果中通常包含accessToken这正是我们要在登出时拿到并撤销的令牌。verifyAccessToken(accessToken)实现在基类 packages/authentication/src/core.ts#L221-L240使用配置中的secret与jwtOptions调用jsonwebtoken.verify验证令牌验证失败会抛出NotAuthenticated错误错误类型定义见 packages/errors/src/index.ts#L74-L78对应 HTTP 401。它被 JWTStrategy.authenticate 在每次携带令牌的受保护请求中调用——这正是我们插入撤销检查的最佳位置。revokeAccessToken(accessToken)框架基类中并不存在此方法它是我们自定义子类新增的方法用于把令牌标记为已撤销。原文档约定先验证令牌有效性再执行撤销逻辑。由此可以推断出整个撤销流程登出时调用remove→ 取出accessToken→ 调用revokeAccessToken写入撤销存储后续每次请求 →verifyAccessToken先查撤销存储 → 命中则抛NotAuthenticated→ 否则走默认 JWT 验证。基本示例使用普通对象存储已撤销令牌以下是最简实现适合理解撤销机制的核心流程。在生产应用中应使用下文使用 Redis一节中的方案替代普通对象存储。const { AuthenticationService } require(feathersjs/authentication); const { NotAuthenticated } require(feathersjs/errors); const revokedTokens {}; class RevokableAuthService extends AuthenticationService { async revokeAccessToken (accessToken) { // First make sure the access token is valid const verified await this.verifyAccessToken(accessToken); revokedTokens[accessToken] true; return verified; } async verifyAccessToken(accessToken) { // First check if the token has been revoked if (revokedTokens[accessToken]) { throw new NotAuthenticated(Token revoked); } return super.verifyAccessToken(accessToken); } async remove (id, params) { const authResult await super.remove(id, params); const { accessToken } authResult; if (accessToken) { // If there is an access token, revoke it await this.revokeAccessToken(accessToken); } return authResult; } } app.use(/authentication, new RevokableAuthService(app));代码要点逐行解析revokeAccessToken先验证再撤销先调用this.verifyAccessToken(accessToken)确认令牌本身有效签名正确、未过期防止把无效或伪造的令牌写进撤销列表。注意此时调用的是覆写后的verifyAccessToken但由于该令牌尚未被标记检查会正常通过并落到super.verifyAccessToken。覆写verifyAccessToken的检查顺序先查撤销列表再调用super.verifyAccessToken(accessToken)走默认的 JWT 签名与过期验证底层实现见 core.ts#L221-L240。由于 JWTStrategy 每次认证都会走到该方法见 jwt.ts#L142一旦令牌被撤销所有携带该令牌的请求都会立即收到 401NotAuthenticated。覆写remove触发撤销先调用super.remove(id, params)完成框架默认的登出逻辑重新认证并触发logout事件见 service.ts#L151-L163 以及构造函数中注册的event(logout)/connection(logout)hooksservice.ts#L42-L45然后从返回结果中取出accessToken执行撤销。注册服务app.use(/authentication, new RevokableAuthService(app))用自定义子类替换默认的认证服务所有依赖app.service(authentication)或POST /authentication的调用都会走新的撤销逻辑。本方案的局限普通对象revokedTokens存于进程内存应用重启即丢失令牌永久保存在对象中不会自动清理内存会随撤销次数无限增长多实例部署时各进程内存不共享撤销只对当前实例生效。因此它仅适合演示或单实例、低并发的临时场景。使用 Redis带过期时间的撤销存储Redis 是存储已撤销 JWT 的理想方案因为它支持键过期TTL一个被撤销的 JWT 不需要永久保存一旦其自然过期就再也无法通过验证届时从 Redis 中清除记录也不会产生任何安全影响。流程与基本示例完全相同只是把普通对象换成 Redis。首先安装 NodeJS Redis 客户端npm install redis然后实现基于 Redis 的认证服务const redis require(redis); const { AuthenticationService } require(feathersjs/authentication); const { NotAuthenticated } require(feathersjs/errors); class RedisAuthService extends AuthenticationService { constructor (app, configKey) { super(app, configKey); const client redis.createClient(); this.redis { client, get: client.get.bind(client), set: client.set.bind(client), exists: client.exists.bind(client), expireat: client.exists.bind(client) } (async () { await this.redis.client.connect(); })() } async revokeAccessToken (accessToken) { // First make sure the access token is valid const verified await this.verifyAccessToken(accessToken); // Calculate the remaining valid time for the token (in seconds) const expiry verified.exp - Math.floor(Date.now() / 1000); // Add the revoked token to Redis and set expiration await this.redis.set(accessToken, 1, { EX: expiry }); return verified; } async verifyAccessToken(accessToken) { if (await this.redis.exists(accessToken)) { throw new NotAuthenticated(Token revoked); } return super.verifyAccessToken(accessToken); } async remove (id, params) { const authResult await super.remove(id, params); const { accessToken } authResult; if (accessToken) { // If there is an access token, revoke it await this.revokeAccessToken(accessToken); } return authResult; } } app.use(/authentication, new RedisAuthService(app));与基本示例的差异详解构造函数中初始化 Redis 客户端redis.createClient()创建客户端并通过 IIFE 异步调用client.connect()建立连接对应redis包 v4 的异步 API。this.redis中保存了get、set、exists等方法与客户端的绑定引用便于后续调用。revokeAccessToken计算剩余有效期verifyAccessToken返回的verified是 JWT 解码后的 payload其中exp是令牌的过期时间戳秒。expiry verified.exp - Math.floor(Date.now() / 1000)计算令牌剩余有效秒数作为 Redis 键的过期时间。set(accessToken, 1, { EX: expiry })以令牌字符串本身为键、1为值写入 Redis并设置EX秒级过期时间。这样撤销记录会在令牌自然过期时被 Redis 自动删除不会永久占用存储。这正是原文档推荐的撤销后不必永久保存的关键设计。verifyAccessToken用exists快速判断await this.redis.exists(accessToken)返回 1 表示该令牌已被撤销直接抛出NotAuthenticated(Token revoked)否则继续走super.verifyAccessToken。原文档中的expireat: client.exists.bind(client)是一个笔误将exists绑定给了expireat属性而该属性在示例中并未被使用实际生效的过期逻辑完全由set的EX选项承担不影响功能正确性。在实际代码中建议直接删除这一行。使用 Redis 方案的优势撤销记录带 TTL令牌过期后自动清理存储量有界Redis 本身可作为独立存储被多个应用实例共享撤销记录全局生效读写均为 O(1) 操作对请求热路径的性能影响极小。撤销在请求链路上的生效过程自定义认证服务注册后撤销机制会贯穿整个认证链路客户端登出调用POST /authentication或app.service(authentication).remove请求体携带strategy与accessToken。服务端removesuper.remove先校验id与令牌一致service.ts#L155-L158并重新认证随后自定义逻辑把令牌写入撤销存储同时触发logout事件。客户端后续请求携带已被撤销的 JWT 访问受保护服务经过 authenticate hook 时调用JWTStrategy.authenticatejwt.ts#L134-L163其中this.authentication.verifyAccessToken(accessToken)触发我们覆写的方法。校验命中撤销列表抛出NotAuthenticated(Token revoked)客户端收到 HTTP 401未命中则走默认签名与过期校验。这一行为可通过 packages/authentication/test/service.test.ts 中关于remove与verifyAccessToken的既有测试辅助理解例如测试验证了id与accessToken不匹配时抛出NotAuthenticated(Invalid access token)service.test.ts#L215-L230说明remove的入参校验是撤销流程的前置防线而create相关测试service.test.ts#L54-L74展示了accessToken会携带aud、iss、jti等标准声明其中exp正是计算 Redis TTL 所依赖的字段。注意事项与扩展建议构造子类时必须调用super(app, configKey)认证服务的构造函数负责初始化配置、注册login/logout事件 hooks 与断线处理service.ts#L39-L61遗漏会导致事件与实时连接认证失效。覆写方法优先调用super如原文档与 认证服务文档 所强调除非完全确定自定义行为等价否则应先调用super.method并基于其返回值扩展避免破坏默认行为。entity可设为null如果使用不依赖实体服务的无状态令牌方案参见 stateless.md认证配置中entity: null撤销逻辑同样适用因为verifyAccessToken与remove不依赖实体服务。撤销粒度本方案以整个令牌字符串为撤销单位适用于登出场景。若需要按用户维度批量作废令牌如修改密码后踢出所有会话可进一步扩展为在 Redis 中按sub用户 ID或jti令牌 ID由 core.ts#L206-L209 默认以 UUID 生成建立索引在verifyAccessToken中联合查询。生产配置实际部署时应把 Redis 连接参数host、port、password放入配置文件并通过 configuration 机制注入而非硬编码在构造函数中。小结JWT 的无状态特性决定了默认不可撤销但 Feathers 通过允许继承AuthenticationService并覆写verifyAccessToken与remove为令牌撤销提供了清晰、优雅的扩展点。本文给出的两种方案普通对象、Redis完整覆盖了从理解机制到生产落地的路径核心思路始终是——登出时记录令牌验证时先查撤销列表并结合 JWT 的exp声明让撤销记录随令牌一同过期。相关底层实现可继续阅读 packages/authentication/src/service.ts、packages/authentication/src/core.ts 与 packages/authentication/src/jwt.ts 深化理解。【免费下载链接】feathersThe API and real-time application framework项目地址: https://gitcode.com/gh_mirrors/fe/feathers创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考