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 d13f8ee..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 @@ -32,6 +40,22 @@ 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 保持一致 +-- 不含 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, + 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 new file mode 100644 index 0000000..3279fa9 --- /dev/null +++ b/docs/wiki/adr/001-multi-provider-identity.md @@ -0,0 +1,116 @@ +--- +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 在 + `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 是业务事实,不泄漏进认证设计。 + +## 统一登录 / 绑定流程与 state 协议 + +**原则:state 是不透明的一次性 nonce,不携带任何身份信息;callback 永远不信任 +state 里的用户身份**(登记为安全不变量 INV-007,随 M1 落进 SecurityInvariantsTests; +INV-006 已被"付费 LLM 端点限流"占用,编号按 SECURITY.md 流水规则永不复用)。 +真实信息挂在服务端 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-007 测试;`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..ad03367 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserIdentityRepository.java @@ -0,0 +1,86 @@ +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(); + } + + // 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, normalize(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, normalize(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..11af601 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/usercenter/repository/UserIdentityRepository.java @@ -0,0 +1,29 @@ +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 处理——那是业务分支(提示"已被绑定"),不是异常路径。 + * 传入的 linkedAt / lastLoginAt 会被忽略:linked_at 由 DB DEFAULT NOW() 生成, + * last_login_at 只经 touchLastLogin 更新。需要保留历史时间戳的导入场景(若出现) + * 得加专门方法,不复用本方法。 + */ + UserIdentity insert(UserIdentity identity); + + void touchLastLogin(long id); +} 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 diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 6385fc2..c8377ce 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -66,6 +66,49 @@ 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 身份回填。本文件在 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 +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..4276674 --- /dev/null +++ b/src/test/java/com/involutionhell/backend/usercenter/UserIdentityRepositoryTests.java @@ -0,0 +1,145 @@ +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 的回填语句在 SPRING_SQL_INIT_MODE=always 环境随启动重复执行, + * 必须幂等。语句从 classpath 的 schema.sql 机械提取(不手抄副本), + * 保证测试守护的永远是生产真正执行的那条 SQL;在 H2 上执行两遍, + * 验证第二遍既不报错也不产生重复行。 + */ + @Test + void githubBackfillIsIdempotent() throws Exception { + long githubId = 900_000_000L + (long) (Math.random() * 1_000_000); + long userId = createUser(githubId); + + 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); + + Integer rows = jdbc.queryForObject( + "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)); + } +} 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,