---
title: "停用用户并不会终止其访问 — FoxAuth"
description: "在下次登录时才检查的标志会让所有会话和令牌继续有效。本文讲如何立即终止访问、按什么顺序，以及哪些东西仍然够不着。"
canonical: "https://foxauth.dev/zh-cn/blog/deactivating-a-user-is-not-deprovisioning/"
lastmod: "2026-10-08T21:45:57+03:00"
---
博客
停用用户并不会终止其访问
在下次登录时才检查的标志会让所有会话和令牌继续有效。本文讲如何立即终止访问、按什么顺序，以及哪些东西仍然够不着。
作者 FoxAuth · 2026-10-08

这个错误我们自己也犯过——在我们服务器的 0.7.0 版中（0.8.0 已经修复）。它是这样的。在控制台中停用一个用户。拿到他的应用程序十分钟前获得的访问令牌，问服务器这个令牌是否仍然有效（这种请求叫作内省）。回答是 "active": true：有效。在该用户的浏览器里打开应用程序——他仍处于登录状态。应用程序的日志里什么也没有：没有人告诉它这个用户已被停用。

从形式上说，什么都没坏。账户上设置了 active: false 标志，代码在每个查找账户的地方都会检查它。新的登录——拒绝。刷新令牌——拒绝。通过设备流程和 CIBA（在另一台设备上登录的方式）登录——同样拒绝。大多数停用功能都是这样实现的。我也是这样做的，并且以为这就够了。如果问题是「这个人能进来吗？」，这项检查就放在了正确的位置。但管理员停用某人时（通常很匆忙），问的是另一个问题：「这个人是不是已经没有访问权限了？」这两个问题的答案不同。二者之间的差距，就是按下按钮之前签发的所有会话和令牌。

active: false 标志只拦住下一次登录，已签发的会话和令牌继续有效

停用会在账户上设置 active: false 标志。这个标志针对的是未来：它会拦住下一次登录。但用户在停用之前拿到的会话和令牌，这个标志碰不到。签发它们时检查已经通过，而没有人会再检查一次。授权服务器上的浏览器会话在过期之前一直有效。访问令牌在其 exp（写在令牌本身里的过期时间）之前一直有效。如果它是不透明令牌（一串没有内容的随机字符串，只有服务器知道它），那么问服务器「这个令牌还有效吗？」，服务器仍然回答「是」。服务器只看两件事：令牌本身是否还存在，以及用户给这个应用程序的授权（grant）是否还存在。它不看 active 标志。

最后，应用程序自己也有一个会话——用户看到的正是这个会话。应用程序把登录检查交给了我们的服务器，所以标准里把它称为依赖方（relying party）。这个会话存活多久，由应用程序自己决定。而没有人通知过它任何事。

下图展示了在我们服务器的两个版本中，停用用户之后会发生什么。上面是 0.7.0：一切照常工作。下面是 0.8.0，这个问题已经修复。

所以只有一个标志是不够的。还需要一个操作，找出并终止标志够不着的一切。我们把它叫作清除。而这个操作中各步骤的顺序，比我预想的更重要。

先记下要通知谁登出，再删除会话

最显而易见的做法只有两行：设置 active: false，然后删除该用户拥有的一切。这是错的，而「会话已清除」这类测试发现不了它。因为必须通知依赖方：用户已经登出。OpenID Connect 为此提供了后端通道登出（back-channel logout）：服务器自己、不经过浏览器，向应用程序发送登出令牌——一条「这个会话已结束」的消息。每个会话一个这样的令牌。每个令牌里都有 sid——会话标识符。应用程序靠它在自己那边找到对应的会话并将其关闭。而 sid 只保存在会话记录里。所以服务器必须先读取会话，记下要通知哪些依赖方、用哪个 sid，然后才能删除它们。如果先删除会话，sid 就随之消失，也就没有东西可以发给应用程序了。服务器上的会话一个不剩，而在每个应用程序里，用户仍然处于登录状态。

