Skip to content

feat(auth): provider 抽象消灭 switch + M2b 绑定流程 + 模块 README - #57

Open
longsizhuo wants to merge 2 commits into
mainfrom
feat/auth-provider-registry-m2b
Open

feat(auth): provider 抽象消灭 switch + M2b 绑定流程 + 模块 README#57
longsizhuo wants to merge 2 commits into
mainfrom
feat/auth-provider-registry-m2b

Conversation

@longsizhuo

Copy link
Copy Markdown
Member

为接入 Google 等新登录方式做的结构准备,外加 M2b 绑定流程。请 review,我不合并。

配套前端 involutionhell#394

1. 漂移源头:3 个散落的 switch

接一个新 provider 原本要改 6 处,其中 3 处是按 provider 名分发的 switch:

AuthService#isProviderEmailVerified
OAuthController#authRequestFor
OAuthController#redirectUriOf

漏改任一处会得到**"能跳转但邮箱不被信任"这种半死状态 —— 登录看着是成功的,只是悄悄多建了一个账号**,极难发现。

新增 AuthProvider 接口 + AuthProviderRegistry,三个 switch 全部删除:

public interface AuthProvider {
    String key();
    AuthRequest newRequest();
    String redirectUri();
    boolean isEmailVerified(AuthUser user);   // 安全判据,不是展示字段
    default void revokeToken(AuthToken t) {}
}

接 Google = 新增一个类 + 三行配置,不改任何已有文件。 注册表里重复 key 会启动即失败,不留"随机命中一个实现"的余地。

2. M2b 绑定流程

GET /oauth/bind/{provider}   (@SaCheckLogin)
  → provider 授权 → 回调 → 挂到当前账号,不建号 / 不换会话 / 不发新 token

与登录共用回调端点,走哪条由服务端的绑定意图决定,不由任何请求参数决定。

INV-007 红线:userId 绝不进 state

state 客户端可伪造。把"绑定到哪个账号"写进去,攻击者就能构造"绑定到我账号"的 state 诱导受害者授权,把受害者的第三方身份绑到自己账号上。

实现:userId 取自发起时 @SaCheckLogin 校验过的会话 → 以随机 state 为 key 存服务端内存(5min TTL、一次性消费)→ 回调取回并二次核对当前会话仍是同一人(中途登出/换号即拒绝)。state 只是不可猜测的查找键。

冲突给可辨识 code 而非 500

code 含义
bind_taken 该第三方账号已绑到别人
bind_duplicate 本账号已绑过同类 provider
bind_already_yours 已经在你账号上了
bind_session 发起到回调之间会话变了

绑定 github 补写 github_id,与解绑时清空对称。绑定不经过 Discord 灰度闸——闸保护的是"建新号",绑定不建号。

为什么绑定必须早于 GA

UNIQUE (provider, provider_user_id) 意味着一个第三方身份只能绑一个账号:

  • 先做 M2b → 用户主动绑定 → 插一行就完事
  • 先 GA → 用户被分叉出新账号 → 新账号占住该身份 → 本尊补绑撞约束 → 变成跨账号迁移 posts/chat/follows 的数据合并

顺序错了,成本差一个数量级。

3. usercenter/README.md

接入新 provider 的完整契约、为什么 isEmailVerified 是安全判据而非展示字段(返回 true 的门槛是"provider 保证用户控制该邮箱",否则等于把账号接管焊进登录流程)、两条 OAuth 流程、绑定为何不能把 userId 放进 state、账号 vs 身份的关系、剩余 3 处必须手动改的地方及其后果。

SECURITY.md INV-007 补上绑定流程的实现说明与两条回归测试。

验证

全量 313 通过(301 → +12):registry 5 条(大小写/未知返回空/重复 key 启动失败/真实 provider 的邮箱信任/半配置拒绝)、绑定 5 条(成功不建号/bind_taken/bind_duplicate/bind_already_yours/github_id 回填)、绑定鉴权 2 条。

未部署,等你 review。

