From 11e6049ae2b8b839f461067623fecc66d4a12011 Mon Sep 17 00:00:00 2001 From: waner Date: Wed, 6 May 2026 16:52:41 +0800 Subject: [PATCH] =?UTF-8?q?fix:long=E8=BD=ACstring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../JacksonLongSerializationConfig.java | 18 +++++++++++ .../exception/GlobalExceptionHandler.java | 5 ++++ .../exception/ReplayProtectionException.java | 22 +++++++++++++- .../impl/ReplayProtectionServiceImpl.java | 12 ++++---- .../InternalApiReplayInterceptor.java | 12 ++++++-- .../openapi/OpenApiSignAuthInterceptor.java | 12 ++++++-- .../JacksonLongSerializationConfigTest.java | 28 +++++++++++++++++ .../service/ReplayProtectionServiceTest.java | 3 +- .../InternalApiReplayInterceptorTest.java | 26 ++++++++++++++++ .../OpenApiSignAuthInterceptorTest.java | 30 +++++++++++++++++++ 10 files changed, 156 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/cisd/tms/common/config/JacksonLongSerializationConfig.java create mode 100644 src/test/java/com/cisd/tms/common/config/JacksonLongSerializationConfigTest.java diff --git a/src/main/java/com/cisd/tms/common/config/JacksonLongSerializationConfig.java b/src/main/java/com/cisd/tms/common/config/JacksonLongSerializationConfig.java new file mode 100644 index 0000000..138ec4e --- /dev/null +++ b/src/main/java/com/cisd/tms/common/config/JacksonLongSerializationConfig.java @@ -0,0 +1,18 @@ +package com.cisd.tms.common.config; + +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JacksonLongSerializationConfig { + + @Bean + public Jackson2ObjectMapperBuilderCustomizer longToStringJacksonCustomizer() { + return builder -> { + builder.serializerByType(Long.class, ToStringSerializer.instance); + builder.serializerByType(Long.TYPE, ToStringSerializer.instance); + }; + } +} diff --git a/src/main/java/com/cisd/tms/common/exception/GlobalExceptionHandler.java b/src/main/java/com/cisd/tms/common/exception/GlobalExceptionHandler.java index 1941f42..212815d 100644 --- a/src/main/java/com/cisd/tms/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/cisd/tms/common/exception/GlobalExceptionHandler.java @@ -74,6 +74,11 @@ public class GlobalExceptionHandler { @ExceptionHandler(ReplayProtectionException.class) public ResponseEntity> handleReplayProtectionException(ReplayProtectionException ex, HttpServletRequest request) { + if (ex.isClientError()) { + return response(HttpStatus.BAD_REQUEST, + ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), ex.getMessage()) + .withPath(request.getRequestURI())); + } log.warn("Replay protection 执行失败", ex); return response(HttpStatus.SERVICE_UNAVAILABLE, ApiResponse.fail(ErrorCode.SERVICE_UNAVAILABLE.getCode(), "replay protection unavailable") diff --git a/src/main/java/com/cisd/tms/modules/security/replay/exception/ReplayProtectionException.java b/src/main/java/com/cisd/tms/modules/security/replay/exception/ReplayProtectionException.java index 1b38c90..1e7aa26 100644 --- a/src/main/java/com/cisd/tms/modules/security/replay/exception/ReplayProtectionException.java +++ b/src/main/java/com/cisd/tms/modules/security/replay/exception/ReplayProtectionException.java @@ -2,11 +2,31 @@ package com.cisd.tms.modules.security.replay.exception; public class ReplayProtectionException extends RuntimeException { + private final boolean clientError; + public ReplayProtectionException(String message) { - super(message); + this(message, false); } public ReplayProtectionException(String message, Throwable cause) { + this(message, cause, false); + } + + private ReplayProtectionException(String message, boolean clientError) { + super(message); + this.clientError = clientError; + } + + private ReplayProtectionException(String message, Throwable cause, boolean clientError) { super(message, cause); + this.clientError = clientError; + } + + public static ReplayProtectionException badRequest(String message) { + return new ReplayProtectionException(message, true); + } + + public boolean isClientError() { + return clientError; } } diff --git a/src/main/java/com/cisd/tms/modules/security/replay/service/impl/ReplayProtectionServiceImpl.java b/src/main/java/com/cisd/tms/modules/security/replay/service/impl/ReplayProtectionServiceImpl.java index 8d33406..4e566da 100644 --- a/src/main/java/com/cisd/tms/modules/security/replay/service/impl/ReplayProtectionServiceImpl.java +++ b/src/main/java/com/cisd/tms/modules/security/replay/service/impl/ReplayProtectionServiceImpl.java @@ -103,25 +103,25 @@ public class ReplayProtectionServiceImpl implements ReplayProtectionService { private void validateRequest(ReplayCheckRequest request) { if (request == null) { - throw new ReplayProtectionException("replay 请求不能为空"); + throw ReplayProtectionException.badRequest("replay 请求不能为空"); } if (request.getScope() == null) { - throw new ReplayProtectionException("replay scope不能为空"); + throw ReplayProtectionException.badRequest("replay scope不能为空"); } if (isBlank(request.getPrincipalId())) { - throw new ReplayProtectionException("principal id不能为空"); + throw ReplayProtectionException.badRequest("principal id不能为空"); } if (isBlank(request.getNonce())) { - throw new ReplayProtectionException("nonce不能为空"); + throw ReplayProtectionException.badRequest("nonce不能为空"); } if (request.getRequestTimestamp() <= 0L) { - throw new ReplayProtectionException("request timestamp不能为空"); + throw ReplayProtectionException.badRequest("request timestamp不能为空"); } } private void ensureTimestampWithinWindow(long requestTimestamp, long nowSeconds, long allowedSkewSeconds) { if (Math.abs(nowSeconds - requestTimestamp) > allowedSkewSeconds) { - throw new ReplayProtectionException("请求时间戳超出允许窗口"); + throw ReplayProtectionException.badRequest("请求时间戳超出允许窗口"); } } diff --git a/src/main/java/com/cisd/tms/security/internal/InternalApiReplayInterceptor.java b/src/main/java/com/cisd/tms/security/internal/InternalApiReplayInterceptor.java index fd8f716..a162615 100644 --- a/src/main/java/com/cisd/tms/security/internal/InternalApiReplayInterceptor.java +++ b/src/main/java/com/cisd/tms/security/internal/InternalApiReplayInterceptor.java @@ -78,8 +78,7 @@ public class InternalApiReplayInterceptor implements HandlerInterceptor { return false; } } catch (ReplayProtectionException ex) { - writeJson(response, HttpServletResponse.SC_SERVICE_UNAVAILABLE, ErrorCode.SERVICE_UNAVAILABLE.getCode(), - "replay protection unavailable"); + writeReplayProtectionError(response, ex); return false; } @@ -147,4 +146,13 @@ public class InternalApiReplayInterceptor implements HandlerInterceptor { private void writeJson(HttpServletResponse response, int status, int code, String message) throws IOException { HttpResponseUtil.writeJson(response, status, ApiResponse.fail(code, message), objectMapper); } + + private void writeReplayProtectionError(HttpServletResponse response, ReplayProtectionException ex) throws IOException { + if (ex.isClientError()) { + writeJson(response, HttpServletResponse.SC_BAD_REQUEST, ErrorCode.BAD_REQUEST.getCode(), ex.getMessage()); + return; + } + writeJson(response, HttpServletResponse.SC_SERVICE_UNAVAILABLE, ErrorCode.SERVICE_UNAVAILABLE.getCode(), + "replay protection unavailable"); + } } diff --git a/src/main/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptor.java b/src/main/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptor.java index a0e6f22..8d65c37 100644 --- a/src/main/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptor.java +++ b/src/main/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptor.java @@ -97,8 +97,7 @@ public class OpenApiSignAuthInterceptor implements HandlerInterceptor { return false; } } catch (ReplayProtectionException ex) { - writeJson(response, HttpServletResponse.SC_SERVICE_UNAVAILABLE, - ErrorCode.SERVICE_UNAVAILABLE.getCode(), "replay protection unavailable"); + writeReplayProtectionError(response, ex); return false; } } @@ -153,4 +152,13 @@ public class OpenApiSignAuthInterceptor implements HandlerInterceptor { objectMapper ); } + + private void writeReplayProtectionError(HttpServletResponse response, ReplayProtectionException ex) throws IOException { + if (ex.isClientError()) { + writeJson(response, HttpServletResponse.SC_BAD_REQUEST, ErrorCode.BAD_REQUEST.getCode(), ex.getMessage()); + return; + } + writeJson(response, HttpServletResponse.SC_SERVICE_UNAVAILABLE, + ErrorCode.SERVICE_UNAVAILABLE.getCode(), "replay protection unavailable"); + } } diff --git a/src/test/java/com/cisd/tms/common/config/JacksonLongSerializationConfigTest.java b/src/test/java/com/cisd/tms/common/config/JacksonLongSerializationConfigTest.java new file mode 100644 index 0000000..4a133fa --- /dev/null +++ b/src/test/java/com/cisd/tms/common/config/JacksonLongSerializationConfigTest.java @@ -0,0 +1,28 @@ +package com.cisd.tms.common.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class JacksonLongSerializationConfigTest { + + @Test + void shouldSerializeLongValuesAsJsonStrings() throws Exception { + JacksonLongSerializationConfig config = new JacksonLongSerializationConfig(); + Jackson2ObjectMapperBuilder builder = Jackson2ObjectMapperBuilder.json(); + config.longToStringJacksonCustomizer().customize(builder); + ObjectMapper objectMapper = builder.build(); + + String json = objectMapper.writeValueAsString(new LongIdPayload( + 2049756829261557762L, + 2049756829261557763L + )); + + assertEquals("{\"id\":\"2049756829261557762\",\"primitiveId\":\"2049756829261557763\"}", json); + } + + private record LongIdPayload(Long id, long primitiveId) { + } +} diff --git a/src/test/java/com/cisd/tms/modules/security/replay/service/ReplayProtectionServiceTest.java b/src/test/java/com/cisd/tms/modules/security/replay/service/ReplayProtectionServiceTest.java index a7fc19e..ff1aeaa 100644 --- a/src/test/java/com/cisd/tms/modules/security/replay/service/ReplayProtectionServiceTest.java +++ b/src/test/java/com/cisd/tms/modules/security/replay/service/ReplayProtectionServiceTest.java @@ -180,7 +180,8 @@ class ReplayProtectionServiceTest { ReplayCheckRequest request = request(ReplayScope.OPENAPI, "demo-app", "nonce-old", EXPIRED_REQUEST_TIMESTAMP, "POST", "/openapi/v1/demo", "abc123"); - Assertions.assertThrows(ReplayProtectionException.class, () -> service.check(request)); + ReplayProtectionException ex = Assertions.assertThrows(ReplayProtectionException.class, () -> service.check(request)); + Assertions.assertTrue(ex.isClientError()); Mockito.verifyNoInteractions(replayNonceRepository); Mockito.verifyNoInteractions(securityEventRepository); } diff --git a/src/test/java/com/cisd/tms/security/internal/InternalApiReplayInterceptorTest.java b/src/test/java/com/cisd/tms/security/internal/InternalApiReplayInterceptorTest.java index c99dcb4..abab6dd 100644 --- a/src/test/java/com/cisd/tms/security/internal/InternalApiReplayInterceptorTest.java +++ b/src/test/java/com/cisd/tms/security/internal/InternalApiReplayInterceptorTest.java @@ -157,6 +157,32 @@ class InternalApiReplayInterceptorTest { Assertions.assertTrue(response.getContentAsString().contains("\"code\":" + ErrorCode.SERVICE_UNAVAILABLE.getCode())); } + @Test + void shouldReturnBadRequestWhenReplayRequestIsInvalid() throws Exception { + ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class); + Mockito.when(replayProtectionService.check(Mockito.any(ReplayCheckRequest.class))) + .thenThrow(ReplayProtectionException.badRequest("请求时间戳超出允许窗口")); + InternalApiReplayInterceptor interceptor = new InternalApiReplayInterceptor( + replayProtectionService, + new TmsSecurityProperties(), + new ObjectMapper(), + FIXED_CLOCK + ); + MockHttpServletRequest sourceRequest = new MockHttpServletRequest("POST", "/api/v1/init/tasks/task-001/execute"); + sourceRequest.addHeader("X-Request-Timestamp", Long.toString(FIXED_REQUEST_TIMESTAMP - 3600L)); + sourceRequest.addHeader("X-Request-Nonce", "nonce-001"); + CachedBodyHttpServletRequest request = new CachedBodyHttpServletRequest(sourceRequest); + request.setAttribute(InternalApiAuthInterceptor.ATTR_SESSION_TOKEN, "session-001"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean allowed = interceptor.preHandle(request, response, protectedHandler("execute")); + + Assertions.assertFalse(allowed); + Assertions.assertEquals(400, response.getStatus()); + Assertions.assertTrue(response.getContentAsString().contains("请求时间戳超出允许窗口")); + Assertions.assertTrue(response.getContentAsString().contains("\"code\":" + ErrorCode.BAD_REQUEST.getCode())); + } + @Test void shouldSkipReplayProtectionWhenDisabled() throws Exception { ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class); diff --git a/src/test/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptorTest.java b/src/test/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptorTest.java index 9afe711..4fb7883 100644 --- a/src/test/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptorTest.java +++ b/src/test/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptorTest.java @@ -151,6 +151,36 @@ class OpenApiSignAuthInterceptorTest { Assertions.assertTrue(response.getContentAsString().contains("\"code\":" + ErrorCode.SERVICE_UNAVAILABLE.getCode())); } + @Test + void shouldReturnBadRequestWhenReplayRequestIsInvalid() throws Exception { + TmsSecurityProperties securityProperties = securityProperties("demo-app", "demo-secret", 300L); + ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class); + Mockito.when(replayProtectionService.check(Mockito.any(ReplayCheckRequest.class))) + .thenThrow(ReplayProtectionException.badRequest("请求时间戳超出允许窗口")); + OpenApiSignAuthInterceptor interceptor = new OpenApiSignAuthInterceptor( + securityProperties, + replayProtectionService, + new ObjectMapper(), + FIXED_CLOCK + ); + MockHttpServletRequest request = signedRequest( + "demo-app", + "demo-secret", + FIXED_NOW_SECONDS, + "nonce-001", + "POST", + "/openapi/v1/demo" + ); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean allowed = interceptor.preHandle(request, response, new Object()); + + Assertions.assertFalse(allowed); + Assertions.assertEquals(400, response.getStatus()); + Assertions.assertTrue(response.getContentAsString().contains("请求时间戳超出允许窗口")); + Assertions.assertTrue(response.getContentAsString().contains("\"code\":" + ErrorCode.BAD_REQUEST.getCode())); + } + @Test void shouldSkipTimestampWindowAndNonceClaimWhenReplayProtectionIsDisabled() throws Exception { TmsSecurityProperties securityProperties = securityProperties("demo-app", "demo-secret", 300L);