为何并发的401认证会使用户登出,以及单次刷新的解决方案
并行请求与刷新令牌轮换是如何相互影响导致意外登出,以及 Axios 拦截器中的共享刷新承诺又是如何避免这种情况的。
一名用户正在填写长表单时,应用程序突然将其带回登录界面。没有出现错误或崩溃,但用户输入的所有内容都消失了。令牌过期逻辑看似正确,刷新流程也看似正常,但会话仍不断提前结束。下文将解释为何当多个请求同时遇到过期令牌时会出现这种情况,为何仅通过阅读代码很难发现此漏洞,以及如何通过对 Axios 拦截器进行微小修改、共享同一个正在处理的刷新承诺来解决该问题。
症状:本不应发生的登出现象
想象一下现场工作人员使用的案件管理平台。工作人员通常通过平板电脑输入检查或检测数据,而网络连接往往不可靠。刚生成一份报告,另一位用户又提交了新报告:因为他们在填写表格时,应用程序就强制使他们登出。在这样的系统中,这绝非小麻烦,可能需要重新花费20分钟来仔细输入数据。
最可能的原因是访问令牌过期,但初步检查后似乎并无问题。访问令牌的有效期为15分钟。当API调用返回401错误时,客户端会调用刷新接口,存储新的令牌后继续操作。按照这种描述,使用过程中不应出现会话中断的情况。如果最明显的解释成立,那么正确的做法是停止依赖对代码的理解,而是亲自重现故障。
重现令牌过期导致的竞争条件问题
该漏洞仅在一种情况下会出现:在访问令牌即将过期的时刻,有若干API调用几乎同时被触发。多部分表单正是理想的触发条件——它可能在几毫秒内依次发送验证请求、自动保存请求以及文件上传状态查询。如果令牌在这段时间内过期,所有请求几乎会同时返回401错误。
拦截器原本是为单次请求场景设计的:遇到401错误时,调用刷新接口获取新令牌,然后再重新发送原始请求。在每次只进行一次请求的情况下,它的功能表现完美。然而当有四次请求同时失败时,它就会立即启动四个独立的刷新请求。
这与一个合理的后端设计决策相契合,即刷新令牌轮换。当刷新令牌被使用后,服务器会使其失效并生成新的令牌,这样被盗的刷新令牌就无法被无限次重复使用。现在来看四种并行发生的刷新情况:
- 第一次刷新请求成功,获得了新的刷新令牌。
- 在第一次响应到来之前,第二、第三和第四次刷新请求已经使用了旧的刷新令牌。
- 服务器会拒绝这些请求,因为该令牌刚刚已被标记为无效。
- 拦截器会将失败的刷新解读为“会话确实已结束”,并强制用户登出。
访问令牌的有效期从来都不是问题。错误的假设是刷新操作永远不会同时发生。许多令牌轮换实现甚至将旧刷新令牌的重复使用视为被盗用的迹象,从而撤销整个令牌系列,这反而让同样的竞争问题演变成更为极端的登出机制。
为何阅读代码也无法发现问题
相比解决方案,这或许才是更有价值的教训。从上到下阅读代码后可以发现,拦截器的设计是正确的:捕获401错误后进行刷新并重试。这就是它被设计来执行的流程,重新阅读代码只是再次确认了这一流程。
但阅读代码无法揭示的是,拦截器并非只运行一次。它会在每次请求失败时运行一次,而这些请求是同时发生的,并非按顺序执行。如果像处理线性脚本那样进行调试,就会在忽略各步骤执行时间的情况下,只考虑每一步的功能。
一旦在每次刷新请求时记录时间戳,这种模式几乎会立即显现出来。在这种情况下,你会看到在大约40毫秒的时间间隔内有四次刷新尝试,且都指向同一个端点。原本需要花一小时来分析逻辑,现在只需两分钟观察时间戳即可。当根据代码判断某个错误“不可能发生”时,通过追踪事件的顺序和时间往往比重新阅读代码更快。
解决方案:仅进行一次刷新,其余请求等待
一旦问题的真实状况明确,解决方案就很简单。拦截器不会让每个401错误都触发独立的刷新操作,而是会检查是否已有刷新在进行中。如果已有刷新在运行,新的错误就不会再启动新的刷新,而是等待同一个待处理的承诺,并在该承诺解决后重新尝试。这种方法通常被称为单次飞行模式。
实现这一功能的第一步是创建一个模块级变量,用于存储正在进行的刷新操作,如果没有刷新在运行,则该变量的值为null:
let refreshPromise = null;
当请求因 401 错误失败时,会调用下面的处理函数。如果 refreshPromise 为空,它会调用 refreshAccessToken() 并保存返回的承诺对象,同时添加一个 finally 块,无论刷新操作成功与否都会将该变量重置为 null,以便在下次到期时能够重新尝试。所有的调用者——无论是第一个还是之后的所有调用者——都会等待同一个承诺对象,将其新的访问令牌设置到原始请求配置中,然后通过 axios 重新发送该请求。
async function handleUnauthorized(originalRequest) {
if (!refreshPromise) {
refreshPromise = refreshAccessToken().finally(() => {
refreshPromise = null;
});
} const newToken = await refreshPromise;
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return axios(originalRequest);
}
语法并非关键,重要的是理念:使用一个共享的承诺对象,让所有失败的请求都等待该对象的结果,而非各自尝试刷新。一旦出现第一个 401 响应,就会启动刷新流程;在刷新进行中若有新的 401 响应出现,会直接复用之前的结果,而无需再次使用刷新令牌。由于 JavaScript 在单线程上运行,refreshPromise 的检查与赋值操作无法在多个调用之间交错执行,正因如此,这样的简单保护机制就足够了。
当将此逻辑集成到实际的拦截器中时,还有几处细节需要处理:
- 如果刷新操作本身失败,所有正在等待的请求会一同被拒绝,这样应用只需执行一次完整的登出操作,而非多次冲突的登出尝试。
_retry 标志),这样在刷新后再次出现 401 错误的请求就不会无限循环。401 错误会导致其再次尝试刷新。关于相同设计在服务器端的实现,包括令牌轮换与撤销机制,请参阅我们关于 Node.js 认证系统的刷新令牌策略 的指南。
核心要点
通过实现单次刷新机制,那些意外的登出现象便不再出现。更重要的变化在于一种习惯的养成:将并发调用视为默认情况,而非需要日后处理的边缘情况。每当你编写拦截器、队列或任何用于处理异步故障的处理器时,都要思考如果在50毫秒的时间窗口内它被执行了四次会怎样。来自繁忙表单和不稳定的移动连接的真实生产环境流量,迟早会出现这种情况。
- 这个漏洞其实与刷新令牌无关,而是源于那些假设事件会依次发生的代码。
- 刷新令牌轮换是一种良好的安全实践,但正是它导致了重复的刷新请求问题。
- 逐行阅读时看似正确的代码,在并发环境下仍可能出错;应记录时间戳以了解事情发生的时机,而不仅仅是发生了什么。
finally块中重置该承诺,从而避免重复尝试的循环。相关阅读
- 在 Node.js 中同时实现访问令牌与刷新令牌 — 了解如何在 Node.js 中将短效的访问令牌与可轮换的刷新令牌结合使用,以在安全性与流畅的用户会话之间取得平衡。
- 为何 React 的 useEffect 回调函数绝不能是异步函数 — 了解为何从 useEffect 中返回异步函数会破坏 React 的清理机制,并查看四种安全处理异步逻辑的正确方式。