From 9e91771e167b60cc9be26265a25cbf8abe78d675 Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Sat, 18 Jul 2026 23:31:24 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(usercenter):=20M0=20=E5=A4=9A=20provid?= =?UTF-8?q?er=20=E8=BA=AB=E4=BB=BD=E8=A1=A8=20user=5Fidentities=20+=20?= =?UTF-8?q?=E5=B9=82=E7=AD=89=E5=9B=9E=E5=A1=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC involutionhell-backend#42 / ADR-001 的第一阶段,纯增量零行为变化: - user_identities 表:UNIQUE(provider,provider_user_id) + UNIQUE(user_id,provider) + provider 小写 CHECK + FK ON DELETE CASCADE - 启动回填存量 github_id → provider='github' 行,ON CONFLICT DO NOTHING 保幂等 - UserIdentity model + JdbcUserIdentityRepository(沿用 JDBC 惯例) - 5 个仓库测试覆盖两条 UNIQUE、级联删除、回填幂等 - ADR-001(OKF frontmatter)记录设计决策与 OAuth state 防护协议 生产数据干跑验证:51 个 github_id 账号,0 重复 / 0 非数字 cast,回填无冲突。 全量 251 测试通过。 --- docs/wiki/adr/001-multi-provider-identity.md | 111 ++++++++++++++++ .../usercenter/model/UserIdentity.java | 19 +++ .../JdbcUserIdentityRepository.java | 80 ++++++++++++ .../repository/UserIdentityRepository.java | 26 ++++ src/main/resources/schema.sql | 37 ++++++ .../UserIdentityRepositoryTests.java | 119 ++++++++++++++++++ src/test/resources/test-schema.sql | 16 +++ 7 files changed, 408 insertions(+) create mode 100644 docs/wiki/adr/001-multi-provider-identity.md create mode 100644 src/main/java/com/involutionhell/backend/usercenter/model/UserIdentity.java create mode 100644 src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserIdentityRepository.java create mode 100644 src/main/java/com/involutionhell/backend/usercenter/repository/UserIdentityRepository.java create mode 100644 src/test/java/com/involutionhell/backend/usercenter/UserIdentityRepositoryTests.java diff --git a/docs/wiki/adr/001-multi-provider-identity.md b/docs/wiki/adr/001-multi-provider-identity.md new file mode 100644 index 0000000..19e0fd5 --- /dev/null +++ b/docs/wiki/adr/001-multi-provider-identity.md @@ -0,0 +1,111 @@ +--- +type: adr +title: 多 Provider 身份体系与 OAuth state 防护 +tags: [auth, oauth, identity, security] +intent: 身份体系设计决策 +status: accepted +date: 2026-07-18 +supersedes: [] +superseded_by: [] +# schema_source 留空:okf 的符号解析器是 Python-AST 专用,这个 Java/SQL repo +# 里它无法机器解析,权威代码指针改放 documents.symbols(纯标签,可 grep 可读)。 +schema_source: [] +documents: + endpoints: + - GET /oauth/render/{provider} + - GET /api/auth/callback/{provider} + - POST /api/auth/link/{provider}/start + - POST /api/auth/link/{provider}/confirm + symbols: + - src/main/resources/schema.sql (user_identities 表 DDL + 回填) + - src/main/java/com/involutionhell/backend/usercenter/model/UserIdentity.java + - src/main/java/com/involutionhell/backend/usercenter/repository/UserIdentityRepository.java + - src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java (loginByGithub → M1 loginByProvider) + - src/main/java/com/involutionhell/backend/usercenter/controller/OAuthController.java (render/callback) +--- + +# ADR-001:多 Provider 身份体系与 OAuth state 防护 + +完整讨论与对抗性 review 见 [RFC issue #42](https://github.com/InvolutionHell/involutionhell-backend/issues/42)。 +本文只记结论和"为什么";表结构、字段、约束以 `schema_source` 指向的代码为准,不在此复述。 + +## 背景 + +站点要接入多个第三方登录(Discord、Google……)。旧模型是单 provider 捷径: +`user_accounts.github_id` 列 + `username = "github_" + githubId`,每接一家都要改核心表, +且 provider 烧进了用户名。(历史巧合:库里废弃的 NextAuth `accounts` 表形状本来是对的, +Sa-Token 迁移时被塌缩掉了。) + +## 决策 + +**账号与登录方式分表**:`user_accounts` = 人(无任何 provider 字段);`user_identities` = +登录方式,每行一个 `(provider, provider_user_id)`。接新 provider = OAuth App 注册 + +两个 env key + AuthRequest 工厂一个 case,零 schema 变更。 + +关键约束的 why(DDL 见 schema.sql): + +- `UNIQUE (provider, provider_user_id)` — 一个第三方身份只能绑一个账号。 +- `UNIQUE (user_id, provider)` — 同账号同 provider 至多一个身份:`/u/{githubId}` + canonical URL 和贡献归属都假设 1:1,放开是一句 DROP,收紧要洗数据。 +- FK `ON DELETE CASCADE` — 删号不留幽灵身份。注意"无 FK"惯例只适用于 Prisma + 跨系统引用(根 CLAUDE.md),后端表引后端表照常加 FK。 +- 启动回填用无冲突目标的 `ON CONFLICT DO NOTHING` — schema.sql 每次启动执行, + 必须幂等(有回归测试原样执行两遍验证)。 + +**GitHub 的特殊性下沉到业务层**:认证层 provider 平权;贡献归属、排行榜、认领档案 +查 `provider = 'github'`。文档是 git-based 是业务事实,不泄漏进认证设计。 + +## 统一登录 / 绑定流程与 state 协议 + +**原则:state 是不透明的一次性 nonce,不携带任何身份信息;callback 永远不信任 +state 里的用户身份**(登记为安全不变量 INV-006,随 M1 落进 SecurityInvariantsTests)。 +真实信息挂在服务端 intent 记录上(nonce 为 key,Caffeine 存储,5 分钟 TTL)。 + +- **登录**(无会话):`/oauth/render/{provider}` 生成 nonce → 存 intent{mode=login} → + 种 `oauth_flow=` cookie(httpOnly + **SameSite=Lax**,Strict 会把跨站顶级 + 导航的 cookie 剥掉)→ 跳 provider。callback 核对 URL state == cookie nonce, + 防登录 CSRF:攻击者无法向受害者浏览器种自己的 cookie。 +- **绑定**(已登录,从设置页发起):satoken 在 localStorage,callback(provider 发起的 + 顶级 GET)拿不到会话,所以把"你是谁"提前到发起时捕获—— + 1. `POST /api/auth/link/{provider}/start`(fetch 带 satoken,可认证)→ 服务端记 + intent{mode=bind, userId=当前会话} → 种 cookie → 返回授权链接; + 2. callback 核对 state==cookie,把 provider 身份暂存进 intent,跳回 + `settings?link_confirm=`; + 3. 前端确认页 → `POST .../confirm`(再带 satoken)→ 服务端二次核对 + 当前会话 == intent.userId → 插入 identity。 + 绑定劫持(攻击者发起流程诱导受害者授权)被 cookie 那关挡住:受害者浏览器没有 + 攻击者的 `oauth_flow` cookie。 +- 绑定回跳契约:`settings?linked={provider}` / `?link_error=identity_taken`(撞 + UNIQUE 是必然出现的用户可见错误,不混进 oauth_failed)。 + +## 其他已定结论 + +- **留 JustAuth,不回 Auth.js/NextAuth**:Auth.js 是前端库,搬回等于推翻 Sa-Token + 迁移、把认证边界移回前端;JustAuth 已覆盖 OAuth 协议与 provider 目录,真正要手写 + 的只有 provider→user 映射(本表的业务逻辑,换任何库都躲不掉)。Discord 若不在 + JustAuth 内置列表,写自定义 AuthSource(约 30 行)。 +- **intent / state 存储**:单实例进程内(Caffeine)够用;触发迁移的条件是**多实例** + (GraalVM native 部署常伴随),届时连同 Sa-Token session、JustAuth state cache + 一起迁 Redis——注意本栈目前没有自己的 Redis(机器上两个 Redis 容器分属 + infisical 和 umami,不共享),迁移意味着新容器 + 解开 pom 里注释掉的依赖。 +- **密码语义**:第三方注册用户的 `password_hash` 用不可用 sentinel `'!'` + (discord-bridge 先例),配 `hasUsablePassword()` 判定;"解绑不得移除最后一种 + 登录方式"的判定中,不可用密码不算登录方式,否则 OAuth 用户解绑唯一身份后永久锁死。 +- **新用户 username 生成**:provider login 转 slug + 冲突短随机后缀;**禁纯数字** + (撞 `/u/` 路由"纯数字=github_id"的解析约定)、禁 provider 前缀。存量 + `github_` 用户名永不强迁。 +- **资料刷新只填空缺字段**:多 provider 下 last-login-wins 会互相覆盖头像、 + 用未验证邮箱覆盖 email。 +- **禁止邮箱静默合并**(未验证邮箱 provider 是账户接管向量);同邮箱只提示引导, + 合并必须在已登录会话内主动完成。提示功能须随第二个 provider 同期上线, + 否则上线当天就会产生分叉账号。 + +## 迁移阶段 + +| 阶段 | 内容 | 状态 | +|---|---|---| +| M0 | 建表 + 幂等回填 + repository | ✅ 本 ADR 随附 PR | +| M1 | `loginByProvider` 统一流程 + state/cookie 硬化 + INV-006 测试;`github_id` 双写 | 待做 | +| M2 | 绑定/解绑 + 设置页 UI + 确认页 | 待做 | +| M3 | Discord 上线;`/u/`、follows 查询改走 identities | 待做 | +| M4 | identities 稳定一个版本后删 `github_id` 列(单独拆期,保回滚路径) | 待做 | diff --git a/src/main/java/com/involutionhell/backend/usercenter/model/UserIdentity.java b/src/main/java/com/involutionhell/backend/usercenter/model/UserIdentity.java new file mode 100644 index 0000000..0be0c0a --- /dev/null +++ b/src/main/java/com/involutionhell/backend/usercenter/model/UserIdentity.java @@ -0,0 +1,19 @@ +package com.involutionhell.backend.usercenter.model; + +import java.time.Instant; + +/** + * 第三方登录身份,对应 user_identities 表的一行。 + * 一个 UserAccount 可挂多个 provider 身份(每个 provider 至多一个)。 + */ +public record UserIdentity( + Long id, + long userId, + String provider, + String providerUserId, + String emailAtLink, + String displayNameAtLink, + Instant linkedAt, + Instant lastLoginAt +) { +} diff --git a/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserIdentityRepository.java b/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserIdentityRepository.java new file mode 100644 index 0000000..53188b1 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserIdentityRepository.java @@ -0,0 +1,80 @@ +package com.involutionhell.backend.usercenter.repository; + +import com.involutionhell.backend.usercenter.model.UserIdentity; + +import java.sql.PreparedStatement; +import java.sql.Timestamp; +import java.util.List; +import java.util.Optional; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Repository; + +/** + * 基于 Spring JDBC 的登录身份仓库实现,读写 user_identities 表。 + */ +@Repository +public class JdbcUserIdentityRepository implements UserIdentityRepository { + + private final JdbcTemplate jdbc; + + private final RowMapper rowMapper = (rs, rowNum) -> new UserIdentity( + rs.getLong("id"), + rs.getLong("user_id"), + rs.getString("provider"), + rs.getString("provider_user_id"), + rs.getString("email_at_link"), + rs.getString("display_name_at_link"), + toInstant(rs.getTimestamp("linked_at")), + toInstant(rs.getTimestamp("last_login_at")) + ); + + public JdbcUserIdentityRepository(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + private static java.time.Instant toInstant(Timestamp ts) { + return ts == null ? null : ts.toInstant(); + } + + @Override + public Optional findByProviderAndProviderUserId(String provider, String providerUserId) { + List rows = jdbc.query( + "SELECT * FROM user_identities WHERE provider = ? AND provider_user_id = ?", + rowMapper, provider, providerUserId); + return rows.stream().findFirst(); + } + + @Override + public List findByUserId(long userId) { + return jdbc.query( + "SELECT * FROM user_identities WHERE user_id = ? ORDER BY linked_at", + rowMapper, userId); + } + + @Override + public UserIdentity insert(UserIdentity identity) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbc.update(con -> { + PreparedStatement ps = con.prepareStatement( + "INSERT INTO user_identities (user_id, provider, provider_user_id, email_at_link, display_name_at_link) " + + "VALUES (?, ?, ?, ?, ?)", + new String[]{"id"}); + ps.setLong(1, identity.userId()); + ps.setString(2, identity.provider()); + ps.setString(3, identity.providerUserId()); + ps.setString(4, identity.emailAtLink()); + ps.setString(5, identity.displayNameAtLink()); + return ps; + }, keyHolder); + long id = keyHolder.getKey().longValue(); + return jdbc.queryForObject("SELECT * FROM user_identities WHERE id = ?", rowMapper, id); + } + + @Override + public void touchLastLogin(long id) { + jdbc.update("UPDATE user_identities SET last_login_at = CURRENT_TIMESTAMP WHERE id = ?", id); + } +} diff --git a/src/main/java/com/involutionhell/backend/usercenter/repository/UserIdentityRepository.java b/src/main/java/com/involutionhell/backend/usercenter/repository/UserIdentityRepository.java new file mode 100644 index 0000000..014c725 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/usercenter/repository/UserIdentityRepository.java @@ -0,0 +1,26 @@ +package com.involutionhell.backend.usercenter.repository; + +import com.involutionhell.backend.usercenter.model.UserIdentity; + +import java.util.List; +import java.util.Optional; + +/** + * user_identities 仓库接口。登录流程按 (provider, providerUserId) 定位账号, + * 设置页按 userId 列出已绑定身份。 + */ +public interface UserIdentityRepository { + + Optional findByProviderAndProviderUserId(String provider, String providerUserId); + + List findByUserId(long userId); + + /** + * 插入新身份并返回带生成 id 的记录。 + * 撞 UNIQUE(身份已绑他人 / 该账号同 provider 已有身份)由调用方捕获 + * DuplicateKeyException 处理——那是业务分支(提示"已被绑定"),不是异常路径。 + */ + UserIdentity insert(UserIdentity identity); + + void touchLastLogin(long id); +} diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 6385fc2..d95aa9d 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -66,6 +66,43 @@ ON CONFLICT (username) DO UPDATE SET -- superadmin 语义:拥有全部 admin 权限 + 能管理其他人的 admin 角色。 -- API 层禁止通过 /api/admin/users 接口授予或撤销 superadmin(防误操作锁死后台)。 +-- ============================================================================= +-- 登录身份(user_identities) +-- ============================================================================= +-- 一个账号可挂多个第三方登录方式(github / discord / google / ...),认证层 +-- provider 平权;GitHub 的特殊性(贡献归属)只体现在业务层 provider='github' +-- 的查询里。设计决策与 OAuth state 防护协议见 docs/wiki/adr/001-multi-provider-identity.md。 +-- +-- 约束语义: +-- UNIQUE (provider, provider_user_id) —— 一个第三方身份只能绑一个账号 +-- UNIQUE (user_id, provider) —— 一个账号同一 provider 只能绑一个身份 +-- (/u/{githubId} canonical URL 与贡献归属都假设 1:1,放开是 DROP CONSTRAINT +-- 一句话,收紧要洗数据,故默认收紧) +-- CHECK (provider = lower(provider)) —— provider 规范化小写,防大小写分裂 +-- FK ON DELETE CASCADE —— 删号不留幽灵身份(否则登录路径会命中 +-- 不存在的账号) +CREATE TABLE IF NOT EXISTS user_identities ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES user_accounts(id) ON DELETE CASCADE, + provider VARCHAR(32) NOT NULL CHECK (provider = lower(provider)), + provider_user_id VARCHAR(255) NOT NULL, + email_at_link VARCHAR(255), + display_name_at_link VARCHAR(255), + linked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_login_at TIMESTAMPTZ, + UNIQUE (provider, provider_user_id), + UNIQUE (user_id, provider) +); + +-- 存量 GitHub 身份回填。schema.sql 每次启动执行,靠 ON CONFLICT 幂等; +-- 不带冲突目标 = 命中任一 UNIQUE 都静默跳过,顺带补齐双写期漂移的行。 +-- github_id 列在 user_identities 稳定运行一个版本前保持双写(migration M1-M3)。 +INSERT INTO user_identities (user_id, provider, provider_user_id) +SELECT id, 'github', CAST(github_id AS VARCHAR) +FROM user_accounts +WHERE github_id IS NOT NULL +ON CONFLICT DO NOTHING; + -- ============================================================================= -- 关注关系(user_follows) -- ============================================================================= diff --git a/src/test/java/com/involutionhell/backend/usercenter/UserIdentityRepositoryTests.java b/src/test/java/com/involutionhell/backend/usercenter/UserIdentityRepositoryTests.java new file mode 100644 index 0000000..f5158dc --- /dev/null +++ b/src/test/java/com/involutionhell/backend/usercenter/UserIdentityRepositoryTests.java @@ -0,0 +1,119 @@ +package com.involutionhell.backend.usercenter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.involutionhell.backend.support.AbstractWebIntegrationTest; +import com.involutionhell.backend.usercenter.model.UserIdentity; +import com.involutionhell.backend.usercenter.repository.UserIdentityRepository; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * user_identities 表与仓库的行为契约: + * 两条 UNIQUE 约束、FK 级联删除、启动回填幂等性。 + * 约束语义的 why 见 docs/wiki/adr/001-multi-provider-identity.md。 + */ +class UserIdentityRepositoryTests extends AbstractWebIntegrationTest { + + @Autowired + private UserIdentityRepository repository; + + @Autowired + private JdbcTemplate jdbc; + + // H2 测试上下文共享同一内存库(DB_CLOSE_DELAY=-1),本类插入的 user_accounts 行 + // 若不清理会泄漏进别的测试(如 JdbcUserAccountRepositoryTests 断言只有 4 个种子用户)。 + // FK ON DELETE CASCADE 会连带删掉这些账号的 user_identities。 + @AfterEach + void cleanup() { + jdbc.update("DELETE FROM user_accounts WHERE username LIKE 'identity-test-%'"); + } + + /** 建一个最小可用的 user_accounts 行,返回 id。 */ + private long createUser(Long githubId) { + String username = "identity-test-" + UUID.randomUUID(); + jdbc.update( + "INSERT INTO user_accounts (username, password_hash, enabled, roles, permissions, github_id) " + + "VALUES (?, '!', TRUE, 'user', '', ?)", + username, githubId); + return jdbc.queryForObject("SELECT id FROM user_accounts WHERE username = ?", Long.class, username); + } + + private UserIdentity identity(long userId, String provider, String providerUserId) { + return new UserIdentity(null, userId, provider, providerUserId, null, null, null, null); + } + + @Test + void insertAndFindRoundtrip() { + long userId = createUser(null); + String puid = UUID.randomUUID().toString(); + + UserIdentity saved = repository.insert(identity(userId, "discord", puid)); + assertThat(saved.id()).isNotNull(); + assertThat(saved.linkedAt()).isNotNull(); + assertThat(saved.lastLoginAt()).isNull(); + + assertThat(repository.findByProviderAndProviderUserId("discord", puid)) + .hasValueSatisfying(found -> assertThat(found.userId()).isEqualTo(userId)); + assertThat(repository.findByUserId(userId)).hasSize(1); + + repository.touchLastLogin(saved.id()); + assertThat(repository.findByProviderAndProviderUserId("discord", puid)) + .hasValueSatisfying(found -> assertThat(found.lastLoginAt()).isNotNull()); + } + + @Test + void sameProviderIdentityCannotBindTwoAccounts() { + long first = createUser(null); + long second = createUser(null); + String puid = UUID.randomUUID().toString(); + + repository.insert(identity(first, "github", puid)); + assertThatThrownBy(() -> repository.insert(identity(second, "github", puid))) + .isInstanceOf(DuplicateKeyException.class); + } + + @Test + void oneAccountCannotBindSameProviderTwice() { + long userId = createUser(null); + + repository.insert(identity(userId, "google", UUID.randomUUID().toString())); + assertThatThrownBy(() -> repository.insert(identity(userId, "google", UUID.randomUUID().toString()))) + .isInstanceOf(DuplicateKeyException.class); + } + + @Test + void deletingAccountCascadesIdentities() { + long userId = createUser(null); + repository.insert(identity(userId, "discord", UUID.randomUUID().toString())); + + jdbc.update("DELETE FROM user_accounts WHERE id = ?", userId); + + assertThat(repository.findByUserId(userId)).isEmpty(); + } + + /** + * 生产 schema.sql 的回填语句每次启动都会执行,必须幂等—— + * 这里在 H2 上原样执行两遍,验证第二遍既不报错也不产生重复行。 + */ + @Test + void githubBackfillIsIdempotent() { + long githubId = 900_000_000L + (long) (Math.random() * 1_000_000); + long userId = createUser(githubId); + + String backfill = "INSERT INTO user_identities (user_id, provider, provider_user_id) " + + "SELECT id, 'github', CAST(github_id AS VARCHAR) FROM user_accounts " + + "WHERE github_id IS NOT NULL ON CONFLICT DO NOTHING"; + jdbc.execute(backfill); + jdbc.execute(backfill); + + Integer rows = jdbc.queryForObject( + "SELECT COUNT(*) FROM user_identities WHERE user_id = ?", Integer.class, userId); + assertThat(rows).isEqualTo(1); + } +} diff --git a/src/test/resources/test-schema.sql b/src/test/resources/test-schema.sql index 0c10fdc..d5bfa27 100644 --- a/src/test/resources/test-schema.sql +++ b/src/test/resources/test-schema.sql @@ -25,6 +25,22 @@ MERGE INTO user_accounts (username, password_hash, display_name, enabled, roles, KEY (username) VALUES ('auditor', '$2b$10$/1OfzhrA6CITrjJsDbzk.uMLq6cHa/iOP./wL2BAPo9t7QRq7Ca5W', 'Auditor', TRUE, 'auditor', 'user:profile:read,user:center:read'); +-- 登录身份表(与生产 schema.sql 的 user_identities 对应;TIMESTAMPTZ 用 TIMESTAMP 代替)。 +-- 生产的启动回填(INSERT...SELECT...ON CONFLICT DO NOTHING)这里不放:种子账号无 github_id, +-- 回填幂等性由 UserIdentityRepositoryTests 在 H2 上直接执行该语句验证。 +CREATE TABLE IF NOT EXISTS user_identities ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES user_accounts(id) ON DELETE CASCADE, + provider VARCHAR(32) NOT NULL CHECK (provider = lower(provider)), + provider_user_id VARCHAR(255) NOT NULL, + email_at_link VARCHAR(255), + display_name_at_link VARCHAR(255), + linked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_login_at TIMESTAMP, + UNIQUE (provider, provider_user_id), + UNIQUE (user_id, provider) +); + -- Events 相关表(测试用 H2 语法)。JSONB 用 VARCHAR 代替,与 user_accounts.preferences 的策略一致 CREATE TABLE IF NOT EXISTS events ( id BIGSERIAL PRIMARY KEY, From 0f2f1691bcfe2da81ba8c767e2e0f9386a77fc24 Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Sun, 19 Jul 2026 03:25:34 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix(usercenter):=20=E5=A4=84=E7=90=86=20M0?= =?UTF-8?q?=20review=20=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xhigh review (PR #43) 的已确认问题: - init.sql 补 user_identities 表:三处 schema 同步惯例(INV-004 教训), 否则 docker-compose 起的库缺表,M1 登录 500 - ADR 的 state 不变量 INV-006 → INV-007:INV-006 已被 PR #41 限流占用 - 回填注释澄清边界:依赖 SPRING_SQL_INIT_MODE=always;只治行缺失不治值变化; M2 解绑 github 须同时清 github_id 列,否则重启静默复活已撤销绑定 - 仓库层 provider 归一化小写:JustAuth source 名大写,不归一化查询侧静默查空 - insert() javadoc 声明忽略 linkedAt/lastLoginAt(由 DB default 生成) - 幂等测试改从 classpath 的 schema.sql 机械提取回填语句,不再手抄副本 全量 252 测试通过。 --- docker/init-db/init.sql | 15 ++++++++ docs/wiki/adr/001-multi-provider-identity.md | 13 +++++-- .../JdbcUserIdentityRepository.java | 10 ++++- .../repository/UserIdentityRepository.java | 3 ++ src/main/resources/schema.sql | 12 ++++-- .../UserIdentityRepositoryTests.java | 38 ++++++++++++++++--- 6 files changed, 76 insertions(+), 15 deletions(-) diff --git a/docker/init-db/init.sql b/docker/init-db/init.sql index d13f8ee..d7f7cb2 100644 --- a/docker/init-db/init.sql +++ b/docker/init-db/init.sql @@ -32,6 +32,21 @@ CREATE TABLE IF NOT EXISTS user_follows ( CREATE INDEX IF NOT EXISTS idx_user_follows_followee ON user_follows(followee_id, created_at DESC); +-- 登录身份(user_identities)—— 与 schema.sql 保持一致 +-- 注意 init.sql 版的 user_accounts 没有 github_id 列(全新库无存量),故不含回填。 +CREATE TABLE IF NOT EXISTS user_identities ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES user_accounts(id) ON DELETE CASCADE, + provider VARCHAR(32) NOT NULL CHECK (provider = lower(provider)), + provider_user_id VARCHAR(255) NOT NULL, + email_at_link VARCHAR(255), + display_name_at_link VARCHAR(255), + linked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_login_at TIMESTAMPTZ, + UNIQUE (provider, provider_user_id), + UNIQUE (user_id, provider) +); + -- Prisma tables (frontend/prisma/schema.prisma) -- users table diff --git a/docs/wiki/adr/001-multi-provider-identity.md b/docs/wiki/adr/001-multi-provider-identity.md index 19e0fd5..3279fa9 100644 --- a/docs/wiki/adr/001-multi-provider-identity.md +++ b/docs/wiki/adr/001-multi-provider-identity.md @@ -49,8 +49,12 @@ Sa-Token 迁移时被塌缩掉了。) canonical URL 和贡献归属都假设 1:1,放开是一句 DROP,收紧要洗数据。 - FK `ON DELETE CASCADE` — 删号不留幽灵身份。注意"无 FK"惯例只适用于 Prisma 跨系统引用(根 CLAUDE.md),后端表引后端表照常加 FK。 -- 启动回填用无冲突目标的 `ON CONFLICT DO NOTHING` — schema.sql 每次启动执行, - 必须幂等(有回归测试原样执行两遍验证)。 +- 启动回填用无冲突目标的 `ON CONFLICT DO NOTHING` — schema.sql 在 + `SPRING_SQL_INIT_MODE=always` 的环境随启动执行(默认 never),必须幂等 + (回归测试从 classpath 提取真实语句执行两遍验证)。全新库走 + docker/init-db/init.sql(三处 schema 同步惯例,见 INV-004 的 user_follows 教训)。 + 回填只治"行缺失"不治"值变化";**M2 解绑 github 必须同时清空 github_id 列**, + 否则下次执行回填会静默复活已撤销的绑定。 **GitHub 的特殊性下沉到业务层**:认证层 provider 平权;贡献归属、排行榜、认领档案 查 `provider = 'github'`。文档是 git-based 是业务事实,不泄漏进认证设计。 @@ -58,7 +62,8 @@ Sa-Token 迁移时被塌缩掉了。) ## 统一登录 / 绑定流程与 state 协议 **原则:state 是不透明的一次性 nonce,不携带任何身份信息;callback 永远不信任 -state 里的用户身份**(登记为安全不变量 INV-006,随 M1 落进 SecurityInvariantsTests)。 +state 里的用户身份**(登记为安全不变量 INV-007,随 M1 落进 SecurityInvariantsTests; +INV-006 已被"付费 LLM 端点限流"占用,编号按 SECURITY.md 流水规则永不复用)。 真实信息挂在服务端 intent 记录上(nonce 为 key,Caffeine 存储,5 分钟 TTL)。 - **登录**(无会话):`/oauth/render/{provider}` 生成 nonce → 存 intent{mode=login} → @@ -105,7 +110,7 @@ state 里的用户身份**(登记为安全不变量 INV-006,随 M1 落进 Se | 阶段 | 内容 | 状态 | |---|---|---| | M0 | 建表 + 幂等回填 + repository | ✅ 本 ADR 随附 PR | -| M1 | `loginByProvider` 统一流程 + state/cookie 硬化 + INV-006 测试;`github_id` 双写 | 待做 | +| M1 | `loginByProvider` 统一流程 + state/cookie 硬化 + INV-007 测试;`github_id` 双写 | 待做 | | M2 | 绑定/解绑 + 设置页 UI + 确认页 | 待做 | | M3 | Discord 上线;`/u/`、follows 查询改走 identities | 待做 | | M4 | identities 稳定一个版本后删 `github_id` 列(单独拆期,保回滚路径) | 待做 | 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 53188b1..ad03367 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserIdentityRepository.java +++ b/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserIdentityRepository.java @@ -39,11 +39,17 @@ private static java.time.Instant toInstant(Timestamp ts) { return ts == null ? null : ts.toInstant(); } + // provider 在仓库入口统一小写:JustAuth 的 source 名是大写("GITHUB"), + // 而表存小写(CHECK 约束)。不归一化的话查询侧静默查空 → 老用户被当新用户建号。 + private static String normalize(String provider) { + return provider == null ? null : provider.toLowerCase(java.util.Locale.ROOT); + } + @Override public Optional findByProviderAndProviderUserId(String provider, String providerUserId) { List rows = jdbc.query( "SELECT * FROM user_identities WHERE provider = ? AND provider_user_id = ?", - rowMapper, provider, providerUserId); + rowMapper, normalize(provider), providerUserId); return rows.stream().findFirst(); } @@ -63,7 +69,7 @@ public UserIdentity insert(UserIdentity identity) { "VALUES (?, ?, ?, ?, ?)", new String[]{"id"}); ps.setLong(1, identity.userId()); - ps.setString(2, identity.provider()); + ps.setString(2, normalize(identity.provider())); ps.setString(3, identity.providerUserId()); ps.setString(4, identity.emailAtLink()); ps.setString(5, identity.displayNameAtLink()); 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 014c725..11af601 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/repository/UserIdentityRepository.java +++ b/src/main/java/com/involutionhell/backend/usercenter/repository/UserIdentityRepository.java @@ -19,6 +19,9 @@ public interface UserIdentityRepository { * 插入新身份并返回带生成 id 的记录。 * 撞 UNIQUE(身份已绑他人 / 该账号同 provider 已有身份)由调用方捕获 * DuplicateKeyException 处理——那是业务分支(提示"已被绑定"),不是异常路径。 + * 传入的 linkedAt / lastLoginAt 会被忽略:linked_at 由 DB DEFAULT NOW() 生成, + * last_login_at 只经 touchLastLogin 更新。需要保留历史时间戳的导入场景(若出现) + * 得加专门方法,不复用本方法。 */ UserIdentity insert(UserIdentity identity); diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index d95aa9d..c8377ce 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -94,9 +94,15 @@ CREATE TABLE IF NOT EXISTS user_identities ( UNIQUE (user_id, provider) ); --- 存量 GitHub 身份回填。schema.sql 每次启动执行,靠 ON CONFLICT 幂等; --- 不带冲突目标 = 命中任一 UNIQUE 都静默跳过,顺带补齐双写期漂移的行。 --- github_id 列在 user_identities 稳定运行一个版本前保持双写(migration M1-M3)。 +-- 存量 GitHub 身份回填。本文件在 SPRING_SQL_INIT_MODE=always 时随启动执行 +-- (默认 never,见 application.properties),ON CONFLICT 无冲突目标保证重跑幂等。 +-- 边界(详见 ADR-001): +-- 1. 只治"行缺失",不治"值变化"——github_id 改过值的账号,identity 行不会跟着 +-- 变(撞 UNIQUE(user_id,provider) 被跳过),值同步是 M1 双写逻辑的责任; +-- 2. 只要 github_id 列仍有值,删除的 github identity 行会在下次执行时被重新 +-- 插入——因此 M2 解绑 github 时必须同时清空 user_accounts.github_id, +-- 否则重启会静默复活用户已撤销的绑定。 +-- github_id 列在 user_identities 稳定运行一个版本前保持双写(M1-M3,M4 删列)。 INSERT INTO user_identities (user_id, provider, provider_user_id) SELECT id, 'github', CAST(github_id AS VARCHAR) FROM user_accounts diff --git a/src/test/java/com/involutionhell/backend/usercenter/UserIdentityRepositoryTests.java b/src/test/java/com/involutionhell/backend/usercenter/UserIdentityRepositoryTests.java index f5158dc..4276674 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/UserIdentityRepositoryTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/UserIdentityRepositoryTests.java @@ -98,17 +98,27 @@ void deletingAccountCascadesIdentities() { } /** - * 生产 schema.sql 的回填语句每次启动都会执行,必须幂等—— - * 这里在 H2 上原样执行两遍,验证第二遍既不报错也不产生重复行。 + * 生产 schema.sql 的回填语句在 SPRING_SQL_INIT_MODE=always 环境随启动重复执行, + * 必须幂等。语句从 classpath 的 schema.sql 机械提取(不手抄副本), + * 保证测试守护的永远是生产真正执行的那条 SQL;在 H2 上执行两遍, + * 验证第二遍既不报错也不产生重复行。 */ @Test - void githubBackfillIsIdempotent() { + void githubBackfillIsIdempotent() throws Exception { long githubId = 900_000_000L + (long) (Math.random() * 1_000_000); long userId = createUser(githubId); - String backfill = "INSERT INTO user_identities (user_id, provider, provider_user_id) " - + "SELECT id, 'github', CAST(github_id AS VARCHAR) FROM user_accounts " - + "WHERE github_id IS NOT NULL ON CONFLICT DO NOTHING"; + String schema = new String( + getClass().getResourceAsStream("/schema.sql").readAllBytes(), + java.nio.charset.StandardCharsets.UTF_8); + java.util.regex.Matcher m = java.util.regex.Pattern + .compile("INSERT INTO user_identities[^;]+;") + .matcher(schema); + assertThat(m.find()) + .as("schema.sql 里应能定位到 user_identities 回填语句") + .isTrue(); + String backfill = m.group(); + jdbc.execute(backfill); jdbc.execute(backfill); @@ -116,4 +126,20 @@ void githubBackfillIsIdempotent() { "SELECT COUNT(*) FROM user_identities WHERE user_id = ?", Integer.class, userId); assertThat(rows).isEqualTo(1); } + + /** + * JustAuth 的 source 名是大写("GITHUB"),表存小写(CHECK 约束)。 + * 仓库入口必须归一化,否则查询侧静默查空、插入侧撞 CHECK。 + */ + @Test + void providerIsNormalizedToLowercaseOnBothPaths() { + long userId = createUser(null); + String puid = UUID.randomUUID().toString(); + + UserIdentity saved = repository.insert(identity(userId, "GITHUB", puid)); + assertThat(saved.provider()).isEqualTo("github"); + + assertThat(repository.findByProviderAndProviderUserId("GitHub", puid)) + .hasValueSatisfying(found -> assertThat(found.userId()).isEqualTo(userId)); + } } From 7dee4befe5a1cb5f8f882c809d10bbe13aec836d Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Sun, 19 Jul 2026 09:54:20 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(db):=20=E4=BF=9D=E8=AF=81=E5=85=A8?= =?UTF-8?q?=E6=96=B0=E8=B4=A1=E7=8C=AE=E8=80=85=E8=83=BD=E5=B9=B2=E5=87=80?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E2=80=94=E2=80=94init.sql=20=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=20user=5Faccounts=20=E5=88=97=20+=20=E5=8E=BB?= =?UTF-8?q?=E6=8E=89=20never=20=E5=BB=BA=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M0 的 user_identities 回填读 user_accounts.github_id,但 docker/init-db/init.sql 建的 user_accounts 只有 7 列、缺 github_id/avatar_url/email/preferences,schema.sql 的 CREATE TABLE IF NOT EXISTS 又补不上已存在表的列——全新 docker 卷首启时回填 直接 column "github_id" does not exist 崩,后端起不来(既有 bug:github 登录 INSERT 本就缺列,M0 把它升级成硬崩)。 - init.sql 的 user_accounts 补齐到与 schema.sql 一致 - .env.example / application.properties 去掉"改成 never"的误导建议:schema.sql 幂等,always 是安全默认;改 never 会让后续新增表在本地缺失(曾踩坑) - README 加建表两路径说明 + 缺表/缺列排查(down -v 重建卷) 验证:一次性 throwaway 容器跑 init.sql→schema.sql 两遍 exit=0、无缺列崩溃、 user_identities 建成且幂等。全量 252 测试通过。 --- .env.example | 9 ++++++--- README.md | 12 ++++++++++++ docker/init-db/init.sql | 15 ++++++++++++--- src/main/resources/application.properties | 6 +++--- 4 files changed, 33 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 96120f8..014d659 100644 --- a/.env.example +++ b/.env.example @@ -16,7 +16,10 @@ PGSSLMODE=disable # 如果在这里设 SPRING_DATASOURCE_URL=...localhost..., 会被 docker compose 注入 # 进 backend 容器,容器内 localhost 不指向 postgres 服务,会连接失败。 -# 首次启动需为 always 以执行 schema.sql 初始化建表,之后可改为 never +# 保持 always:schema.sql 全幂等(CREATE TABLE IF NOT EXISTS + ON CONFLICT), +# 每次启动 reconcile 一遍,pull 到新增的表/列会自动补上。改成 never 后,别人加的 +# 新表在你本地不会建(docker 卷已存在时 init.sql 也不再重跑),登录会 500—— +# 曾经踩过这个坑,别关。 SPRING_SQL_INIT_MODE=always # --- 数据库(Neon.tech 或其他 PostgreSQL)--- @@ -29,8 +32,8 @@ SPRING_SQL_INIT_MODE=always # Spring Boot JDBC 连接(由上面的 PG 变量转换而来) # SPRING_DATASOURCE_URL=jdbc:postgresql://ep-xxxx.ap-southeast-2.aws.neon.tech/neondb?sslmode=require -# 首次部署时设为 always 以初始化 schema.sql,之后改为 never -# SPRING_SQL_INIT_MODE=never +# 生产同样建议 always(schema.sql 幂等,每次启动 reconcile 新增 schema) +# SPRING_SQL_INIT_MODE=always # --- 本地开发用 Docker PostgreSQL(无 Neon 账号的开发者使用)--- # 这些变量被 docker-compose.yml 读取,用于创建本地 Postgres 容器。 diff --git a/README.md b/README.md index 15ac46e..584ee3f 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,18 @@ cp .env.example .env docker compose up -d postgres ``` +> [!IMPORTANT] +> **两条建表路径,别混淆**:`docker/init-db/init.sql` 只在数据卷**首次创建**时跑一次; +> 之后的 schema 演进靠后端启动时执行 `backend/src/main/resources/schema.sql` +> (需 `SPRING_SQL_INIT_MODE=always`,`.env.example` 默认即是,**别改成 never**)。 +> 两个文件的表结构必须保持一致——`schema.sql` 的 `CREATE TABLE IF NOT EXISTS` +> 补不上已存在表的缺列。 +> +> **pull 到新增表/列后**如果遇到 `relation "xxx" does not exist` 或缺列报错: +> 确认 `SPRING_SQL_INIT_MODE=always` 后重启后端即可(schema.sql 幂等 reconcile); +> 若本地库结构已错乱,`docker compose down -v && docker compose up -d postgres` +> 重建卷从 init.sql 干净初始化(会清空本地数据,仅本地开发库)。 + ### 3. 配置 GitHub OAuth(首次必做) 后端的登录走 GitHub OAuth,**每个开发者要用自己的 OAuth App**——不要复制别人的 Client ID,回调 URL 不会匹配,GitHub 会直接拒绝: diff --git a/docker/init-db/init.sql b/docker/init-db/init.sql index d7f7cb2..c0ce141 100644 --- a/docker/init-db/init.sql +++ b/docker/init-db/init.sql @@ -1,14 +1,22 @@ -- Init DB script for local development -- backend/src/main/resources/schema.sql +-- 列必须与 schema.sql 的 user_accounts 完全一致:init.sql 只在数据卷首次创建时 +-- 跑一次,schema.sql 的 CREATE TABLE IF NOT EXISTS 对已存在的表是 no-op、补不上 +-- 缺列。这里少列会让 github 登录(INSERT 列出 avatar_url/email/github_id/ +-- preferences)和 user_identities 回填(读 github_id)在全新库上直接失败。 CREATE TABLE IF NOT EXISTS user_accounts ( - id BIGSERIAL PRIMARY KEY, + id BIGSERIAL PRIMARY KEY, username VARCHAR(255) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, display_name VARCHAR(255), enabled BOOLEAN NOT NULL DEFAULT TRUE, roles TEXT NOT NULL DEFAULT '', - permissions TEXT NOT NULL DEFAULT '' + permissions TEXT NOT NULL DEFAULT '', + avatar_url VARCHAR(500), + email VARCHAR(255), + github_id BIGINT UNIQUE, + preferences JSONB NOT NULL DEFAULT '{}'::jsonb ); -- Default seeds for user_accounts @@ -33,7 +41,8 @@ CREATE INDEX IF NOT EXISTS idx_user_follows_followee ON user_follows(followee_id, created_at DESC); -- 登录身份(user_identities)—— 与 schema.sql 保持一致 --- 注意 init.sql 版的 user_accounts 没有 github_id 列(全新库无存量),故不含回填。 +-- 不含 schema.sql 里的 github_id 回填:全新库的种子账号(admin/alice/auditor)都无 +-- github_id,回填是 0 行;真有存量时 schema.sql 会在启动(mode=always)时回填。 CREATE TABLE IF NOT EXISTS user_identities ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES user_accounts(id) ON DELETE CASCADE, diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index fe85aeb..081af16 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -8,9 +8,9 @@ spring.datasource.username=${PGUSER:neondb_owner} spring.datasource.password=${PGPASSWORD:} spring.datasource.driver-class-name=org.postgresql.Driver -# 初始化配置 -# 默认 never:生产环境不在每次启动时执行 schema.sql -# 首次部署或本地初始化时设置 SPRING_SQL_INIT_MODE=always +# 初始化配置。schema.sql 全幂等,推荐 SPRING_SQL_INIT_MODE=always(.env.example +# 默认即 always)——这样 pull 到新增的表/列会在下次启动自动 reconcile。 +# 代码默认留 never 只是保守兜底:显式不设该变量时不擅自动库。 spring.sql.init.mode=${SPRING_SQL_INIT_MODE:never} spring.sql.init.schema-locations=classpath:schema.sql