credential-totp-enrollment

凭据注册与强制 TOTP 绑定

这是 YouTeacher 的用户名/密码路径:账号怎么创建、第二因子怎么绑上去、以及这个账号之后 登录时会发生什么。三个各自独立的应用服务承担三个阶段,值得记住的设计取舍是它们是 解耦的——生成 TOTP 密钥、创建用户、验证绑定,是三个分开的步骤,靠一条短时效的 待处理记录(pending record)协调,而不是塞进一次庞大的调用里。

注册

SignupWithCredentials 光有密码是跑不起来的。它首先要求一个六位的邮箱一次性验证码, 并且是消费它——在一次原子操作里完成校验并删除——所以对邮箱控制权的证明是被“花掉”的, 而不只是被看一眼。邮箱、密码、用户类型各自先经过领域值对象(value object)的校验,格式非法 或强度不足的输入在任何东西写库之前就被挡掉。这里有一个管理员域覆盖:如果邮箱域名匹配 配置好的管理员域,账号会被提升为管理员类型,无论请求里写的是什么。

注册接着去找上一步 provisioning 留下的待处理 TOTP 记录。如果它存在且没过期,就复用其中 的密钥和已哈希的密码——并重新核对注册时提交的密码与 provisioning 时用的那个一致——然后删掉 这条待处理记录。如果没有,则退回到当场现生成一个密钥。账号创建本身对一条先前路径是幂等的: 由 passkey 注册流程预先创建的用户(一条还没有第二因子密钥的用户行)会被补完,而不是当作 重复账号拒掉。返回结果总是标明需要第二因子。

生成第二因子

ProvisionTotpSecret 负责生成 TOTP 密钥及其绑定 URI。它按邮箱做限流,所以这个端点没法被 反复轰。它按账号是否已存在分叉:已存在的账号必须先证明密码,才会替换它的密钥;尚未创建的账号 则把密钥和一份已哈希的密码停放进一条短时效(分钟量级)的待处理记录,等注册去认领。这份绑定 URI 就是任何验证器 App 都认得的标准 otpauth 格式,由 issuer 标签和账号名拼成。

验证绑定

VerifyTotpEnrollment 确认用户的验证器确实能产出有效验证码。它沿用同样的两套世界之分:已存在的 账号对着它存好的密钥核验并标记为已验证;尚未创建的账号对着待处理记录核验并在那里打上已验证标记, 这样注册之后就能信任它、不必再挑战一次。核验失败按每个目标键限流,所以填错验证码要付出一次 尝试的代价,而不是白试。

登录

PasswordAndTotpLogin 校验输入、按邮箱限流,并核验账号存在、处于启用状态、且与存好的密码哈希 匹配。成功后它签发一个会话令牌、持久化一条带 TTL 的会话,并返回用户档案——其中包含强因子状态 (强第二因子是否完成、用的哪种方式、什么时候完成的)。值得注意的是,第二因子的状态是随返回的 档案一起带出来的,而不是在这个服务里重新发起挑战。每一种结果——被限流、凭据无效、账号被禁用、成功 ——都会写一条登录审计行;每次成功登录还会顺带触发对过期会话和过老审计行的非阻塞清理。

在 HTTP 边界上,登录路由会先跑一次人机校验(captcha)(对用服务令牌自证身份的可信服务客户端 有豁免),并把会话设成一个HttpOnly、SameSite=lax 的 cookie,生产环境下标记为 secure。

值得记住的形状

四个服务里反复出现的模式,是已存在用户 vs. 待处理用户的分叉,用一条会过期的待处理记录来调和。 它让第二因子能在账号存在之前就设置好,却始终不会在数据库里留下一个半成品账号;也让一个已有的 密码/passkey 账号能走同一套代码路径重置自己的 TOTP。

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →

credential-totp-enrollment