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/SECURITY.md b/SECURITY.md index 46c1368..9d55add 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -125,3 +125,21 @@ 本身,不能只依赖前端网关。上限经 `openai.stream.requests-per-minute` 配置(默认 10/分钟/用户),调大需说明场景。 - **历史**:2026-04-16 由 #297 报告;2026-07-18 加限流 + 本不变量。 + +## INV-007 · OAuth callback 不得信任 state 中的用户身份 + +- **保护点**:`OAuthController#login`(`/api/auth/callback/{provider}`)的 state + 双提交校验——URL 里的 `state` 必须等于本次 `renderAuth` 种下的 `ih_oauth_state` + httpOnly cookie,缺失/不匹配即在换 token 前拒绝。 +- **测试**: + - `OAuthControllerIntegrationTests#callbackWithoutStateCookieIsRejectedBeforeTokenExchange` + - `OAuthControllerIntegrationTests#callbackWithMatchingStateCookieProceedsPastStateCheck`(反向) + - `OAuthControllerIntegrationTests#renderSetsStateCookie`(前置条件) +- **为什么**:callback 是 provider 发起的顶级 GET,不带 Authorization header;若 + 直接信任 URL `state`(尤其未来绑定流程把 loginId 塞进 state),攻击者可发起 + 流程拿到合法 state 诱导受害者授权,把受害者的第三方身份绑/登进攻击者预期的 + 账号(登录 CSRF / 绑定劫持)。防线:state 必须回证到"发起本次流程的同一浏览器" + ——即 render 时种下、callback 时比对的 cookie,攻击者无法向受害者浏览器种此 + cookie。绑定目标账号(M2)同理只能来自服务端校验过的当前会话,绝不取自 state。 +- **历史**:2026-07-19 随多 provider 身份体系 M1 引入(RFC #42 / ADR-001)。 + 编号说明:INV-006 已被"付费 LLM 端点限流"占用,按流水规则用 INV-007。 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..5ef1a11 --- /dev/null +++ b/docs/wiki/adr/001-multi-provider-identity.md @@ -0,0 +1,117 @@ +--- +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 | ✅ PR #43 | +| M1 | `loginByProvider` 统一流程(username 主查 + identity 双写) + state/cookie 硬化 + INV-007;`github_id` 列双写沿用 | ✅ 本 PR | +| M2a | 解绑 + 列表后端(解绑锁死防护 + github 解绑清 github_id 列) | ✅ 本 PR | +| M2b | 绑定流程(intent store + callback 分支)后端 + 设置页前端 UI | 待做 | +| M3 | Discord 上线;`/u/`、follows 查询改走 identities | 待做 | +| M4 | identities 稳定一个版本后删 `github_id` 列(单独拆期,保回滚路径) | 待做 | diff --git a/src/main/java/com/involutionhell/backend/usercenter/controller/IdentityController.java b/src/main/java/com/involutionhell/backend/usercenter/controller/IdentityController.java new file mode 100644 index 0000000..edd2572 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/usercenter/controller/IdentityController.java @@ -0,0 +1,43 @@ +package com.involutionhell.backend.usercenter.controller; + +import cn.dev33.satoken.annotation.SaCheckLogin; +import cn.dev33.satoken.stp.StpUtil; +import com.involutionhell.backend.common.api.ApiResponse; +import com.involutionhell.backend.usercenter.dto.LinkedIdentityView; +import com.involutionhell.backend.usercenter.service.UserIdentityService; +import java.util.List; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 当前登录用户的第三方登录身份管理(M2a:查看 / 解绑)。 + * 路径走 /api/user-center/*,已被前端 next.config rewrite 覆盖。 + * 绑定新 provider(M2b)走 OAuth 流程,不在此。 + */ +@RestController +@RequestMapping("/api/user-center/identities") +public class IdentityController { + + private final UserIdentityService userIdentityService; + + public IdentityController(UserIdentityService userIdentityService) { + this.userIdentityService = userIdentityService; + } + + /** 列出当前用户已绑定的登录身份。 */ + @SaCheckLogin + @GetMapping + public ApiResponse> list() { + return ApiResponse.ok(userIdentityService.listForUser(StpUtil.getLoginIdAsLong())); + } + + /** 解绑指定 provider。返回解绑后剩余身份列表。 */ + @SaCheckLogin + @DeleteMapping("/{provider}") + public ApiResponse> unbind(@PathVariable String provider) { + return ApiResponse.ok(userIdentityService.unbind(StpUtil.getLoginIdAsLong(), 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 c700513..a9beae3 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/controller/OAuthController.java +++ b/src/main/java/com/involutionhell/backend/usercenter/controller/OAuthController.java @@ -52,6 +52,11 @@ private AuthRequest getAuthRequest() { .build()); } + // 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; + /** * 构建授权链接并重定向到 GitHub * 前端直接跳转到后端此地址(NEXT_PUBLIC_BACKEND_URL + /oauth/render/github)发起登录 @@ -60,8 +65,31 @@ private AuthRequest getAuthRequest() { public void renderAuth(HttpServletResponse response) throws IOException { // 打印当前使用的 GitHub Client ID 和 redirect_uri,便于排查 token 配置问题 log.info("[OAuth] GitHub Client ID = {}, redirect_uri = {}", githubClientId, githubRedirectUri); + String state = me.zhyd.oauth.utils.AuthStateUtils.createState(); + // 把 state 同时种进 httpOnly cookie。SameSite=Lax 是关键:callback 是 github.com + // 发起的跨站顶级导航,Strict 会剥掉 cookie;Lax 恰好在顶级 GET 导航时携带。 + response.addHeader("Set-Cookie", buildStateCookie(state, STATE_COOKIE_MAX_AGE_SECONDS)); AuthRequest authRequest = getAuthRequest(); - response.sendRedirect(authRequest.authorize(me.zhyd.oauth.utils.AuthStateUtils.createState())); + response.sendRedirect(authRequest.authorize(state)); + } + + private String buildStateCookie(String value, int maxAgeSeconds) { + return org.springframework.http.ResponseCookie.from(STATE_COOKIE, value) + .httpOnly(true) + .secure(frontEndUrl.startsWith("https")) // 本地 http 下不置 Secure,否则浏览器不回传 + .sameSite("Lax") + .path("/") + .maxAge(maxAgeSeconds) + .build() + .toString(); + } + + private String readStateCookie(jakarta.servlet.http.HttpServletRequest request) { + if (request.getCookies() == null) return null; + for (jakarta.servlet.http.Cookie c : request.getCookies()) { + if (STATE_COOKIE.equals(c.getName())) return c.getValue(); + } + return null; } /** @@ -71,6 +99,7 @@ public void renderAuth(HttpServletResponse response) throws IOException { @GetMapping("/api/auth/callback/github") public void login(@RequestParam(required = false) String code, @RequestParam(required = false) String state, + jakarta.servlet.http.HttpServletRequest request, HttpServletResponse response) throws IOException { // 参数缺失时直接走失败分支:若 @RequestParam 保持 required=true,Spring 在进入方法前 // 就抛 MissingServletRequestParameterException → 默认 500 白屏; @@ -81,6 +110,17 @@ public void login(@RequestParam(required = false) String code, return; } + // INV-007:state 必须等于本次 render 种下的 cookie(双提交校验)。缺失/不匹配 + // 即拒绝,且在换 token 之前——不给伪造 state 触发登录的机会,也不白打 GitHub。 + String cookieState = readStateCookie(request); + // 用完即清(无论后续成败),避免 cookie 泄漏 / 复用。 + response.addHeader("Set-Cookie", buildStateCookie("", 0)); + if (cookieState == null || !cookieState.equals(state)) { + log.warn("[OAuth] state 与 cookie 不匹配(可能的 CSRF 或 cookie 丢失),拒绝登录"); + response.sendRedirect(frontEndUrl + "/login?error=oauth_state"); + return; + } + AuthCallback callback = new AuthCallback(); callback.setCode(code); callback.setState(state); diff --git a/src/main/java/com/involutionhell/backend/usercenter/dto/LinkedIdentityView.java b/src/main/java/com/involutionhell/backend/usercenter/dto/LinkedIdentityView.java new file mode 100644 index 0000000..fb53d74 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/usercenter/dto/LinkedIdentityView.java @@ -0,0 +1,19 @@ +package com.involutionhell.backend.usercenter.dto; + +import com.involutionhell.backend.usercenter.model.UserIdentity; +import java.time.Instant; + +/** + * 设置页展示用的已绑定身份视图。不含 provider_user_id 等可标识第三方账号的字段, + * 只暴露 provider、绑定/最近登录时间和绑定时的展示名。 + */ +public record LinkedIdentityView( + String provider, + String displayNameAtLink, + Instant linkedAt, + Instant lastLoginAt +) { + public static LinkedIdentityView from(UserIdentity i) { + return new LinkedIdentityView(i.provider(), i.displayNameAtLink(), i.linkedAt(), i.lastLoginAt()); + } +} 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/JdbcUserAccountRepository.java b/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepository.java index 55db779..3397a22 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepository.java +++ b/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepository.java @@ -139,6 +139,11 @@ public UserAccount updateProfile(Long userId, String displayName, String avatarU .orElseThrow(() -> new IllegalArgumentException("用户不存在: " + userId)); } + @Override + public void clearGithubId(Long userId) { + jdbc.update("UPDATE user_accounts SET github_id = NULL WHERE id = ?", userId); + } + @Override public void updatePasswordHash(Long userId, String passwordHash) { // 用于 AuthService 在登录成功后把 legacy SHA-256 哈希就地升级为 bcrypt(INV-003) 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..64eae23 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserIdentityRepository.java @@ -0,0 +1,93 @@ +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); + } + + @Override + public int deleteByUserIdAndProvider(long userId, String provider) { + return jdbc.update( + "DELETE FROM user_identities WHERE user_id = ? AND provider = ?", + userId, normalize(provider)); + } +} diff --git a/src/main/java/com/involutionhell/backend/usercenter/repository/UserAccountRepository.java b/src/main/java/com/involutionhell/backend/usercenter/repository/UserAccountRepository.java index 04b29bd..2bce636 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/repository/UserAccountRepository.java +++ b/src/main/java/com/involutionhell/backend/usercenter/repository/UserAccountRepository.java @@ -54,6 +54,12 @@ public interface UserAccountRepository { */ void updatePasswordHash(Long userId, String passwordHash); + /** + * 清空指定用户的 github_id 列。解绑 github 身份时同步调用——否则 schema.sql + * 的启动回填会在下次重启时按残留的 github_id 把身份静默复活(ADR-001)。 + */ + void clearGithubId(Long userId); + /** * 查询指定用户的偏好 Map,用户不存在时抛 IllegalArgumentException。 */ 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..64a6097 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/usercenter/repository/UserIdentityRepository.java @@ -0,0 +1,34 @@ +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); + + /** + * 删除某账号的某 provider 身份,返回受影响行数(0 = 本无此绑定)。 + */ + int deleteByUserIdAndProvider(long userId, String provider); +} 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 d77cfc8..1b6a565 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java +++ b/src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java @@ -5,7 +5,9 @@ import com.involutionhell.backend.usercenter.dto.LoginResponse; import com.involutionhell.backend.usercenter.dto.UserView; import com.involutionhell.backend.usercenter.model.UserAccount; +import com.involutionhell.backend.usercenter.model.UserIdentity; import com.involutionhell.backend.usercenter.repository.UserAccountRepository; +import com.involutionhell.backend.usercenter.repository.UserIdentityRepository; import me.zhyd.oauth.model.AuthUser; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -22,16 +24,19 @@ public class AuthService { private final UserCenterService userCenterService; private final PasswordService passwordService; private final UserAccountRepository userAccountRepository; + private final UserIdentityRepository userIdentityRepository; /** * 创建认证服务并注入用户与密码服务。 */ public AuthService(UserCenterService userCenterService, PasswordService passwordService, - UserAccountRepository userAccountRepository) { + UserAccountRepository userAccountRepository, + UserIdentityRepository userIdentityRepository) { this.userCenterService = userCenterService; this.passwordService = passwordService; this.userAccountRepository = userAccountRepository; + this.userIdentityRepository = userIdentityRepository; } /** @@ -71,58 +76,87 @@ public LoginResponse login(LoginRequest request) { } /** - * 第三方 GitHub 授权登录逻辑。 - * 如果用户不存在,则自动注册;如果已存在,则刷新其头像、邮箱等资料。 + * GitHub 授权登录(薄委托)。历史入口,保留供 OAuthController 调用。 */ public LoginResponse loginByGithub(AuthUser githubUser) { - // 使用特殊的 github_ 前缀来标识这是第三方登录的用户,防止与普通用户名冲突 - String githubUsername = "github_" + githubUser.getUuid(); - - // 从 JustAuth 提取 GitHub 资料字段 - String displayName = githubUser.getNickname() != null ? githubUser.getNickname() : githubUser.getUsername(); - String avatarUrl = githubUser.getAvatar(); - String email = githubUser.getEmail(); - // JustAuth 对 GitHub 的 uuid 就是 GitHub 的数字用户 ID(字符串形式) - // 用 final 变量包装,确保 lambda 内可以引用(try-catch 双路赋值不是 effectively final) - Long parsedGithubId; - try { - parsedGithubId = Long.parseLong(githubUser.getUuid()); - } catch (NumberFormatException e) { - parsedGithubId = null; + return loginByProvider("github", githubUser); + } + + /** + * 第三方 provider 授权登录(M1 统一流程)。 + * 不存在则自动注册,已存在则刷新资料;无论哪条路径都维护一行 user_identities。 + * + * 双写期语义(ADR-001,M1-M3):账号仍按 "{provider}_{providerUserId}" 用户名主查, + * user_identities 作为并行写入的第二真相源(M3 才翻转成主查)。github 的 github_id + * 列同样双写(createUser/updateProfile 已写)。identity 缺失时惰性补齐(自愈), + * 兜住 M0-M1 窗口内注册、回填尚未覆盖的账号。 + */ + public LoginResponse loginByProvider(String provider, AuthUser authUser) { + String providerUserId = authUser.getUuid(); + // 保留 "{provider}_{id}" 用户名约定(github 即 "github_{id}",与历史一致)。 + String username = provider + "_" + providerUserId; + + String displayName = authUser.getNickname() != null ? authUser.getNickname() : authUser.getUsername(); + String avatarUrl = authUser.getAvatar(); + String email = authUser.getEmail(); + // github 的 uuid 就是数字用户 ID;非数字(极罕见)时置 null。 + // 非 github provider 不写 github_id 列。 + Long parsedGithubId = null; + if ("github".equals(provider)) { + try { + parsedGithubId = Long.parseLong(providerUserId); + } catch (NumberFormatException e) { + parsedGithubId = null; + } } final Long githubId = parsedGithubId; - // 查找是否已经有该用户 - UserAccount userAccount = userCenterService.findByUsername(githubUsername).map(existing -> { - // 已存在:刷新头像、邮箱、展示名称(GitHub 用户可能更新了自己的资料) - return userCenterService.updateProfile(existing.id(), displayName, avatarUrl, email, githubId); - }).orElseGet(() -> { - // 不存在:自动注册新用户 + UserAccount userAccount = userCenterService.findByUsername(username).map(existing -> + userCenterService.updateProfile(existing.id(), displayName, avatarUrl, email, githubId) + ).orElseGet(() -> { UserAccount newUser = new UserAccount( - null, // ID 由数据库自动生成 - githubUsername, - // 给第三方用户生成一个随机超长密码,他们不需要用密码登录 + null, + username, + // 第三方用户不用密码登录,塞随机超长密码占位(password_hash NOT NULL) passwordService.hash(UUID.randomUUID().toString()), displayName, - true, // 默认启用 - Set.of("user"), // 赋予默认角色(小写,与 normalizeSet 一致) - Set.of(), // 默认权限 + true, + Set.of("user"), + Set.of(), avatarUrl, email, githubId, - null // 偏好由数据库默认值初始化为 {} + null ); return userCenterService.createUser(newUser); }); - // 检查该用户是否已被系统管理员禁用 if (!userAccount.enabled()) { throw new IllegalStateException("账号已被禁用"); } - // 执行 Sa-Token 登录并返回信息 + ensureIdentity(userAccount.id(), provider, providerUserId, email, displayName); + return executeLogin(userAccount); } + + /** + * 维护 user_identities 双写:缺行则插入(惰性自愈),有则刷新 last_login_at。 + * 写失败不阻断登录——与 INV-003 lazy upgrade 同策略,记日志后继续, + * 下次登录还会再试,绝不让 identity 写入把用户挡在门外。 + */ + private void ensureIdentity(long userId, String provider, String providerUserId, + String email, String displayName) { + try { + userIdentityRepository.findByProviderAndProviderUserId(provider, providerUserId) + .ifPresentOrElse( + existing -> userIdentityRepository.touchLastLogin(existing.id()), + () -> userIdentityRepository.insert(new UserIdentity( + null, userId, provider, providerUserId, email, displayName, null, null))); + } catch (Exception e) { + log.warn("user_identities 双写失败(provider={} userId={}),不阻断登录", provider, userId, e); + } + } /** * 执行底层 Sa-Token 登录操作并封装返回结果。 diff --git a/src/main/java/com/involutionhell/backend/usercenter/service/UserIdentityService.java b/src/main/java/com/involutionhell/backend/usercenter/service/UserIdentityService.java new file mode 100644 index 0000000..4930de0 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/usercenter/service/UserIdentityService.java @@ -0,0 +1,62 @@ +package com.involutionhell.backend.usercenter.service; + +import com.involutionhell.backend.usercenter.dto.LinkedIdentityView; +import com.involutionhell.backend.usercenter.model.UserIdentity; +import com.involutionhell.backend.usercenter.repository.UserAccountRepository; +import com.involutionhell.backend.usercenter.repository.UserIdentityRepository; +import java.util.List; +import java.util.Locale; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 登录身份的读取与解绑(M2a)。绑定(新建第二 provider)走 M2b 的 OAuth 流程。 + */ +@Service +public class UserIdentityService { + + private final UserIdentityRepository userIdentityRepository; + private final UserAccountRepository userAccountRepository; + + public UserIdentityService(UserIdentityRepository userIdentityRepository, + UserAccountRepository userAccountRepository) { + this.userIdentityRepository = userIdentityRepository; + this.userAccountRepository = userAccountRepository; + } + + public List listForUser(long userId) { + return userIdentityRepository.findByUserId(userId).stream() + .map(LinkedIdentityView::from) + .toList(); + } + + /** + * 解绑指定 provider 身份。返回解绑后剩余身份列表。 + * + * 两条安全规则: + * 1. 不能解绑最后一种登录方式——否则用户可能永久锁死(OAuth 用户的随机密码 + * 不是可用登录方式,且无法可靠区分,故保守地只按"剩余身份数"判定,不把密码 + * 算作兜底;代价是纯密码用户暂时不能解绑其唯一绑定,安全方向优先)。 + * 2. 解绑 github 时同步清空 user_accounts.github_id——否则 schema.sql 启动回填 + * 会按残留列值把该身份静默复活(ADR-001)。同事务保证两步原子。 + */ + @Transactional + public List unbind(long userId, String provider) { + String normalized = provider == null ? null : provider.toLowerCase(Locale.ROOT); + List current = userIdentityRepository.findByUserId(userId); + + boolean owns = current.stream().anyMatch(i -> i.provider().equals(normalized)); + if (!owns) { + throw new IllegalArgumentException("未绑定该登录方式: " + provider); + } + if (current.size() <= 1) { + throw new IllegalStateException("这是你唯一的登录方式,不能解绑"); + } + + userIdentityRepository.deleteByUserIdAndProvider(userId, normalized); + if ("github".equals(normalized)) { + userAccountRepository.clearGithubId(userId); + } + return listForUser(userId); + } +} 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/java/com/involutionhell/backend/usercenter/UserIdentityServiceTests.java b/src/test/java/com/involutionhell/backend/usercenter/UserIdentityServiceTests.java new file mode 100644 index 0000000..e73148d --- /dev/null +++ b/src/test/java/com/involutionhell/backend/usercenter/UserIdentityServiceTests.java @@ -0,0 +1,110 @@ +package com.involutionhell.backend.usercenter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.involutionhell.backend.support.AbstractWebIntegrationTest; +import com.involutionhell.backend.usercenter.service.UserIdentityService; +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.jdbc.core.JdbcTemplate; + +/** + * 身份查看 / 解绑(M2a)的行为契约:解绑最后一种身份被挡(防锁死)、 + * 解绑 github 同步清 github_id 列(防启动回填静默复活)、鉴权门。 + */ +class UserIdentityServiceTests extends AbstractWebIntegrationTest { + + @Autowired + private UserIdentityService service; + + @Autowired + private JdbcTemplate jdbc; + + @AfterEach + void cleanup() { + jdbc.update("DELETE FROM user_accounts WHERE username LIKE 'ident-svc-%'"); + } + + private long createUser(Long githubId) { + String username = "ident-svc-" + 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 void addIdentity(long userId, String provider, String providerUserId) { + jdbc.update("INSERT INTO user_identities (user_id, provider, provider_user_id) VALUES (?, ?, ?)", + userId, provider, providerUserId); + } + + @Test + void listReturnsUsersIdentities() { + long userId = createUser(123L); + addIdentity(userId, "github", "123"); + addIdentity(userId, "discord", "snow-1"); + + assertThat(service.listForUser(userId)) + .extracting("provider") + .containsExactlyInAnyOrder("github", "discord"); + } + + @Test + void unbindRemovesNonLastIdentity() { + long userId = createUser(123L); + addIdentity(userId, "github", "123"); + addIdentity(userId, "discord", "snow-1"); + + var remaining = service.unbind(userId, "discord"); + + assertThat(remaining).extracting("provider").containsExactly("github"); + } + + @Test + void unbindingLastIdentityIsBlocked() { + long userId = createUser(123L); + addIdentity(userId, "github", "123"); + + assertThatThrownBy(() -> service.unbind(userId, "github")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("唯一的登录方式"); + + // 仍在——没被删 + assertThat(service.listForUser(userId)).hasSize(1); + } + + @Test + void unbindingProviderNotOwnedIsRejected() { + long userId = createUser(123L); + addIdentity(userId, "github", "123"); + addIdentity(userId, "discord", "snow-1"); + + assertThatThrownBy(() -> service.unbind(userId, "google")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void unbindingGithubClearsGithubIdColumnToPreventBackfillRevival() { + long userId = createUser(999L); + addIdentity(userId, "github", "999"); + addIdentity(userId, "discord", "snow-1"); // 保证 github 不是最后一种 + + service.unbind(userId, "github"); + + Long githubId = jdbc.queryForObject( + "SELECT github_id FROM user_accounts WHERE id = ?", Long.class, userId); + assertThat(githubId) + .as("解绑 github 必须清空 github_id 列,否则 schema.sql 回填会复活该身份") + .isNull(); + } + + @Test + void unbindEndpointRejectsAnonymous() throws Exception { + mockMvc.perform(delete("/api/user-center/identities/github")) + .andExpect(status().isUnauthorized()); + } +} 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 53baa4f..5682521 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerIntegrationTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerIntegrationTests.java @@ -62,9 +62,23 @@ void renderAuthIncludesRedirectUriInAuthorizationUrl() throws Exception { // ============================================= @Test - void callbackRedirectsToFrontendErrorPageWhenOAuthFails() throws Exception { - // 不携带合法的 code 和 state,JustAuth 会返回失败响应 - // 控制器应将其重定向至前端错误页(/login?error=oauth_failed) + void renderSetsStateCookie() throws Exception { + MvcResult result = mockMvc.perform(get("/oauth/render/github")) + .andExpect(status().is3xxRedirection()) + .andReturn(); + + String setCookie = result.getResponse().getHeader("Set-Cookie"); + assertThat(setCookie) + .as("render 必须种下 httpOnly + SameSite=Lax 的 state cookie") + .isNotNull() + .contains("ih_oauth_state=") + .contains("HttpOnly") + .contains("SameSite=Lax"); + } + + @Test + void callbackWithoutStateCookieIsRejectedBeforeTokenExchange() throws Exception { + // 带 code+state 但无 state cookie(伪造 state / cookie 丢失)→ INV-007 在换 token 前拒绝 MvcResult result = mockMvc.perform( get("/api/auth/callback/github") .param("code", "invalid-code") @@ -74,7 +88,26 @@ void callbackRedirectsToFrontendErrorPageWhenOAuthFails() throws Exception { String location = result.getResponse().getRedirectedUrl(); assertThat(location) - .as("OAuth 失败时应重定向至前端错误页") + .as("state 与 cookie 不匹配时应拒绝并重定向到 state 错误页") + .isNotNull() + .endsWith("/login?error=oauth_state"); + } + + @Test + void callbackWithMatchingStateCookieProceedsPastStateCheck() throws Exception { + // state == cookie,越过 INV-007 校验后进入 JustAuth 换 token;code 无效 → oauth_failed。 + // 关键是它没有停在 oauth_state,证明 cookie 匹配这条正路是通的。 + MvcResult result = mockMvc.perform( + get("/api/auth/callback/github") + .param("code", "invalid-code") + .param("state", "matching-state") + .cookie(new jakarta.servlet.http.Cookie("ih_oauth_state", "matching-state"))) + .andExpect(status().is3xxRedirection()) + .andReturn(); + + String location = result.getResponse().getRedirectedUrl(); + assertThat(location) + .as("cookie 匹配后应越过 state 校验,止于 JustAuth 换 token 失败") .isNotNull() .endsWith("/login?error=oauth_failed"); } 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 517300d..cc1585f 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/service/AuthServiceTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/service/AuthServiceTests.java @@ -40,9 +40,25 @@ class AuthServiceTests { @Mock private UserAccountRepository userAccountRepository; + @Mock + private com.involutionhell.backend.usercenter.repository.UserIdentityRepository userIdentityRepository; + @InjectMocks private AuthService authService; + /** + * identity 双写默认:缺行(Optional.empty)→ ensureIdentity 走 insert 路径。 + * lenient 因为账号密码登录相关测试不触及 identity 分支。 + */ + @org.junit.jupiter.api.BeforeEach + void stubIdentityLookupEmpty() { + org.mockito.Mockito.lenient() + .when(userIdentityRepository.findByProviderAndProviderUserId( + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyString())) + .thenReturn(Optional.empty()); + } + // ============================================= // 辅助方法 // ============================================= @@ -346,6 +362,71 @@ void loginByGithubThrowsWhenNewlyRegisteredAccountIsDisabled() { .hasMessage("账号已被禁用"); } + // ============================================= + // loginByProvider() - identity 双写(M1) + // ============================================= + + @Test + void newUserGetsIdentityInserted() { + AuthUser ghUser = githubUser("12345", "Nick", null, null); + when(userCenterService.findByUsername("github_12345")).thenReturn(Optional.empty()); + when(passwordService.hash(any())).thenReturn("hash"); + when(userCenterService.createUser(any())).thenReturn(enabledUser(10L, "github_12345", "hash")); + + try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { + stpUtil.when(StpUtil::getTokenName).thenReturn("satoken"); + stpUtil.when(StpUtil::getTokenValue).thenReturn("token"); + authService.loginByGithub(ghUser); + } + + org.mockito.ArgumentCaptor cap = + org.mockito.ArgumentCaptor.forClass(com.involutionhell.backend.usercenter.model.UserIdentity.class); + verify(userIdentityRepository).insert(cap.capture()); + assertThat(cap.getValue().userId()).isEqualTo(10L); + assertThat(cap.getValue().provider()).isEqualTo("github"); + assertThat(cap.getValue().providerUserId()).isEqualTo("12345"); + } + + @Test + void existingIdentityRefreshesLastLoginInsteadOfInserting() { + AuthUser ghUser = githubUser("12345", "Nick", null, null); + when(userCenterService.findByUsername("github_12345")) + .thenReturn(Optional.of(enabledUser(10L, "github_12345", "hash"))); + when(userCenterService.updateProfile(any(), any(), any(), any(), any())) + .thenReturn(enabledUser(10L, "github_12345", "hash")); + // 该 provider 身份已存在 → 不应再 insert,只刷新 last_login_at + when(userIdentityRepository.findByProviderAndProviderUserId("github", "12345")) + .thenReturn(Optional.of(new com.involutionhell.backend.usercenter.model.UserIdentity( + 7L, 10L, "github", "12345", null, null, null, null))); + + try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { + stpUtil.when(StpUtil::getTokenName).thenReturn("satoken"); + stpUtil.when(StpUtil::getTokenValue).thenReturn("token"); + authService.loginByGithub(ghUser); + } + + verify(userIdentityRepository).touchLastLogin(7L); + verify(userIdentityRepository, org.mockito.Mockito.never()).insert(any()); + } + + @Test + void identityWriteFailureDoesNotBlockLogin() { + AuthUser ghUser = githubUser("12345", "Nick", null, null); + when(userCenterService.findByUsername("github_12345")).thenReturn(Optional.empty()); + when(passwordService.hash(any())).thenReturn("hash"); + when(userCenterService.createUser(any())).thenReturn(enabledUser(10L, "github_12345", "hash")); + // identity 写入炸掉——不能阻断登录(与 INV-003 lazy upgrade 同策略) + org.mockito.Mockito.doThrow(new RuntimeException("simulated identity write failure")) + .when(userIdentityRepository).insert(any()); + + try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { + stpUtil.when(StpUtil::getTokenName).thenReturn("satoken"); + stpUtil.when(StpUtil::getTokenValue).thenReturn("token-xyz"); + LoginResponse response = authService.loginByGithub(ghUser); + assertThat(response.tokenValue()).isEqualTo("token-xyz"); + } + } + // ============================================= // logout() 和 currentUser() // ============================================= 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,