From 7e32449109993260e3b45e666538bdd3034c2431 Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Sun, 19 Jul 2026 09:35:13 +0000 Subject: [PATCH] =?UTF-8?q?feat(usercenter):=20M1=20loginByProvider=20?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=B5=81=E7=A8=8B=20+=20OAuth=20state/cookie?= =?UTF-8?q?=20=E7=A1=AC=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC #42 / ADR-001 第二阶段。 loginByProvider(loginByGithub 委托至此): - 账号仍按 "{provider}_{id}" 用户名主查(双写期,M3 才翻转到 identity 主查) - 无论新建/已存在都维护一行 user_identities(identity 双写),缺行惰性补齐, 兜住 M0-M1 窗口回填未覆盖的账号;identity 写失败不阻断登录(同 INV-003 策略) - github_id 列双写沿用(createUser/updateProfile 已写) OAuth state/cookie 硬化(INV-007,登录 CSRF): - render 种 httpOnly + SameSite=Lax 的 ih_oauth_state cookie - callback 在换 token 前校验 URL state == cookie,缺失/不匹配即拒(error=oauth_state) - callback 端点保持 github 专用(泛化 AuthRequest 工厂留到 M3 真接第二个 provider) 新增 INV-007 进 SECURITY.md(INV-006 已被限流占用,按流水规则顺延)。 全量 257 测试通过;生产数据核查:51 个 github 账号 username 约定 0 例外。 --- SECURITY.md | 18 ++++ docs/wiki/adr/001-multi-provider-identity.md | 4 +- .../controller/OAuthController.java | 42 +++++++- .../usercenter/service/AuthService.java | 98 +++++++++++++------ .../OAuthControllerIntegrationTests.java | 41 +++++++- .../usercenter/service/AuthServiceTests.java | 81 +++++++++++++++ 6 files changed, 245 insertions(+), 39 deletions(-) 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..2111f01 100644 --- a/docs/wiki/adr/001-multi-provider-identity.md +++ b/docs/wiki/adr/001-multi-provider-identity.md @@ -109,8 +109,8 @@ INV-006 已被"付费 LLM 端点限流"占用,编号按 SECURITY.md 流水规 | 阶段 | 内容 | 状态 | |---|---|---| -| M0 | 建表 + 幂等回填 + repository | ✅ 本 ADR 随附 PR | -| M1 | `loginByProvider` 统一流程 + state/cookie 硬化 + INV-007 测试;`github_id` 双写 | 待做 | +| M0 | 建表 + 幂等回填 + repository | ✅ PR #43 | +| M1 | `loginByProvider` 统一流程(username 主查 + identity 双写) + state/cookie 硬化 + INV-007;`github_id` 列双写沿用 | ✅ 本 PR | | M2 | 绑定/解绑 + 设置页 UI + 确认页 | 待做 | | M3 | Discord 上线;`/u/`、follows 查询改走 identities | 待做 | | M4 | identities 稳定一个版本后删 `github_id` 列(单独拆期,保回滚路径) | 待做 | 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/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/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() // =============================================