fix(backend): 修复 v1.1.0 引入的 4 项后端测试失败

上一轮 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 跳过)。
This commit is contained in:
WorkBuddy
2026-09-30 16:21:16 +08:00
parent 47cccc9ca5
commit c1c75edc55
5 changed files with 31 additions and 20 deletions
@@ -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.cookie.OperatorCookieWriter;
import com.danyang.match.platform.auth.service.OperatorAuthService; 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.dto.ApiResponse;
import com.danyang.match.platform.shared.enums.ApiResponseCode; 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.Cookie;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -23,7 +24,6 @@ public class OperatorAuthController {
private final OperatorAuthService operatorAuthService; private final OperatorAuthService operatorAuthService;
private final OperatorCookieWriter cookieWriter; private final OperatorCookieWriter cookieWriter;
private final JwtTokenService jwtTokenService;
@PostMapping("/login") @PostMapping("/login")
public ApiResponse<Map<String, Object>> login(@RequestBody Map<String, String> body, public ApiResponse<Map<String, Object>> login(@RequestBody Map<String, String> body,
@@ -34,10 +34,11 @@ public class OperatorAuthController {
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "用户名和密码不能为空"); return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "用户名和密码不能为空");
} }
Map<String, Object> result = operatorAuthService.login(username, password); Map<String, Object> 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, cookieWriter.writeLoginCookies(response,
(String) result.get("accessToken"), (String) result.get("refreshToken"), (String) result.get("accessToken"), (String) result.get("refreshToken"));
jwtTokenService.getExpirationMs() / 1000, jwtTokenService.getRefreshExpirationMs() / 1000);
return ApiResponse.ok(result); return ApiResponse.ok(result);
} }
@@ -61,25 +62,22 @@ public class OperatorAuthController {
String operatorId = (body != null) ? body.get("operatorId") : null; String operatorId = (body != null) ? body.get("operatorId") : null;
Map<String, Object> result = operatorAuthService.refreshToken(operatorId, refreshToken); Map<String, Object> result = operatorAuthService.refreshToken(operatorId, refreshToken);
cookieWriter.writeLoginCookies(response, cookieWriter.writeLoginCookies(response,
(String) result.get("accessToken"), (String) result.get("refreshToken"), (String) result.get("accessToken"), (String) result.get("refreshToken"));
jwtTokenService.getExpirationMs() / 1000, jwtTokenService.getRefreshExpirationMs() / 1000);
return ApiResponse.ok(result); return ApiResponse.ok(result);
} }
/** /**
* 登出:后端将当前 access token 加入黑名单并清除 HttpOnly Cookie。 * 登出:后端将当前 access token 加入黑名单并清除 HttpOnly Cookie。
* token 取自 Authorization 头,缺省时回退到 access Cookie。 * operatorId 取自 SecurityContext 中的 OperatorPrincipal(由 JwtAuthFilter 写入),
* 不再由控制器自行解析 JWT;token 取自 Authorization 头,缺省时回退到 access Cookie,
* 仅用于黑名单登记。
*/ */
@PostMapping("/logout") @PostMapping("/logout")
public ApiResponse<Void> logout(HttpServletRequest request, HttpServletResponse response) { public ApiResponse<Void> logout(HttpServletRequest request, HttpServletResponse response,
@AuthenticationPrincipal OperatorPrincipal principal) {
String token = readBearer(request); String token = readBearer(request);
if (token != null) { if (token != null && principal != null) {
try { operatorAuthService.logout(principal.id(), token);
String operatorId = jwtTokenService.getUserIdFromToken(token);
operatorAuthService.logout(operatorId, token);
} catch (Exception ignored) {
// token 已失效也无妨,仍清除 Cookie
}
} }
cookieWriter.clearCookies(response); cookieWriter.clearCookies(response);
return ApiResponse.ok(); return ApiResponse.ok();
@@ -27,6 +27,20 @@ public class OperatorCookieWriter {
@Value("${app.auth-cookie-secure:true}") @Value("${app.auth-cookie-secure:true}")
private boolean secure; 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, public void writeLoginCookies(HttpServletResponse response, String accessToken, String refreshToken,
long accessMaxAgeSec, long refreshMaxAgeSec) { long accessMaxAgeSec, long refreshMaxAgeSec) {
response.addHeader(HttpHeaders.SET_COOKIE, build(ACCESS_COOKIE, accessToken, accessMaxAgeSec).toString()); response.addHeader(HttpHeaders.SET_COOKIE, build(ACCESS_COOKIE, accessToken, accessMaxAgeSec).toString());
@@ -208,7 +208,9 @@ public class PhotoService {
if (!amazonS3.doesObjectExist(privateBucket, fileKey)) { if (!amazonS3.doesObjectExist(privateBucket, fileKey)) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "文件尚未上传完成"); 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); return savePhoto(userId, fileKey, photoType, sortOrder);
} }
@@ -68,7 +68,6 @@ class AuthServiceRefreshTtlTest {
when(userMapper.findByWechatOpenid("o1")).thenReturn(Optional.of(user)); when(userMapper.findByWechatOpenid("o1")).thenReturn(Optional.of(user));
when(jwtTokenService.generateAccessToken("u1", null)).thenReturn("access"); when(jwtTokenService.generateAccessToken("u1", null)).thenReturn("access");
when(jwtTokenService.generateRefreshToken("u1")).thenReturn("refresh"); when(jwtTokenService.generateRefreshToken("u1")).thenReturn("refresh");
when(jwtTokenService.getExpirationMs()).thenReturn(ACCESS_MS);
when(jwtTokenService.getRefreshExpirationMs()).thenReturn(REFRESH_MS); when(jwtTokenService.getRefreshExpirationMs()).thenReturn(REFRESH_MS);
when(auditLogMapper.insert(any())).thenReturn(1); when(auditLogMapper.insert(any())).thenReturn(1);
when(userMapper.updateById(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(redisTemplate.opsForValue().get("auth:refresh_token:u1")).thenReturn("rt");
when(jwtTokenService.generateAccessToken("u1", null)).thenReturn("newAccess"); when(jwtTokenService.generateAccessToken("u1", null)).thenReturn("newAccess");
when(jwtTokenService.generateRefreshToken("u1")).thenReturn("newRefresh"); when(jwtTokenService.generateRefreshToken("u1")).thenReturn("newRefresh");
when(jwtTokenService.getExpirationMs()).thenReturn(ACCESS_MS);
when(jwtTokenService.getRefreshExpirationMs()).thenReturn(REFRESH_MS); when(jwtTokenService.getRefreshExpirationMs()).thenReturn(REFRESH_MS);
authService.refreshToken("u1", "rt"); authService.refreshToken("u1", "rt");
@@ -154,7 +154,6 @@ class JwtAuthFilterTest {
when(jwtTokenService.getUserIdFromToken(headerToken)).thenReturn("user-1"); when(jwtTokenService.getUserIdFromToken(headerToken)).thenReturn("user-1");
when(jwtTokenService.parseToken(headerToken)).thenReturn(claims); when(jwtTokenService.parseToken(headerToken)).thenReturn(claims);
when(claims.get("type", String.class)).thenReturn("access"); 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"); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/content/operator/articles");
request.addHeader("Authorization", "Bearer " + headerToken); request.addHeader("Authorization", "Bearer " + headerToken);