注册与开户架构:创建时证明了什么、哪条记录是账户、第二种登录方式怎么挂上去、谁能替用户开户销户
比较 Password with Verified Email、Passkey Registration、Federated Identity with Account Linking 与 Directory Provisioning with Invitations 四种注册与开户拓扑:账户创建时证明了什么、哪条记录是账户而哪些只是登录方式、第二种方式怎么挂到已有账户上而不让同邮箱的人接管、除了本人谁能建、停用和删除账户、状态归谁,以及一句泄露存在性的回复或一次按邮箱的自动合并能波及多远、怎么收住。
账户是一行被产品信任的记录。所有注册与开户设计都要回答四个问题:创建时这个人证明了什么,是控制一个邮箱、持有一个认证器、在别的身份提供方登录过,还是什么都没证明、由管理员担保;哪条记录是账户,哪些记录只是登录它的方式;第二种登录方式怎么挂到已有账户上,而不让任何一个碰巧共用邮箱的人接管它;除了本人,谁能创建、停用和删除这个账户。这四个答案,而不是用的哪家身份服务,才定义了拓扑。
想亲手走一遍四种拓扑、注入故障再恢复,打开互动 Lab:/system-design-lab/user-registration-account-provisioning-architectures。
有约束的设计问题
一家公司先后要给四个产品定注册方案:任何邮箱都要能注册的消费级产品,用户就是想用密码,账户存不存在绝不能被外人查到;被钓鱼过、想彻底去掉密码的移动端产品,用户几乎都在支持认证器的手机上,但必须有丢设备的恢复路径;用户习惯一键用 Google 登录、而且很多人早就用密码注册过的产品,同邮箱的人绝不能互相接管;企业客户在自己的目录里管理员工、还要偶尔请外部承包商的 B2B 产品,入职离职由目录决定、产品必须立刻执行。每一个该怎么搭?
四种 topology signature
| 架构 | 创建时证明了什么 | 账户的键 | 第二种方式怎么挂上 | 除了本人谁能建或删 | State owner | 适合 | 主要代价 |
|---|---|---|---|---|---|---|---|
| Password with Verified Email | 控制那个邮箱,链接点开后才算 | 产品自己的 id;邮箱是属性 | 密码找回后,登录态里再加方式 | 没有;用户自助 | 带哈希和 verified 标记的账户记录 | 任何邮箱都要能注册的消费级产品 | 密码可钓鱼可重用;三条路都要藏住账户存不存在;未验证账户堆积 |
| Passkey Registration | 持有一个对服务器 challenge 签了名的认证器 | 产品自己的 id;user handle 不透明 | 登录态里再注册一把 passkey | 没有;用户自助 | 带公钥的账户记录 | 想抗钓鱼、去密码的移动端优先产品 | 丢认证器要恢复路径;用户仍期待一个标识符来找回;平台支持不齐 |
| Federated Identity with Linking | 一个来自可信发行者的有效 ID token | 产品自己的 id;身份按 issuer + subject | 第二个提供方只在双方都认证后关联 | 提供方停发令牌就把用户锁在外面 | 带 identities 的账户记录 | 用户习惯大提供方一键登录的消费级产品 | 依赖提供方的可用性和政策;按邮箱关联是接管;提供方邮箱可能未验证 |
| Directory Provisioning with Invitations | 创建时什么都没证明;目录或管理员担保;首次登录时才证明 | 产品自己的 id;externalId 用于对应 | 目录推更新;受邀者接受时选方式 | 目录建、改、停用;管理员邀请和撤销 | 带 externalId 和 active 的账户记录 | 客户在企业目录里管理员工的 B2B 产品 | 开户和登录解耦,没执行的停用留下活账户;邀请令牌和密码一样敏感 |
一次性码和 magic link 是无密码注册的带外形态,遵守同样的单次、短有效期规则;密码找回是注册的镜像,用同一套令牌纪律;多因素注册是给已有账户加第二个认证器,属于登录话题。它们在这里解释,不单独画。登录之后的令牌签发和 scope 见 OAuth 2.0 and OIDC,跨产品单点登录见 SSO,给 Agent 的委托授权见 Agent 身份与委托授权架构。
1. Password with Verified Email:先建未验证的账户并存慢哈希,单次过期链接证明邮箱归属,回复一律同一句话
存储:NIST 说「记忆型秘密应当加盐并使用合适的单向密钥派生函数做哈希」;OWASP 说「使用 Argon2id,最低配置为 19 MiB 内存、迭代次数 2、并行度 1」,「前者不可用时应使用 scrypt」,bcrypt「只应在 Argon2 和 scrypt 都不可用的遗留系统里用于密码存储」,「盐是一个唯一的、随机生成的字符串,作为哈希过程的一部分加到每个密码上」,「因为哈希是单向函数……它是密码校验最合适的方法」。密码本身:「验证方应当要求订阅者选择的记忆型秘密至少 8 个字符」;「验证方不应对记忆型秘密施加其他组成规则(例如要求混合不同字符类型或禁止连续重复字符)」;「验证方应当把候选秘密与一份包含常用、可预期或已泄露值的列表比对」;「验证方应当把单个账户连续失败的认证尝试限制在不超过 100 次」;「失败登录的计数器应当关联到账户本身,而不是来源 IP 地址」。枚举:「应用应当以通用方式响应(HTTP 和 HTML 都是)」;注册的回复是「激活账户的链接已发送到你提供的地址」,找回的回复是「如果该邮箱在我们的数据库里,我们会给你发一封重置密码的邮件」,并且「无论用户或密码是什么,应用都走同样的处理过程,让响应时间大致相同」;找回令牌必须「用密码学安全的算法随机生成」「足够长以抵御暴力破解」「单次使用并在适当时间后过期」「使用后作废」;「确保响应在一致的时间内返回,防止攻击者枚举哪些账户存在」。
账户先存在,再被证明。注册 API 用加盐的慢哈希存密码,创建一条 verified=false 的账户记录,签发随机、单次、会过期的验证令牌,把链接发到那个邮箱;用户点链接,API 确认令牌没用过也没过期,才把账户标为已验证并作废令牌,在此之前账户什么都不能做。注册、登录、找回三条路对「邮箱已存在」和「不存在」回同一句话、花同样的时间。
典型事故:注册接口对已存在的邮箱回 409「该邮箱已注册」,攻击者拿邮箱名单挨个提交,回复不同的就是你的用户——三条路同一句话、同样步骤,邮箱已存在时给原主人发提醒邮件,把两种情况的响应时间差做成测试。令牌表没有 used 标记也没有过期时间,一年前的验证邮件被转发给了别人——令牌随机、够长、单次、有过期,用过或过期立即作废,账户改邮箱时作废所有旧令牌。
2. Passkey Registration:服务器发 challenge,认证器生成密钥对,服务器只存公钥和不透明的 user handle
WebAuthn:注册仪式里「依赖方生成一个 challenge……认证器创建一个新凭据」;「认证器创建一对非对称密钥……公钥部分返回给依赖方」;「凭据私钥绑定到特定认证器……预期永远不会暴露给任何其他方」;「user handle 是一个最长 64 字节的不透明字节序列,不用于显示」,规范的隐私考量要求它不包含邮箱这类个人信息。带外的无密码形态遵守同样的规则:「在所有情况下,如果未在 10 分钟内完成,认证应当视为无效」;「验证方在有效期内应当只接受给定的认证秘密一次」。
没有密码可偷。服务器生成随机 challenge 和随机的不透明 user handle 存进短期存储;浏览器把 challenge 交给用户的认证器,认证器为这个站点生成密钥对,私钥永远留在认证器里,返回公钥、credential id 和一份对 challenge 的签名;服务器核对 challenge 是刚发的、origin 是自己的域名,然后把公钥和 credential id 存进账户记录。以后登录就是再签一个新 challenge,钓鱼站拿不到私钥也签不出来。第一把 passkey 不能是唯一入口。
典型事故:代码把 user.id 设成邮箱的字节以便「找回时好认」,共享电脑上的认证器和云同步的密码库都看到了这个人的邮箱——handle 用随机不透明字节,邮箱只留在账户记录里,已发出的凭据按用户重新注册替换。注册时只让用户建了一把 passkey,手机丢了——第一把注册完立刻要求第二把(另一台设备或硬件钥匙)或一个已验证的邮箱恢复渠道。
3. Federated Identity with Account Linking:在外部 IdP 登录,按 issuer + subject 认人而不按邮箱,第二个提供方只在双方都认证后才关联
OpenID Connect 定义 sub 是「在发行者内本地唯一且永不重新分配的最终用户标识符,供客户端消费」;「OpenID 提供方的发行者标识符……必须与 iss 声明的值完全匹配」;「客户端必须验证 aud 声明包含它在 iss 所标识的发行者处注册的 client_id」。Google 对它的令牌说:检查「ID token 里 aud 的值等于你应用的某个 client ID」「iss 的值等于 accounts.google.com 或 https://accounts.google.com」「ID token 的过期时间 exp 尚未过去」;并且「只使用 Google ID token 的 sub 字段作为用户标识,因为它在所有 Google 账户中唯一且永不重用」;「不要用邮箱地址作为标识,因为一个 Google 账户在不同时间点可以有多个邮箱地址」。关联:「如果用户先用 Auth0 数据库登录、再用 Google 或 Facebook 登录,这两次登录在 Auth0 看来是两个不同的用户」;关联后「user_id 和其他主要资料属性仍然是主身份的」,「次要账户被嵌入主资料的 user.identities 数组」;安全规则是「你的租户应当在关联发生之前要求两个账户都完成认证」,并且「每一次手动账户关联都应当提示用户输入凭据」。
把认人交给别人,把记账留给自己。应用把浏览器重定向到提供方,回调带回 ID token,应用先验签、核对 iss、aud、exp,再按 (iss, sub) 去账户记录里找人:第一次来即时开户,来过就登录。邮箱只是属性。同一个人先用密码注册、后用 Google 登录,系统看到的是两个账户;不能因为邮箱相同就合并,正确做法是先按新身份处理,只在用户同时证明了两个账户之后才把第二个身份挂到第一个账户下。
典型事故:lookup 找不到 (iss, sub) 时退而按邮箱查,查到就挂上去并登录,在不验证邮箱的提供方注册了受害者邮箱的人接管了密码账户——删掉按邮箱的退路,同邮箱的新身份先开新账户,关联只在双方都认证后可用并要求重新输入凭据,把已经被自动合并的账户找出来通知用户。验证器只验了签名没核 iss 和 aud,Google 为攻击者自己的应用签发的、含受害者 sub 的令牌登进来了——验签、iss、aud、exp 四项全验,做成回归测试。
4. Directory Provisioning with Invitations:企业目录通过 SCIM 建、改、停用账户,目录外的人靠绑定邮箱的单次邀请进来
SCIM:「服务提供方成功创建新资源时,应当返回 HTTP 状态码 201(Created)」;id 由服务提供方分配,客户端提供 externalId 用于对应;「HTTP PATCH 是可选的服务端功能,让客户端用一串 add、remove 或 replace 操作更新 SCIM 资源的一个或多个属性」;「客户端通过 DELETE 请求移除资源。服务提供方可以选择不永久删除该资源,但对与已删除资源相关的所有操作必须返回 404(Not Found)」;查找用 filter=userName eq "bjensen" 这样的过滤器。邀请令牌遵守找回令牌的规则:随机、够长、「单次使用并在适当时间后过期」、「使用后作废」,邀请路径对「已有账户」和「没有」回同一句话。
账户由系统和管理员创建,不由本人注册。目录把员工推给 SCIM 端点,产品返回 201 和自己分配的 id,账户在员工第一次登录之前就存在;离职时目录发 PATCH active=false,产品必须在同一步撤销会话和 API 密钥;DELETE 之后回 404。目录里没有的人由管理员邀请:随机、单次、会过期、绑定到指定邮箱的令牌发到那个邮箱,受邀者证明控制那个邮箱、选一种登录方式,账户才创建并挂到组织下。
典型事故:目录当天下午发了 PATCH,账户记录显示已停用,但手机上的会话和 API 密钥继续工作了几周,审计报告却写着「已停用」——把停用做成一个原子操作,改 active 的同一步撤销全部会话和密钥、拒绝新登录,每个请求校验 active,监控停用到会话失效的延迟。邀请令牌没绑定邮箱,bob 把邮件转给同事,同事用自己的邮箱接受了,账户挂在 bob 名下却由别人控制——令牌绑定被邀请的地址并单次、会过期,接受时必须证明控制那个地址,别的地址一律拒绝。
四种拓扑共同的底线
- 说清哪条记录是账户。 四种拓扑里都是账户记录,带着 verified 标记、公钥、identities 列表或 externalId 和 active;邮箱、subject、externalId 都只是属性。
- 说清创建时证明了什么。 点开的链接、签过名的 challenge、验证过的 ID token,还是管理员的担保;证明之前账户不能行动。
- 令牌当密码对待。 验证、找回、邀请的令牌都随机、够长、单次、会过期、用过作废,不记日志。
- 三条路同一句话。 注册、登录、找回对存在和不存在的账户措辞相同、耗时相同;邮箱已存在时通知原主人而不是提交者。
- 关联是认证行为,不是邮箱巧合。 身份按 (iss, sub) 记,第二种方式只在双方都认证后挂上;停用要在同一步撤销会话。
故障与恢复
| 架构 | 故障 | 用户看到什么 | 恢复 |
|---|---|---|---|
| Password | 注册页说「该邮箱已注册」 | 用户名单被枚举 | 三条路同一句话同样耗时;给原主人发提醒 |
| Password | 验证链接可重用、永不过期 | 旧邮件能激活账户 | 单次、过期、用过作废;改邮箱时作废旧令牌 |
| Passkey | user handle 用了邮箱 | 邮箱泄露给每个认证器 | 随机不透明字节;邮箱只留在账户记录 |
| Passkey | 唯一的 passkey 在丢了的手机上 | 永远进不去 | 注册完立刻要求第二把或已验证的恢复渠道 |
| Federated | 按邮箱自动合并 | 密码账户被接管 | 只按 (iss, sub) 查;双方认证后才关联 |
| Federated | 身份按邮箱记,提供方那边邮箱变了 | 变成第二个账户 | 按 subject 记;邮箱是可更新的属性 |
| Federated | 只验签不核 iss、aud | 别的应用的令牌登进来 | 签名、iss、aud、exp 四项全验 |
| Directory | 停用只改记录不撤会话 | 离职的人还在用 | 同一步撤销会话和密钥;每个请求校验 active |
| Directory | 邀请被转发到别的地址接受 | 账户挂错人 | 令牌绑定邮箱且单次;接受时证明地址 |
怎么选
- 任何邮箱都要能注册:Password with Verified Email,慢哈希、只要求长度和泄露名单检查、单次过期链接验证、未验证账户无权限、三条路同一句话。
- 抗钓鱼比普及率重要:Passkey Registration,新鲜 challenge、只存公钥、不透明 handle、第一把之后立刻要第二把或恢复渠道。
- 用户期待一键登录:Federated Identity,验签核 iss、aud、exp,按 (iss, sub) 记身份并即时开户,第二个提供方双方认证后才关联。
- 客户在别处管理员工:Directory Provisioning,暴露 SCIM,存 externalId,停用立刻含会话,目录外的人用绑定邮箱的单次邀请。
- 无论哪种:写下哪条记录是账户、创建时证明了什么、谁能删它。
回到开头的四个产品:消费级密码注册走 Password with Verified Email;去密码的移动端走 Passkey;社交一键登录走 Federated Identity;企业目录走 Directory Provisioning。
面试时这样回答
- 先复述约束:谁来注册、能证明什么、有没有企业目录、同邮箱的人会不会撞上、丢了设备怎么办。
- 说开户路径:点名图上的边,例如「慢哈希建未验证账户,单次令牌发邮箱,点链接才标已验证」「发 challenge 和不透明 handle,认证器生成密钥对,核对 origin 后存公钥」「验签核 iss、aud、exp,按 (iss, sub) 查并即时开户,同邮箱双方认证后才关联」「SCIM POST 带 externalId 回 201,PATCH 停用同步撤会话,单次邀请绑定邮箱」。
- 说状态归谁:账户记录,以及它带着 verified、公钥、identities 还是 externalId 和 active。
- 说代价与一个故障:例如按邮箱自动合并让人接管了密码账户,修法是只按 (iss, sub) 查、同邮箱先开新账户、双方认证后才关联、关联时重新输入凭据。
一手证据
- NIST SP 800-63B:Digital Identity Guidelines — Authentication and Lifecycle Management
- OWASP:Password Storage Cheat Sheet
- OWASP:Authentication Cheat Sheet
- OWASP:Forgot Password Cheat Sheet
- OpenID Foundation:OpenID Connect Core 1.0
- Google Identity:Verify the Google ID token on your server side
- Auth0:User Account Linking
- W3C:Web Authentication Level 2
- IETF RFC 7644:SCIM Protocol