凭据注册与强制 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。