--- name: standard-multi-provider-auth description: > 多渠道登录身份归并。接入一种登录渠道、把第三方账号绑到已有用户,或在中国大陆产品上把登录收束到已验证手机号时使用。 --- # 多渠道登录身份归并 本篇负责认出这个人。会话由谁签发见 `skills/wayne-mode/playbooks/login.md`。 只接一种登录方式时,写专用流程,不必上 Provider 字典。 手机号是全民身份、运营商能校验本机号,这两条只在中国大陆成立。下文「手机号是主身份」「号码验证」「从可选改为强制补绑」只用于大陆产品。统一抽象、稳定标识、关联表、登录主流程、登录与绑定分离、凭据校验在任何地域都适用。 ## 统一抽象 各家原始字段不同,登录主流程只收一个 `ProviderUserInfo`:`provider`、`provider_user_id`(见下节)、冗余的 `email` / `phone`(按邮箱或手机号查重)、`metadata`(JSONB,装各家专有字段,不为每个字段单独建列)。 Provider 接口只有 `authenticate(primary, secondary)`。两个泛化参数覆盖不同凭据:OAuth 是 `code` + `state`,短信是 `phone` + `otp`,Apple 是 `identity_token` + `nonce`。按家拆成 `authenticate_wechat` / `authenticate_apple` 之后,主流程无法统一分发,每加一家就要改一次主流程。 登录用例依赖注入的 `dict[str, Provider]`,不直接 import 具体实现。新加一家只往字典里注册一项。 可选基类只收样板:`name`、统一日志、把第三方 SDK 异常包成领域异常。Protocol 的写法见 `standard-fastapi-ddd-backend` 的「模块分层」。 ## 稳定标识 `provider_user_id` 用各平台跨应用稳定的那个标识。 - 微信用 unionid。openid 是 per-app 的,同一个人在 App、公众号、小程序下各不相同;同一个开放平台账号下的 unionid 才跨应用稳定。openid 推不出 unionid,已经拆开的账号无法程序化合并,只能人工申诉后改库 - Apple / Google 用 id_token 里的 `sub`。邮箱可以换绑,`sub` 不变 - 手机号先规范化成 E.164 再存。同一个号码带不带国家码、带不带前导 0,必须落成同一个值,否则查重对不上 这个值在写入第一批真实账号之前定下来。换标识等于把同一个人认成多个用户,事后合并不了。 ## 关联表 一个用户挂多个认证账号,用关联表,唯一约束是 `(provider, provider_user_id)`。 在 users 表上逐家加列(`wechat_openid`、`apple_sub`、`google_sub`)时,每接一家就要改表结构和查询,也表达不了「同一个人绑了两个微信账号」。关联表加一家 provider 是数据操作,不动表结构。 唯一索引做成软删除感知的部分唯一索引:只在 `deleted_at IS NULL` 时生效。否则账号软删除后,同一个第三方身份无法再绑定。 ## 登录主流程 判据是 `find_by_provider(provider, provider_user_id)` 是否命中。 - 命中:取出该用户,确认未被封禁、未软删,交给会话签发。不改已有资料;provider 带回的昵称或头像只在用户该字段为空时补上。用户已经自己设置过的值,不用 provider 的返回值覆盖 - 未命中:新建 user,并写入带完整 `metadata` 的 provider 账号记录。两步放在同一个事务里。分开提交时,中间崩溃会留下没有任何登录方式能进的孤儿 user 两个请求用同一个 provider 身份同时首次登录时,都会先查空再插入,其中一个撞上 `(provider, provider_user_id)` 唯一约束。捕获这次冲突,删掉本请求刚建的孤儿 user,回查撞上的那条账号,把本请求收成对并发赢家的一次幂等登录。异常直接冒泡会变成 500。 ## 登录与绑定分离 「用一个新 provider 找到或创建账号」和「给已登录用户再绑一个 provider」是两个用例。 | | 登录 | 绑定 | |---|---|---| | 前提 | 未登录 | 已登录 | | provider 身份已存在 | 登录该身份对应的用户 | 报错:这个第三方账号已经绑给别人,不能把当前用户的数据挪过去 | | provider 身份不存在 | 新建用户 | 挂到当前登录用户 | 登录里「已存在」是跨端登录;绑定里「已存在」是占用,必须拒绝。合成一个用例后,两种「已存在」会互相干扰。 跨账号合并(先用手机号注册,再用第三方登录,想收成同一个人)也是绑定:用已验证的强身份做凭证,例如手机号 OTP。仅凭两次登录都声称是同一个人就自动合并,会被用来拿走他人账号。 ## 手机号是主身份 只用于中国大陆产品。社交平台返回的身份只在该平台内可信。每种登录方式最终都归到一个已验证的手机号。社交首次登录未命中 provider 时不建号:先补绑手机号,再连同该 provider 身份一起建号。 不收束到手机号时,社交账号被封、用户换了 Apple ID、微信侧解绑手机号,用户无法自证,客服也无从核对。先走手机号、后走社交(或反过来)会建成两个账号,会员和历史数据不相通,事后同样无法自动合并。 规则对所有 provider 一致。一半强制、一半不强制,用户分不清这次为什么要绑。不提供跳过。分发平台的审核若强制提供跳过(例如限制收集非必要信息),记为已知偏离,不预先做成可跳过。 多出的注册步骤,以及一键取号的按次费用,不作为放宽这条规则的理由。 ## 号码验证 只用于中国大陆产品。「验证这个手机号属于当前这个人」做成一个组件,两个入口: | 入口 | 起点 | 验证通过后 | |---|---|---| | 手机号登录 | 未登录 | 按手机号查:命中即登录,未命中建号 | | 社交登录补绑 | 已拿到 provider 身份、尚未建号 | 建号并挂上该 provider 身份 | 两条通道的产出都是一个已验证的 E.164 手机号,对下游等价: - 运营商一键取号:客户端 SDK 取 token,服务端拿 token 换真实号码。运营商已经验证过归属,不再要用户输入验证码 - 短信 OTP:一键取号失败时的通道 两条通道落成同一个 provider 身份:`(provider=手机号那一家, provider_user_id=E.164 号码)`。一键取号可以在注册表里占独立 key(凭据形态与 OTP 不同,分发要分开),但它写入的 `provider` 字段是手机号那一家。给一键取号另开一个身份标识,同一个人用一键和用短信会分成两个账号。 短信通道始终可达,不能把它当成取消。一键取号的硬失败都走短信: - 必须有蜂窝数据。纯 WiFi、无 SIM、平板 WiFi 版取号失败 - 双卡设备取到的是默认数据卡的号,不一定是用户要的那个 - 部分虚商号段不支持 授权页三个出口分开处理: | 出口 | 用户的意思 | 落到哪 | |---|---|---| | 「用短信验证」 | 还是这个号码,换验证方式 | 留在当前流程,切到短信表单 | | 「换其他登录方式」 | 不用手机号了 | 回登录方式选择页 | | 返回 / 取消 | 放弃 | 回上一步,不报错、不提示 | 合并任意两个,点了 A 会发生 B。把「返回」并进「切短信表单」时,用户点了返回仍停在绑定页。 SDK 自带的通常只有后两个出口。「还是这个号、换验证方式」要自己加。多数 SDK 能往授权页塞自定义控件。没有这个按钮时,一机多卡的用户无法换号码验证,只能放弃手机号登录。 可用性探测失败时隐藏一键入口,直接展示短信表单。凭据未配置(例如 CI 漏注入密钥)走同一条路:少一个入口,而不是留一个点了必然失败的按钮。 SDK 只能从厂商控制台下载、码值与授权页定制两端不通用、iOS framework 往往不是 modular、回调 listener 会多次触发。这些见 `references/one-tap-number-auth.md`。 ## 从可选改为强制补绑 只用于中国大陆产品,出现在「原本社交登录不绑手机号,后来要求绑」这一次反转。只改首次登录未命中那个分支盖不住。把「手机号」换成别的强制身份(例如邮箱)时,下面三条同样成立。 已经建好的社交账号下次登录会命中 provider,走不到「未命中 → 补绑」。拦截做在后端登录端点:命中 provider、但该用户名下没有手机号身份时,不签发会话,返回「需要绑定」,与首次登录未命中汇成同一条路。只做前端闸门时,会话已经生效,用户关掉引导、改地址就能绕过。 前端的补绑跳转放在登录成功的收尾,做一次。不要用「已登录且未绑手机号」包住路由树:这个判据会一直为真,已登录用户每次冷启动、每次切页都会被重新送去绑定页,绑不完。 「是否已绑手机号」在社交登录命中已有账号的分支上真查库。手机号系端点上这个字段恒为真,社交命中存量账号恰恰是「命中 provider 但没有手机号」。吃默认的真值会让闸门在整个会话里放行,要等下次刷新用户信息才纠正,中间不报错。 前端按三态处理该字段:`true` 放行,`false` 拦截,`undefined` 放行。`undefined` 表示后端还没部署这个字段。此时漏拦;拦死会让未升级的环境全部登不进去。 补绑撞上已被占用的手机号时,按这个顺序判定: | 情况 | 处理 | |---|---| | 手机号未被占用 | 直接挂到当前账号 | | 已属另一账号,当前账号无付费 | 返回专用错误码,要求二次确认。确认后转绑:provider 身份改挂到目标账号,当前账号软删,为目标账号签发会话 | | 已属另一账号,当前账号有付费 | 阻断,转人工,不自动处理 | 付费判定在「是否已二次确认」之前。顺序反了,前端带上已确认标记就能绕过这道阀。 转绑前先确认目标账号可用(未封禁、未软删)。这步不可逆,不能押在「异常会整体回滚」上。中间一旦有一次提交,会留下 provider 身份已改挂、原账号已软删、会话没签出,两个账号都进不去。 转绑是否迁移业务数据要写成明确决定。不迁移通常是对的(涉及多张业务表和多个 `(user_id, X)` 唯一约束),这个决定要记下来。 ## 凭据校验 各 provider 换回的凭据按各自协议校验。OAuth 在服务端用一次性 `code` 换 token。Apple / Google 的 id_token 验签之后还要核对 `audience`、`issuer`、`exp`。