设置页的"已绑定登录方式"对多数人只显示光秃秃的 "GitHub",而 Discord 那行能
显示 "Discord · 某某"。原因不在前端:ensureIdentity 在身份行已存在时只调
touchLastLogin 刷新时间戳,把手里本次登录拿到的 email / displayName 丢掉了。

M0 回填建出来的行只有 (user_id, provider, provider_user_id),email_at_link 和
display_name_at_link 一直是空的,而只更新时间戳意味着**再登录多少次也补不上**。
实测 github 55 行里 51 行缺展示名、55 行全部缺邮箱。

touchLastLogin → recordLogin(id, email, displayName):一条 UPDATE 同时刷新
last_login_at 并用 COALESCE 补齐空列。只填空值,不覆盖已有值——列名的 at_link
语义是"绑定当时的值",不该被后来的登录改写;本次没拿到邮箱/名字时参数为 null,
COALESCE 保持原值,等下次再补。

存量 51 行会在各自下次登录时自愈,不需要数据迁移。

UserIdentityRepositoryTests 补一条:空行被补齐 / 已有值不被覆盖 / 传 null 不抹空。
全量 301 测试通过。
## 为什么

接一个新登录 provider 原本要改 6 处,其中 3 处是散落的 switch(provider):
authRequestFor / isProviderEmailVerified / redirectUriOf。漏改任一处会得到
"能跳转但邮箱不被信任"这种半死状态——登录看着是成功的,只是悄悄多建了个账号。
接 Google 之前必须先把这个漂移源头堵掉。

## provider 抽象

新增 AuthProvider 接口(key / newRequest / redirectUri / isEmailVerified /
revokeToken)与 AuthProviderRegistry(按 key 收集所有 bean,重复 key 启动即失败,
不留"随机命中一个"的余地)。GithubAuthProvider、DiscordAuthProvider 各自封装
自己的配置与邮箱信任判据。三个 switch 全部删除。

接新 provider = 新增一个类 + 三行配置,不改任何已有文件。

## M2b 绑定流程

/oauth/bind/{provider}(@SaCheckLogin)→ 授权 → 回调挂到当前账号,不建号、
不换会话、不发新 token。与登录共用回调端点,走哪条由服务端意图决定,不由请求参数。

INV-007 红线:绑定目标 userId 绝不进 state。userId 取自发起时已校验的会话,
以随机 state 为 key 存服务端内存(5min TTL、一次性),回调时二次核对当前会话
仍是同一人。state 只是不可猜测的查找键。

冲突给可辨识 code 而非 500:bind_taken(已绑他人)/ bind_duplicate(本账号已绑
同类)/ bind_already_yours / bind_session。绑定 github 补写 github_id,与解绑
时清空对称。

绑定不经过 Discord 灰度闸——闸保护的是"建新号",绑定不建号。

**为什么绑定必须早于 GA**:UNIQUE(provider, provider_user_id) 意味着一个第三方
身份只能绑一个账号。先放开 → 用户被分叉 → 新账号占住该身份 → 本尊补绑撞约束,
从"插一行"变成"跨账号迁移 posts/chat/follows"。顺序错了成本差一个数量级。

## 前端

设置页加"连接"按钮。可绑列表来自新端点 GET /identities/providers(后端已注册的
provider),前端不再维护写死列表——接新 provider 按钮会自动出现。绑定回调结果
(?bind=ok / ?bind_error=)给出可区分文案。用 Suspense 包裹(useSearchParams)。

## 文档

usercenter/README.md:接入新 provider 的完整契约、邮箱信任为何是安全判据而非展示
字段、两条 OAuth 流程、绑定为何不能把 userId 放进 state、账号 vs 身份的关系。
SECURITY.md INV-007 补上绑定流程的实现与两条回归测试。

全量 313 测试通过(301 → +12)。前端 72 通过 / typecheck / lint 0 error /
build 通过,login 仍为 ● SSG(settings 改动前后都是 ƒ,非本次引入)。
Copilot AI review requested due to automatic review settings July 26, 2026 12:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants