强因子的生命周期与重认证窗口
所谓“强因子”(strong factor),指任何比密码更强的认证方式:TOTP、passkey,或已绑定的 OAuth 账号。系统为每个用户记录:是否至少有一个这样的因子处于激活状态、它最近一次被证明是在什么时候,并在放行敏感的账户变更之前,要求一次新鲜的证明。本节说明这套状态是如何被推导、保持更新、并加以强制的。
值对象
StrongFactor 是一个领域值对象,持有三个字段:complete(是否有任一强因子激活)、method(totp | passkey | oauth | null)、completedAt(该因子最近一次被证明的时刻)。它要么被构造为 incomplete()——空状态——要么通过 fromProps() 从已存储字段构造。complete(method, completedAt) 返回一个新的“已完成”实例;isWithinReauthWindow(windowMs) 回答“上一次证明是否足够新鲜”:没有时间戳时返回 false,否则把已过去的时间与调用方给定的窗口做比较。窗口并不写死在领域里——由调用方决定证明保鲜多久。
推导:状态是算出来的,而非盲目断言
完成状态不是某人手动翻动的开关,而是从底层记录重新算出来的。快照层为一个用户读取三个来源:是否存在一个已验证的 TOTP 密钥、passkey 数量是否大于零、是否有任何已绑定的 OAuth 账号。只要其中任一存在,该用户即为“完成”。当多个来源同时存在时,上报的 method 遵循固定优先级——先 TOTP,再 passkey,最后 OAuth。
两条写入路径:断言 vs. 刷新
存储状态的改变恰好只有两种方式:
- 断言(
recordStrongFactorAssertion)——用于某个因子刚被证明或刚被启用。它强制把complete置为true,写入给定的 method,并把当前时刻盖为新的completedAt,覆盖此前的时间戳。这正是重置重认证计时的动作。 - 刷新(
refreshStrongFactorState)——用于某个因子可能被移除时。它根据当前来源重新计算快照并持久化,因此解绑最后一个强因子会把用户翻回“未完成”。
时间戳规则是刻意设计的:普通刷新时,若用户本就已完成,则保留原有的 completedAt,因此仅仅重算状态并不会悄悄延长保鲜窗口。只有断言(或显式强制)才会把计时往前推。
两条路径都把相同的三个字段(complete、method、completedAt)写入两条记录——User 记录与 Credential 记录——让这两份冗余副本保持一致。
观察者:生命周期变化被自动捕捉
启用与解绑不必自己记得去更新强因子状态。一个 Prisma 中间件(registerStrongFactorObserver)监听对 Passkey 与 Account 模型的写入。创建/upsert 一个 passkey 或 account 会触发一次断言(method 分别为 passkey 或 oauth);删除则触发一次刷新。它从操作中抽取受影响的用户 id 并逐一更新。TOTP 不走这条监听——它的已验证状态在推导时被读取,并由验证它的流程来断言。
变更边界处的重认证窗口
在一次敏感账户变更之前,应用层会用已存储的用户字段构造出一个 StrongFactor,并检查两件事。第一,因子必须“完成”,否则请求直接被拒。第二,如果上一次证明落在一个较短的重认证窗口(五分钟)之内,请求无需重新证明即可通过。超出该窗口,用户必须提供一次新鲜证明——例如一个当前有效的 TOTP 码——一旦验证通过,就被记录为一次新的断言,从而重置窗口。效果是:证明一次强因子,就换来一小段宽限期,用于连续做几件敏感操作;期满之后,必须再次展示“持有”。
为什么是这个形状
把完成状态存成推导出来的状态、而非手工设置的标志,意味着真相总能回溯到真实的 TOTP/passkey/账号记录——标志与现实无从背离,因为刷新会重新算它。重认证窗口把“拥有”一个强因子与“最近证明过”它分开:一个长期存活的会话在触碰任何危险操作前,仍可被强制展示一次实时的持有证明。