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/docs/wiki/adr/001-multi-provider-identity.md b/docs/wiki/adr/001-multi-provider-identity.md index 3279fa9..5ef1a11 100644 --- a/docs/wiki/adr/001-multi-provider-identity.md +++ b/docs/wiki/adr/001-multi-provider-identity.md @@ -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` 列(单独拆期,保回滚路径) | 待做 | 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/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 index ad03367..64eae23 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserIdentityRepository.java +++ b/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserIdentityRepository.java @@ -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)); + } } 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 index 11af601..64a6097 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/repository/UserIdentityRepository.java +++ b/src/main/java/com/involutionhell/backend/usercenter/repository/UserIdentityRepository.java @@ -26,4 +26,9 @@ public interface UserIdentityRepository { 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/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() // =============================================