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.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<Map<String, Object>> login(@RequestBody Map<String, String> body,
@@ -34,10 +34,11 @@ public class OperatorAuthController {
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "用户名和密码不能为空");
}
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,
(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<String, Object> 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<Void> logout(HttpServletRequest request, HttpServletResponse response) {
public ApiResponse<Void> 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();
@@ -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());
@@ -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);
}
@@ -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");
@@ -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);