Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)---
Expand All@@ -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
# 生产同样建议 alwaysschema.sql 幂等,每次启动 reconcile 新增 schema)
# SPRING_SQL_INIT_MODE=always

# --- 本地开发用 Docker PostgreSQL(无 Neon 账号的开发者使用)---
# 这些变量被 docker-compose.yml 读取,用于创建本地 Postgres 容器。
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 会直接拒绝:

Expand Down
28 changes: 26 additions & 2 deletions docker/init-db/init.sql
Original file line numberDiff line numberDiff line change
@@ -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
Expand All@@ -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
Expand Down
116 changes: 116 additions & 0 deletions docs/wiki/adr/001-multi-provider-identity.md
Original file line numberDiff line numberDiff line change
@@ -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=<nonce>` 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=<nonce>`;
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_<id>` 用户名永不强迁。
- **资料刷新只填空缺字段**:多 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` 列(单独拆期,保回滚路径) | 待做 |
Original file line numberDiff line numberDiff line change
@@ -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
) {
}
Original file line numberDiff line numberDiff line change
@@ -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<UserIdentity> 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<UserIdentity> findByProviderAndProviderUserId(String provider, String providerUserId) {
List<UserIdentity> rows = jdbc.query(
"SELECT * FROM user_identities WHERE provider = ? AND provider_user_id = ?",
rowMapper, normalize(provider), providerUserId);
return rows.stream().findFirst();
}

@Override
public List<UserIdentity> 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);
}
}
Original file line numberDiff line numberDiff line change
@@ -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<UserIdentity> findByProviderAndProviderUserId(String provider, String providerUserId);

List<UserIdentity> 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);
}
6 changes: 3 additions & 3 deletions src/main/resources/application.properties
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
Loading