From c1c75edc550c6ad4d5f12eba20199d4428be2b9f Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Wed, 30 Sep 2026 16:21:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(backend):=20=E4=BF=AE=E5=A4=8D=20v1.1.0=20?= =?UTF-8?q?=E5=BC=95=E5=85=A5=E7=9A=84=204=20=E9=A1=B9=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一轮 v1.1.0 修复从未真正运行过后端测试,本次接入后端契约门禁前 全量 mvn verify 暴露 4 处缺陷: 1. PhotoService.confirmDirectUpload:countActiveProfilePhotos 返回 long, 窄化赋值给 int sortOrder 导致整个后端编译失败(全量测试 0 跑)。 2. OperatorAuthController 直接引用 JwtTokenService 计算 Cookie max-age 并在 logout 解析 JWT,违反 PrincipalInjectionContractTest 契约(控制器不得 解析/引用 JWT 工具)。改为由 OperatorCookieWriter 持有 TTL、logout 从 SecurityContext 的 OperatorPrincipal 取 operatorId。 3-4. AuthServiceRefreshTtlTest 与 JwtAuthFilterTest 的 UnnecessaryStubbing: 移除未被调用的 getExpirationMs() / validateToken("bad-cookie") 桩。 全量 mvn verify:809 测试 0 失败 0 错误(3 跳过)。 --- .../controller/OperatorAuthController.java | 30 +++++++++---------- .../auth/cookie/OperatorCookieWriter.java | 14 +++++++++ .../platform/photo/service/PhotoService.java | 4 ++- .../service/AuthServiceRefreshTtlTest.java | 2 -- .../shared/filter/JwtAuthFilterTest.java | 1 - 5 files changed, 31 insertions(+), 20 deletions(-) diff --git a/backend/src/main/java/com/danyang/match/platform/auth/controller/OperatorAuthController.java b/backend/src/main/java/com/danyang/match/platform/auth/controller/OperatorAuthController.java index 2216dec..7115d4c 100644 --- a/backend/src/main/java/com/danyang/match/platform/auth/controller/OperatorAuthController.java +++ b/backend/src/main/java/com/danyang/match/platform/auth/controller/OperatorAuthController.java @@ -2,13 +2,14 @@ package com.danyang.match.platform.auth.controller; import com.danyang.match.platform.auth.cookie.OperatorCookieWriter; import com.danyang.match.platform.auth.service.OperatorAuthService; -import com.danyang.match.platform.auth.token.JwtTokenService; import com.danyang.match.platform.shared.dto.ApiResponse; import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.security.principal.OperatorPrincipal; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -23,7 +24,6 @@ public class OperatorAuthController { private final OperatorAuthService operatorAuthService; private final OperatorCookieWriter cookieWriter; - private final JwtTokenService jwtTokenService; @PostMapping("/login") public ApiResponse> login(@RequestBody Map body, @@ -34,10 +34,11 @@ public class OperatorAuthController { return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "用户名和密码不能为空"); } Map result = operatorAuthService.login(username, password); - // 同时下发 HttpOnly + SameSite=Lax Cookie(access / refresh),抗 XSS 读取令牌 + // 同时下发 HttpOnly + SameSite=Lax Cookie(access / refresh),抗 XSS 读取令牌。 + // Cookie max-age 由 OperatorCookieWriter 按 JWT 有效期计算,控制器不引用 JWT 工具 + // (控制器禁止解析/引用 JWT 工具,见 PrincipalInjectionContractTest 契约)。 cookieWriter.writeLoginCookies(response, - (String) result.get("accessToken"), (String) result.get("refreshToken"), - jwtTokenService.getExpirationMs() / 1000, jwtTokenService.getRefreshExpirationMs() / 1000); + (String) result.get("accessToken"), (String) result.get("refreshToken")); return ApiResponse.ok(result); } @@ -61,25 +62,22 @@ public class OperatorAuthController { String operatorId = (body != null) ? body.get("operatorId") : null; Map result = operatorAuthService.refreshToken(operatorId, refreshToken); cookieWriter.writeLoginCookies(response, - (String) result.get("accessToken"), (String) result.get("refreshToken"), - jwtTokenService.getExpirationMs() / 1000, jwtTokenService.getRefreshExpirationMs() / 1000); + (String) result.get("accessToken"), (String) result.get("refreshToken")); return ApiResponse.ok(result); } /** * 登出:后端将当前 access token 加入黑名单并清除 HttpOnly Cookie。 - * token 取自 Authorization 头,缺省时回退到 access Cookie。 + * operatorId 取自 SecurityContext 中的 OperatorPrincipal(由 JwtAuthFilter 写入), + * 不再由控制器自行解析 JWT;token 取自 Authorization 头,缺省时回退到 access Cookie, + * 仅用于黑名单登记。 */ @PostMapping("/logout") - public ApiResponse logout(HttpServletRequest request, HttpServletResponse response) { + public ApiResponse logout(HttpServletRequest request, HttpServletResponse response, + @AuthenticationPrincipal OperatorPrincipal principal) { String token = readBearer(request); - if (token != null) { - try { - String operatorId = jwtTokenService.getUserIdFromToken(token); - operatorAuthService.logout(operatorId, token); - } catch (Exception ignored) { - // token 已失效也无妨,仍清除 Cookie - } + if (token != null && principal != null) { + operatorAuthService.logout(principal.id(), token); } cookieWriter.clearCookies(response); return ApiResponse.ok(); diff --git a/backend/src/main/java/com/danyang/match/platform/auth/cookie/OperatorCookieWriter.java b/backend/src/main/java/com/danyang/match/platform/auth/cookie/OperatorCookieWriter.java index ac3e3b3..fbb8d78 100644 --- a/backend/src/main/java/com/danyang/match/platform/auth/cookie/OperatorCookieWriter.java +++ b/backend/src/main/java/com/danyang/match/platform/auth/cookie/OperatorCookieWriter.java @@ -27,6 +27,20 @@ public class OperatorCookieWriter { @Value("${app.auth-cookie-secure:true}") private boolean secure; + // Cookie 生命周期与 JWT 自身有效期对齐(access 2h / refresh 14d),由配置驱动、不写死。 + // 由本组件持有而非控制器持有:控制器不得直接引用 JwtTokenService + // (PrincipalInjectionContractTest 契约禁止控制器解析/引用 JWT 工具)。 + @Value("${jwt.access-token-expiration-ms:7200000}") + private long accessExpirationMs; + + @Value("${jwt.refresh-token-expiration-ms:1209600000}") + private long refreshExpirationMs; + + /** 登录/刷新下发 Cookie,max-age 自动取 JWT 有效期(秒)。 */ + public void writeLoginCookies(HttpServletResponse response, String accessToken, String refreshToken) { + writeLoginCookies(response, accessToken, refreshToken, accessExpirationMs / 1000, refreshExpirationMs / 1000); + } + public void writeLoginCookies(HttpServletResponse response, String accessToken, String refreshToken, long accessMaxAgeSec, long refreshMaxAgeSec) { response.addHeader(HttpHeaders.SET_COOKIE, build(ACCESS_COOKIE, accessToken, accessMaxAgeSec).toString()); diff --git a/backend/src/main/java/com/danyang/match/platform/photo/service/PhotoService.java b/backend/src/main/java/com/danyang/match/platform/photo/service/PhotoService.java index 8503ead..2f4c275 100644 --- a/backend/src/main/java/com/danyang/match/platform/photo/service/PhotoService.java +++ b/backend/src/main/java/com/danyang/match/platform/photo/service/PhotoService.java @@ -208,7 +208,9 @@ public class PhotoService { if (!amazonS3.doesObjectExist(privateBucket, fileKey)) { throw new BusinessException(ApiResponseCode.BAD_REQUEST, "文件尚未上传完成"); } - int sortOrder = photoMapper.countActiveProfilePhotos(userId) + 1; + // countActiveProfilePhotos 返回 long,落库排序用 int(与 savePhoto 形参一致); + // 照片数量不会触及 int 上界,此处安全窄化。 + int sortOrder = (int) (photoMapper.countActiveProfilePhotos(userId) + 1); return savePhoto(userId, fileKey, photoType, sortOrder); } diff --git a/backend/src/test/java/com/danyang/match/platform/auth/service/AuthServiceRefreshTtlTest.java b/backend/src/test/java/com/danyang/match/platform/auth/service/AuthServiceRefreshTtlTest.java index 8b4c4b7..c524a4e 100644 --- a/backend/src/test/java/com/danyang/match/platform/auth/service/AuthServiceRefreshTtlTest.java +++ b/backend/src/test/java/com/danyang/match/platform/auth/service/AuthServiceRefreshTtlTest.java @@ -68,7 +68,6 @@ class AuthServiceRefreshTtlTest { when(userMapper.findByWechatOpenid("o1")).thenReturn(Optional.of(user)); when(jwtTokenService.generateAccessToken("u1", null)).thenReturn("access"); when(jwtTokenService.generateRefreshToken("u1")).thenReturn("refresh"); - when(jwtTokenService.getExpirationMs()).thenReturn(ACCESS_MS); when(jwtTokenService.getRefreshExpirationMs()).thenReturn(REFRESH_MS); when(auditLogMapper.insert(any())).thenReturn(1); when(userMapper.updateById(any())).thenReturn(1); @@ -88,7 +87,6 @@ class AuthServiceRefreshTtlTest { when(redisTemplate.opsForValue().get("auth:refresh_token:u1")).thenReturn("rt"); when(jwtTokenService.generateAccessToken("u1", null)).thenReturn("newAccess"); when(jwtTokenService.generateRefreshToken("u1")).thenReturn("newRefresh"); - when(jwtTokenService.getExpirationMs()).thenReturn(ACCESS_MS); when(jwtTokenService.getRefreshExpirationMs()).thenReturn(REFRESH_MS); authService.refreshToken("u1", "rt"); diff --git a/backend/src/test/java/com/danyang/match/platform/shared/filter/JwtAuthFilterTest.java b/backend/src/test/java/com/danyang/match/platform/shared/filter/JwtAuthFilterTest.java index 7376421..5b6f02e 100644 --- a/backend/src/test/java/com/danyang/match/platform/shared/filter/JwtAuthFilterTest.java +++ b/backend/src/test/java/com/danyang/match/platform/shared/filter/JwtAuthFilterTest.java @@ -154,7 +154,6 @@ class JwtAuthFilterTest { when(jwtTokenService.getUserIdFromToken(headerToken)).thenReturn("user-1"); when(jwtTokenService.parseToken(headerToken)).thenReturn(claims); when(claims.get("type", String.class)).thenReturn("access"); - when(jwtTokenService.validateToken("bad-cookie")).thenReturn(false); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/content/operator/articles"); request.addHeader("Authorization", "Bearer " + headerToken);