身份生命周期与账户关联规范
本文定义 AuthKeystone 的目标安全策略和迁移边界。第 2 节记录已上线的现行行为(L0); 其余标为“目标”的能力在完成实现与验收前,不得在对外契约中承诺已经生效。
1. 基本原则
- 第三方身份认证不等于邮箱已验证。
sub是身份的稳定主键;email是可变资料,email_verified才能表达邮箱是否可作为安全凭据。 - 不基于原始邮箱自动合并账户。 相同字符串的邮箱不能证明两个外部身份属于同一人;邮箱作为关联凭据须满足双侧已验证(见第 2 节)。
- 先认证,再关联。 关联外部身份必须同时证明当前账户控制权和外部账户控制权。
- 最小身份先行。 没有可信邮箱的用户可以完成身份认证,但只能进入待完善或受限状态,不能直接获得一般业务权限。
2. 现行实现(L0,2026-08 已上线;多绑模型 2026-09 上线)
身份绑定存储(2026-09 起):user_idp_bindings 关联表是真源——一个账号可绑定多个外部 IdP(如 GitHub 与 Gitee 同时绑定),每条绑定 (provider_id, subject) 全局唯一。users.oauth_provider / oauth_id 两列降级为「最新一条绑定」的双轨投影(写时同事务同步,读点零改动,后续一次性退役)。绑定/登录/合并/解绑全部走表:登录按 (provider_id, subject) 直查表定位账号;解绑按 provider 选择性解一条(POST /api/auth/sso/unbind,body 带 provider_id),解绑后剩余绑定数 + 有无密码 ≥ 1 才放行;绑定列表 GET /api/auth/sso/bindings。历史硬编码 github / gitee 存量已回填(能映射到 provider 记录的映射,映射不到落 legacy 哨兵)。管理端用户更新 API 不再接受 oauth 字段直写。
邮箱可信度来源(决定 email_verified):
- OIDC IdP 的
email_verifiedclaim(缺失视为未验证) - GitHub / Gitee emails 端点条目(
verified=true/state=confirmed,primary 优先,未验证条目一律不取) - OAuth2 托管平台(GitHub / Gitee)的注册邮箱,视为平台已确认
邮箱补全:userinfo 未返回 email(如 GitHub / Gitee 私有邮箱设置)时,回退拉取 emails 端点取已验证邮箱;拉取失败降级为无邮箱登录,不阻断。老用户在 IdP 侧后补公开邮箱并重新登录后,本地账号自动补全 email 并同步置 email_verified。
JIT 建号:首次外部身份登录按 IdP 配置即时建号,email_verified 按上述来源写入;无可信邮箱时建无邮箱账号(可后续绑定邮箱补全)。
账号合并(email 关联):
- 双侧已验证才自动合并:IdP 侧邮箱已验证,且本地同邮箱账号
email_verified=TRUE,才把外部身份绑入既有账号;任一侧未验证,该邮箱不具备「同一人」的证明力,不合并 - 特权账号拒绝静默合并:同邮箱命中
admin/org_admin一律拒绝自动并入,须原账号登录后在个人中心显式绑定 - 已绑定身份再次登录按
(provider, subject)直查定位同一账号,不经邮箱合并
显式绑定 / 解绑(个人中心):登录态下主动绑定外部 IdP(可多个共存,各自独立解绑);解绑前校验账号仍保留至少一种可登录方式(剩余绑定 + 密码 ≥ 1)。
删除账号:管理员删除用户即失效其全部会话(JWT 逐 jti 清除 + SSO cookie 会话一并踢出),该用户浏览器下一次任意请求 401 回登录页。
3. 注册来源与状态
| 来源 | 身份凭据 | 邮箱状态 | 可进入的状态 |
|---|---|---|---|
| 密码注册 | 本地密码 | 验证码成功后为 verified | 正式账户 |
| OIDC / 社交登录 | (provider_id, subject) | 独立记录为 none、unverified 或 verified | JIT 策略允许时创建或登录 |
| OIDC / 社交登录,无邮箱 | (provider_id, subject) | none | 待完善、受限会话 |
| 匿名登录 | 匿名会话 | none | 受限主体;升级认证后才可成为正式账户 |
email scope 只表示用户资料可能包含邮箱,不表示邮箱可信。下游只有在收到 email_verified=true 时,才可把邮箱用于恢复、权限、通知对象确认或账户关联。
4. JIT 注册策略
首次通过外部身份登录时,按 IdP 配置决定是否允许即时创建账户:
| 策略 | 含义 | 适用场景 |
|---|---|---|
disabled | 不创建账户,只允许已关联身份登录 | 高敏感或邀请制产品 |
verified_email | 仅上游明确提供可信邮箱验证证据时创建 | 常规消费者产品 |
allowed_domain | 仅受信任域名且邮箱已验证时创建 | 企业 / 校园场景 |
provider_membership | 仅指定组织或成员关系通过时创建 | 企业 IdP、SAML/OIDC 联邦 |
Gitee、GitHub 等 OAuth 2.0 提供方返回 email 字段时,不能仅凭该字段把邮箱标记为已验证(托管平台注册邮箱除外,见第 2 节可信度来源);没有可验证的上游证据时,应要求用户完成本地邮箱验证。
5. 账户关联
现行行为与目标
不得仅凭两个身份资料中的 email 文本相同,就自动归并到同一用户。这会造成预占账户(pre-account takeover)风险:攻击者可先用未验证邮箱创建身份,等待真实邮箱持有人使用另一 IdP 登录后被错误合并。
现行实现(L0)据此要求双侧已验证才允许自动关联,且对特权账号一律拒绝静默并入(见第 2 节);目标态进一步以显式绑定为主路径,邮箱自动关联仅保留为双侧已验证场景下的便捷合并。
推荐流程
- 用户先登录现有 AuthKeystone 账户。
- 对当前账户执行近期重新认证,避免被遗留会话劫持后静默绑定。
- 用户授权外部 IdP,并验证回调中的稳定
subject。 - 检查该
(provider_id, subject)尚未属于其他用户后,写入绑定。 - 向用户展示绑定成功、解绑入口和必要的安全通知。
一个用户可拥有多个身份。目标数据模型为:
user_identities(
user_id,
provider_id,
subject,
profile_email,
email_verified,
linked_at,
last_used_at,
...
)
其中唯一约束应为 (provider_id, subject);profile_email 不应作为账户归并键。
6. 邮箱恢复与升级
- 魔法链接、密码重置和以邮箱证明账户控制权的流程,仅可发送到
email_verified=true的地址。 - 外部身份资料中的未验证邮箱,可用于展示或引导补全,不能作为找回密码或强制合并依据。
- 无可信邮箱的用户需要绑定已验证邮箱、添加密码或配置其他恢复因子后,才具备完整恢复能力。
- 解绑前应校验账户仍保留至少一个可登录、可恢复的方式;高风险操作要求近期重新认证。
7. 迁移与验收
现有只保存单个 oauth_provider/oauth_id 的账户模型,迁移到多身份模型时应:
- 回填现有第三方身份为一条
user_identities记录,保留原用户主键。 - 对历史第三方邮箱默认标记为
unverified,除非存有可审计的可信验证证据。 - 邮箱自动关联收敛为双侧已验证门禁(现行 L0 已生效);目标态移除登录路径上的静默合并,冲突时引导用户登录现有账户后手动绑定。
- 对绑定、解绑、JIT 创建、恢复分别记录审计日志。
发布前至少覆盖以下场景:
| 场景 | 预期结果 |
|---|---|
| 新用户使用带可信已验证邮箱的 IdP 登录 | 按 JIT 策略创建账户,邮箱为 verified |
| 新用户使用无可信邮箱证据的 IdP 登录 | 创建受限账户或要求本地验证,邮箱不标记为 verified |
| 已存在密码账户,同邮箱外部登录(双侧已验证) | 自动合并:外部身份绑入既有账号 |
| 同邮箱命中特权账号(admin / org_admin) | 拒绝静默合并;引导登录后在个人中心显式绑定 |
| 同邮箱但任一侧未验证 | 不合并;按 JIT 策略新建账号 |
| 已绑定身份再次登录 | 通过 (provider_id, subject) 定位同一用户 |
| 未验证邮箱请求魔法链接或重置 | 拒绝发送 |
| 解绑唯一登录方式 | 拒绝或先要求添加其他恢复方式 |
| 管理员删除用户 | 全端会话立即失效(JWT + SSO cookie),下次请求 401 |