Node.js认证系统的刷新令牌策略
了解如何在 Node.js 中设计、旋转、撤销以及安全存储刷新令牌,从而使令牌被盗和登出功能能够按预期运行。
当一个 Node.js 项目首次实现登录功能时,似乎很快就能完成这项工作。
其流程看起来相当简单:用户输入邮箱和密码,服务器将凭证与数据库中的信息进行比对,如果匹配,则生成 JWT 并返回给客户端。此后每次请求都会携带该令牌。从表面上看,这似乎就是一个完整的认证系统。
但仔细审视后会发现,这个简单的流程存在许多未得到解答的问题:
- 令牌过期后会发生什么?
- 是否要求用户每次都重新登录?
- 如果令牌被盗,攻击者能使用它多久?
- 实际操作中如何实现用户登出?
- 是否有办法撤销已经发放的令牌?
“先签名生成JWT,稍后再验证”这种做法无法回答上述问题。通常这时就需要摆脱照搬的登录教程,真正了解刷新令牌的工作原理。
为什么访问令牌的有效期很短
短暂的访问令牌并非有人忘记更改的默认值,而是经过深思熟虑的设计选择。
试想如果访问令牌的有效期为30天且落入坏人手中,攻击者就能拥有该用户账户整整一个月的访问权。这样的风险显然是不可接受的。正因如此,访问令牌的有效期通常只有几分钟而非几周——较短的生命周期能在令牌泄露时限制其危害范围。
不过,这样的设计选择也会带来新的问题。如果令牌每15分钟过期一次,用户是否就需要每隔15分钟重新输入密码?显然这是不可行的。刷新令牌的存在正是为了解决这一缺陷。
那么刷新令牌到底有什么作用呢?
乍看之下,刷新令牌似乎只是另一种令牌,但它在系统中的作用与访问令牌截然不同。
其工作流程通常如下:
- 用户登录。
- 服务器返回访问令牌和刷新令牌。
- 使用访问令牌来发起API请求。
- 最终访问令牌会过期。
- 客户端将刷新令牌发送到专用的刷新端点。
- 如果服务器成功验证该令牌,就会生成全新的访问令牌。
这里需要牢记的一个概念是:刷新令牌绝不应直接调用你的 API 接口。它的唯一作用就是获取新的访问令牌。一旦在心中将这两项功能区分开来,其余的设计就会逐渐清晰起来。
访问令牌与刷新令牌的对比
访问令牌用于调用受保护的 API,而刷新令牌仅用于获取新的访问令牌。访问令牌的有效期较短,刷新令牌的有效期则较长。几乎每次请求都会附带发送访问令牌,而刷新令牌则仅偶尔使用。如果访问令牌泄露会带来问题,但若刷新令牌泄露后果更严重,因为它可以被用来不断生成新的访问令牌。每次调用 API 都会检查访问令牌,而刷新令牌则仅由专门的刷新或会话逻辑来检查。
常见的说法是访问令牌的有效期为15分钟,而刷新令牌的有效期为7天,但这些数字并无特殊之处。合适的数值完全取决于您的应用程序及用户所能接受的时长。
为何刷新令牌值得比乍看之下更多的重视
只要刷新令牌有效,就能持续保持会话的活跃状态。如果攻击者获得了它,他们不仅能获得一次访问机会,还能不断生成新的访问令牌,直到该刷新令牌过期或被撤销。
这一现实状况决定了应当如何处理这些令牌。它们并非普通的应用程序数据,更像是实体房屋钥匙。以这种方式对待它们意味着需要认真考虑以下问题:
- 过期处理
- 撤销机制
- 令牌轮换
通常正是在这里,看似简单的 JWT 设置才会开始暴露出其弱点。
令牌轮换:避免单个令牌永久有效
刷新令牌轮换这一概念改变了该系统的设计方式。系统不会允许单个刷新令牌被无限重复使用,而是每当当前令牌成功使用后,服务器就会发放一个新的令牌,同时立即作废旧令牌。
这样一来,就不会有一个长期有效的凭证持续存在,而是形成一系列依次更替的令牌:
当令牌A被使用后,会触发令牌B的发放,同时令牌A则被废弃。随后令牌B被使用,又会促使令牌C的发放,而令牌B则再次被废弃。这种模式会无限循环下去,链中仅有最新的令牌始终有效。
为何这确实有用
假设存在这样一种情况:在合法用户仍持有令牌A的同时,攻击者设法获得了它的副本。
如果合法用户先使用该令牌,那么令牌A就会被标记为已使用并实际上失效,此时会发放令牌B来替代它。之后当攻击者试图再次使用同一个令牌A时,服务器会识别出它已是已被消耗的令牌,这显然是一个危险信号。根据系统配置的严格程度,这种检测可能会触发与该会话相关的整个令牌链的撤销,而不仅仅是那个已被攻破的单一令牌。
这样就能让你处于比那种谁先拿到令牌就永远掌控全局的架构强得多的位置。
撤销机制:因为登出应当真正具备意义
JWT通常被描述为无状态的,从技术层面来看这确实没错。但任何现实世界的系统至少都需要一些状态信息,而登出正是这种需求最明显的体现。
如果用户点击登出,而仅仅只是将令牌从前端删除,那么该令牌在自然过期之前依然完全有效。服务器根本无法感知用户已经“登出”,因此只要该令牌在过期前再次被提交,服务器仍会继续接受它。
刷新令牌为从服务器端追踪并终止会话提供了实际机制。在用户登出之前,该会话的刷新令牌处于有效状态;一旦完成登出操作,该令牌应被标记为已撤销,此后任何尝试使用它进行刷新的操作都会直接失败。这才是真正的登出流程,而非仅做表面处理的操作。
登出操作应在后端完成,而不仅仅是前端
初学者通常采用的简化版登出方式只是从本地存储或客户端保存令牌的任何位置删除该令牌,然后便结束操作。
更为完整且规范的登出流程通常会遵循以下步骤:
- 客户端发送
POST /auth/logout请求 - 服务器确定该请求对应的是哪个会话
此处需要强调的关键点是,登出本质上属于服务器端的安全操作。如果您的登出实现仅修改客户端存储的数据,实际上并未真正将用户登出,只是让界面忘记该用户的存在而已。
确定该令牌应存储的位置
存储方式的重要性比初看之下要大得多。对于基于浏览器的应用,常见的做法是将刷新令牌放在带有若干特定属性的cookie中:
HttpOnly,可防止客户端JavaScript直接读取该令牌Secure,确保该令牌仅通过HTTPS传输
SameSite,可降低遭受某些类型跨站攻击的风险然而,这些设置都无法让 Cookie 自动变得绝对安全。你仍需考虑 CSRF 防护措施、域名与路径的限定方式、会话过期处理机制,以及登出功能如何与这些要素协同工作。并没有一种可以直接从教程中复制过来、无需根据自身系统架构进行调整就能使用的存储方案。
如果刷新令牌仍然泄露会怎样
正是这种特定情况使得定期更换令牌的做法变得十分重要。
如果合法用户和攻击者都以某种方式获得了相同的刷新令牌,且您的系统允许该令牌无限次重复使用,那么就真的无法区分这两方了。从服务器的角度来看,这两种请求看起来都具有相同的合法性。
通过实施令牌轮换机制,该令牌在首次被使用后就会立即失效。因此,如果同一个令牌再次被使用,那就属于异常行为,可被视为一种警示信号。设计合理的系统可以将这种重复使用视为危险信号,并据此采取相应措施,比如撤销会话、标记账户,或应用符合您风险承受能力的其他策略。
这正是为什么刷新令牌应被视为一个安全设计难题,而非只需通过生成另一个JWT就能解决的问题的核心原因。
刷新令牌也需要设置过期时间
这很容易被忽视,但刷新令牌也不应是永久有效的。如果没有过期时间,被盗的刷新令牌实际上就会变成一个永远无法关闭的后门。
常见的设置方式是访问令牌的有效期为15分钟左右,而刷新令牌的有效期为7天,不过具体数值应依据你自身的风险承受能力来决定,而非随意采用遇到的第一份教程中的数字。
有必要在令牌有效期和会话有效期之间做出明确区分,因为它们并非同一个概念。即便每个单独的令牌仅能存在短暂时间,但通过不断的轮换机制,会话仍可保持长时间活跃状态。
值得避免或警惕的错误
- 访问令牌的有效期过长,一旦泄露会造成更大危害
- 刷新令牌完全没有过期时间,为攻击者提供了永久的入侵途径
- 完全不进行令牌轮换,使得窃取行为更难被发现
- 没有撤销机制,无法在会话自然过期前终止其运行
- 将登出操作视为仅在前端发生的操作
- 对敏感信息管理不当,导致令牌出现在日志、URL或本不应存放它们的客户端存储中
- 忽视重复使用检测,因为不检查重复情况就轮换令牌实际上并不能提供太多保护
实际测试刷新流程
认证功能应与应用程序中的其他关键路径一样接受充分的测试。以下是值得执行的基本测试步骤:
- 登录,确认能同时获取访问令牌和刷新令牌
- 使用有效的访问令牌调用受保护路由,确认能收到
200 OK响应 - 使用已过期的访问令牌调用受保护路由,确认会收到
401错误 - 调用刷新接口,确认能获得新的访问令牌(如果启用了令牌轮换,则还会获得新的刷新令牌)
- 尝试重新使用已被轮换的旧刷新令牌,确认会被拒绝
- 登出后,使用该已失效的会话尝试刷新,确认同样会被拒绝
在现阶段发现问题远比应用上线后才发现要便宜得多。
整体情况
Login
↓
Access Token + Refresh Token issued
↓
API requests using Access Token
↓
Access Token expires
↓
Refresh Token sent to refresh endpoint
↓
Server validates session
↓
Refresh Token rotated
↓
New Access Token issued
↓
API requests continue
如果在刷新流程的任何环节验证失败——令牌已过期、被撤销或根本无效——系统会返回401响应,用户必须重新从开头登录。将“调用API的短期权限”与“保持登录状态的长期权限”分开,正是这一整套设计的核心理念,用一句话概括就是如此。
预生产环境检查清单
令牌设计
- 访问令牌真的会很快过期吗?
- 刷新令牌是否有自己的有效期?
- 刷新令牌是否仅能用于指定的刷新接口,而不能用于其他任意路径?
- 令牌中携带的信息是否避免了不必要的冗余?
安全性
- 所有通信是否都必须使用HTTPS?
会话管理
- 能否按需撤销刷新令牌?
- 登出操作是在服务器端执行,而不仅仅是客户端吗?
- 是否真正实现了令牌轮换机制?
- 能否检测到令牌被重复使用的情况?
测试覆盖范围
- 有效的刷新令牌能成功使用
- 过期的刷新令牌会失败
- 已被撤销的刷新令牌会失败
- 格式错误或无效的刷新令牌会失败
- 此前已轮换掉的令牌会失败
- 正确执行登出操作能终止对应的会话
归根结底要点
身份验证并不仅仅是在用户登录时确认其身份,更重要的是决定——并实际执行——这种信任关系在之后应持续多久。
JWT让“验证用户身份”这一环节变得简单。但它不会免费提供会话管理、令牌撤销功能、正确的登出机制、防止令牌被盗的保护措施、重复使用检测、安全存储或合理的过期时间。所有这些都需要你自行做出选择。你的应用规模越大、使用人数越多,这些选择就越重要。
下一步是什么
既然访问令牌和刷新令牌的功能已经明了,接下来值得深入研究的领域就是更大规模下的会话管理:
- 如何处理在五台不同设备上保持登录状态的用户?
编写登录路由本身可能只需要十行代码,但要构建真正值得信赖的认证系统则需要付出远超此数的努力。
相关阅读
- 找出慢速 Node.js 接口的真正瓶颈 — 学习一种系统化的方法,通过请求路径——从 Node.js 代码到数据库查询——利用计时功能和 EXPLAIN ANALYZE 工具来追踪后端延迟。
- 在 Node.js 应用中构建生产级错误处理机制 — 了解如何对 Node.js 错误进行分类、设计自定义的错误层级结构、集中处理异步错误,以及如何保护堆栈跟踪信息以确保应用的稳定性。