所以顺序被写死在代码里。一个函数完成两个步骤，并且顺序永远不变：先通知应用程序，再删除记录：

export async function revokeAccountAccess(

accountId: string

): Promise<CascadeResult> {

await notifyRelyingParties(accountId);

return sweepAccountOwned(accountId);

}

notifyRelyingParties 首先读取该用户的所有会话。以前服务器做不到这一点：要找到某个用户的记录，唯一的办法就是删除这些记录的那个方法。所以三个存储适配器都新增了 findByOwner 方法——它只查找，不删除任何东西。

如果某个应用程序没有响应，函数不会停下来。服务器像普通登出时一样上报一个失败事件，然后继续执行。即使某个应用程序的登出地址无法使用，用户也必须失去访问权限。

现在说说清除本身——删除该用户拥有的一切。它不是一份凭记忆列出的表清单。我们服务器上的记录按存储区域划分：会话、令牌、授权码等等。每个区域自己声明，哪个字段记录了所有者。清除只是从每个区域中删除属于该用户的一切。因此，未来版本中新增的区域会被自动清除。没有人需要记得把它加到什么地方。

手工维护列表的版本我写过不止一次。一旦出现新的区域，它就会悄无声息地失效：新区域的记录比用户活得更久，而没有人注意到。还有另一种显而易见的做法——遍历该用户获得的授权（grant）。但这样会漏掉大部分记录。会话、进行中的登录、已绑定的第二因素和邮箱验证请求都挂在用户本身上，而不是挂在授权上。

令牌检查（内省）完全不需要改动。只要签发令牌所依据的授权不存在了，它本来就会回答 active: false。所以清除删掉授权时，所有不透明令牌会同时失效。

清除不碰登录失败计数器：它们不放人进来，而是起保护作用

有两个区域是清除特意不碰的：登录失败次数计数器和密码重置次数计数器。它们绑定的是电子邮件地址，而不是用户。看上去可能像是被遗漏了。其实不是。这些计数器不放任何人进来——它们统计有人针对这个地址猜了多少次密码。而你最可能需要紧急停用一个用户的时候，恰恰是他正遭受攻击的时候。如果在这时把计数器清零，攻击者就会在事件处理中途获得新的尝试次数。由此得出规则：清除删除让人进来的东西，保留把人挡在外面的东西。

如果清除没有删干净，服务器返回 500 并列出残留的内容

在我们的管理后台，审计日志有两条规则。第一：日志在变更之前写入。第二：日志只能追加，旧记录不会更改。这两条规则都是对的。但正因为有它们，最简单的报告部分失败的方式就行不通了——把「这三个区域没有清除干净」补写进一条在尝试之前就已写好的记录。

所以，如果清除之后还有残留，停用会返回 500，并列出没有清除干净的区域。此时用户已经无法登录：标志是最先设置的。残留的记录最多存活到各自区域的正常过期时间，也不会让任何人重新进来。要把活干完，只需再停用该用户一次。服务器不会回答「已停用」，而是再执行一次清除。每次都会做点什么的重复操作看起来很奇怪——直到你需要从这样的失败中恢复。

目录会覆盖 active 标志，所以需要单独的锁定

用户可以由企业目录管理，例如 Entra 或 Okta。目录通过 SCIM 协议在我们的服务器上自行创建、修改和停用用户——这叫作预配。对于这类用户，active 标志归目录所有：目录在每次同步时都会写入它。假设在事件响应中，管理员在我们这边手动停用了这样一个用户。几分钟后，定时同步会重新写入这个标志，悄悄推翻管理员的决定。这样的保护不能依赖。

所以我们有第二个标志——锁定。只有管理员能设置和解除它，目录碰不到它。是否放用户进来，由一个函数决定：它检查两个标志，每条登录路径都会调用它。这一点和标志本身同样重要。如果「是否放行」的检查有两份副本，它们迟早会出现分歧，被锁定的用户就会从落后的那份副本中通过。我们就遇到过这种情况。通过外部身份提供方（上游提供方）登录时只检查了 active。已经与这类提供方关联的被锁定用户通过了这一步。直到下一项检查才拦住他。

