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
18 changes: 18 additions & 0 deletions SECURITY.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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。
7 changes: 4 additions & 3 deletions docs/wiki/adr/001-multi-provider-identity.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -109,8 +109,9 @@ INV-006 已被"付费 LLM 端点限流"占用,编号按 SECURITY.md 流水规

| 阶段 | 内容 | 状态 |
|---|---|---|
| M0 | 建表 + 幂等回填 + repository | ✅ 本 ADR 随附 PR |
| M1 | `loginByProvider` 统一流程 + state/cookie 硬化 + INV-007 测试;`github_id` 双写 | 待做 |
| M2 | 绑定/解绑 + 设置页 UI + 确认页 | 待做 |
| 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` 列(单独拆期,保回滚路径) | 待做 |
Original file line numberDiff line numberDiff line change
@@ -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<LinkedIdentityView>> list() {
return ApiResponse.ok(userIdentityService.listForUser(StpUtil.getLoginIdAsLong()));
}

/** 解绑指定 provider。返回解绑后剩余身份列表。 */
@SaCheckLogin
@DeleteMapping("/{provider}")
public ApiResponse<List<LinkedIdentityView>> unbind(@PathVariable String provider) {
return ApiResponse.ok(userIdentityService.unbind(StpUtil.getLoginIdAsLong(), provider));
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)发起登录
Expand All@@ -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;
}

/**
Expand All@@ -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 白屏;
Expand All@@ -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);
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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());
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,4 +83,11 @@ public UserIdentity insert(UserIdentity identity) {
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));
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -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。
*/
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,4 +26,9 @@ public interface UserIdentityRepository {
UserIdentity insert(UserIdentity identity);

void touchLastLogin(long id);

/**
* 删除某账号的某 provider 身份,返回受影响行数(0 = 本无此绑定)。
*/
int deleteByUserIdAndProvider(long userId, String provider);
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand All@@ -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;
}

/**
Expand DownExpand Up@@ -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 登录操作并封装返回结果。
Expand Down
Loading