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:
+14
-16
@@ -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();
|
||||||
|
|||||||
+14
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
-2
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user