From 2a5462655ee5c03bc3880e6a944547da7350d0cc Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Sun, 26 Jul 2026 06:58:12 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(usercenter):=20=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E6=97=B6=E8=A1=A5=E9=BD=90=E8=BA=AB=E4=BB=BD=E8=A1=8C=E9=87=8C?= =?UTF-8?q?=E7=A9=BA=E7=9A=84=20email/display=5Fname?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设置页的"已绑定登录方式"对多数人只显示光秃秃的 "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 测试通过。 --- .../JdbcUserIdentityRepository.java | 12 +++++-- .../repository/UserIdentityRepository.java | 13 ++++++-- .../usercenter/service/AuthService.java | 7 ++-- .../UserIdentityRepositoryTests.java | 32 ++++++++++++++++++- .../usercenter/service/AuthServiceTests.java | 4 ++- 5 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserIdentityRepository.java b/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserIdentityRepository.java index 64eae23..9b7575f 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserIdentityRepository.java +++ b/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserIdentityRepository.java @@ -80,8 +80,16 @@ public UserIdentity insert(UserIdentity identity) { } @Override - public void touchLastLogin(long id) { - jdbc.update("UPDATE user_identities SET last_login_at = CURRENT_TIMESTAMP WHERE id = ?", id); + public void recordLogin(long id, String emailAtLink, String displayNameAtLink) { + // COALESCE 只填空值,不覆盖已有的 at_link 值;本次登录若没拿到邮箱/名字, + // 参数为 null,COALESCE 结果仍是原值,等下次再补。一条 UPDATE 完成两件事。 + jdbc.update(""" + UPDATE user_identities + SET last_login_at = CURRENT_TIMESTAMP, + email_at_link = COALESCE(email_at_link, ?), + display_name_at_link = COALESCE(display_name_at_link, ?) + WHERE id = ?""", + emailAtLink, displayNameAtLink, id); } @Override diff --git a/src/main/java/com/involutionhell/backend/usercenter/repository/UserIdentityRepository.java b/src/main/java/com/involutionhell/backend/usercenter/repository/UserIdentityRepository.java index 64a6097..394e6be 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/repository/UserIdentityRepository.java +++ b/src/main/java/com/involutionhell/backend/usercenter/repository/UserIdentityRepository.java @@ -20,12 +20,21 @@ public interface UserIdentityRepository { * 撞 UNIQUE(身份已绑他人 / 该账号同 provider 已有身份)由调用方捕获 * DuplicateKeyException 处理——那是业务分支(提示"已被绑定"),不是异常路径。 * 传入的 linkedAt / lastLoginAt 会被忽略:linked_at 由 DB DEFAULT NOW() 生成, - * last_login_at 只经 touchLastLogin 更新。需要保留历史时间戳的导入场景(若出现) + * last_login_at 只经 recordLogin 更新。需要保留历史时间戳的导入场景(若出现) * 得加专门方法,不复用本方法。 */ UserIdentity insert(UserIdentity identity); - void touchLastLogin(long id); + /** + * 记录一次登录:刷新 last_login_at,并**补齐**仍为空的 email_at_link / + * display_name_at_link。 + * + * 补齐是必要的:M0 回填建出来的身份行只有 (user_id, provider, provider_user_id), + * 这两列一直是空的,而设置页靠 display_name_at_link 显示"GitHub · 某某", + * 空就只剩一个光秃秃的 provider 名。只在为空时填,不覆盖已有值——列名的 + * at_link 语义是"绑定当时的值",不该被后来的登录改写。 + */ + void recordLogin(long id, String emailAtLink, String displayNameAtLink); /** * 删除某账号的某 provider 身份,返回受影响行数(0 = 本无此绑定)。 diff --git a/src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java b/src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java index b3f21e4..225c83b 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java +++ b/src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java @@ -216,7 +216,9 @@ public boolean hasIdentity(String provider, String providerUserId) { } /** - * 维护 user_identities 双写:缺行则插入(惰性自愈),有则刷新 last_login_at。 + * 维护 user_identities 双写:缺行则插入(惰性自愈),有行则刷新 last_login_at + * 并补齐仍为空的 email/display_name——回填建出来的行这两列一直是空的, + * 只更新时间戳的话设置页永远显示不出账号名。 * 写失败不阻断登录——与 INV-003 lazy upgrade 同策略,记日志后继续, * 下次登录还会再试,绝不让 identity 写入把用户挡在门外。 */ @@ -225,7 +227,8 @@ private void ensureIdentity(long userId, String provider, String providerUserId, try { userIdentityRepository.findByProviderAndProviderUserId(provider, providerUserId) .ifPresentOrElse( - existing -> userIdentityRepository.touchLastLogin(existing.id()), + // 顺带补齐回填行留下的空 email/display_name(自愈,见 recordLogin) + existing -> userIdentityRepository.recordLogin(existing.id(), email, displayName), () -> userIdentityRepository.insert(new UserIdentity( null, userId, provider, providerUserId, email, displayName, null, null))); } catch (Exception e) { diff --git a/src/test/java/com/involutionhell/backend/usercenter/UserIdentityRepositoryTests.java b/src/test/java/com/involutionhell/backend/usercenter/UserIdentityRepositoryTests.java index 4276674..0233339 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/UserIdentityRepositoryTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/UserIdentityRepositoryTests.java @@ -62,11 +62,41 @@ void insertAndFindRoundtrip() { .hasValueSatisfying(found -> assertThat(found.userId()).isEqualTo(userId)); assertThat(repository.findByUserId(userId)).hasSize(1); - repository.touchLastLogin(saved.id()); + repository.recordLogin(saved.id(), "a@e.com", "Nick"); assertThat(repository.findByProviderAndProviderUserId("discord", puid)) .hasValueSatisfying(found -> assertThat(found.lastLoginAt()).isNotNull()); } + @Test + void recordLoginBackfillsEmptyLinkInfoButNeverOverwritesIt() { + // M0 回填出来的行只有 (user_id, provider, provider_user_id),email/display_name 为空。 + // 设置页靠 display_name_at_link 显示 "GitHub · 某某",不补齐就永远只剩一个 provider 名。 + long userId = createUser(null); + String puid = "backfill-" + userId; + UserIdentity backfilled = repository.insert( + new UserIdentity(null, userId, "github", puid, null, null, null, null)); + + repository.recordLogin(backfilled.id(), "first@e.com", "First"); + assertThat(repository.findByProviderAndProviderUserId("github", puid)) + .hasValueSatisfying(found -> { + assertThat(found.emailAtLink()).isEqualTo("first@e.com"); + assertThat(found.displayNameAtLink()).isEqualTo("First"); + }); + + // 已有值不被后来的登录改写——列名的 at_link 语义是"绑定当时的值" + repository.recordLogin(backfilled.id(), "changed@e.com", "Changed"); + assertThat(repository.findByProviderAndProviderUserId("github", puid)) + .hasValueSatisfying(found -> { + assertThat(found.emailAtLink()).isEqualTo("first@e.com"); + assertThat(found.displayNameAtLink()).isEqualTo("First"); + }); + + // 本次登录没拿到邮箱/名字时(参数为 null)不该把已有值抹成空 + repository.recordLogin(backfilled.id(), null, null); + assertThat(repository.findByProviderAndProviderUserId("github", puid)) + .hasValueSatisfying(found -> assertThat(found.emailAtLink()).isEqualTo("first@e.com")); + } + @Test void sameProviderIdentityCannotBindTwoAccounts() { long first = createUser(null); diff --git a/src/test/java/com/involutionhell/backend/usercenter/service/AuthServiceTests.java b/src/test/java/com/involutionhell/backend/usercenter/service/AuthServiceTests.java index 6f9a288..7de10f7 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/service/AuthServiceTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/service/AuthServiceTests.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.verify; @@ -424,7 +425,8 @@ void existingIdentityRefreshesLastLoginInsteadOfInserting() { authService.loginByGithub(ghUser); } - verify(userIdentityRepository).touchLastLogin(7L); + // 已有 identity 行:刷新登录时间的同时把本次拿到的邮箱/名字传下去补空 + verify(userIdentityRepository).recordLogin(eq(7L), any(), any()); verify(userIdentityRepository, org.mockito.Mockito.never()).insert(any()); } From 5503a3a3ef9eeda499f98e27760e4a5f9c412e18 Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Sun, 26 Jul 2026 12:04:23 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat(auth):=20provider=20=E6=8A=BD=E8=B1=A1?= =?UTF-8?q?=E6=B6=88=E7=81=AD=20switch=20+=20M2b=20=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=20+=20=E6=A8=A1=E5=9D=97=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 为什么 接一个新登录 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 改动前后都是 ƒ,非本次引入)。 --- SECURITY.md | 10 +- .../backend/usercenter/README.md | 171 +++++++++++ .../controller/IdentityController.java | 15 +- .../controller/OAuthController.java | 269 +++++++++++------- .../usercenter/oauth/AuthProvider.java | 45 +++ .../oauth/AuthProviderRegistry.java | 49 ++++ .../usercenter/oauth/AuthProviders.java | 18 ++ .../usercenter/oauth/DiscordAuthProvider.java | 62 ++++ .../usercenter/oauth/GithubAuthProvider.java | 49 ++++ .../usercenter/service/AuthService.java | 21 +- .../service/UserIdentityService.java | 69 ++++- .../usercenter/UserIdentityServiceTests.java | 70 +++++ .../OAuthControllerAllowlistTests.java | 5 +- .../OAuthControllerIntegrationTests.java | 19 ++ .../oauth/AuthProviderRegistryTests.java | 75 +++++ .../usercenter/service/AuthServiceTests.java | 15 +- 16 files changed, 839 insertions(+), 123 deletions(-) create mode 100644 src/main/java/com/involutionhell/backend/usercenter/README.md create mode 100644 src/main/java/com/involutionhell/backend/usercenter/oauth/AuthProvider.java create mode 100644 src/main/java/com/involutionhell/backend/usercenter/oauth/AuthProviderRegistry.java create mode 100644 src/main/java/com/involutionhell/backend/usercenter/oauth/AuthProviders.java create mode 100644 src/main/java/com/involutionhell/backend/usercenter/oauth/DiscordAuthProvider.java create mode 100644 src/main/java/com/involutionhell/backend/usercenter/oauth/GithubAuthProvider.java create mode 100644 src/test/java/com/involutionhell/backend/usercenter/oauth/AuthProviderRegistryTests.java diff --git a/SECURITY.md b/SECURITY.md index 47f3c09..aad1bd6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -135,12 +135,20 @@ - `OAuthControllerIntegrationTests#callbackWithoutStateCookieIsRejectedBeforeTokenExchange` - `OAuthControllerIntegrationTests#callbackWithMatchingStateCookieProceedsPastStateCheck`(反向) - `OAuthControllerIntegrationTests#renderSetsStateCookie`(前置条件) + - `OAuthControllerIntegrationTests#bindEntryRequiresLogin`(绑定入口鉴权) + - `OAuthControllerIntegrationTests#bindEntryRequiresLoginEvenForUnknownProvider` - **为什么**:callback 是 provider 发起的顶级 GET,不带 Authorization header;若 直接信任 URL `state`(尤其未来绑定流程把 loginId 塞进 state),攻击者可发起 流程拿到合法 state 诱导受害者授权,把受害者的第三方身份绑/登进攻击者预期的 账号(登录 CSRF / 绑定劫持)。防线:state 必须回证到"发起本次流程的同一浏览器" ——即 render 时种下、callback 时比对的 cookie,攻击者无法向受害者浏览器种此 - cookie。绑定目标账号(M2)同理只能来自服务端校验过的当前会话,绝不取自 state。 + cookie。 + + **绑定流程(M2b,已实现)同理**:`/oauth/bind/{provider}` 带 `@SaCheckLogin`, + userId 取自服务端已校验的会话;以随机 state 为 key 把它存进服务端内存 + (`OAuthController#bindIntents`,5 分钟 TTL、一次性消费),回调时取回并**二次核对 + 当前会话仍是同一个人**(中途登出/换号即拒绝)。state 在此仅作不可猜测的查找键, + 不承载任何可信信息——把 userId 写进 state 就等于把绑定劫持焊死进流程。 - **历史**:2026-07-19 随多 provider 身份体系 M1 引入(RFC #42 / ADR-001)。 编号说明:INV-006 已被"付费 LLM 端点限流"占用,按流水规则用 INV-007。 diff --git a/src/main/java/com/involutionhell/backend/usercenter/README.md b/src/main/java/com/involutionhell/backend/usercenter/README.md new file mode 100644 index 0000000..cf31a62 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/usercenter/README.md @@ -0,0 +1,171 @@ +# usercenter — 登录 / 身份 / 账号 + +这个包管三件事:**认证**(你是谁)、**身份绑定**(哪些第三方账号属于同一个人)、 +**账号**(资料/角色/关注)。接入新的登录方式(Google、Microsoft…)之前先读完这篇。 + +> 安全不变量 INV-001..INV-008 在 `backend/SECURITY.md`,每条都有回归测试。 +> 改 auth/角色/密码/关注逻辑前必须先读那份,本文不重复。 + +--- + +## 1. 接入一个新的登录 provider + +**只需要新增一个类**,不改任何已有文件: + +```java +@Component +public class GoogleAuthProvider implements AuthProvider { + // 构造器用 @Value 注入 client-id / secret / redirect-uri + + @Override public String key() { return "google"; } // 小写,与 URL 段、DB 列一致 + + @Override public AuthRequest newRequest() { + AuthProviders.requireConfigured(key(), clientId, clientSecret); + return new AuthGoogleRequest(AuthConfig.builder()...build()); // JustAuth 内置就直接用 + } + + @Override public String redirectUri() { return redirectUri; } + + @Override public boolean isEmailVerified(AuthUser user) { + // ⚠️ 安全判据,见下面第 2 节。拿不准就 return false + return user.getRawUserInfo().getBooleanValue("email_verified"); + } +} +``` + +`AuthProviderRegistry` 会自动收集所有 `AuthProvider` bean,**登录、绑定、邮箱信任判定 +三条路径立刻全部生效**。 + +### 为什么是这个形状 + +历史上 provider 名散落在三个 `switch` 里(`authRequestFor` / `isProviderEmailVerified` / +`redirectUriOf`),接一个新 provider 要改 6 处,漏改一处就得到"能跳转但邮箱不被信任" +这种半死状态——排查起来极其痛苦,因为登录看起来是成功的,只是**悄悄多建了一个账号**。 +接口把这些收敛成一个类。 + +### 剩下必须手动改的地方(共 3 处,无法再收敛) + +| 位置 | 改什么 | 不改会怎样 | +|---|---|---| +| `application.properties` | `justauth.type..client-id/secret/redirect-uri` | provider 被判为"未配置",跳 `error=oauth_provider` | +| `frontend/messages/{zh,en}.json` | `login.` 按钮文案 | 按钮显示原始 key | +| `frontend/app/components/SignInButton.tsx` | `provider` 联合类型加一项 | typecheck 失败 | + +前端**不需要**再维护"有哪些 provider"的列表:`GET /api/user-center/identities/providers` +返回后端已注册的全部 key,设置页据此渲染"连接"按钮。 + +### 上线前自查 + +- [ ] provider 的 OAuth App 回调 URL 与 `redirect-uri` 完全一致(含 scheme 和结尾斜杠) +- [ ] `isEmailVerified` 的判据查过该 provider 官方文档,不是猜的 +- [ ] `next.config.mjs` 的 rewrite 覆盖 `/oauth/*` 与 `/api/auth/callback/*`(已覆盖,新增 provider 无需改) +- [ ] 本地跑通:登录建号 → 登出 → 再登录命中同一账号 → 设置页能看到该身份 + +--- + +## 2. 邮箱信任是安全判据,不是展示字段 + +`isEmailVerified` 决定**是否允许把这个第三方身份自动挂靠到一个已有账号上**。 + +- 返回 `true` → provider 断言"这个邮箱确实属于本人",匹配到唯一已有账号就直接挂靠, + 用户无感,不会被分叉出第二个账号。 +- 返回 `false` → 一律建新账号。 + +**返回 true 的门槛是"provider 保证用户控制该邮箱"**。如果一个 provider 允许用户填任意 +邮箱而不验证,那么返回 true 就等于把账号接管漏洞焊死进登录流程:攻击者注册一个第三方号、 +邮箱填成受害者的,登录即接管。`default` 分支永远是 `false`,新 provider 拿不准也是 `false` +——代价只是多一个账号,而不是账号被偷。 + +已有判据: + +| provider | 判据 | 依据 | +|---|---|---| +| github | 恒 `true` | JustAuth 取的是 primary email,GitHub 强制 primary 已验证 | +| discord | `/users/@me` 的 `verified` 布尔 | Discord 官方字段 | + +--- + +## 3. 两条 OAuth 流程 + +两条流程**共用同一个回调端点** `/api/auth/callback/{provider}`,走哪条由**服务端**的 +绑定意图决定,不由任何请求参数决定。 + +``` +登录 GET /oauth/render/{provider} → provider 授权页 → 回调 → 建号或按已验证邮箱挂靠 → 发 token +绑定 GET /oauth/bind/{provider} → provider 授权页 → 回调 → 挂到当前账号 + (需已登录) 不建号 / 不换会话 / 不发新 token +``` + +### 绑定流程为什么不能把 userId 放进 state(INV-007) + +`state` 是客户端可见、可伪造的。把"绑定到哪个账号"写进 state,攻击者就能构造一个 +"绑定到我的账号"的 state 诱导受害者走完授权,把受害者的第三方身份绑到攻击者账号上。 + +正确做法(当前实现): + +1. `/oauth/bind/{provider}` 上有 `@SaCheckLogin`,userId 取自**服务端已校验的会话** +2. 以随机 `state` 为 key,把 userId 存进服务端内存(`bindIntents`,5 分钟 TTL,一次性消费) +3. 回调时用 state 取回意图,并**二次核对当前会话仍是同一个人**(中途登出/换号则拒绝) +4. `state` 同时走 `ih_oauth_state` httpOnly cookie 双提交校验(防登录 CSRF) + +`state` 在这里只是**不可猜测的查找键**,不承载任何可信信息。 + +### 绑定冲突 + +对应 `user_identities` 的两条唯一约束,都返回可辨识的 code 而非 500: + +| code | 含义 | 用户该怎么办 | +|---|---|---| +| `bind_taken` | 该第三方账号已绑到**别的**账号 | 先去那个账号解绑 | +| `bind_duplicate` | 本账号已绑过同类 provider | 先解绑再绑新的 | +| `bind_already_yours` | 已经绑在你自己账号上了 | 无需操作 | +| `bind_session` | 从发起到回调之间会话变了 | 重新发起 | + +> **为什么绑定入口必须早于"对所有人开放新 provider"** +> `UNIQUE (provider, provider_user_id)` 意味着一个第三方身份只能绑一个账号。 +> 先开放 → 用户被分叉出新账号 → 新账号**占住**了那个身份 → 本尊再想补绑就撞约束, +> 从"插一行"变成"跨账号迁移 posts/chat/follows 的数据合并"。 +> 顺序错了,成本差一个数量级。 + +--- + +## 4. 账号 vs 身份 + +``` +user_accounts ← 人。opaque BigInt id,不受任何 provider 控制,永不变 + ▲ ▲ ▲ + │ │ └── user_identities(google, ) + │ └───── user_identities(discord, ) + └──────── user_identities(github, ) +``` + +- **主键是我们自己的 surrogate id**,不是邮箱、也不是任何 provider 的 id。 + 邮箱只是"自动挂靠的锚",会变、且不是每个 provider 都给。 +- `user_accounts.github_id` 是**双写期遗留**(贡献归属与 `/u/{githubId}` 还依赖它)。 + 绑定 github 时补写、解绑时清空,两侧必须对称——否则 `schema.sql` 的启动回填会按 + 残留列值把身份静默复活。M4 会移除该列。 +- 解绑不能移除**最后一种**登录方式(否则用户永久锁死),前后端都要拦。 + +--- + +## 5. 灰度闸(Discord 当前状态) + +`auth.discord.allowlist` 非空即启用:只放行名单内的 Discord id **以及已有账号的回访登录**。 +闸保护的是**建新号**,不是把老用户锁在自己账号外面;绑定流程同理不经过闸(绑定不建号)。 + +清空该变量 = GA。**GA 前提是新用户建号路径已经安全**(见 SECURITY.md INV-008)。 + +--- + +## 6. 目录速查 + +``` +controller/ AuthController(密码登录/登出/me) · OAuthController(登录+绑定) · IdentityController(查看/解绑/可绑列表) + UserCenterController · UserPreferencesController +oauth/ AuthProvider(接口) · AuthProviderRegistry(单一真相源) · GithubAuthProvider · DiscordAuthProvider + DiscordAuthSource / AuthDiscordRequest(JustAuth 没内置 Discord,自己实现) +service/ AuthService(登录/自动挂靠) · UserIdentityService(绑定/解绑) · PasswordService(INV-003) + RegistrationService(注册 OTP 引擎,尚未接线) · UserCenterService(账号) +repository/ Jdbc*(裸 JDBC,无 ORM;表结构以 schema.sql 为准) +follows/ 关注关系(社交,与认证无关) +``` diff --git a/src/main/java/com/involutionhell/backend/usercenter/controller/IdentityController.java b/src/main/java/com/involutionhell/backend/usercenter/controller/IdentityController.java index edd2572..bb0869b 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/controller/IdentityController.java +++ b/src/main/java/com/involutionhell/backend/usercenter/controller/IdentityController.java @@ -22,9 +22,12 @@ public class IdentityController { private final UserIdentityService userIdentityService; + private final com.involutionhell.backend.usercenter.oauth.AuthProviderRegistry providers; - public IdentityController(UserIdentityService userIdentityService) { + public IdentityController(UserIdentityService userIdentityService, + com.involutionhell.backend.usercenter.oauth.AuthProviderRegistry providers) { this.userIdentityService = userIdentityService; + this.providers = providers; } /** 列出当前用户已绑定的登录身份。 */ @@ -34,6 +37,16 @@ public ApiResponse> list() { return ApiResponse.ok(userIdentityService.listForUser(StpUtil.getLoginIdAsLong())); } + /** + * 后端已注册的全部登录方式。前端据此渲染"连接"按钮——接入新 provider 时 + * 按钮会自动出现,不需要改前端的写死列表(少一处维护漂移)。 + */ + @SaCheckLogin + @GetMapping("/providers") + public ApiResponse> supportedProviders() { + return ApiResponse.ok(providers.keys().stream().sorted().toList()); + } + /** 解绑指定 provider。返回解绑后剩余身份列表。 */ @SaCheckLogin @DeleteMapping("/{provider}") diff --git a/src/main/java/com/involutionhell/backend/usercenter/controller/OAuthController.java b/src/main/java/com/involutionhell/backend/usercenter/controller/OAuthController.java index 8cd57e5..8a43c11 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/controller/OAuthController.java +++ b/src/main/java/com/involutionhell/backend/usercenter/controller/OAuthController.java @@ -1,13 +1,22 @@ package com.involutionhell.backend.usercenter.controller; +import cn.dev33.satoken.annotation.SaCheckLogin; +import cn.dev33.satoken.stp.StpUtil; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; import com.involutionhell.backend.usercenter.dto.LoginResponse; -import com.involutionhell.backend.usercenter.oauth.AuthDiscordRequest; +import com.involutionhell.backend.usercenter.oauth.AuthProvider; +import com.involutionhell.backend.usercenter.oauth.AuthProviderRegistry; import com.involutionhell.backend.usercenter.service.AuthService; -import me.zhyd.oauth.config.AuthConfig; +import com.involutionhell.backend.usercenter.service.UserIdentityService; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.time.Duration; +import java.util.Optional; +import java.util.Set; import me.zhyd.oauth.model.AuthCallback; import me.zhyd.oauth.model.AuthResponse; import me.zhyd.oauth.model.AuthUser; -import me.zhyd.oauth.request.AuthGithubRequest; import me.zhyd.oauth.request.AuthRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -16,34 +25,21 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.util.Set; /** - * 第三方 OAuth 登录入口。provider 无关(github 内置 / discord 自定义 source); - * github 的特殊性下沉到 AuthService 的业务层。端点路径带 {provider},github 的 - * /api/auth/callback/github 与 OAuth App 注册的回调 URL 保持一致。 + * 第三方 OAuth 的两条流程,共用同一个回调端点: + * 1. **登录**:/oauth/render/{provider} → 授权 → 回调 → 建号或挂靠已有账号 → 发 token + * 2. **绑定(M2b)**:/oauth/bind/{provider}(需已登录)→ 授权 → 回调 → 把该身份挂到 + * **当前账号**,不建号、不换会话 + * + * provider 全部经 {@link AuthProviderRegistry} 查找,本类不含任何 provider 名的 switch; + * 接入新 provider 见 usercenter/README.md。 */ @RestController public class OAuthController { private static final Logger log = LoggerFactory.getLogger(OAuthController.class); - @Value("${justauth.type.github.client-id:}") - private String githubClientId; - @Value("${justauth.type.github.client-secret:}") - private String githubClientSecret; - @Value("${justauth.type.github.redirect-uri:}") - private String githubRedirectUri; - - @Value("${justauth.type.discord.client-id:}") - private String discordClientId; - @Value("${justauth.type.discord.client-secret:}") - private String discordClientSecret; - @Value("${justauth.type.discord.redirect-uri:}") - private String discordRedirectUri; - // Discord 登录灰度白名单:逗号分隔的 Discord user id。配了值=只放行名单内 id // (以及已有账号的回访登录),其余人在回调处被弹回 /login?error=discord_canary; // 完全不配=闸关闭,对所有人开放(GA 就是清空它)。 @@ -59,39 +55,28 @@ public class OAuthController { private String frontEndUrl; private final AuthService authService; - - public OAuthController(AuthService authService) { - this.authService = authService; - } + private final UserIdentityService userIdentityService; + private final AuthProviderRegistry providers; /** - * 按 provider 造 AuthRequest。未知或未配置 → IllegalArgumentException, - * 由调用方兜底重定向到错误页(不 500)。 + * 绑定意图:state → 发起绑定的 userId。 + * + * INV-007 的红线是"绑定目标账号绝不能取自 state"——攻击者可以伪造 state 内容。 + * 所以这里只把 state 当**不可猜测的查找键**,真正的 userId 来自发起绑定那一刻 + * 服务端已校验的会话(@SaCheckLogin),存在服务端内存里,攻击者改不了。 + * TTL 与 state cookie 同为 5 分钟,一次性消费。 */ - private AuthRequest authRequestFor(String provider) { - return switch (provider) { - case "github" -> { - requireConfigured("github", githubClientId, githubClientSecret); - yield new AuthGithubRequest(AuthConfig.builder() - .clientId(githubClientId).clientSecret(githubClientSecret) - .redirectUri(githubRedirectUri).build()); - } - case "discord" -> { - requireConfigured("discord", discordClientId, discordClientSecret); - yield new AuthDiscordRequest(AuthConfig.builder() - .clientId(discordClientId).clientSecret(discordClientSecret) - .redirectUri(discordRedirectUri).build()); - } - default -> throw new IllegalArgumentException("不支持的 OAuth provider: " + provider); - }; - } + private final Cache bindIntents = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofSeconds(STATE_COOKIE_MAX_AGE_SECONDS)) + .maximumSize(10_000) + .build(); - // client-id 与 secret 都要有:只配一半时提前挡在 oauth_provider(配置问题), - // 而不是让 token 交换阶段以 oauth_failed 失败——后者会误导成"provider 侧拒绝"。 - private void requireConfigured(String provider, String clientId, String clientSecret) { - if (clientId == null || clientId.isBlank() || clientSecret == null || clientSecret.isBlank()) { - throw new IllegalArgumentException(provider + " OAuth 未配置(缺 client-id 或 secret)"); - } + public OAuthController(AuthService authService, + UserIdentityService userIdentityService, + AuthProviderRegistry providers) { + this.authService = authService; + this.userIdentityService = userIdentityService; + this.providers = providers; } /** @@ -140,6 +125,8 @@ static Set parseAllowlist(String raw) { * 灰度放行判定。闸未启用=全放行(GA)。启用时放行两类人:白名单内的 id,以及 * **已经有账号的回访用户**——灰度要挡的是"建新号"(OTP wiring 未完成,新用户 * 可能被分叉出第二个账号),不是把已有账号的人锁在自己账号外面。 + * + * 绑定流程(/oauth/bind)不经过这里:绑定不建号,不产生分叉风险。 */ boolean discordAllowed(String discordUserId) { if (!discordGateActive) { @@ -154,51 +141,51 @@ boolean discordAllowed(String discordUserId) { return authService.hasIdentity("discord", discordUserId); } - /** - * 拒绝后撤销刚换到的 access token。用户已经在 Discord 上点过授权、我们已经拿到 - * 他的邮箱和 token,既然不让他登录,就别把用不上的授权和 token 留着。 - * 失败不阻断——撤销只是清理,不能反过来把拒绝流程搞崩。 - */ - private void revokeDiscordToken(AuthRequest authRequest, AuthUser authUser) { - if (!(authRequest instanceof AuthDiscordRequest discord) || authUser == null) { - return; - } - try { - discord.revokeToken(authUser.getToken()); - } catch (Exception e) { - log.warn("[Discord 灰度] 撤销 token 失败(不影响拒绝流程): {}", e.getClass().getSimpleName()); - } - } - - // 仅用于排查日志:redirect_uri 是公开信息,不含密钥。 - private String redirectUriOf(String provider) { - return switch (provider) { - case "github" -> githubRedirectUri; - case "discord" -> discordRedirectUri; - default -> "(n/a)"; - }; - } - // OAuth state 双提交 cookie 名。INV-007:callback 校验 URL state 必须等于此 cookie, // 二者都由本次 render 生成——防登录 CSRF(攻击者无法向受害者浏览器种此 cookie)。 static final String STATE_COOKIE = "ih_oauth_state"; private static final int STATE_COOKIE_MAX_AGE_SECONDS = 300; - /** - * 构建授权链接并重定向到 provider。前端跳到 /oauth/render/{provider} 发起登录。 - */ + /** 发起登录:跳 provider 授权页。 */ @GetMapping("/oauth/render/{provider}") public void renderAuth(@PathVariable String provider, HttpServletResponse response) throws IOException { + startOAuth(provider, null, response, "/login?error=oauth_provider"); + } + + /** + * 发起绑定(M2b):把 {provider} 挂到**当前登录账号**。 + * @SaCheckLogin 保证 userId 来自服务端校验过的会话,而不是任何客户端可控输入。 + */ + @SaCheckLogin + @GetMapping("/oauth/bind/{provider}") + public void renderBind(@PathVariable String provider, HttpServletResponse response) throws IOException { + startOAuth(provider, StpUtil.getLoginIdAsLong(), response, "/settings?bind_error=oauth_provider"); + } + + /** 登录与绑定共用的发起逻辑;bindUserId 非空即为绑定流程。 */ + private void startOAuth(String provider, Long bindUserId, HttpServletResponse response, String errorPath) + throws IOException { + Optional found = providers.find(provider); + if (found.isEmpty()) { + log.warn("[OAuth] 未知 provider={}", provider); + response.sendRedirect(frontEndUrl + errorPath); + return; + } + AuthProvider p = found.get(); AuthRequest authRequest; try { - authRequest = authRequestFor(provider); + authRequest = p.newRequest(); } catch (IllegalArgumentException e) { - log.warn("[OAuth] render 未知/未配置 provider={}: {}", provider, e.getMessage()); - response.sendRedirect(frontEndUrl + "/login?error=oauth_provider"); + log.warn("[OAuth] provider={} 未配置: {}", provider, e.getMessage()); + response.sendRedirect(frontEndUrl + errorPath); return; } - log.info("[OAuth] render provider={}, redirect_uri={}", provider, redirectUriOf(provider)); String state = me.zhyd.oauth.utils.AuthStateUtils.createState(); + if (bindUserId != null) { + bindIntents.put(state, bindUserId); + } + log.info("[OAuth] {} provider={}, redirect_uri={}", + bindUserId != null ? "bind" : "render", provider, p.redirectUri()); // state 同时种进 httpOnly cookie。SameSite=Lax 是关键:callback 是 provider 发起的 // 跨站顶级导航,Strict 会剥掉 cookie;Lax 恰好在顶级 GET 导航时携带。 response.addHeader("Set-Cookie", buildStateCookie(state, STATE_COOKIE_MAX_AGE_SECONDS)); @@ -225,8 +212,8 @@ private String readStateCookie(jakarta.servlet.http.HttpServletRequest request) } /** - * OAuth 回调。github 的路径 /api/auth/callback/github 与 OAuth App 注册一致; - * discord 走 /api/auth/callback/discord。 + * OAuth 回调,登录与绑定共用。走哪条路由由服务端的绑定意图决定(见 bindIntents), + * 不由任何请求参数决定。 */ @GetMapping("/api/auth/callback/{provider}") public void login(@PathVariable String provider, @@ -234,10 +221,14 @@ public void login(@PathVariable String provider, @RequestParam(required = false) String state, jakarta.servlet.http.HttpServletRequest request, HttpServletResponse response) throws IOException { + // 意图在最前面取出并消费(一次性),后续所有错误分支才知道该跳登录页还是设置页。 + Long bindUserId = state == null ? null : bindIntents.asMap().remove(state); + boolean isBind = bindUserId != null; + // 参数缺失(用户直接访问 / provider 异常回调)统一兜底到前端错误页。 if (code == null || state == null) { log.warn("[OAuth] {} callback missing code/state (direct access?), redirecting", provider); - response.sendRedirect(frontEndUrl + "/login?error=oauth_failed"); + response.sendRedirect(errorUrl(isBind, "oauth_failed")); return; } @@ -247,16 +238,23 @@ public void login(@PathVariable String provider, response.addHeader("Set-Cookie", buildStateCookie("", 0)); // 用完即清 if (cookieState == null || !cookieState.equals(state)) { log.warn("[OAuth] {} state 与 cookie 不匹配(CSRF 或 cookie 丢失),拒绝", provider); - response.sendRedirect(frontEndUrl + "/login?error=oauth_state"); + response.sendRedirect(errorUrl(isBind, "oauth_state")); return; } + Optional found = providers.find(provider); + if (found.isEmpty()) { + log.warn("[OAuth] callback 未知 provider={}", provider); + response.sendRedirect(errorUrl(isBind, "oauth_provider")); + return; + } + AuthProvider p = found.get(); AuthRequest authRequest; try { - authRequest = authRequestFor(provider); + authRequest = p.newRequest(); } catch (IllegalArgumentException e) { - log.warn("[OAuth] callback 未知/未配置 provider={}: {}", provider, e.getMessage()); - response.sendRedirect(frontEndUrl + "/login?error=oauth_provider"); + log.warn("[OAuth] callback provider={} 未配置: {}", provider, e.getMessage()); + response.sendRedirect(errorUrl(isBind, "oauth_provider")); return; } @@ -265,22 +263,81 @@ public void login(@PathVariable String provider, callback.setState(state); AuthResponse authResponse = authRequest.login(callback); - if (authResponse.ok()) { - AuthUser authUser = (AuthUser) authResponse.getData(); - // Discord 灰度:不放行的 id 在此弹回(换 token 已发生,但不建号/不登入)。 - // 直连 /oauth/render/discord 绕过前端按钮的人也一并挡在这里。 - if ("discord".equals(provider) && !discordAllowed(authUser.getUuid())) { - log.info("[OAuth] discord 灰度:uuid={} 不在放行范围,拒绝登录", authUser.getUuid()); - revokeDiscordToken(authRequest, authUser); - response.sendRedirect(frontEndUrl + "/login?error=discord_canary"); - return; - } - LoginResponse loginResponse = authService.loginByProvider(provider, authUser); - // token 放 URL fragment(#token=),不进服务器日志/Referer;前端读入 localStorage - response.sendRedirect(frontEndUrl + "/#token=" + loginResponse.tokenValue()); + if (!authResponse.ok()) { + log.warn("[OAuth] {} 授权失败: {}", provider, authResponse.getMsg()); + response.sendRedirect(errorUrl(isBind, "oauth_failed")); + return; + } + + AuthUser authUser = (AuthUser) authResponse.getData(); + if (isBind) { + handleBind(p, authUser, bindUserId, response); } else { - log.warn("[OAuth] {} 登录失败: {}", provider, authResponse.getMsg()); - response.sendRedirect(frontEndUrl + "/login?error=oauth_failed"); + handleLogin(p, authUser, response); } } + + /** 登录:灰度闸 → 建号/挂靠 → 发 token。 */ + private void handleLogin(AuthProvider p, AuthUser authUser, HttpServletResponse response) throws IOException { + // Discord 灰度:不放行的 id 在此弹回(换 token 已发生,但不建号/不登入)。 + // 直连 /oauth/render/discord 绕过前端按钮的人也一并挡在这里。 + if ("discord".equals(p.key()) && !discordAllowed(authUser.getUuid())) { + log.info("[OAuth] discord 灰度:uuid={} 不在放行范围,拒绝登录", authUser.getUuid()); + revokeQuietly(p, authUser); + response.sendRedirect(frontEndUrl + "/login?error=discord_canary"); + return; + } + LoginResponse loginResponse = authService.loginByProvider(p.key(), authUser); + // token 放 URL fragment(#token=),不进服务器日志/Referer;前端读入 localStorage + response.sendRedirect(frontEndUrl + "/#token=" + loginResponse.tokenValue()); + } + + /** + * 绑定:把该第三方身份挂到发起绑定的账号上。不建号、不换会话、不发新 token。 + * + * 二次核对当前会话仍是发起人——意图虽然存在服务端,但从发起到回调之间用户可能 + * 已经登出或换号登录,那时把身份绑到旧 userId 上就是错的账号。 + */ + private void handleBind(AuthProvider p, AuthUser authUser, long bindUserId, HttpServletResponse response) + throws IOException { + if (!StpUtil.isLogin() || StpUtil.getLoginIdAsLong() != bindUserId) { + log.warn("[OAuth] bind provider={} 会话与发起人不一致,拒绝绑定", p.key()); + revokeQuietly(p, authUser); + response.sendRedirect(frontEndUrl + "/settings?bind_error=bind_session"); + return; + } + try { + userIdentityService.bind(bindUserId, p.key(), authUser.getUuid(), + authUser.getEmail(), displayNameOf(authUser)); + log.info("[OAuth] bind 成功 provider={} userId={}", p.key(), bindUserId); + response.sendRedirect(frontEndUrl + "/settings?bind=ok"); + } catch (UserIdentityService.IdentityAlreadyBoundException e) { + // 该第三方身份已经属于另一个账号(UNIQUE(provider, provider_user_id))。 + // 这正是"先 GA 再做 M2b"会陷进去的死局:分叉账号占着身份,本尊补绑不进来。 + log.info("[OAuth] bind 冲突 provider={}: {}", p.key(), e.getMessage()); + revokeQuietly(p, authUser); + response.sendRedirect(frontEndUrl + "/settings?bind_error=" + e.errorCode()); + return; + } + } + + private static String displayNameOf(AuthUser authUser) { + return authUser.getNickname() != null ? authUser.getNickname() : authUser.getUsername(); + } + + /** 拒绝/失败后撤销刚换到的 token——不让用户白授权还留着我们用不上的凭据。失败不阻断。 */ + private void revokeQuietly(AuthProvider p, AuthUser authUser) { + if (authUser == null) { + return; + } + try { + p.revokeToken(authUser.getToken()); + } catch (Exception e) { + log.warn("[OAuth] 撤销 {} token 失败(不影响主流程): {}", p.key(), e.getClass().getSimpleName()); + } + } + + private String errorUrl(boolean isBind, String code) { + return frontEndUrl + (isBind ? "/settings?bind_error=" : "/login?error=") + code; + } } diff --git a/src/main/java/com/involutionhell/backend/usercenter/oauth/AuthProvider.java b/src/main/java/com/involutionhell/backend/usercenter/oauth/AuthProvider.java new file mode 100644 index 0000000..3317888 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/usercenter/oauth/AuthProvider.java @@ -0,0 +1,45 @@ +package com.involutionhell.backend.usercenter.oauth; + +import me.zhyd.oauth.model.AuthToken; +import me.zhyd.oauth.model.AuthUser; +import me.zhyd.oauth.request.AuthRequest; + +/** + * 一个第三方登录 provider 的完整契约。**接入新 provider 只需新增一个本接口的实现**, + * 不要再往控制器/服务里加 `switch (provider)` —— 那正是维护漂移的来源: + * 曾经同一个 provider 名散落在 authRequestFor / isProviderEmailVerified / redirectUriOf + * 三个 switch 里,漏改一处就得到"能跳转但邮箱不被信任"这种半死状态。 + * + * 实现类声明成 Spring bean 即可,{@link AuthProviderRegistry} 会自动按 {@link #key()} 收集。 + * 详细接入步骤见 usercenter/README.md。 + */ +public interface AuthProvider { + + /** provider 标识,必须小写,与 URL 路径段、user_identities.provider 列一致。 */ + String key(); + + /** + * 构造本次授权用的 JustAuth 请求。 + * 未配置 client-id / secret 时抛 {@link IllegalArgumentException}, + * 由调用方兜底重定向到 error=oauth_provider(配置问题,不该是 500)。 + */ + AuthRequest newRequest(); + + /** 回调地址,仅用于排查日志(公开信息,不含密钥)。 */ + String redirectUri(); + + /** + * 该 provider 是否**已验证**这个邮箱。只有 true 才允许按邮箱自动关联到已有账号, + * 否则攻击者用一个谎称受害者邮箱的第三方号就能撞进别人账号(ADR-001 红线)。 + * 拿不准就返回 false —— 代价只是多建一个账号,而不是账号被接管。 + */ + boolean isEmailVerified(AuthUser user); + + /** + * 撤销已换到的 access token。用于"换完 token 才决定拒绝该用户"的场景(灰度闸)。 + * provider 不支持就保持默认空实现。 + */ + default void revokeToken(AuthToken token) { + // 默认不做事:多数 provider 的 token 会自然过期,撤销只是礼貌性清理 + } +} diff --git a/src/main/java/com/involutionhell/backend/usercenter/oauth/AuthProviderRegistry.java b/src/main/java/com/involutionhell/backend/usercenter/oauth/AuthProviderRegistry.java new file mode 100644 index 0000000..31045c8 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/usercenter/oauth/AuthProviderRegistry.java @@ -0,0 +1,49 @@ +package com.involutionhell.backend.usercenter.oauth; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * provider 名 → {@link AuthProvider} 的唯一映射。Spring 注入所有 AuthProvider bean, + * 按 key 建索引;新增 provider 不需要改这个类。 + * + * 这是"provider 名"在整个后端的**单一真相源**:控制器和服务一律经此查找, + * 不再各自维护 switch。 + */ +@Component +public class AuthProviderRegistry { + + private static final Logger log = LoggerFactory.getLogger(AuthProviderRegistry.class); + + private final Map byKey; + + public AuthProviderRegistry(List providers) { + this.byKey = providers.stream().collect(Collectors.toUnmodifiableMap( + p -> p.key().toLowerCase(Locale.ROOT), + Function.identity(), + (a, b) -> { + // 同名两个实现 = 行为不确定,启动即失败好过线上随机命中一个 + throw new IllegalStateException( + "重复的 AuthProvider key: " + a.key() + "(" + a.getClass() + " / " + b.getClass() + ")"); + })); + log.info("[Auth] 已注册 {} 个登录 provider: {}", byKey.size(), byKey.keySet()); + } + + /** 找不到返回空(未知 provider),由调用方兜底到错误页而不是 500。 */ + public Optional find(String key) { + return key == null ? Optional.empty() + : Optional.ofNullable(byKey.get(key.toLowerCase(Locale.ROOT))); + } + + /** 已注册的 provider 名,供前端展示"可绑定的登录方式"。 */ + public java.util.Set keys() { + return byKey.keySet(); + } +} diff --git a/src/main/java/com/involutionhell/backend/usercenter/oauth/AuthProviders.java b/src/main/java/com/involutionhell/backend/usercenter/oauth/AuthProviders.java new file mode 100644 index 0000000..e48b6c2 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/usercenter/oauth/AuthProviders.java @@ -0,0 +1,18 @@ +package com.involutionhell.backend.usercenter.oauth; + +/** AuthProvider 实现共用的小工具。 */ +final class AuthProviders { + + private AuthProviders() { + } + + /** + * client-id 与 secret 都要有:只配一半时提前挡在 oauth_provider(配置问题), + * 而不是让 token 交换阶段以 oauth_failed 失败——后者会误导成"provider 侧拒绝"。 + */ + static void requireConfigured(String provider, String clientId, String clientSecret) { + if (clientId == null || clientId.isBlank() || clientSecret == null || clientSecret.isBlank()) { + throw new IllegalArgumentException(provider + " OAuth 未配置(缺 client-id 或 secret)"); + } + } +} diff --git a/src/main/java/com/involutionhell/backend/usercenter/oauth/DiscordAuthProvider.java b/src/main/java/com/involutionhell/backend/usercenter/oauth/DiscordAuthProvider.java new file mode 100644 index 0000000..ed2aa26 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/usercenter/oauth/DiscordAuthProvider.java @@ -0,0 +1,62 @@ +package com.involutionhell.backend.usercenter.oauth; + +import com.alibaba.fastjson.JSONObject; +import me.zhyd.oauth.config.AuthConfig; +import me.zhyd.oauth.model.AuthToken; +import me.zhyd.oauth.model.AuthUser; +import me.zhyd.oauth.request.AuthRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * Discord 登录。JustAuth 1.16.6 没有内置 Discord source,自定义实现见 + * {@link DiscordAuthSource} / {@link AuthDiscordRequest}。 + */ +@Component +public class DiscordAuthProvider implements AuthProvider { + + private final String clientId; + private final String clientSecret; + private final String redirectUri; + + public DiscordAuthProvider( + @Value("${justauth.type.discord.client-id:}") String clientId, + @Value("${justauth.type.discord.client-secret:}") String clientSecret, + @Value("${justauth.type.discord.redirect-uri:}") String redirectUri) { + this.clientId = clientId; + this.clientSecret = clientSecret; + this.redirectUri = redirectUri; + } + + @Override + public String key() { + return "discord"; + } + + @Override + public AuthRequest newRequest() { + AuthProviders.requireConfigured(key(), clientId, clientSecret); + return new AuthDiscordRequest(AuthConfig.builder() + .clientId(clientId).clientSecret(clientSecret).redirectUri(redirectUri).build()); + } + + @Override + public String redirectUri() { + return redirectUri; + } + + /** Discord /users/@me 的 "verified" 布尔表示该账号邮箱已验证。 */ + @Override + public boolean isEmailVerified(AuthUser user) { + JSONObject raw = user == null ? null : user.getRawUserInfo(); + return raw != null && raw.getBooleanValue("verified"); + } + + @Override + public void revokeToken(AuthToken token) { + if (token == null) { + return; + } + ((AuthDiscordRequest) newRequest()).revokeToken(token); + } +} diff --git a/src/main/java/com/involutionhell/backend/usercenter/oauth/GithubAuthProvider.java b/src/main/java/com/involutionhell/backend/usercenter/oauth/GithubAuthProvider.java new file mode 100644 index 0000000..66a7f17 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/usercenter/oauth/GithubAuthProvider.java @@ -0,0 +1,49 @@ +package com.involutionhell.backend.usercenter.oauth; + +import me.zhyd.oauth.config.AuthConfig; +import me.zhyd.oauth.model.AuthUser; +import me.zhyd.oauth.request.AuthGithubRequest; +import me.zhyd.oauth.request.AuthRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** GitHub 登录。JustAuth 内置 source,直接包一层。 */ +@Component +public class GithubAuthProvider implements AuthProvider { + + private final String clientId; + private final String clientSecret; + private final String redirectUri; + + public GithubAuthProvider( + @Value("${justauth.type.github.client-id:}") String clientId, + @Value("${justauth.type.github.client-secret:}") String clientSecret, + @Value("${justauth.type.github.redirect-uri:}") String redirectUri) { + this.clientId = clientId; + this.clientSecret = clientSecret; + this.redirectUri = redirectUri; + } + + @Override + public String key() { + return "github"; + } + + @Override + public AuthRequest newRequest() { + AuthProviders.requireConfigured(key(), clientId, clientSecret); + return new AuthGithubRequest(AuthConfig.builder() + .clientId(clientId).clientSecret(clientSecret).redirectUri(redirectUri).build()); + } + + @Override + public String redirectUri() { + return redirectUri; + } + + /** JustAuth 取的是 GitHub 的 primary email,而 GitHub 要求 primary 必须已验证,故信任。 */ + @Override + public boolean isEmailVerified(AuthUser user) { + return true; + } +} diff --git a/src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java b/src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java index 225c83b..babc7e2 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java +++ b/src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java @@ -25,6 +25,7 @@ public class AuthService { private final PasswordService passwordService; private final UserAccountRepository userAccountRepository; private final UserIdentityRepository userIdentityRepository; + private final com.involutionhell.backend.usercenter.oauth.AuthProviderRegistry providers; /** * 创建认证服务并注入用户与密码服务。 @@ -32,11 +33,13 @@ public class AuthService { public AuthService(UserCenterService userCenterService, PasswordService passwordService, UserAccountRepository userAccountRepository, - UserIdentityRepository userIdentityRepository) { + UserIdentityRepository userIdentityRepository, + com.involutionhell.backend.usercenter.oauth.AuthProviderRegistry providers) { this.userCenterService = userCenterService; this.passwordService = passwordService; this.userAccountRepository = userAccountRepository; this.userIdentityRepository = userIdentityRepository; + this.providers = providers; } /** @@ -184,19 +187,13 @@ private UserAccount autoLinkByVerifiedEmailOrCreate(String provider, AuthUser au /** * provider 是否已验证该邮箱。只有 true 才允许按邮箱自动关联。 - * - discord:/users/@me 的 "verified" 布尔(Discord 已验证账号邮箱) - * - github:JustAuth 取的是 primary email,GitHub 要求 primary 已验证,信任 - * - 其它未知 provider:保守返回 false,不自动关联 + * 判定下沉到各 AuthProvider 实现(接新 provider 不用改这里); + * 未注册的 provider 保守返回 false,绝不自动关联。 */ private boolean isProviderEmailVerified(String provider, AuthUser authUser) { - return switch (provider) { - case "discord" -> { - com.alibaba.fastjson.JSONObject raw = authUser.getRawUserInfo(); - yield raw != null && raw.getBooleanValue("verified"); - } - case "github" -> true; - default -> false; - }; + return providers.find(provider) + .map(p -> p.isEmailVerified(authUser)) + .orElse(false); } /** diff --git a/src/main/java/com/involutionhell/backend/usercenter/service/UserIdentityService.java b/src/main/java/com/involutionhell/backend/usercenter/service/UserIdentityService.java index 4930de0..5d2f384 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/service/UserIdentityService.java +++ b/src/main/java/com/involutionhell/backend/usercenter/service/UserIdentityService.java @@ -10,11 +10,14 @@ import org.springframework.transaction.annotation.Transactional; /** - * 登录身份的读取与解绑(M2a)。绑定(新建第二 provider)走 M2b 的 OAuth 流程。 + * 登录身份的读取、绑定(M2b)与解绑(M2a)。 + * 绑定由 OAuthController 的 /oauth/bind/{provider} 流程走完 OAuth 后调用。 */ @Service public class UserIdentityService { + private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(UserIdentityService.class); + private final UserIdentityRepository userIdentityRepository; private final UserAccountRepository userAccountRepository; @@ -30,6 +33,70 @@ public List listForUser(long userId) { .toList(); } + /** 绑定冲突。带一个前端可辨识的 code,便于给出"该账号已被占用"之类的具体文案。 */ + public static class IdentityAlreadyBoundException extends RuntimeException { + private final String errorCode; + + IdentityAlreadyBoundException(String errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + public String errorCode() { + return errorCode; + } + } + + /** + * 绑定一个第三方身份到已有账号(M2b)。**不建号、不改会话**——调用方必须已经从 + * 服务端校验过的会话拿到 userId。 + * + * 两种冲突分别对应 schema 里的两条唯一约束,都要给出可区分的提示而不是 500: + * - UNIQUE(provider, provider_user_id):该第三方身份已绑到别的账号 + * - UNIQUE(user_id, provider) :本账号已经绑过同类 provider + * + * github 额外补写 user_accounts.github_id(仅当为空)——贡献归属与 /u/{githubId} + * 都依赖该列,与 unbind 时清空它是对称的。 + */ + @Transactional + public List bind(long userId, String provider, String providerUserId, + String email, String displayName) { + String normalized = provider == null ? null : provider.toLowerCase(Locale.ROOT); + if (normalized == null || normalized.isBlank() || providerUserId == null || providerUserId.isBlank()) { + throw new IllegalArgumentException("provider 与 providerUserId 不能为空"); + } + + userIdentityRepository.findByProviderAndProviderUserId(normalized, providerUserId) + .ifPresent(existing -> { + if (existing.userId() == userId) { + throw new IdentityAlreadyBoundException("bind_already_yours", + "该登录方式已经绑定在你的账号上了"); + } + throw new IdentityAlreadyBoundException("bind_taken", + "该第三方账号已绑定到另一个账号,请先在那个账号里解绑"); + }); + + boolean sameProviderBound = userIdentityRepository.findByUserId(userId).stream() + .anyMatch(i -> i.provider().equals(normalized)); + if (sameProviderBound) { + throw new IdentityAlreadyBoundException("bind_duplicate", + "你已经绑定过 " + normalized + " 了,一个账号同一登录方式只能绑一个"); + } + + userIdentityRepository.insert(new UserIdentity( + null, userId, normalized, providerUserId, email, displayName, null, null)); + + if ("github".equals(normalized)) { + try { + userAccountRepository.setGithubIdIfAbsent(userId, Long.parseLong(providerUserId)); + } catch (NumberFormatException e) { + // github 的 uuid 就是数字用户 ID;非数字(极罕见)时跳过,不阻断绑定 + log.warn("绑定 github 时 providerUserId 非数字,跳过 github_id 回填: userId={}", userId); + } + } + return listForUser(userId); + } + /** * 解绑指定 provider 身份。返回解绑后剩余身份列表。 * diff --git a/src/test/java/com/involutionhell/backend/usercenter/UserIdentityServiceTests.java b/src/test/java/com/involutionhell/backend/usercenter/UserIdentityServiceTests.java index e73148d..c28907e 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/UserIdentityServiceTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/UserIdentityServiceTests.java @@ -107,4 +107,74 @@ void unbindEndpointRejectsAnonymous() throws Exception { mockMvc.perform(delete("/api/user-center/identities/github")) .andExpect(status().isUnauthorized()); } + + // ===================== M2b 绑定 ===================== + + @Test + void bindAttachesIdentityToExistingAccountWithoutCreatingOne() { + long userId = createUser(null); + addIdentity(userId, "github", "gh-" + userId); + + var after = service.bind(userId, "discord", "dc-" + userId, "a@e.com", "Nick"); + + assertThat(after).hasSize(2); + assertThat(after).extracting(v -> v.provider()).containsExactlyInAnyOrder("github", "discord"); + // 绑定不建号 + assertThat(jdbc.queryForObject( + "SELECT count(*) FROM user_accounts WHERE username LIKE 'ident-svc-%'", Integer.class)).isEqualTo(1); + } + + @Test + void bindRejectsIdentityAlreadyOwnedByAnotherAccount() { + long owner = createUser(null); + addIdentity(owner, "github", "gh-owner-" + owner); + addIdentity(owner, "discord", "shared-discord-id"); + + long other = createUser(null); + addIdentity(other, "github", "gh-other-" + other); + + // 这正是"先 GA 再做 M2b"的死局:分叉账号占着身份,本尊补绑不进来。 + // 必须给出可辨识的 code,而不是 500。 + assertThatThrownBy(() -> service.bind(other, "discord", "shared-discord-id", null, null)) + .isInstanceOf(UserIdentityService.IdentityAlreadyBoundException.class) + .satisfies(e -> assertThat( + ((UserIdentityService.IdentityAlreadyBoundException) e).errorCode()).isEqualTo("bind_taken")); + } + + @Test + void bindRejectsSecondIdentityOfSameProvider() { + long userId = createUser(null); + addIdentity(userId, "github", "gh-" + userId); + addIdentity(userId, "discord", "dc-a-" + userId); + + assertThatThrownBy(() -> service.bind(userId, "discord", "dc-b-" + userId, null, null)) + .isInstanceOf(UserIdentityService.IdentityAlreadyBoundException.class) + .satisfies(e -> assertThat( + ((UserIdentityService.IdentityAlreadyBoundException) e).errorCode()).isEqualTo("bind_duplicate")); + } + + @Test + void rebindingOwnIdentityIsReportedDistinctly() { + long userId = createUser(null); + addIdentity(userId, "github", "gh-" + userId); + addIdentity(userId, "discord", "dc-" + userId); + + assertThatThrownBy(() -> service.bind(userId, "discord", "dc-" + userId, null, null)) + .isInstanceOf(UserIdentityService.IdentityAlreadyBoundException.class) + .satisfies(e -> assertThat( + ((UserIdentityService.IdentityAlreadyBoundException) e).errorCode()) + .isEqualTo("bind_already_yours")); + } + + @Test + void bindingGithubBackfillsGithubIdColumn() { + // 与 unbind 清空 github_id 对称:贡献归属和 /u/{githubId} 都依赖这列 + long userId = createUser(null); + addIdentity(userId, "discord", "dc-" + userId); + + service.bind(userId, "github", "114514", "a@e.com", "Nick"); + + assertThat(jdbc.queryForObject( + "SELECT github_id FROM user_accounts WHERE id = ?", Long.class, userId)).isEqualTo(114514L); + } } diff --git a/src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerAllowlistTests.java b/src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerAllowlistTests.java index 69f9917..3c05eb8 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerAllowlistTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerAllowlistTests.java @@ -19,7 +19,10 @@ class OAuthControllerAllowlistTests { private final AuthService authService = mock(AuthService.class); - private final OAuthController controller = new OAuthController(authService); + private final OAuthController controller = new OAuthController( + authService, + mock(com.involutionhell.backend.usercenter.service.UserIdentityService.class), + new com.involutionhell.backend.usercenter.oauth.AuthProviderRegistry(java.util.List.of())); private OAuthController withAllowlist(String raw) { controller.configureDiscordAllowlist(raw); diff --git a/src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerIntegrationTests.java b/src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerIntegrationTests.java index 0ad4677..05648b0 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerIntegrationTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerIntegrationTests.java @@ -156,4 +156,23 @@ void callbackWithoutParametersRedirectsToFrontendErrorPage() throws Exception { .isNotNull() .endsWith("/login?error=oauth_failed"); } + + // ============================================= + // GET /oauth/bind/{provider} — 绑定发起(M2b) + // ============================================= + + @Test + void bindEntryRequiresLogin() throws Exception { + // INV-007:绑定目标账号只能来自服务端校验过的会话。未登录就发起绑定必须被拒, + // 否则"绑定到哪个账号"就只能取自客户端可控输入 = 绑定劫持。 + mockMvc.perform(get("/oauth/bind/github")) + .andExpect(status().isUnauthorized()); + } + + @Test + void bindEntryRequiresLoginEvenForUnknownProvider() throws Exception { + // 鉴权必须先于 provider 解析,否则未登录者能通过响应差异探测支持哪些 provider + mockMvc.perform(get("/oauth/bind/myspace")) + .andExpect(status().isUnauthorized()); + } } diff --git a/src/test/java/com/involutionhell/backend/usercenter/oauth/AuthProviderRegistryTests.java b/src/test/java/com/involutionhell/backend/usercenter/oauth/AuthProviderRegistryTests.java new file mode 100644 index 0000000..4642ad8 --- /dev/null +++ b/src/test/java/com/involutionhell/backend/usercenter/oauth/AuthProviderRegistryTests.java @@ -0,0 +1,75 @@ +package com.involutionhell.backend.usercenter.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import me.zhyd.oauth.model.AuthUser; +import me.zhyd.oauth.request.AuthRequest; +import org.junit.jupiter.api.Test; + +/** + * provider 注册表的契约。这是"provider 名"在后端的单一真相源, + * 接入新 provider 只应新增一个 AuthProvider 实现,不改任何已有文件。 + */ +class AuthProviderRegistryTests { + + /** 最小实现,只用来验证注册表本身的行为。 */ + private record StubProvider(String key, boolean verified) implements AuthProvider { + @Override public AuthRequest newRequest() { + throw new UnsupportedOperationException("测试不发起真实 OAuth"); + } + @Override public String redirectUri() { + return "http://cb/" + key; + } + @Override public boolean isEmailVerified(AuthUser user) { + return verified; + } + } + + @Test + void findsRegisteredProviderCaseInsensitively() { + var registry = new AuthProviderRegistry(List.of(new StubProvider("discord", true))); + assertThat(registry.find("discord")).isPresent(); + assertThat(registry.find("DISCORD")).as("URL 路径段大小写不该影响查找").isPresent(); + } + + @Test + void unknownProviderReturnsEmptyInsteadOfThrowing() { + // 调用方据此重定向到错误页;抛异常会变成 500 + var registry = new AuthProviderRegistry(List.of(new StubProvider("github", true))); + assertThat(registry.find("myspace")).isEmpty(); + assertThat(registry.find(null)).isEmpty(); + } + + @Test + void duplicateKeysFailAtStartupRatherThanPickingOneAtRandom() { + // 两个同名实现 = 行为不确定(谁生效取决于 bean 顺序)。启动即炸好过线上随机命中。 + assertThatThrownBy(() -> new AuthProviderRegistry( + List.of(new StubProvider("discord", true), new StubProvider("discord", false)))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("重复的 AuthProvider key"); + } + + @Test + void realProvidersAreRegisteredWithExpectedEmailTrust() { + var registry = new AuthProviderRegistry(List.of( + new GithubAuthProvider("id", "secret", "http://cb/github"), + new DiscordAuthProvider("id", "secret", "http://cb/discord"))); + + assertThat(registry.keys()).containsExactlyInAnyOrder("github", "discord"); + // GitHub 的 primary email 必然已验证 → 可用于自动关联 + assertThat(registry.find("github").orElseThrow().isEmailVerified(AuthUser.builder().build())).isTrue(); + // Discord 未带 verified 标记时必须保守判为未验证,否则是账号接管向量 + assertThat(registry.find("discord").orElseThrow().isEmailVerified(AuthUser.builder().build())).isFalse(); + } + + @Test + void unconfiguredProviderRejectsRequestCreation() { + // 只配一半(有 id 无 secret)也要在发起阶段就挡住,而不是到 token 交换才失败 + var provider = new DiscordAuthProvider("id", "", "http://cb/discord"); + assertThatThrownBy(provider::newRequest) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("未配置"); + } +} diff --git a/src/test/java/com/involutionhell/backend/usercenter/service/AuthServiceTests.java b/src/test/java/com/involutionhell/backend/usercenter/service/AuthServiceTests.java index 7de10f7..30ebb26 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/service/AuthServiceTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/service/AuthServiceTests.java @@ -44,9 +44,22 @@ class AuthServiceTests { @Mock private com.involutionhell.backend.usercenter.repository.UserIdentityRepository userIdentityRepository; - @InjectMocks private AuthService authService; + /** + * 用**真实**的 AuthProviderRegistry + 真实 provider 实例,而不是 mock: + * "该 provider 的邮箱算不算已验证"是自动关联的安全判据,这里要测真逻辑。 + * client-id/secret 随便给,本测试不发起真实 OAuth 请求。 + */ + @org.junit.jupiter.api.BeforeEach + void wireAuthService() { + var registry = new com.involutionhell.backend.usercenter.oauth.AuthProviderRegistry(java.util.List.of( + new com.involutionhell.backend.usercenter.oauth.GithubAuthProvider("id", "secret", "http://cb/github"), + new com.involutionhell.backend.usercenter.oauth.DiscordAuthProvider("id", "secret", "http://cb/discord"))); + authService = new AuthService(userCenterService, passwordService, + userAccountRepository, userIdentityRepository, registry); + } + /** * identity 双写默认:缺行(Optional.empty)→ ensureIdentity 走 insert 路径。 * lenient 因为账号密码登录相关测试不触及 identity 分支。