博客
停用用户并不会终止其访问
在下次登录时才检查的标志会让所有会话和令牌继续有效。本文讲如何立即终止访问、按什么顺序,以及哪些东西仍然够不着。
作者 FoxAuth ·
这个错误我们自己也犯过——在我们服务器的 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 组织上运行过。这些都没有提交认证。不过清除本身,我们已在一台运行中的服务器上完整验证过:登出令牌已送达,刷新令牌和内省被拒绝,重新激活后再次要求授权同意。我会照搬的,正是这一部分。