锁定偏离了 OpenID 的 IPSIE 规范（Interoperability Profiling for Secure Identity in the Enterprise——一套面向企业登录系统的安全要求）。该规范禁止在本地修改由目录管理的用户。我认为这种偏离是合理的。锁定只涉及登录，不修改用户的数据。而一个必须等待别人的系统完成同步的事件响应，就不算事件响应。

锁定执行与停用相同的清除。但解锁我们特意做得更难。我们的服务器也可以由 AI 智能体管理——通过 MCP（AI 智能体调用外部工具所用的协议）。对智能体来说，解锁属于高风险操作，没有确认就无法执行。毕竟，它会重新放行一个被管理员判定为已失陷的用户。

还有一点：无论是重新激活还是解锁，都不会恢复任何东西。用户的授权同意（他对每个应用程序说的「允许」）已随授权一起被删除。所以登录之后，用户要重新同意一次。第一次遇到时会让人意外。但这正是 IPSIE 所说的「all access mechanisms and authorizations」（所有访问机制和授权）的含义。

JWT 访问令牌无法撤销：它一直有效到过期

有一样东西是清除够不着的。与其承诺「停用会立即终止访问」，不如直接说清楚。这就是 JWT 访问令牌。资源服务器（接收令牌的 API）用已发布的密钥自行校验它，不会为此联系授权服务器。所以我们既没有可以拦截的东西，也没有可以删除的东西。这样的令牌在其 exp 之前一直有效。

剩下的唯一手段是缩短令牌的有效期。对于已声明的资源（管理员登记为受保护资源的 API 或 MCP 服务器），我们的服务器签发的令牌有效期为十五分钟。管理员可以调高它，但最长不超过一天。IPSIE SL2 允许普通令牌有效十五分钟，通过 DPoP 绑定到客户端密钥的令牌有效六十分钟。这是一个合理的界限：一个组织应当做好准备，在这么长的时间里离职员工的令牌仍然可用。如果某个资源服务器连这么久都等不了，它可以要求使用不透明令牌，并对每个令牌做内省。这意味着每次请求多一次网络往返，但用户一被停用就能立刻看到。我参与过的大多数资源服务器都会选择那十五分钟。

何时停用、何时锁定、何时在所有地方登出、何时删除

当这个人不应该再登录、而用户由你管理时，停用。当需要立刻关闭登录、而用户由目录管理时——或者当你不信任目录的下一次同步时，锁定。当人没问题、而他的设备有问题时，点击「Sign out everywhere」（在所有地方登出）。这个按钮用同样的清除关闭所有会话，但保持用户处于激活状态。当账户本身应该消失时，删除。删除同样会终止访问：删除之前会执行同样的步骤。

围绕清除的部分机制出现得比清除本身更晚。目录通过预配连接与我们的服务器对接——预配连接是一条把一个目录与一个用户桶关联起来的记录。用户桶是我们服务器对一组拥有自己登录规则的用户的称呼。用户桶有了自己的地址后，就以自己的名义签发令牌，也就是成为一个独立的签发者（issuer）。如果某个连接开始一次性停用过多用户，这个连接就会被暂停。这项保护已经通过了测试。但 Okta 的 CRUD 测试（检查用户的创建、读取、更新和删除）还没有在真实的 Okta 组织上运行过。没有 /Bulk 端点。Okta Universal Logout 会从外部提供方一侧调用同一个清除，但同样从未在真实的 Okta 组织上运行过。这些都没有提交认证。不过清除本身，我们已在一台运行中的服务器上完整验证过：登出令牌已送达，刷新令牌和内省被拒绝，重新激活后再次要求授权同意。我会照搬的，正是这一部分。

全部文章亲自试试
