diff --git a/docs/plans/2026-04-09-replay-protection-plan.md b/docs/plans/2026-04-09-replay-protection-plan.md new file mode 100644 index 0000000..b861cb9 --- /dev/null +++ b/docs/plans/2026-04-09-replay-protection-plan.md @@ -0,0 +1,461 @@ +# Replay Protection Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add TiDB-backed replay protection for `/openapi/**` and selected sensitive internal `/api/**` write/execute endpoints. + +**Architecture:** Replace the current in-memory OpenAPI nonce guard with a database-backed replay nonce service, then add an internal replay interceptor that only protects annotated high-risk handlers. Persist nonce claims in TiDB with a unique key, record replay-related security events, and reject protected requests when replay validation cannot be completed. + +**Tech Stack:** Spring Boot 3, MyBatis-Plus, Flyway SQL migration, JUnit 5, Mockito, MockMvc. + +--- + +### Task 1: Add Replay Nonce and Security Event Schema + +**Files:** +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/resources/db/migration/V5__add_replay_protection_tables.sql` +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/resources/db/migration/V1__tms_schema_full.sql` +- Test: `/Users/waner/Work/CISD/文档/tms-framework/src/test/java/com/cisd/tms/TmsApplicationTests.java` + +**Step 1: Write the failing schema assertions** + +Add assertions that `V1__tms_schema_full.sql` or the new migration defines: +- `tms_replay_nonce` +- `tms_security_event` +- unique key on `(scope, principal_id, nonce)` +- index on `expires_at` + +**Step 2: Run test to verify it fails** + +Run: `mvn -q -Dtest=TmsApplicationTests test` + +Expected: FAIL because replay protection tables do not exist in schema artifacts yet. + +**Step 3: Write minimal implementation** + +Create `V5__add_replay_protection_tables.sql` with: +- `tms_replay_nonce` +- `tms_security_event` + +Update `V1__tms_schema_full.sql` so fresh-init environments also include the same final-state tables. + +**Step 4: Run test to verify it passes** + +Run: `mvn -q -Dtest=TmsApplicationTests test` + +Expected: PASS for schema assertions. + +**Step 5: Commit** + +```bash +git add src/main/resources/db/migration/V1__tms_schema_full.sql src/main/resources/db/migration/V5__add_replay_protection_tables.sql src/test/java/com/cisd/tms/TmsApplicationTests.java +git commit -m "feat: add replay protection schema" +``` + +### Task 2: Add Replay Persistence Models and Repositories + +**Files:** +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/entity/ReplayNonceEntity.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/entity/SafetyEventEntity.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/mapper/ReplayNonceMapper.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/mapper/SecurityEventMapper.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/repository/ReplayNonceRepository.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/repository/SecurityEventRepository.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/repository/impl/ReplayNonceRepositoryImpl.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/repository/impl/SecurityEventRepositoryImpl.java` +- Test: `/Users/waner/Work/CISD/文档/tms-framework/src/test/java/com/cisd/tms/modules/security/replay/ReplayNonceRepositoryTest.java` + +**Step 1: Write the failing repository test** + +Add tests for: +- saving a nonce record +- detecting duplicate `(scope, principalId, nonce)` +- saving a security event + +**Step 2: Run test to verify it fails** + +Run: `mvn -q -Dtest=ReplayNonceRepositoryTest test` + +Expected: FAIL because replay persistence classes do not exist. + +**Step 3: Write minimal implementation** + +Create entities, mappers, repositories, and repository impls consistent with the existing `modules/*/repository/impl` pattern. + +Represent: +- `scope` +- `principalId` +- `nonce` +- `requestTimestamp` +- `requestMethod` +- `requestPath` +- `bodyHash` +- `remoteIp` +- `userAgent` +- `expiresAt` + +**Step 4: Run test to verify it passes** + +Run: `mvn -q -Dtest=ReplayNonceRepositoryTest test` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/main/java/com/cisd/tms/modules/security/replay src/test/java/com/cisd/tms/modules/security/replay/ReplayNonceRepositoryTest.java +git commit -m "feat: add replay persistence layer" +``` + +### Task 3: Add Replay Protection Service and Domain Types + +**Files:** +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/service/ReplayNonceService.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/service/impl/ReplayNonceServiceImpl.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/dto/ReplayCheckRequest.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/dto/ReplayCheckResult.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/enums/ReplayScope.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/enums/SafetyEventType.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/exception/ReplayAttackException.java` +- Test: `/Users/waner/Work/CISD/文档/tms-framework/src/test/java/com/cisd/tms/modules/security/replay/ReplayNonceServiceTest.java` + +**Step 1: Write the failing service test** + +Add tests for: +- first nonce claim succeeds +- duplicate nonce claim returns replay result +- duplicate nonce with different path/body hash marks mismatch +- expired timestamp is rejected before claim +- repository failure causes protected request to fail closed + +**Step 2: Run test to verify it fails** + +Run: `mvn -q -Dtest=ReplayNonceServiceTest test` + +Expected: FAIL because replay service does not exist. + +**Step 3: Write minimal implementation** + +Implement a service that: +- validates timestamp skew +- attempts nonce claim via repository +- on duplicate, compares request fingerprint +- writes security events +- throws a dedicated replay exception for interceptor handling + +Keep timestamp comparison logic in one place so both OpenAPI and internal interceptors share the same rule. + +**Step 4: Run test to verify it passes** + +Run: `mvn -q -Dtest=ReplayNonceServiceTest test` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/main/java/com/cisd/tms/modules/security/replay src/test/java/com/cisd/tms/modules/security/replay/ReplayNonceServiceTest.java +git commit -m "feat: add replay protection service" +``` + +### Task 4: Replace OpenAPI In-Memory Replay Guard + +**Files:** +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptor.java` +- Delete: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/security/openapi/OpenApiReplayGuard.java` +- Test: `/Users/waner/Work/CISD/文档/tms-framework/src/test/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptorTest.java` + +**Step 1: Write the failing interceptor tests** + +Add tests for: +- valid request passes and claims nonce +- duplicate nonce is rejected +- timestamp expired is rejected +- invalid signature is rejected before nonce claim +- duplicate nonce with same appId but different body hash is treated as replay mismatch + +**Step 2: Run test to verify it fails** + +Run: `mvn -q -Dtest=OpenApiSignAuthInterceptorTest test` + +Expected: FAIL because interceptor still uses `OpenApiReplayGuard`. + +**Step 3: Write minimal implementation** + +Refactor `OpenApiSignAuthInterceptor` to: +- compute `bodyHash` +- validate timestamp +- validate signature +- call `ReplayNonceService` + +Remove `OpenApiReplayGuard`. + +Do not change the existing request header names in this task. + +**Step 4: Run test to verify it passes** + +Run: `mvn -q -Dtest=OpenApiSignAuthInterceptorTest test` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/main/java/com/cisd/tms/security/openapi src/test/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptorTest.java +git commit -m "feat: persist openapi replay protection" +``` + +### Task 5: Add Internal Replay Annotation and Interceptor + +**Files:** +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/annotation/ReplayProtected.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/security/internal/InternalReplayProtectionInterceptor.java` +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/common/config/WebMvcConfig.java` +- Test: `/Users/waner/Work/CISD/文档/tms-framework/src/test/java/com/cisd/tms/security/internal/InternalReplayProtectionInterceptorTest.java` + +**Step 1: Write the failing interceptor tests** + +Add tests for: +- unannotated internal handler is ignored +- annotated handler requires `X-Request-Timestamp` +- annotated handler requires `X-Request-Nonce` +- annotated handler claims nonce with current session token +- replayed annotated request is rejected + +**Step 2: Run test to verify it fails** + +Run: `mvn -q -Dtest=InternalReplayProtectionInterceptorTest test` + +Expected: FAIL because annotation and interceptor do not exist. + +**Step 3: Write minimal implementation** + +Create: +- `@ReplayProtected` +- internal interceptor that runs after session auth +- registration in `WebMvcConfig` + +The interceptor should use: +- `InternalApiAuthInterceptor.ATTR_SESSION_TOKEN` +- `X-Request-Timestamp` +- `X-Request-Nonce` + +Reject protected requests when session token is missing or replay validation fails. + +**Step 4: Run test to verify it passes** + +Run: `mvn -q -Dtest=InternalReplayProtectionInterceptorTest test` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/main/java/com/cisd/tms/modules/security/replay/annotation/ReplayProtected.java src/main/java/com/cisd/tms/security/internal/InternalReplayProtectionInterceptor.java src/main/java/com/cisd/tms/common/config/WebMvcConfig.java src/test/java/com/cisd/tms/security/internal/InternalReplayProtectionInterceptorTest.java +git commit -m "feat: add internal replay interceptor" +``` + +### Task 6: Annotate First-Batch Sensitive Internal Endpoints + +**Files:** +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/init/controller/InitController.java` +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/upgrade/controller/UpgradeController.java` +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/auth/controller/AuthController.java` +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/auth/controller/AuthAdminController.java` +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/device/controller/TimeConfigController.java` +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/device/controller/NetworkConfigController.java` +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/device/controller/IpWhitelistController.java` +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/device/controller/DeviceController.java` +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/mk/controller/LmkController.java` +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/device/controller/CryptoCardController.java` +- Test: `/Users/waner/Work/CISD/文档/tms-framework/src/test/java/com/cisd/tms/modules/upgrade/controller/UpgradeControllerTest.java` +- Test: `/Users/waner/Work/CISD/文档/tms-framework/src/test/java/com/cisd/tms/modules/auth/controller/AuthControllerTest.java` + +**Step 1: Write the failing controller coverage tests** + +Add tests for: +- protected endpoints reject missing replay headers +- login/captcha endpoints remain unprotected +- a representative protected endpoint accepts replay headers when replay service allows it + +**Step 2: Run test to verify it fails** + +Run: `mvn -q -Dtest=UpgradeControllerTest,AuthControllerTest test` + +Expected: FAIL because first-batch endpoints are not annotated. + +**Step 3: Write minimal implementation** + +Annotate only: +- init/create/execute/reset-create/reset-execute +- upgrade/create/execute/rollback +- logout/change-password +- all auth admin POST methods +- time/network/whitelist/device restart writes +- all `LmkController` write methods +- selected `CryptoCardController` write-state methods + +Do not annotate: +- login +- captcha +- random issuance +- GET endpoints +- pure preview endpoints + +**Step 4: Run test to verify it passes** + +Run: `mvn -q -Dtest=UpgradeControllerTest,AuthControllerTest test` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/main/java/com/cisd/tms/modules/init/controller/InitController.java src/main/java/com/cisd/tms/modules/upgrade/controller/UpgradeController.java src/main/java/com/cisd/tms/modules/auth/controller/AuthController.java src/main/java/com/cisd/tms/modules/auth/controller/AuthAdminController.java src/main/java/com/cisd/tms/modules/device/controller/TimeConfigController.java src/main/java/com/cisd/tms/modules/device/controller/NetworkConfigController.java src/main/java/com/cisd/tms/modules/device/controller/IpWhitelistController.java src/main/java/com/cisd/tms/modules/device/controller/DeviceController.java src/main/java/com/cisd/tms/modules/mk/controller/LmkController.java src/main/java/com/cisd/tms/modules/device/controller/CryptoCardController.java src/test/java/com/cisd/tms/modules/upgrade/controller/UpgradeControllerTest.java src/test/java/com/cisd/tms/modules/auth/controller/AuthControllerTest.java +git commit -m "feat: protect sensitive internal endpoints from replay" +``` + +### Task 7: Map Replay Exceptions and Add Structured Responses + +**Files:** +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/common/exception/GlobalExceptionHandler.java` +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/common/enums/ErrorCode.java` +- Test: `/Users/waner/Work/CISD/文档/tms-framework/src/test/java/com/cisd/tms/common/exception/GlobalExceptionHandlerTest.java` + +**Step 1: Write the failing exception handler test** + +Add tests for: +- replay attack exception returns expected code/message +- timestamp expired exception returns expected code/message + +**Step 2: Run test to verify it fails** + +Run: `mvn -q -Dtest=GlobalExceptionHandlerTest test` + +Expected: FAIL because replay exceptions are not mapped yet. + +**Step 3: Write minimal implementation** + +Add replay-related error codes and map: +- replay detected +- request timestamp expired +- replay guard unavailable + +Keep response shape consistent with existing `ApiResponse.fail(...).withPath(...)`. + +**Step 4: Run test to verify it passes** + +Run: `mvn -q -Dtest=GlobalExceptionHandlerTest test` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/main/java/com/cisd/tms/common/exception/GlobalExceptionHandler.java src/main/java/com/cisd/tms/common/enums/ErrorCode.java src/test/java/com/cisd/tms/common/exception/GlobalExceptionHandlerTest.java +git commit -m "feat: expose replay protection errors" +``` + +### Task 8: Add Replay Nonce Cleanup Job + +**Files:** +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/config/ReplayProtectionProperties.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/job/ReplayNonceCleanupJob.java` +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/resources/application.yml` +- Test: `/Users/waner/Work/CISD/文档/tms-framework/src/test/java/com/cisd/tms/modules/security/replay/ReplayNonceCleanupJobTest.java` + +**Step 1: Write the failing cleanup job test** + +Add tests for: +- deleting expired rows in small batches +- leaving non-expired rows untouched + +**Step 2: Run test to verify it fails** + +Run: `mvn -q -Dtest=ReplayNonceCleanupJobTest test` + +Expected: FAIL because cleanup job does not exist. + +**Step 3: Write minimal implementation** + +Add: +- replay protection properties +- scheduled cleanup job +- config defaults for ttl and delete batch size + +Use modest defaults such as: +- `ttl-seconds: 600` +- `cleanup-batch-size: 1000` + +**Step 4: Run test to verify it passes** + +Run: `mvn -q -Dtest=ReplayNonceCleanupJobTest test` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/main/java/com/cisd/tms/modules/security/replay/config src/main/java/com/cisd/tms/modules/security/replay/job src/main/resources/application.yml src/test/java/com/cisd/tms/modules/security/replay/ReplayNonceCleanupJobTest.java +git commit -m "feat: add replay nonce cleanup job" +``` + +### Task 9: Run Focused Verification + +**Files:** +- Test: `/Users/waner/Work/CISD/文档/tms-framework/src/test/java/com/cisd/tms/modules/security/replay/ReplayNonceRepositoryTest.java` +- Test: `/Users/waner/Work/CISD/文档/tms-framework/src/test/java/com/cisd/tms/modules/security/replay/ReplayNonceServiceTest.java` +- Test: `/Users/waner/Work/CISD/文档/tms-framework/src/test/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptorTest.java` +- Test: `/Users/waner/Work/CISD/文档/tms-framework/src/test/java/com/cisd/tms/security/internal/InternalReplayProtectionInterceptorTest.java` +- Test: `/Users/waner/Work/CISD/文档/tms-framework/src/test/java/com/cisd/tms/common/exception/GlobalExceptionHandlerTest.java` + +**Step 1: Run focused replay tests** + +Run: `mvn -q -Dtest=ReplayNonceRepositoryTest,ReplayNonceServiceTest,OpenApiSignAuthInterceptorTest,InternalReplayProtectionInterceptorTest,GlobalExceptionHandlerTest test` + +Expected: PASS + +**Step 2: Run representative controller tests** + +Run: `mvn -q -Dtest=UpgradeControllerTest,AuthControllerTest test` + +Expected: PASS + +**Step 3: Run compile check** + +Run: `mvn -q -DskipTests compile` + +Expected: PASS + +**Step 4: Commit** + +```bash +git add -A +git commit -m "test: verify replay protection implementation" +``` + +### Task 10: Update Docs and Integration Guidance + +**Files:** +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/README.md` +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/docs/openapi/auth-mk-frontend-integration.md` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/docs/plans/2026-04-09-replay-protection-design.md` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/docs/plans/2026-04-09-replay-protection-plan.md` + +**Step 1: Document OpenAPI replay headers** + +Update README and integration docs to describe: +- OpenAPI replay protection flow +- internal sensitive API replay headers +- expected replay failure behavior + +**Step 2: Verify docs are consistent with actual endpoint scope** + +Review the protected endpoint list against controller annotations. + +**Step 3: Commit** + +```bash +git add README.md docs/openapi/auth-mk-frontend-integration.md docs/plans/2026-04-09-replay-protection-design.md docs/plans/2026-04-09-replay-protection-plan.md +git commit -m "docs: add replay protection guidance" +``` diff --git a/pom.xml b/pom.xml index 43d1d20..d891a32 100644 --- a/pom.xml +++ b/pom.xml @@ -95,6 +95,13 @@ 1.83 + + + + com.google.code.gson + gson + 2.13.2 + diff --git a/src/main/java/com/cisd/tms/TmsApplication.java b/src/main/java/com/cisd/tms/TmsApplication.java index 239bb5b..9a5a26f 100644 --- a/src/main/java/com/cisd/tms/TmsApplication.java +++ b/src/main/java/com/cisd/tms/TmsApplication.java @@ -3,9 +3,11 @@ package com.cisd.tms; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @ConfigurationPropertiesScan +@EnableScheduling public class TmsApplication { public static void main(String[] args) { diff --git a/src/main/java/com/cisd/tms/common/config/WebMvcConfig.java b/src/main/java/com/cisd/tms/common/config/WebMvcConfig.java index 566a4f0..5e127a7 100644 --- a/src/main/java/com/cisd/tms/common/config/WebMvcConfig.java +++ b/src/main/java/com/cisd/tms/common/config/WebMvcConfig.java @@ -2,6 +2,8 @@ package com.cisd.tms.common.config; import com.cisd.tms.modules.auth.security.InternalAuthorizationInterceptor; import com.cisd.tms.security.internal.InternalApiAuthInterceptor; +import com.cisd.tms.security.internal.InternalApiReplayInterceptor; +import com.cisd.tms.security.internal.CachedBodyHttpServletRequest; import com.cisd.tms.security.openapi.OpenApiSignAuthInterceptor; import com.cisd.tms.common.util.TraceIdUtil; import jakarta.servlet.FilterChain; @@ -21,15 +23,18 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; public class WebMvcConfig implements WebMvcConfigurer { private final InternalApiAuthInterceptor internalApiAuthInterceptor; + private final InternalApiReplayInterceptor internalApiReplayInterceptor; private final InternalAuthorizationInterceptor internalAuthorizationInterceptor; private final OpenApiSignAuthInterceptor openApiSignAuthInterceptor; public WebMvcConfig( InternalApiAuthInterceptor internalApiAuthInterceptor, + InternalApiReplayInterceptor internalApiReplayInterceptor, InternalAuthorizationInterceptor internalAuthorizationInterceptor, OpenApiSignAuthInterceptor openApiSignAuthInterceptor ) { this.internalApiAuthInterceptor = internalApiAuthInterceptor; + this.internalApiReplayInterceptor = internalApiReplayInterceptor; this.internalAuthorizationInterceptor = internalAuthorizationInterceptor; this.openApiSignAuthInterceptor = openApiSignAuthInterceptor; } @@ -55,6 +60,33 @@ public class WebMvcConfig implements WebMvcConfigurer { }; } + @Bean + public OncePerRequestFilter cachedBodyRequestFilter() { + return new OncePerRequestFilter() { + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + if (shouldWrap(request)) { + filterChain.doFilter(new CachedBodyHttpServletRequest(request), response); + return; + } + filterChain.doFilter(request, response); + } + + private boolean shouldWrap(HttpServletRequest request) { + String requestUri = request.getRequestURI(); + if (requestUri == null || !requestUri.startsWith("/api/")) { + return false; + } + if (request instanceof CachedBodyHttpServletRequest) { + return false; + } + String contentType = request.getContentType(); + return contentType == null || !contentType.toLowerCase().startsWith("multipart/"); + } + }; + } + @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(internalApiAuthInterceptor) @@ -67,6 +99,9 @@ public class WebMvcConfig implements WebMvcConfigurer { "/api/v1/auth/captcha" ); + registry.addInterceptor(internalApiReplayInterceptor) + .addPathPatterns("/api/**"); + registry.addInterceptor(internalAuthorizationInterceptor) .addPathPatterns("/api/**"); 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 162310f..85c1de1 100644 --- a/src/main/java/com/cisd/tms/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/cisd/tms/common/exception/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ package com.cisd.tms.common.exception; import com.cisd.tms.common.api.ApiResponse; import com.cisd.tms.common.enums.ErrorCode; import com.cisd.tms.integration.crypto.pcie.PcieCryptoException; +import com.cisd.tms.modules.security.replay.exception.ReplayProtectionException; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.ConstraintViolationException; import org.slf4j.Logger; @@ -58,6 +59,13 @@ public class GlobalExceptionHandler { .withPath(request.getRequestURI()); } + @ExceptionHandler(ReplayProtectionException.class) + public ApiResponse handleReplayProtectionException(ReplayProtectionException ex, HttpServletRequest request) { + log.warn("Replay protection failed", ex); + return ApiResponse.fail(ErrorCode.INTERNAL_ERROR.getCode(), "replay protection unavailable") + .withPath(request.getRequestURI()); + } + @ExceptionHandler(Exception.class) public ApiResponse handleException(Exception ex, HttpServletRequest request) { log.error("Unhandled exception", ex); diff --git a/src/main/java/com/cisd/tms/modules/auth/controller/AuthAdminController.java b/src/main/java/com/cisd/tms/modules/auth/controller/AuthAdminController.java index 4ea2145..bcbabb9 100644 --- a/src/main/java/com/cisd/tms/modules/auth/controller/AuthAdminController.java +++ b/src/main/java/com/cisd/tms/modules/auth/controller/AuthAdminController.java @@ -8,6 +8,7 @@ import com.cisd.tms.modules.auth.service.AuthAdminService; import com.cisd.tms.modules.mk.dto.UKeySignDTO; import com.cisd.tms.modules.mk.dto.UKeySignResult; import com.cisd.tms.security.internal.InternalApiAuthInterceptor; +import com.cisd.tms.security.internal.ReplayProtected; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -23,6 +24,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/v1/auth") @RequiredArgsConstructor +@ReplayProtected @Tag(name = "认证管理", description = "角色启用、密码重置与 UKey 绑定接口") public class AuthAdminController { diff --git a/src/main/java/com/cisd/tms/modules/auth/controller/AuthController.java b/src/main/java/com/cisd/tms/modules/auth/controller/AuthController.java index 6a28c3f..95f7c40 100644 --- a/src/main/java/com/cisd/tms/modules/auth/controller/AuthController.java +++ b/src/main/java/com/cisd/tms/modules/auth/controller/AuthController.java @@ -11,6 +11,7 @@ import com.cisd.tms.modules.auth.dto.UkeyLoginRandomResponse; import com.cisd.tms.modules.auth.dto.UkeyLoginRequest; import com.cisd.tms.modules.auth.service.AuthService; import com.cisd.tms.security.internal.InternalApiAuthInterceptor; +import com.cisd.tms.security.internal.ReplayProtected; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -72,6 +73,7 @@ public class AuthController { @PostMapping("/change-password") @Operation(summary = "修改当前角色口令", description = "基于当前会话校验并更新当前角色口令。") + @ReplayProtected public ApiResponse changePassword(@Valid @RequestBody ChangePasswordRequest request, HttpServletRequest httpRequest) { authService.changePassword( (String) httpRequest.getAttribute(InternalApiAuthInterceptor.ATTR_SESSION_TOKEN), diff --git a/src/main/java/com/cisd/tms/modules/device/controller/CryptoCardController.java b/src/main/java/com/cisd/tms/modules/device/controller/CryptoCardController.java index b7dc2a7..fa9b85d 100644 --- a/src/main/java/com/cisd/tms/modules/device/controller/CryptoCardController.java +++ b/src/main/java/com/cisd/tms/modules/device/controller/CryptoCardController.java @@ -36,6 +36,7 @@ import com.cisd.tms.integration.crypto.pcie.model.SymmetricKekCryptoRequest; import com.cisd.tms.integration.crypto.pcie.model.StandardKeyPairResult; import com.cisd.tms.integration.crypto.pcie.service.PcieDeviceStatusFormatter; import com.cisd.tms.integration.crypto.pcie.service.PcieCryptoService; +import com.cisd.tms.security.internal.ReplayProtected; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -61,6 +62,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/v1/device/crypto") @RequiredArgsConstructor +@ReplayProtected @Tag(name = "密码卡调试", description = "密码卡设备调试、密钥操作和文件管理接口") public class CryptoCardController { diff --git a/src/main/java/com/cisd/tms/modules/device/controller/DeviceController.java b/src/main/java/com/cisd/tms/modules/device/controller/DeviceController.java index 3792b9c..9bda776 100644 --- a/src/main/java/com/cisd/tms/modules/device/controller/DeviceController.java +++ b/src/main/java/com/cisd/tms/modules/device/controller/DeviceController.java @@ -9,6 +9,7 @@ import com.cisd.tms.modules.device.service.DeviceProfileService; import com.cisd.tms.modules.device.service.DeviceRuntimeStatusService; import com.cisd.tms.modules.device.service.DeviceService; import com.cisd.tms.security.internal.InternalApiAuthInterceptor; +import com.cisd.tms.security.internal.ReplayProtected; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -55,6 +56,7 @@ public class DeviceController { @PostMapping("/restart") @Operation(summary = "重启设备服务", description = "受理设备重启请求,并记录触发操作的内部用户。") + @ReplayProtected public ApiResponse restart(HttpServletRequest request) { String operator = (String) request.getAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE); return ApiResponse.success(deviceService.restart(operator)); diff --git a/src/main/java/com/cisd/tms/modules/device/controller/IpWhitelistController.java b/src/main/java/com/cisd/tms/modules/device/controller/IpWhitelistController.java index b542410..789da7f 100644 --- a/src/main/java/com/cisd/tms/modules/device/controller/IpWhitelistController.java +++ b/src/main/java/com/cisd/tms/modules/device/controller/IpWhitelistController.java @@ -5,6 +5,7 @@ import com.cisd.tms.common.api.ApiResponse; import com.cisd.tms.modules.device.dto.network.IpWhitelistResponse; import com.cisd.tms.modules.device.service.IpWhitelistService; import com.cisd.tms.modules.device.dto.network.IpWhitelistRequest; +import com.cisd.tms.security.internal.ReplayProtected; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.beans.factory.annotation.Autowired; @@ -21,6 +22,7 @@ public class IpWhitelistController { @PostMapping("/add") @Operation(summary = "添加IP白名单", description = "新增一条IP白名单记录") + @ReplayProtected public ApiResponse addWhitelist(@RequestBody IpWhitelistRequest req) { ipWhitelistService.addWhitelist(req); return ApiResponse.success("添加成功"); @@ -29,6 +31,7 @@ public class IpWhitelistController { @PostMapping("/update") @Operation(summary = "更新IP白名单", description = "根据ID更新IP白名单信息") + @ReplayProtected public ApiResponse updateWhitelist(@RequestBody IpWhitelistRequest req) { ipWhitelistService.updateWhitelist(req); return ApiResponse.success("更新成功"); @@ -36,6 +39,7 @@ public class IpWhitelistController { @PostMapping("/delete/{id}") @Operation(summary = "删除IP白名单", description = "根据ID删除指定IP白名单") + @ReplayProtected public ApiResponse deleteWhitelist(@PathVariable Long id) { ipWhitelistService.deleteWhitelist(id); return ApiResponse.success("删除成功"); @@ -48,4 +52,4 @@ public class IpWhitelistController { IPage page = ipWhitelistService.getWhitelistPage(req); return ApiResponse.success(page); } -} \ No newline at end of file +} diff --git a/src/main/java/com/cisd/tms/modules/device/controller/NetworkConfigController.java b/src/main/java/com/cisd/tms/modules/device/controller/NetworkConfigController.java index 805e0ea..4b36294 100644 --- a/src/main/java/com/cisd/tms/modules/device/controller/NetworkConfigController.java +++ b/src/main/java/com/cisd/tms/modules/device/controller/NetworkConfigController.java @@ -4,6 +4,7 @@ package com.cisd.tms.modules.device.controller; import com.cisd.tms.common.api.ApiResponse; import com.cisd.tms.modules.device.dto.network.*; import com.cisd.tms.modules.device.service.NetworkConfigService; +import com.cisd.tms.security.internal.ReplayProtected; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.web.bind.annotation.*; @@ -36,6 +37,7 @@ public class NetworkConfigController { @Operation(summary = "设置IPv4配置", description = "为指定网络接口配置IPv4地址、掩码、网关等信息") @PostMapping("/ipv4-config/set") + @ReplayProtected public ApiResponse setIpv4Config(@RequestBody Ipv4ConfigRequest req) { String resultMessage = networkConfigService.setIpv4Config(req); return ApiResponse.success(resultMessage); @@ -50,6 +52,7 @@ public class NetworkConfigController { @Operation(summary = "设置IPv6配置", description = "为指定网络接口配置IPv6地址、前缀长度、网关等信息") @PostMapping("/ipv6-config/set") + @ReplayProtected public ApiResponse setIpv6Config(@RequestBody Ipv6ConfigRequest req) { String resultMessage = networkConfigService.setIpv6Config(req); return ApiResponse.success(resultMessage); @@ -63,6 +66,7 @@ public class NetworkConfigController { @Operation(summary = "创建Bond", description = "创建一个新的Bond聚合接口,需指定名称、模式和从属接口") @PostMapping("/bond/create") + @ReplayProtected public ApiResponse createBond(@RequestBody BondCreateRequest req) { String resultMessage = networkConfigService.createBond(req); return ApiResponse.success(resultMessage); @@ -71,6 +75,7 @@ public class NetworkConfigController { @Operation(summary = "删除Bond", description = "根据Bond名称删除指定的聚合接口") @PostMapping("/bond/delete/{bondName}") + @ReplayProtected public ApiResponse deleteBond(@PathVariable("bondName") String bondName){ String resultMessage = networkConfigService.deleteBond(bondName); return ApiResponse.success(resultMessage); @@ -78,6 +83,7 @@ public class NetworkConfigController { @Operation(summary = "添加从属接口到Bond", description = "向指定Bond中添加一个或多个从属网络接口") @PostMapping("/bond-slave/add") + @ReplayProtected public ApiResponse addSlavesTOBond(@RequestBody BondAddSlavesRequest req) { String resultMessage = networkConfigService.addSlavesTOBond(req); return ApiResponse.success(resultMessage); @@ -85,6 +91,7 @@ public class NetworkConfigController { @Operation(summary = "从Bond中移除从属接口", description = "从指定Bond中移除一个或多个从属网络接口") @PostMapping("/bond-slave/remove") + @ReplayProtected public ApiResponse removeSlaveFromBond(@RequestBody BondRemoveSlaveRequest req) { String resultMessage = networkConfigService.removeSlaveFromBond(req); return ApiResponse.success(resultMessage); @@ -112,6 +119,7 @@ public class NetworkConfigController { @Operation(summary = "设置Bond模式", description = "修改指定Bond的绑定模式") @PostMapping("/bond/mode/set") + @ReplayProtected public ApiResponse setBondMode(@RequestBody BondModifyModeRequest req) { String resultMessage = networkConfigService.setBondMode(req); @@ -127,6 +135,7 @@ public class NetworkConfigController { @Operation(summary = "设置默认路由", description = "配置或修改系统的默认网关路由") @PostMapping("/routes/default/set") + @ReplayProtected public ApiResponse setDefaultRoute(@RequestBody SetDefaultRouteRequest req) { String resultMessage = networkConfigService.setDefaultRoute(req); return ApiResponse.success(resultMessage); @@ -134,6 +143,7 @@ public class NetworkConfigController { @Operation(summary = "添加静态路由", description = "新增一条静态路由规则") @PostMapping("/routes/static/add") + @ReplayProtected public ApiResponse addStaticRoute(@RequestBody AddStaticRouteRequest req) { String resultMessage = networkConfigService.addStaticRoute(req); return ApiResponse.success(resultMessage); @@ -141,6 +151,7 @@ public class NetworkConfigController { @Operation(summary = "删除默认路由", description = "删除指定的默认路由") @PostMapping("/routes/default/delete") + @ReplayProtected public ApiResponse deleteDefaultRoute(@RequestBody DeleteDefaultRouteRequest req) { String resultMessage = networkConfigService.deleteDefaultRoute(req); return ApiResponse.success(resultMessage); @@ -148,6 +159,7 @@ public class NetworkConfigController { @Operation(summary = "删除静态路由", description = "删除指定的静态路由") @PostMapping("/routes/static/delete") + @ReplayProtected public ApiResponse deleteStaticRoute(@RequestBody DeleteStaticRouteRequest req) { String resultMessage = networkConfigService.deleteStaticRoute(req); return ApiResponse.success(resultMessage); diff --git a/src/main/java/com/cisd/tms/modules/device/controller/TimeConfigController.java b/src/main/java/com/cisd/tms/modules/device/controller/TimeConfigController.java index 0d546f6..0b013b1 100644 --- a/src/main/java/com/cisd/tms/modules/device/controller/TimeConfigController.java +++ b/src/main/java/com/cisd/tms/modules/device/controller/TimeConfigController.java @@ -5,6 +5,7 @@ import com.cisd.tms.common.api.ApiResponse; import com.cisd.tms.common.enums.ErrorCode; import com.cisd.tms.modules.device.dto.TimeConfigRequest; import com.cisd.tms.modules.device.service.TimeConfigService; +import com.cisd.tms.security.internal.ReplayProtected; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.PostMapping; @@ -15,6 +16,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/v1/device") @RequiredArgsConstructor +@ReplayProtected @Tag(name = "device-time-config-controller") public class TimeConfigController { diff --git a/src/main/java/com/cisd/tms/modules/init/controller/InitController.java b/src/main/java/com/cisd/tms/modules/init/controller/InitController.java index c3daaa2..6090f1f 100644 --- a/src/main/java/com/cisd/tms/modules/init/controller/InitController.java +++ b/src/main/java/com/cisd/tms/modules/init/controller/InitController.java @@ -13,6 +13,7 @@ import com.cisd.tms.modules.init.dto.InitTaskStepResponse; import com.cisd.tms.modules.init.dto.ResetCreateTaskResponse; import com.cisd.tms.modules.init.dto.ResetPreviewResponse; import com.cisd.tms.modules.init.service.InitService; +import com.cisd.tms.security.internal.ReplayProtected; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -64,6 +65,7 @@ public class InitController { */ @PostMapping("/tasks") @Operation(summary = "创建初始化任务", description = "根据初始化请求生成任务和步骤记录,任务初始状态为 PENDING。") + @ReplayProtected public ApiResponse createTask(@Valid @RequestBody InitPreviewRequest request) { return ApiResponse.success(initService.createTask(request)); } @@ -112,6 +114,7 @@ public class InitController { */ @PostMapping("/tasks/{taskId}/execute") @Operation(summary = "执行初始化任务", description = "异步受理初始化任务执行请求,立即返回当前任务快照,实际步骤在后台继续运行。") + @ReplayProtected public ApiResponse executeTask( @Parameter(description = "初始化任务唯一标识") @PathVariable String taskId @@ -127,12 +130,14 @@ public class InitController { @PostMapping("/reset/tasks") @Operation(summary = "创建重置任务", description = "自动读取当前设备有效初始化快照生成重置任务和步骤记录,任务初始状态为 PENDING。") + @ReplayProtected public ApiResponse createResetTask() { return ApiResponse.success(initService.createResetTask()); } @PostMapping("/reset/tasks/{taskId}/execute") @Operation(summary = "执行重置任务", description = "异步受理重置任务执行请求,立即返回当前任务快照,实际步骤在后台继续运行。") + @ReplayProtected public ApiResponse executeResetTask( @Parameter(description = "重置任务唯一标识") @PathVariable String taskId diff --git a/src/main/java/com/cisd/tms/modules/mk/controller/LmkController.java b/src/main/java/com/cisd/tms/modules/mk/controller/LmkController.java index e6e90e1..7a8ffd4 100644 --- a/src/main/java/com/cisd/tms/modules/mk/controller/LmkController.java +++ b/src/main/java/com/cisd/tms/modules/mk/controller/LmkController.java @@ -6,6 +6,7 @@ import com.cisd.tms.modules.mk.dto.MasterKeyRecoverPacketsRequest; import com.cisd.tms.modules.mk.dto.MasterKeyStateResult; import com.cisd.tms.modules.mk.enums.MasterKeyStatus; import com.cisd.tms.modules.mk.service.LmkService; +import com.cisd.tms.security.internal.ReplayProtected; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -28,12 +29,14 @@ public class LmkController { @PostMapping("/masterKey/init") @Operation(summary = "初始化主密钥", description = "执行密码卡初始化识别、主密钥生成加载、内部密钥生成,并返回主密钥状态与校验值。") + @ReplayProtected public ApiResponse initMasterKey() { return ApiResponse.success(lmkService.initMasterKey()); } @PostMapping("/masterKey/destroy") @Operation(summary = "销毁主密钥", description = "销毁当前密码卡中的主密钥,失败直接抛出异常。") + @ReplayProtected public ApiResponse destroyMasterKey() { //销毁主密钥、内部密钥、用户密钥 lmkService.destroyMasterKey(); @@ -53,6 +56,7 @@ public class LmkController { */ @PostMapping("/masterKey/recover") @Operation(summary = "恢复全部密钥", description = "请求体需传入三份按 componentIndex 标识的分量包。三份分量包必须分别对应 componentIndex=1、2、3。服务端会依次恢复 LMK、IK 和用户密钥。") + @ReplayProtected public ApiResponse recoverLmkEx(@Valid @RequestBody MasterKeyRecoverPacketsRequest request) { lmkService.recoverKeyPackets(request.getPackets()); return ApiResponse.success(); diff --git a/src/main/java/com/cisd/tms/modules/security/replay/dto/ReplayCheckRequest.java b/src/main/java/com/cisd/tms/modules/security/replay/dto/ReplayCheckRequest.java new file mode 100644 index 0000000..8601953 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/dto/ReplayCheckRequest.java @@ -0,0 +1,88 @@ +package com.cisd.tms.modules.security.replay.dto; + +import com.cisd.tms.modules.security.replay.enums.ReplayScope; + +public class ReplayCheckRequest { + + private ReplayScope scope; + private String principalId; + private String nonce; + private long requestTimestamp; + private String requestMethod; + private String requestPath; + private String bodyHash; + private String remoteIp; + private String userAgent; + + public ReplayScope getScope() { + return scope; + } + + public void setScope(ReplayScope scope) { + this.scope = scope; + } + + public String getPrincipalId() { + return principalId; + } + + public void setPrincipalId(String principalId) { + this.principalId = principalId; + } + + public String getNonce() { + return nonce; + } + + public void setNonce(String nonce) { + this.nonce = nonce; + } + + public long getRequestTimestamp() { + return requestTimestamp; + } + + public void setRequestTimestamp(long requestTimestamp) { + this.requestTimestamp = requestTimestamp; + } + + public String getRequestMethod() { + return requestMethod; + } + + public void setRequestMethod(String requestMethod) { + this.requestMethod = requestMethod; + } + + public String getRequestPath() { + return requestPath; + } + + public void setRequestPath(String requestPath) { + this.requestPath = requestPath; + } + + public String getBodyHash() { + return bodyHash; + } + + public void setBodyHash(String bodyHash) { + this.bodyHash = bodyHash; + } + + public String getRemoteIp() { + return remoteIp; + } + + public void setRemoteIp(String remoteIp) { + this.remoteIp = remoteIp; + } + + public String getUserAgent() { + return userAgent; + } + + public void setUserAgent(String userAgent) { + this.userAgent = userAgent; + } +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/dto/ReplayCheckResult.java b/src/main/java/com/cisd/tms/modules/security/replay/dto/ReplayCheckResult.java new file mode 100644 index 0000000..46e630e --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/dto/ReplayCheckResult.java @@ -0,0 +1,53 @@ +package com.cisd.tms.modules.security.replay.dto; + +import com.cisd.tms.modules.security.replay.enums.ReplayCheckStatus; +import java.time.LocalDateTime; + +public class ReplayCheckResult { + + private ReplayCheckStatus status; + private boolean replayed; + private boolean mismatch; + private boolean securityEventRecorded; + private LocalDateTime expiresAt; + + public ReplayCheckStatus getStatus() { + return status; + } + + public void setStatus(ReplayCheckStatus status) { + this.status = status; + } + + public boolean isReplayed() { + return replayed; + } + + public void setReplayed(boolean replayed) { + this.replayed = replayed; + } + + public boolean isMismatch() { + return mismatch; + } + + public void setMismatch(boolean mismatch) { + this.mismatch = mismatch; + } + + public boolean isSecurityEventRecorded() { + return securityEventRecorded; + } + + public void setSecurityEventRecorded(boolean securityEventRecorded) { + this.securityEventRecorded = securityEventRecorded; + } + + public LocalDateTime getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(LocalDateTime expiresAt) { + this.expiresAt = expiresAt; + } +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/entity/ReplayNonceEntity.java b/src/main/java/com/cisd/tms/modules/security/replay/entity/ReplayNonceEntity.java new file mode 100644 index 0000000..0675dc7 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/entity/ReplayNonceEntity.java @@ -0,0 +1,100 @@ +package com.cisd.tms.modules.security.replay.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.cisd.tms.infrastructure.persistence.entity.BaseEntity; +import java.time.LocalDateTime; + +@TableName("tms_replay_nonce") +public class ReplayNonceEntity extends BaseEntity { + + private String scope; + private String principalId; + private String nonce; + private Long requestTimestamp; + private String requestMethod; + private String requestPath; + private String bodyHash; + private String remoteIp; + private String userAgent; + private LocalDateTime expiresAt; + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public String getPrincipalId() { + return principalId; + } + + public void setPrincipalId(String principalId) { + this.principalId = principalId; + } + + public String getNonce() { + return nonce; + } + + public void setNonce(String nonce) { + this.nonce = nonce; + } + + public Long getRequestTimestamp() { + return requestTimestamp; + } + + public void setRequestTimestamp(Long requestTimestamp) { + this.requestTimestamp = requestTimestamp; + } + + public String getRequestMethod() { + return requestMethod; + } + + public void setRequestMethod(String requestMethod) { + this.requestMethod = requestMethod; + } + + public String getRequestPath() { + return requestPath; + } + + public void setRequestPath(String requestPath) { + this.requestPath = requestPath; + } + + public String getBodyHash() { + return bodyHash; + } + + public void setBodyHash(String bodyHash) { + this.bodyHash = bodyHash; + } + + public String getRemoteIp() { + return remoteIp; + } + + public void setRemoteIp(String remoteIp) { + this.remoteIp = remoteIp; + } + + public String getUserAgent() { + return userAgent; + } + + public void setUserAgent(String userAgent) { + this.userAgent = userAgent; + } + + public LocalDateTime getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(LocalDateTime expiresAt) { + this.expiresAt = expiresAt; + } +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/entity/SecurityEventEntity.java b/src/main/java/com/cisd/tms/modules/security/replay/entity/SecurityEventEntity.java new file mode 100644 index 0000000..1b1273a --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/entity/SecurityEventEntity.java @@ -0,0 +1,81 @@ +package com.cisd.tms.modules.security.replay.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.cisd.tms.infrastructure.persistence.entity.BaseEntity; + +@TableName("tms_security_event") +public class SecurityEventEntity extends BaseEntity { + + private String eventType; + private String scope; + private String principalId; + private String requestMethod; + private String requestPath; + private String nonce; + private String remoteIp; + private String detailJson; + + public String getEventType() { + return eventType; + } + + public void setEventType(String eventType) { + this.eventType = eventType; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public String getPrincipalId() { + return principalId; + } + + public void setPrincipalId(String principalId) { + this.principalId = principalId; + } + + public String getRequestMethod() { + return requestMethod; + } + + public void setRequestMethod(String requestMethod) { + this.requestMethod = requestMethod; + } + + public String getRequestPath() { + return requestPath; + } + + public void setRequestPath(String requestPath) { + this.requestPath = requestPath; + } + + public String getNonce() { + return nonce; + } + + public void setNonce(String nonce) { + this.nonce = nonce; + } + + public String getRemoteIp() { + return remoteIp; + } + + public void setRemoteIp(String remoteIp) { + this.remoteIp = remoteIp; + } + + public String getDetailJson() { + return detailJson; + } + + public void setDetailJson(String detailJson) { + this.detailJson = detailJson; + } +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/enums/ReplayCheckStatus.java b/src/main/java/com/cisd/tms/modules/security/replay/enums/ReplayCheckStatus.java new file mode 100644 index 0000000..13d2631 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/enums/ReplayCheckStatus.java @@ -0,0 +1,7 @@ +package com.cisd.tms.modules.security.replay.enums; + +public enum ReplayCheckStatus { + CLAIMED, + REPLAYED, + MISMATCH +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/enums/ReplayScope.java b/src/main/java/com/cisd/tms/modules/security/replay/enums/ReplayScope.java new file mode 100644 index 0000000..442155e --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/enums/ReplayScope.java @@ -0,0 +1,6 @@ +package com.cisd.tms.modules.security.replay.enums; + +public enum ReplayScope { + OPENAPI, + INTERNAL_API +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/enums/SecurityEventType.java b/src/main/java/com/cisd/tms/modules/security/replay/enums/SecurityEventType.java new file mode 100644 index 0000000..d5ef0b4 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/enums/SecurityEventType.java @@ -0,0 +1,5 @@ +package com.cisd.tms.modules.security.replay.enums; + +public enum SecurityEventType { + REPLAY_MISMATCH +} 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 new file mode 100644 index 0000000..1b38c90 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/exception/ReplayProtectionException.java @@ -0,0 +1,12 @@ +package com.cisd.tms.modules.security.replay.exception; + +public class ReplayProtectionException extends RuntimeException { + + public ReplayProtectionException(String message) { + super(message); + } + + public ReplayProtectionException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/job/ReplayNonceCleanupJob.java b/src/main/java/com/cisd/tms/modules/security/replay/job/ReplayNonceCleanupJob.java new file mode 100644 index 0000000..6b6d557 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/job/ReplayNonceCleanupJob.java @@ -0,0 +1,32 @@ +package com.cisd.tms.modules.security.replay.job; + +import com.cisd.tms.modules.security.replay.repository.ReplayNonceRepository; +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +public class ReplayNonceCleanupJob { + + private static final int DELETE_BATCH_LIMIT = 1000; + + private final ReplayNonceRepository replayNonceRepository; + private final Clock clock; + + public ReplayNonceCleanupJob(ReplayNonceRepository replayNonceRepository, Clock clock) { + this.replayNonceRepository = replayNonceRepository; + this.clock = clock; + } + + @Scheduled(fixedDelay = 60000L) + public void cleanupExpiredNonces() { + // 分批清理过期 nonce,避免单次删除过多记录导致数据库抖动。 + replayNonceRepository.deleteExpiredBefore(nowUtc(), DELETE_BATCH_LIMIT); + } + + private LocalDateTime nowUtc() { + return LocalDateTime.ofInstant(clock.instant(), ZoneOffset.UTC); + } +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/mapper/ReplayNonceMapper.java b/src/main/java/com/cisd/tms/modules/security/replay/mapper/ReplayNonceMapper.java new file mode 100644 index 0000000..d2a514d --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/mapper/ReplayNonceMapper.java @@ -0,0 +1,75 @@ +package com.cisd.tms.modules.security.replay.mapper; + +import com.cisd.tms.infrastructure.persistence.mapper.BaseMapperX; +import com.cisd.tms.modules.security.replay.entity.ReplayNonceEntity; +import java.time.LocalDateTime; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Result; +import org.apache.ibatis.annotations.Results; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ReplayNonceMapper extends BaseMapperX { + + @Select(""" + SELECT id, + scope, + principal_id, + nonce, + request_timestamp, + request_method, + request_path, + body_hash, + remote_ip, + user_agent, + expires_at, + create_time, + update_time + FROM tms_replay_nonce + WHERE scope = #{scope} + AND principal_id = #{principalId} + AND nonce = #{nonce} + LIMIT 1 + """) + @Results(id = "ReplayNonceResultMap", value = { + @Result(column = "id", property = "id", id = true), + @Result(column = "scope", property = "scope"), + @Result(column = "principal_id", property = "principalId"), + @Result(column = "nonce", property = "nonce"), + @Result(column = "request_timestamp", property = "requestTimestamp"), + @Result(column = "request_method", property = "requestMethod"), + @Result(column = "request_path", property = "requestPath"), + @Result(column = "body_hash", property = "bodyHash"), + @Result(column = "remote_ip", property = "remoteIp"), + @Result(column = "user_agent", property = "userAgent"), + @Result(column = "expires_at", property = "expiresAt"), + @Result(column = "create_time", property = "createTime"), + @Result(column = "update_time", property = "updateTime") + }) + ReplayNonceEntity selectByScopeAndPrincipalIdAndNonce( + @Param("scope") String scope, + @Param("principalId") String principalId, + @Param("nonce") String nonce + ); + + @Delete(""" + DELETE FROM tms_replay_nonce + WHERE scope = #{scope} + AND principal_id = #{principalId} + AND nonce = #{nonce} + """) + int deleteByScopeAndPrincipalIdAndNonce( + @Param("scope") String scope, + @Param("principalId") String principalId, + @Param("nonce") String nonce + ); + + @Delete(""" + DELETE FROM tms_replay_nonce + WHERE expires_at < #{cutoff} + LIMIT #{limit} + """) + int deleteExpiredBefore(@Param("cutoff") LocalDateTime cutoff, @Param("limit") int limit); +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/mapper/SecurityEventMapper.java b/src/main/java/com/cisd/tms/modules/security/replay/mapper/SecurityEventMapper.java new file mode 100644 index 0000000..b73990e --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/mapper/SecurityEventMapper.java @@ -0,0 +1,9 @@ +package com.cisd.tms.modules.security.replay.mapper; + +import com.cisd.tms.infrastructure.persistence.mapper.BaseMapperX; +import com.cisd.tms.modules.security.replay.entity.SecurityEventEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SecurityEventMapper extends BaseMapperX { +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/repository/ReplayNonceRepository.java b/src/main/java/com/cisd/tms/modules/security/replay/repository/ReplayNonceRepository.java new file mode 100644 index 0000000..6684151 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/repository/ReplayNonceRepository.java @@ -0,0 +1,20 @@ +package com.cisd.tms.modules.security.replay.repository; + +import com.cisd.tms.modules.security.replay.entity.ReplayNonceEntity; +import java.time.LocalDateTime; +import java.util.Optional; + +public interface ReplayNonceRepository { + + void save(ReplayNonceEntity entity); + + boolean trySave(ReplayNonceEntity entity); + + Optional findByScopeAndPrincipalIdAndNonce(String scope, String principalId, String nonce); + + int deleteById(Long id); + + int deleteByScopeAndPrincipalIdAndNonce(String scope, String principalId, String nonce); + + int deleteExpiredBefore(LocalDateTime cutoff, int limit); +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/repository/SecurityEventRepository.java b/src/main/java/com/cisd/tms/modules/security/replay/repository/SecurityEventRepository.java new file mode 100644 index 0000000..2fd7708 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/repository/SecurityEventRepository.java @@ -0,0 +1,8 @@ +package com.cisd.tms.modules.security.replay.repository; + +import com.cisd.tms.modules.security.replay.entity.SecurityEventEntity; + +public interface SecurityEventRepository { + + void save(SecurityEventEntity entity); +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/repository/impl/ReplayNonceRepositoryImpl.java b/src/main/java/com/cisd/tms/modules/security/replay/repository/impl/ReplayNonceRepositoryImpl.java new file mode 100644 index 0000000..c4a1af0 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/repository/impl/ReplayNonceRepositoryImpl.java @@ -0,0 +1,54 @@ +package com.cisd.tms.modules.security.replay.repository.impl; + +import com.cisd.tms.modules.security.replay.entity.ReplayNonceEntity; +import com.cisd.tms.modules.security.replay.mapper.ReplayNonceMapper; +import com.cisd.tms.modules.security.replay.repository.ReplayNonceRepository; +import java.time.LocalDateTime; +import java.util.Optional; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Repository; + +@Repository +public class ReplayNonceRepositoryImpl implements ReplayNonceRepository { + + private final ReplayNonceMapper replayNonceMapper; + + public ReplayNonceRepositoryImpl(ReplayNonceMapper replayNonceMapper) { + this.replayNonceMapper = replayNonceMapper; + } + + @Override + public void save(ReplayNonceEntity entity) { + replayNonceMapper.insert(entity); + } + + @Override + public boolean trySave(ReplayNonceEntity entity) { + try { + replayNonceMapper.insert(entity); + return true; + } catch (DataIntegrityViolationException ex) { + return false; + } + } + + @Override + public Optional findByScopeAndPrincipalIdAndNonce(String scope, String principalId, String nonce) { + return Optional.ofNullable(replayNonceMapper.selectByScopeAndPrincipalIdAndNonce(scope, principalId, nonce)); + } + + @Override + public int deleteById(Long id) { + return replayNonceMapper.deleteById(id); + } + + @Override + public int deleteByScopeAndPrincipalIdAndNonce(String scope, String principalId, String nonce) { + return replayNonceMapper.deleteByScopeAndPrincipalIdAndNonce(scope, principalId, nonce); + } + + @Override + public int deleteExpiredBefore(LocalDateTime cutoff, int limit) { + return replayNonceMapper.deleteExpiredBefore(cutoff, limit); + } +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/repository/impl/SecurityEventRepositoryImpl.java b/src/main/java/com/cisd/tms/modules/security/replay/repository/impl/SecurityEventRepositoryImpl.java new file mode 100644 index 0000000..6dd078b --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/repository/impl/SecurityEventRepositoryImpl.java @@ -0,0 +1,21 @@ +package com.cisd.tms.modules.security.replay.repository.impl; + +import com.cisd.tms.modules.security.replay.entity.SecurityEventEntity; +import com.cisd.tms.modules.security.replay.mapper.SecurityEventMapper; +import com.cisd.tms.modules.security.replay.repository.SecurityEventRepository; +import org.springframework.stereotype.Repository; + +@Repository +public class SecurityEventRepositoryImpl implements SecurityEventRepository { + + private final SecurityEventMapper securityEventMapper; + + public SecurityEventRepositoryImpl(SecurityEventMapper securityEventMapper) { + this.securityEventMapper = securityEventMapper; + } + + @Override + public void save(SecurityEventEntity entity) { + securityEventMapper.insert(entity); + } +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/service/ReplayProtectionService.java b/src/main/java/com/cisd/tms/modules/security/replay/service/ReplayProtectionService.java new file mode 100644 index 0000000..dded2c7 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/service/ReplayProtectionService.java @@ -0,0 +1,9 @@ +package com.cisd.tms.modules.security.replay.service; + +import com.cisd.tms.modules.security.replay.dto.ReplayCheckRequest; +import com.cisd.tms.modules.security.replay.dto.ReplayCheckResult; + +public interface ReplayProtectionService { + + ReplayCheckResult check(ReplayCheckRequest request); +} 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 new file mode 100644 index 0000000..dc6c02d --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/service/impl/ReplayProtectionServiceImpl.java @@ -0,0 +1,230 @@ +package com.cisd.tms.modules.security.replay.service.impl; + +import com.cisd.tms.common.config.properties.TmsSecurityProperties; +import com.cisd.tms.modules.security.replay.dto.ReplayCheckRequest; +import com.cisd.tms.modules.security.replay.dto.ReplayCheckResult; +import com.cisd.tms.modules.security.replay.entity.ReplayNonceEntity; +import com.cisd.tms.modules.security.replay.entity.SecurityEventEntity; +import com.cisd.tms.modules.security.replay.enums.ReplayCheckStatus; +import com.cisd.tms.modules.security.replay.enums.ReplayScope; +import com.cisd.tms.modules.security.replay.enums.SecurityEventType; +import com.cisd.tms.modules.security.replay.exception.ReplayProtectionException; +import com.cisd.tms.modules.security.replay.repository.ReplayNonceRepository; +import com.cisd.tms.modules.security.replay.repository.SecurityEventRepository; +import com.cisd.tms.modules.security.replay.service.ReplayProtectionService; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Objects; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class ReplayProtectionServiceImpl implements ReplayProtectionService { + + private static final long DEFAULT_ALLOWED_SKEW_SECONDS = 300L; + + private final ReplayNonceRepository replayNonceRepository; + private final SecurityEventRepository securityEventRepository; + private final TmsSecurityProperties securityProperties; + private final Clock clock; + + @Autowired + public ReplayProtectionServiceImpl( + ReplayNonceRepository replayNonceRepository, + SecurityEventRepository securityEventRepository, + TmsSecurityProperties securityProperties, + Clock clock + ) { + this.replayNonceRepository = replayNonceRepository; + this.securityEventRepository = securityEventRepository; + this.securityProperties = securityProperties; + this.clock = clock; + } + + public ReplayProtectionServiceImpl( + ReplayNonceRepository replayNonceRepository, + SecurityEventRepository securityEventRepository, + Clock clock + ) { + this(replayNonceRepository, securityEventRepository, new TmsSecurityProperties(), clock); + } + + @Override + public ReplayCheckResult check(ReplayCheckRequest request) { + validateRequest(request); + long nowSeconds = clock.instant().getEpochSecond(); + long allowedSkewSeconds = allowedSkewSeconds(request.getScope()); + ensureTimestampWithinWindow(request.getRequestTimestamp(), nowSeconds, allowedSkewSeconds); + + String scope = request.getScope().name(); + String principalId = normalize(request.getPrincipalId()); + String nonce = normalize(request.getNonce()); + + var existing = replayNonceRepository.findByScopeAndPrincipalIdAndNonce(scope, principalId, nonce); + if (existing.isPresent()) { + if (isExpired(existing.get(), nowSeconds)) { + // 过期记录即使清理任务尚未来得及删除,也不应永久阻塞新的合法请求。 + reclaimExpiredNonce(existing.get()); + existing = replayNonceRepository.findByScopeAndPrincipalIdAndNonce(scope, principalId, nonce); + } + } + if (existing.isPresent()) { + return buildReplayResult(request, existing.get()); + } + + ReplayNonceEntity entity = buildReplayEntity(request, nowSeconds, allowedSkewSeconds); + try { + if (replayNonceRepository.trySave(entity)) { + return buildClaimedResult(entity); + } + } catch (RuntimeException ex) { + throw new ReplayProtectionException("failed to persist replay nonce", ex); + } + + var reloaded = replayNonceRepository.findByScopeAndPrincipalIdAndNonce(scope, principalId, nonce); + if (reloaded.isEmpty()) { + throw new ReplayProtectionException("replay nonce claim failed without existing record"); + } + return buildReplayResult(request, reloaded.get()); + } + + private void reclaimExpiredNonce(ReplayNonceEntity expiredEntity) { + if (expiredEntity.getId() == null) { + throw new ReplayProtectionException("expired replay nonce id is missing"); + } + try { + replayNonceRepository.deleteById(expiredEntity.getId()); + } catch (RuntimeException ex) { + throw new ReplayProtectionException("failed to remove expired replay nonce", ex); + } + } + + private void validateRequest(ReplayCheckRequest request) { + if (request == null) { + throw new ReplayProtectionException("replay request must not be null"); + } + if (request.getScope() == null) { + throw new ReplayProtectionException("replay scope is required"); + } + if (isBlank(request.getPrincipalId())) { + throw new ReplayProtectionException("principal id is required"); + } + if (isBlank(request.getNonce())) { + throw new ReplayProtectionException("nonce is required"); + } + if (request.getRequestTimestamp() <= 0L) { + throw new ReplayProtectionException("request timestamp is required"); + } + } + + private void ensureTimestampWithinWindow(long requestTimestamp, long nowSeconds, long allowedSkewSeconds) { + if (Math.abs(nowSeconds - requestTimestamp) > allowedSkewSeconds) { + throw new ReplayProtectionException("request timestamp is outside allowed window"); + } + } + + private ReplayCheckResult buildClaimedResult(ReplayNonceEntity entity) { + ReplayCheckResult result = new ReplayCheckResult(); + result.setStatus(ReplayCheckStatus.CLAIMED); + result.setReplayed(false); + result.setMismatch(false); + result.setSecurityEventRecorded(false); + result.setExpiresAt(entity.getExpiresAt()); + return result; + } + + private ReplayCheckResult buildReplayResult(ReplayCheckRequest request, ReplayNonceEntity existing) { + boolean mismatch = !sameFingerprint(request, existing); + if (mismatch) { + // 同一 nonce 命中但请求指纹不同,按更高风险事件记录,便于后续审计或告警。 + recordSecurityEvent(request); + } + + ReplayCheckResult result = new ReplayCheckResult(); + result.setStatus(mismatch ? ReplayCheckStatus.MISMATCH : ReplayCheckStatus.REPLAYED); + result.setReplayed(true); + result.setMismatch(mismatch); + result.setSecurityEventRecorded(mismatch); + result.setExpiresAt(existing.getExpiresAt()); + return result; + } + + private void recordSecurityEvent(ReplayCheckRequest request) { + SecurityEventEntity event = new SecurityEventEntity(); + event.setEventType(SecurityEventType.REPLAY_MISMATCH.name()); + event.setScope(request.getScope().name()); + event.setPrincipalId(normalize(request.getPrincipalId())); + event.setRequestMethod(normalize(request.getRequestMethod())); + event.setRequestPath(normalize(request.getRequestPath())); + event.setNonce(normalize(request.getNonce())); + event.setRemoteIp(normalize(request.getRemoteIp())); + event.setDetailJson(buildMismatchDetail(request)); + + try { + securityEventRepository.save(event); + } catch (RuntimeException ex) { + throw new ReplayProtectionException("failed to persist security event", ex); + } + } + + private String buildMismatchDetail(ReplayCheckRequest request) { + return "request fingerprint mismatch: scope=" + request.getScope().name() + + ", method=" + normalize(request.getRequestMethod()) + + ", path=" + normalize(request.getRequestPath()) + + ", bodyHash=" + normalize(request.getBodyHash()); + } + + private ReplayNonceEntity buildReplayEntity(ReplayCheckRequest request, long nowSeconds, long allowedSkewSeconds) { + ReplayNonceEntity entity = new ReplayNonceEntity(); + entity.setScope(request.getScope().name()); + entity.setPrincipalId(normalize(request.getPrincipalId())); + entity.setNonce(normalize(request.getNonce())); + entity.setRequestTimestamp(request.getRequestTimestamp()); + entity.setRequestMethod(normalize(request.getRequestMethod())); + entity.setRequestPath(normalize(request.getRequestPath())); + entity.setBodyHash(normalize(request.getBodyHash())); + entity.setRemoteIp(normalize(request.getRemoteIp())); + entity.setUserAgent(normalize(request.getUserAgent())); + entity.setExpiresAt(LocalDateTime.ofInstant(Instant.ofEpochSecond(nowSeconds + allowedSkewSeconds * 2L), ZoneOffset.UTC)); + return entity; + } + + private long allowedSkewSeconds(ReplayScope scope) { + // OpenAPI 允许使用配置时间窗,内部管理端走固定默认值,避免由调用方自行放宽窗口。 + if (scope == ReplayScope.OPENAPI) { + long configuredSkewSeconds = securityProperties.getOpenapi().getTimestampSkewSeconds(); + if (configuredSkewSeconds > 0L) { + return configuredSkewSeconds; + } + } + return DEFAULT_ALLOWED_SKEW_SECONDS; + } + + private boolean sameFingerprint(ReplayCheckRequest request, ReplayNonceEntity existing) { + return same(normalize(request.getRequestMethod()), normalize(existing.getRequestMethod())) + && same(normalize(request.getRequestPath()), normalize(existing.getRequestPath())) + && same(normalize(request.getBodyHash()), normalize(existing.getBodyHash())); + } + + private boolean same(String left, String right) { + return Objects.equals(left, right); + } + + private boolean isExpired(ReplayNonceEntity entity, long nowSeconds) { + if (entity.getExpiresAt() == null) { + return false; + } + LocalDateTime now = LocalDateTime.ofInstant(Instant.ofEpochSecond(nowSeconds), ZoneOffset.UTC); + return !entity.getExpiresAt().isAfter(now); + } + + private String normalize(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/com/cisd/tms/modules/upgrade/controller/UpgradeController.java b/src/main/java/com/cisd/tms/modules/upgrade/controller/UpgradeController.java index c95a677..c2de5df 100644 --- a/src/main/java/com/cisd/tms/modules/upgrade/controller/UpgradeController.java +++ b/src/main/java/com/cisd/tms/modules/upgrade/controller/UpgradeController.java @@ -11,6 +11,7 @@ import com.cisd.tms.modules.upgrade.dto.UpgradePreviewResponse; import com.cisd.tms.modules.upgrade.dto.UpgradeTaskPageResponse; import com.cisd.tms.modules.upgrade.dto.UpgradeTaskResponse; import com.cisd.tms.modules.upgrade.service.UpgradeService; +import com.cisd.tms.security.internal.ReplayProtected; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -36,6 +37,7 @@ public class UpgradeController { @PostMapping("/upgrade-packages") @Operation(summary = "上传离线升级包", description = "上传离线升级包,返回 fileId 供升级预检和任务创建使用。") + @ReplayProtected public ApiResponse uploadPackage( @Parameter(description = "待上传的离线升级包") @RequestParam("file") MultipartFile file @@ -51,12 +53,14 @@ public class UpgradeController { @PostMapping("/upgrades") @Operation(summary = "创建离线升级任务", description = "根据 fileId 创建离线升级任务,状态初始为 PENDING_CONFIRM。") + @ReplayProtected public ApiResponse create(@Valid @RequestBody UpgradeCreateTaskRequest request) { return ApiResponse.success(upgradeService.createTask(request)); } @PostMapping("/upgrades/{taskId}/execute") @Operation(summary = "执行离线升级任务", description = "异步受理升级执行请求,立即返回任务当前快照。") + @ReplayProtected public ApiResponse execute( @Parameter(description = "升级任务号") @PathVariable String taskId @@ -66,6 +70,7 @@ public class UpgradeController { @PostMapping("/upgrades/{taskId}/rollback") @Operation(summary = "回滚离线升级任务", description = "异步受理升级回滚请求,立即返回任务当前快照。") + @ReplayProtected public ApiResponse rollback( @Parameter(description = "升级任务号") @PathVariable String taskId diff --git a/src/main/java/com/cisd/tms/security/internal/CachedBodyHttpServletRequest.java b/src/main/java/com/cisd/tms/security/internal/CachedBodyHttpServletRequest.java new file mode 100644 index 0000000..b45564a --- /dev/null +++ b/src/main/java/com/cisd/tms/security/internal/CachedBodyHttpServletRequest.java @@ -0,0 +1,69 @@ +package com.cisd.tms.security.internal; + +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import org.springframework.util.StreamUtils; + +public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { + + private final byte[] cachedBody; + + public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException { + super(request); + this.cachedBody = StreamUtils.copyToByteArray(request.getInputStream()); + } + + public byte[] getCachedBody() { + return cachedBody.clone(); + } + + @Override + public ServletInputStream getInputStream() { + return new CachedBodyServletInputStream(cachedBody); + } + + @Override + public BufferedReader getReader() { + Charset charset = getCharacterEncoding() == null + ? StandardCharsets.UTF_8 + : Charset.forName(getCharacterEncoding()); + return new BufferedReader(new InputStreamReader(getInputStream(), charset)); + } + + private static class CachedBodyServletInputStream extends ServletInputStream { + + private final ByteArrayInputStream inputStream; + + private CachedBodyServletInputStream(byte[] cachedBody) { + this.inputStream = new ByteArrayInputStream(cachedBody); + } + + @Override + public boolean isFinished() { + return inputStream.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener readListener) { + throw new UnsupportedOperationException("ReadListener is not supported"); + } + + @Override + public int read() { + return inputStream.read(); + } + } +} diff --git a/src/main/java/com/cisd/tms/security/internal/InternalApiAuthInterceptor.java b/src/main/java/com/cisd/tms/security/internal/InternalApiAuthInterceptor.java index 0726bad..f6421fc 100644 --- a/src/main/java/com/cisd/tms/security/internal/InternalApiAuthInterceptor.java +++ b/src/main/java/com/cisd/tms/security/internal/InternalApiAuthInterceptor.java @@ -13,12 +13,15 @@ import java.io.IOException; import java.time.Clock; import java.time.LocalDateTime; import java.time.ZoneOffset; + +import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.web.cors.CorsUtils; import org.springframework.web.servlet.HandlerInterceptor; @Component +@RequiredArgsConstructor public class InternalApiAuthInterceptor implements HandlerInterceptor { public static final String ATTR_ROLE_CODE = "CURRENT_ROLE_CODE"; @@ -33,27 +36,6 @@ public class InternalApiAuthInterceptor implements HandlerInterceptor { private final ObjectMapper objectMapper; private final Clock clock; - @Autowired - public InternalApiAuthInterceptor( - AuthSessionRepository authSessionRepository, - ObjectMapper objectMapper, - TmsSecurityProperties securityProperties - ) { - this(authSessionRepository, objectMapper, securityProperties, Clock.systemUTC()); - } - - InternalApiAuthInterceptor( - AuthSessionRepository authSessionRepository, - ObjectMapper objectMapper, - TmsSecurityProperties securityProperties, - Clock clock - ) { - this.authSessionRepository = authSessionRepository; - this.securityProperties = securityProperties; - this.objectMapper = objectMapper; - this.clock = clock; - } - @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (CorsUtils.isPreFlightRequest(request)) { diff --git a/src/main/java/com/cisd/tms/security/internal/InternalApiReplayInterceptor.java b/src/main/java/com/cisd/tms/security/internal/InternalApiReplayInterceptor.java new file mode 100644 index 0000000..e0f677e --- /dev/null +++ b/src/main/java/com/cisd/tms/security/internal/InternalApiReplayInterceptor.java @@ -0,0 +1,145 @@ +package com.cisd.tms.security.internal; + +import com.cisd.tms.common.api.ApiResponse; +import com.cisd.tms.common.enums.ErrorCode; +import com.cisd.tms.common.util.HttpResponseUtil; +import com.cisd.tms.modules.security.replay.dto.ReplayCheckRequest; +import com.cisd.tms.modules.security.replay.dto.ReplayCheckResult; +import com.cisd.tms.modules.security.replay.enums.ReplayScope; +import com.cisd.tms.modules.security.replay.exception.ReplayProtectionException; +import com.cisd.tms.modules.security.replay.service.ReplayProtectionService; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Clock; +import lombok.RequiredArgsConstructor; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import org.springframework.web.cors.CorsUtils; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +@RequiredArgsConstructor +public class InternalApiReplayInterceptor implements HandlerInterceptor { + + static final String TIMESTAMP_HEADER = "X-Request-Timestamp"; + static final String NONCE_HEADER = "X-Request-Nonce"; + + private final ReplayProtectionService replayProtectionService; + private final ObjectMapper objectMapper; + private final Clock clock; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + if (CorsUtils.isPreFlightRequest(request) || !(handler instanceof HandlerMethod handlerMethod)) { + return true; + } + // 只有显式标记的敏感接口才启用防重放,避免把普通查询接口一并拦住。 + if (findAnnotation(handlerMethod, ReplayProtected.class) == null) { + return true; + } + + String sessionToken = (String) request.getAttribute(InternalApiAuthInterceptor.ATTR_SESSION_TOKEN); + if (isBlank(sessionToken)) { + writeJson(response, HttpServletResponse.SC_UNAUTHORIZED, ErrorCode.UNAUTHORIZED.getCode(), "session token missing"); + return false; + } + + String timestamp = trimHeader(request, TIMESTAMP_HEADER); + String nonce = trimHeader(request, NONCE_HEADER); + if (isBlank(timestamp) || isBlank(nonce)) { + writeJson(response, HttpServletResponse.SC_BAD_REQUEST, ErrorCode.BAD_REQUEST.getCode(), "missing replay protection headers"); + return false; + } + + long requestTimestamp; + try { + requestTimestamp = Long.parseLong(timestamp); + } catch (NumberFormatException ex) { + writeJson(response, HttpServletResponse.SC_BAD_REQUEST, ErrorCode.BAD_REQUEST.getCode(), "invalid replay timestamp"); + return false; + } + + ReplayCheckRequest replayRequest = buildReplayRequest(request, sessionToken, nonce, requestTimestamp); + try { + ReplayCheckResult result = replayProtectionService.check(replayRequest); + if (result.isReplayed()) { + writeJson(response, HttpServletResponse.SC_CONFLICT, ErrorCode.UNAUTHORIZED.getCode(), "replayed nonce"); + return false; + } + } catch (ReplayProtectionException ex) { + writeJson(response, HttpServletResponse.SC_SERVICE_UNAVAILABLE, ErrorCode.INTERNAL_ERROR.getCode(), + "replay protection unavailable"); + return false; + } + + return true; + } + + private ReplayCheckRequest buildReplayRequest( + HttpServletRequest request, + String sessionToken, + String nonce, + long requestTimestamp + ) { + // 内部接口使用会话 token 作为主体标识,并把请求体哈希纳入指纹,识别同 nonce 的不同内容重放。 + ReplayCheckRequest replayRequest = new ReplayCheckRequest(); + replayRequest.setScope(ReplayScope.INTERNAL_API); + replayRequest.setPrincipalId(sessionToken); + replayRequest.setNonce(nonce); + replayRequest.setRequestTimestamp(requestTimestamp); + replayRequest.setRequestMethod(request.getMethod()); + replayRequest.setRequestPath(request.getRequestURI()); + replayRequest.setBodyHash(bodyHash(request)); + replayRequest.setRemoteIp(request.getRemoteAddr()); + replayRequest.setUserAgent(trimHeader(request, "User-Agent")); + return replayRequest; + } + + private String bodyHash(HttpServletRequest request) { + if (!(request instanceof CachedBodyHttpServletRequest cachedRequest)) { + return null; + } + byte[] body = cachedRequest.getCachedBody(); + if (body.length == 0) { + return null; + } + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(body); + StringBuilder builder = new StringBuilder(digest.length * 2); + for (byte b : digest) { + builder.append(String.format("%02x", b)); + } + return builder.toString(); + } catch (Exception ex) { + throw new ReplayProtectionException("failed to hash request body", ex); + } + } + + @Nullable + private T findAnnotation(HandlerMethod handlerMethod, Class annotationType) { + T methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(handlerMethod.getMethod(), annotationType); + if (methodAnnotation != null) { + return methodAnnotation; + } + return AnnotatedElementUtils.findMergedAnnotation(handlerMethod.getBeanType(), annotationType); + } + + private String trimHeader(HttpServletRequest request, String name) { + String value = request.getHeader(name); + return value == null ? null : value.trim(); + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private void writeJson(HttpServletResponse response, int status, int code, String message) throws IOException { + HttpResponseUtil.writeJson(response, status, ApiResponse.fail(code, message), objectMapper); + } +} diff --git a/src/main/java/com/cisd/tms/security/internal/ReplayProtected.java b/src/main/java/com/cisd/tms/security/internal/ReplayProtected.java new file mode 100644 index 0000000..0b64358 --- /dev/null +++ b/src/main/java/com/cisd/tms/security/internal/ReplayProtected.java @@ -0,0 +1,11 @@ +package com.cisd.tms.security.internal; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ReplayProtected { +} diff --git a/src/main/java/com/cisd/tms/security/openapi/OpenApiReplayGuard.java b/src/main/java/com/cisd/tms/security/openapi/OpenApiReplayGuard.java deleted file mode 100644 index 8c9ec95..0000000 --- a/src/main/java/com/cisd/tms/security/openapi/OpenApiReplayGuard.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.cisd.tms.security.openapi; - -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicLong; -import org.springframework.stereotype.Component; - -@Component -public class OpenApiReplayGuard { - - private final Map nonceExpireSeconds = new ConcurrentHashMap<>(); - private final AtomicLong cleanupCounter = new AtomicLong(0); - - public boolean tryAcquire(String appId, String nonce, long nowSeconds, long ttlSeconds) { - if (ttlSeconds <= 0) { - ttlSeconds = 300; - } - - String key = appId + ":" + nonce; - long expiry = nowSeconds + ttlSeconds; - AtomicBoolean accepted = new AtomicBoolean(false); - - nonceExpireSeconds.compute(key, (k, existingExpiry) -> { - if (existingExpiry == null || existingExpiry < nowSeconds) { - accepted.set(true); - return expiry; - } - accepted.set(false); - return existingExpiry; - }); - - // Opportunistic cleanup to keep memory growth under control. - if (cleanupCounter.incrementAndGet() % 1000 == 0) { - nonceExpireSeconds.entrySet().removeIf(e -> e.getValue() < nowSeconds); - } - - return accepted.get(); - } -} 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 a0cca50..639c1ba 100644 --- a/src/main/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptor.java +++ b/src/main/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptor.java @@ -5,19 +5,28 @@ import com.cisd.tms.common.config.properties.TmsSecurityProperties; import com.cisd.tms.common.enums.ErrorCode; import com.cisd.tms.common.util.HmacUtil; import com.cisd.tms.common.util.HttpResponseUtil; +import com.cisd.tms.modules.security.replay.dto.ReplayCheckRequest; +import com.cisd.tms.modules.security.replay.dto.ReplayCheckResult; +import com.cisd.tms.modules.security.replay.enums.ReplayScope; +import com.cisd.tms.modules.security.replay.exception.ReplayProtectionException; +import com.cisd.tms.modules.security.replay.service.ReplayProtectionService; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; +import java.time.Clock; import java.time.Instant; import java.util.Map; + +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.web.cors.CorsUtils; import org.springframework.web.servlet.HandlerInterceptor; @Component +@RequiredArgsConstructor public class OpenApiSignAuthInterceptor implements HandlerInterceptor { private static final String APP_ID_HEADER = "X-App-Id"; @@ -26,18 +35,9 @@ public class OpenApiSignAuthInterceptor implements HandlerInterceptor { private static final String SIGNATURE_HEADER = "X-Signature"; private final TmsSecurityProperties securityProperties; - private final OpenApiReplayGuard replayGuard; + private final ReplayProtectionService replayProtectionService; private final ObjectMapper objectMapper; - - public OpenApiSignAuthInterceptor( - TmsSecurityProperties securityProperties, - OpenApiReplayGuard replayGuard, - ObjectMapper objectMapper - ) { - this.securityProperties = securityProperties; - this.replayGuard = replayGuard; - this.objectMapper = objectMapper; - } + private final Clock clock; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { @@ -70,18 +70,13 @@ public class OpenApiSignAuthInterceptor implements HandlerInterceptor { return false; } - long now = Instant.now().getEpochSecond(); + long now = Instant.ofEpochMilli(clock.millis()).getEpochSecond(); long skew = securityProperties.getOpenapi().getTimestampSkewSeconds(); if (Math.abs(now - requestEpoch) > skew) { writeUnauthorized(response, "timestamp expired"); return false; } - if (!replayGuard.tryAcquire(appId, nonce, now, skew * 2)) { - writeUnauthorized(response, "replayed nonce"); - return false; - } - String content = appId + "\n" + timestamp + "\n" + nonce; String expected = HmacUtil.hmacSha256Hex(appSecret, content); @@ -90,9 +85,40 @@ public class OpenApiSignAuthInterceptor implements HandlerInterceptor { return false; } + try { + // 先完成签名校验,再占用 nonce,避免伪造请求提前消耗合法 nonce。 + ReplayCheckResult result = replayProtectionService.check(buildReplayRequest(request, appId, nonce, requestEpoch)); + if (result.isReplayed()) { + writeUnauthorized(response, "replayed nonce"); + return false; + } + } catch (ReplayProtectionException ex) { + writeUnauthorized(response, "replay protection unavailable"); + return false; + } + return true; } + private ReplayCheckRequest buildReplayRequest( + HttpServletRequest request, + String appId, + String nonce, + long requestEpoch + ) { + // OpenAPI 当前把 appId、时间戳、nonce 作为防重放主键输入,并额外记录方法、路径等上下文。 + ReplayCheckRequest replayRequest = new ReplayCheckRequest(); + replayRequest.setScope(ReplayScope.OPENAPI); + replayRequest.setPrincipalId(appId); + replayRequest.setNonce(nonce); + replayRequest.setRequestTimestamp(requestEpoch); + replayRequest.setRequestMethod(request.getMethod()); + replayRequest.setRequestPath(request.getRequestURI()); + replayRequest.setRemoteIp(request.getRemoteAddr()); + replayRequest.setUserAgent(trimHeader(request, "User-Agent")); + return replayRequest; + } + private boolean secureEquals(String expected, String actual) { return MessageDigest.isEqual( expected.getBytes(StandardCharsets.UTF_8), diff --git a/src/main/resources/db/migration/V1__tms_schema_full.sql b/src/main/resources/db/migration/V1__tms_schema_full.sql index 10e18e0..8605c2a 100644 --- a/src/main/resources/db/migration/V1__tms_schema_full.sql +++ b/src/main/resources/db/migration/V1__tms_schema_full.sql @@ -234,6 +234,45 @@ CREATE TABLE IF NOT EXISTS tms_auth_audit_log ( KEY idx_tms_auth_audit_log_role_code (role_code) ); +-- ----------------------------------------------------------------------------- +-- 防重放与安全事件 +-- ----------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS tms_replay_nonce ( + id BIGINT PRIMARY KEY, + scope VARCHAR(32) NOT NULL COMMENT '防重放作用域:OPENAPI / INTERNAL_API', + principal_id VARCHAR(128) NOT NULL COMMENT '主体标识:appId / sessionToken', + nonce VARCHAR(128) NOT NULL, + request_timestamp BIGINT NOT NULL, + request_method VARCHAR(16) NULL, + request_path VARCHAR(256) NULL, + body_hash VARCHAR(128) NULL, + remote_ip VARCHAR(64) NULL, + user_agent VARCHAR(512) NULL, + expires_at DATETIME(3) NOT NULL, + create_time DATETIME(3) NOT NULL, + update_time DATETIME(3) NOT NULL, + UNIQUE KEY uk_tms_replay_nonce_scope_principal_nonce (scope, principal_id, nonce), + KEY idx_tms_replay_nonce_expires_at (expires_at), + KEY idx_tms_replay_nonce_principal_time (principal_id, create_time) +); + +CREATE TABLE IF NOT EXISTS tms_security_event ( + id BIGINT PRIMARY KEY, + event_type VARCHAR(64) NOT NULL, + scope VARCHAR(32) NOT NULL, + principal_id VARCHAR(128) NULL, + request_method VARCHAR(16) NULL, + request_path VARCHAR(256) NULL, + nonce VARCHAR(128) NULL, + remote_ip VARCHAR(64) NULL, + detail_json JSON NULL, + create_time DATETIME(3) NOT NULL, + update_time DATETIME(3) NOT NULL, + KEY idx_tms_security_event_event_type (event_type), + KEY idx_tms_security_event_scope_principal (scope, principal_id) +); + INSERT IGNORE INTO tms_role_account ( id, role_code, diff --git a/src/main/resources/db/migration/V5__add_replay_protection_tables.sql b/src/main/resources/db/migration/V5__add_replay_protection_tables.sql new file mode 100644 index 0000000..ef9f4be --- /dev/null +++ b/src/main/resources/db/migration/V5__add_replay_protection_tables.sql @@ -0,0 +1,38 @@ +-- ----------------------------------------------------------------------------- +-- Replay protection tables +-- ----------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS tms_replay_nonce ( + id BIGINT PRIMARY KEY, + scope VARCHAR(32) NOT NULL COMMENT '防重放作用域:OPENAPI / INTERNAL_API', + principal_id VARCHAR(128) NOT NULL COMMENT '主体标识:appId / sessionToken', + nonce VARCHAR(128) NOT NULL, + request_timestamp BIGINT NOT NULL, + request_method VARCHAR(16) NULL, + request_path VARCHAR(256) NULL, + body_hash VARCHAR(128) NULL, + remote_ip VARCHAR(64) NULL, + user_agent VARCHAR(512) NULL, + expires_at DATETIME(3) NOT NULL, + create_time DATETIME(3) NOT NULL, + update_time DATETIME(3) NOT NULL, + UNIQUE KEY uk_tms_replay_nonce_scope_principal_nonce (scope, principal_id, nonce), + KEY idx_tms_replay_nonce_expires_at (expires_at), + KEY idx_tms_replay_nonce_principal_time (principal_id, create_time) +); + +CREATE TABLE IF NOT EXISTS tms_security_event ( + id BIGINT PRIMARY KEY, + event_type VARCHAR(64) NOT NULL, + scope VARCHAR(32) NOT NULL, + principal_id VARCHAR(128) NULL, + request_method VARCHAR(16) NULL, + request_path VARCHAR(256) NULL, + nonce VARCHAR(128) NULL, + remote_ip VARCHAR(64) NULL, + detail_json JSON NULL, + create_time DATETIME(3) NOT NULL, + update_time DATETIME(3) NOT NULL, + KEY idx_tms_security_event_event_type (event_type), + KEY idx_tms_security_event_scope_principal (scope, principal_id) +); diff --git a/src/test/java/com/cisd/tms/TmsApplicationTests.java b/src/test/java/com/cisd/tms/TmsApplicationTests.java index caf1096..42f47e9 100644 --- a/src/test/java/com/cisd/tms/TmsApplicationTests.java +++ b/src/test/java/com/cisd/tms/TmsApplicationTests.java @@ -54,12 +54,17 @@ class TmsApplicationTests { @Test void shouldCreateAuthRoleTablesAndSeedFixedRoleAccounts() { ClassPathResource migration = new ClassPathResource("db/migration/V1__tms_schema_full.sql"); + ClassPathResource replayMigration = new ClassPathResource("db/migration/V5__add_replay_protection_tables.sql"); org.junit.jupiter.api.Assertions.assertTrue(migration.exists()); + org.junit.jupiter.api.Assertions.assertTrue(replayMigration.exists()); String sql = org.junit.jupiter.api.Assertions.assertDoesNotThrow( () -> new String(migration.getInputStream().readAllBytes(), StandardCharsets.UTF_8) ); + String replaySql = org.junit.jupiter.api.Assertions.assertDoesNotThrow( + () -> new String(replayMigration.getInputStream().readAllBytes(), StandardCharsets.UTF_8) + ); org.junit.jupiter.api.Assertions.assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS tms_role_account")); org.junit.jupiter.api.Assertions.assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS tms_role_ukey_binding")); @@ -72,5 +77,12 @@ class TmsApplicationTests { org.junit.jupiter.api.Assertions.assertTrue(sql.contains("'KEY_ADMIN'")); org.junit.jupiter.api.Assertions.assertTrue(sql.contains("'ACTIVE'")); org.junit.jupiter.api.Assertions.assertTrue(sql.contains("'UNENABLED'")); + org.junit.jupiter.api.Assertions.assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS tms_replay_nonce")); + org.junit.jupiter.api.Assertions.assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS tms_security_event")); + + org.junit.jupiter.api.Assertions.assertTrue(replaySql.contains("CREATE TABLE IF NOT EXISTS tms_replay_nonce")); + org.junit.jupiter.api.Assertions.assertTrue(replaySql.contains("CREATE TABLE IF NOT EXISTS tms_security_event")); + org.junit.jupiter.api.Assertions.assertTrue(replaySql.contains("UNIQUE KEY uk_tms_replay_nonce_scope_principal_nonce (scope, principal_id, nonce)")); + org.junit.jupiter.api.Assertions.assertTrue(replaySql.contains("KEY idx_tms_replay_nonce_expires_at (expires_at)")); } } diff --git a/src/test/java/com/cisd/tms/modules/security/replay/ReplayNonceRepositoryTest.java b/src/test/java/com/cisd/tms/modules/security/replay/ReplayNonceRepositoryTest.java new file mode 100644 index 0000000..02bdd12 --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/security/replay/ReplayNonceRepositoryTest.java @@ -0,0 +1,134 @@ +package com.cisd.tms.modules.security.replay; + +import com.cisd.tms.modules.security.replay.entity.ReplayNonceEntity; +import com.cisd.tms.modules.security.replay.entity.SecurityEventEntity; +import com.cisd.tms.modules.security.replay.mapper.ReplayNonceMapper; +import com.cisd.tms.modules.security.replay.mapper.SecurityEventMapper; +import com.cisd.tms.modules.security.replay.repository.ReplayNonceRepository; +import com.cisd.tms.modules.security.replay.repository.SecurityEventRepository; +import com.cisd.tms.modules.security.replay.repository.impl.ReplayNonceRepositoryImpl; +import com.cisd.tms.modules.security.replay.repository.impl.SecurityEventRepositoryImpl; +import java.time.LocalDateTime; +import java.lang.reflect.Method; +import java.util.Optional; +import org.apache.ibatis.annotations.Delete; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.apache.ibatis.annotations.Select; + +class ReplayNonceRepositoryTest { + + @Test + void shouldSaveNonceAndLookupExistingNonce() { + ReplayNonceMapper mapper = Mockito.mock(ReplayNonceMapper.class); + ReplayNonceRepository repository = new ReplayNonceRepositoryImpl(mapper); + ReplayNonceEntity entity = replayNonce("OPENAPI", "demo-app", "nonce-001"); + Mockito.when(mapper.selectByScopeAndPrincipalIdAndNonce("OPENAPI", "demo-app", "nonce-001")) + .thenReturn(entity); + + repository.save(entity); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ReplayNonceEntity.class); + Mockito.verify(mapper).insert(captor.capture()); + Assertions.assertEquals("OPENAPI", captor.getValue().getScope()); + Assertions.assertEquals("demo-app", captor.getValue().getPrincipalId()); + Assertions.assertEquals("nonce-001", captor.getValue().getNonce()); + + Optional found = repository.findByScopeAndPrincipalIdAndNonce("OPENAPI", "demo-app", "nonce-001"); + Mockito.verify(mapper).selectByScopeAndPrincipalIdAndNonce("OPENAPI", "demo-app", "nonce-001"); + Assertions.assertTrue(found.isPresent()); + Assertions.assertEquals("nonce-001", found.orElseThrow().getNonce()); + } + + @Test + void shouldSaveSecurityEvent() { + SecurityEventMapper mapper = Mockito.mock(SecurityEventMapper.class); + SecurityEventRepository repository = new SecurityEventRepositoryImpl(mapper); + SecurityEventEntity entity = securityEvent("REPLAY_DETECTED", "INTERNAL_API", "token-001"); + + repository.save(entity); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SecurityEventEntity.class); + Mockito.verify(mapper).insert(captor.capture()); + Assertions.assertEquals("REPLAY_DETECTED", captor.getValue().getEventType()); + Assertions.assertEquals("INTERNAL_API", captor.getValue().getScope()); + Assertions.assertEquals("token-001", captor.getValue().getPrincipalId()); + } + + @Test + void shouldRepresentDuplicateNonceByLookingUpExistingRecord() { + ReplayNonceMapper mapper = Mockito.mock(ReplayNonceMapper.class); + ReplayNonceRepository repository = new ReplayNonceRepositoryImpl(mapper); + ReplayNonceEntity existing = replayNonce("INTERNAL_API", "token-001", "nonce-dup"); + Mockito.when(mapper.selectByScopeAndPrincipalIdAndNonce("INTERNAL_API", "token-001", "nonce-dup")) + .thenReturn(existing); + + Optional duplicate = repository.findByScopeAndPrincipalIdAndNonce( + "INTERNAL_API", "token-001", "nonce-dup"); + + Assertions.assertTrue(duplicate.isPresent()); + Assertions.assertEquals("nonce-dup", duplicate.orElseThrow().getNonce()); + Assertions.assertEquals("token-001", duplicate.orElseThrow().getPrincipalId()); + } + + @Test + void shouldDeclareSqlMappingForNonceLookup() throws NoSuchMethodException { + Method method = ReplayNonceMapper.class.getMethod( + "selectByScopeAndPrincipalIdAndNonce", String.class, String.class, String.class); + + Assertions.assertTrue(method.isAnnotationPresent(Select.class)); + } + + @Test + void shouldDeleteExpiredNoncesBeforeCutoff() { + ReplayNonceMapper mapper = Mockito.mock(ReplayNonceMapper.class); + ReplayNonceRepository repository = new ReplayNonceRepositoryImpl(mapper); + LocalDateTime cutoff = LocalDateTime.of(2026, 4, 9, 10, 30, 0); + Mockito.when(mapper.deleteExpiredBefore(cutoff, 1000)).thenReturn(7); + + int deletedCount = repository.deleteExpiredBefore(cutoff, 1000); + + Mockito.verify(mapper).deleteExpiredBefore(cutoff, 1000); + Assertions.assertEquals(7, deletedCount); + } + + @Test + void shouldDeclareSqlMappingForExpiredNonceDeletion() throws NoSuchMethodException { + Method method = ReplayNonceMapper.class.getMethod( + "deleteExpiredBefore", LocalDateTime.class, int.class); + + Assertions.assertTrue(method.isAnnotationPresent(Delete.class)); + } + + private static ReplayNonceEntity replayNonce(String scope, String principalId, String nonce) { + ReplayNonceEntity entity = new ReplayNonceEntity(); + entity.setScope(scope); + entity.setPrincipalId(principalId); + entity.setNonce(nonce); + entity.setRequestTimestamp(1740000000L); + entity.setRequestMethod("POST"); + entity.setRequestPath("/api/v1/demo"); + entity.setBodyHash("abc123"); + entity.setRemoteIp("127.0.0.1"); + entity.setUserAgent("JUnit"); + entity.setExpiresAt(LocalDateTime.of(2026, 4, 9, 10, 0, 0)); + return entity; + } + + private static SecurityEventEntity securityEvent(String eventType, String scope, String principalId) { + SecurityEventEntity entity = new SecurityEventEntity(); + entity.setEventType(eventType); + entity.setScope(scope); + entity.setPrincipalId(principalId); + entity.setRequestMethod("POST"); + entity.setRequestPath("/api/v1/demo"); + entity.setNonce("nonce-001"); + entity.setRemoteIp("127.0.0.1"); + entity.setDetailJson("{\"reason\":\"replay\"}"); + entity.setCreateTime(LocalDateTime.of(2026, 4, 9, 10, 0, 0)); + entity.setUpdateTime(LocalDateTime.of(2026, 4, 9, 10, 0, 0)); + return entity; + } +} diff --git a/src/test/java/com/cisd/tms/modules/security/replay/job/ReplayNonceCleanupJobTest.java b/src/test/java/com/cisd/tms/modules/security/replay/job/ReplayNonceCleanupJobTest.java new file mode 100644 index 0000000..2f1840f --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/security/replay/job/ReplayNonceCleanupJobTest.java @@ -0,0 +1,27 @@ +package com.cisd.tms.modules.security.replay.job; + +import com.cisd.tms.modules.security.replay.repository.ReplayNonceRepository; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class ReplayNonceCleanupJobTest { + + private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-04-09T10:30:00Z"), ZoneOffset.UTC); + + @Test + void shouldDeleteExpiredNoncesWithCurrentUtcTimeAndBatchLimit() { + ReplayNonceRepository replayNonceRepository = Mockito.mock(ReplayNonceRepository.class); + ReplayNonceCleanupJob cleanupJob = new ReplayNonceCleanupJob(replayNonceRepository, FIXED_CLOCK); + + cleanupJob.cleanupExpiredNonces(); + + Mockito.verify(replayNonceRepository).deleteExpiredBefore( + LocalDateTime.of(2026, 4, 9, 10, 30, 0), + 1000 + ); + } +} 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 new file mode 100644 index 0000000..a7fc19e --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/security/replay/service/ReplayProtectionServiceTest.java @@ -0,0 +1,283 @@ +package com.cisd.tms.modules.security.replay.service; + +import com.cisd.tms.common.config.properties.TmsSecurityProperties; +import com.cisd.tms.modules.security.replay.dto.ReplayCheckRequest; +import com.cisd.tms.modules.security.replay.dto.ReplayCheckResult; +import com.cisd.tms.modules.security.replay.entity.ReplayNonceEntity; +import com.cisd.tms.modules.security.replay.entity.SecurityEventEntity; +import com.cisd.tms.modules.security.replay.enums.ReplayCheckStatus; +import com.cisd.tms.modules.security.replay.enums.ReplayScope; +import com.cisd.tms.modules.security.replay.enums.SecurityEventType; +import com.cisd.tms.modules.security.replay.exception.ReplayProtectionException; +import com.cisd.tms.modules.security.replay.repository.ReplayNonceRepository; +import com.cisd.tms.modules.security.replay.repository.SecurityEventRepository; +import com.cisd.tms.modules.security.replay.service.impl.ReplayProtectionServiceImpl; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Optional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +class ReplayProtectionServiceTest { + + private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-04-09T10:00:00Z"), ZoneOffset.UTC); + private static final long FIXED_REQUEST_TIMESTAMP = 1775728800L; + private static final long EXPIRED_REQUEST_TIMESTAMP = 1775728440L; + + @Test + void shouldClaimFirstNonceAndPersistRecord() { + ReplayNonceRepository replayNonceRepository = Mockito.mock(ReplayNonceRepository.class); + SecurityEventRepository securityEventRepository = Mockito.mock(SecurityEventRepository.class); + Mockito.when(replayNonceRepository.findByScopeAndPrincipalIdAndNonce("OPENAPI", "demo-app", "nonce-001")) + .thenReturn(Optional.empty()); + Mockito.when(replayNonceRepository.trySave(Mockito.any(ReplayNonceEntity.class))).thenReturn(true); + + ReplayProtectionService service = new ReplayProtectionServiceImpl( + replayNonceRepository, + securityEventRepository, + FIXED_CLOCK + ); + + ReplayCheckRequest request = request(ReplayScope.OPENAPI, "demo-app", "nonce-001", FIXED_REQUEST_TIMESTAMP, + "POST", "/openapi/v1/demo", "abc123"); + + ReplayCheckResult result = service.check(request); + + Assertions.assertEquals(ReplayCheckStatus.CLAIMED, result.getStatus()); + Assertions.assertFalse(result.isReplayed()); + Assertions.assertFalse(result.isMismatch()); + Assertions.assertFalse(result.isSecurityEventRecorded()); + Assertions.assertEquals(LocalDateTime.of(2026, 4, 9, 10, 10), result.getExpiresAt()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ReplayNonceEntity.class); + Mockito.verify(replayNonceRepository).trySave(captor.capture()); + ReplayNonceEntity saved = captor.getValue(); + Assertions.assertEquals("OPENAPI", saved.getScope()); + Assertions.assertEquals("demo-app", saved.getPrincipalId()); + Assertions.assertEquals("nonce-001", saved.getNonce()); + Assertions.assertEquals(FIXED_REQUEST_TIMESTAMP, saved.getRequestTimestamp()); + Assertions.assertEquals("POST", saved.getRequestMethod()); + Assertions.assertEquals("/openapi/v1/demo", saved.getRequestPath()); + Assertions.assertEquals("abc123", saved.getBodyHash()); + Assertions.assertEquals("127.0.0.1", saved.getRemoteIp()); + Assertions.assertEquals("JUnit", saved.getUserAgent()); + Assertions.assertEquals(LocalDateTime.of(2026, 4, 9, 10, 10), saved.getExpiresAt()); + Mockito.verifyNoInteractions(securityEventRepository); + } + + @Test + void shouldReturnReplayResultForDuplicateNonce() { + ReplayNonceRepository replayNonceRepository = Mockito.mock(ReplayNonceRepository.class); + SecurityEventRepository securityEventRepository = Mockito.mock(SecurityEventRepository.class); + ReplayNonceEntity existing = persistedNonce("INTERNAL_API", "session-001", "nonce-dup", + "PUT", "/api/v1/device", "body-1"); + Mockito.when(replayNonceRepository.findByScopeAndPrincipalIdAndNonce("INTERNAL_API", "session-001", "nonce-dup")) + .thenReturn(Optional.of(existing)); + + ReplayProtectionService service = new ReplayProtectionServiceImpl( + replayNonceRepository, + securityEventRepository, + FIXED_CLOCK + ); + + ReplayCheckRequest request = request(ReplayScope.INTERNAL_API, "session-001", "nonce-dup", FIXED_REQUEST_TIMESTAMP, + "PUT", "/api/v1/device", "body-1"); + + ReplayCheckResult result = service.check(request); + + Assertions.assertEquals(ReplayCheckStatus.REPLAYED, result.getStatus()); + Assertions.assertTrue(result.isReplayed()); + Assertions.assertFalse(result.isMismatch()); + Assertions.assertFalse(result.isSecurityEventRecorded()); + Mockito.verify(replayNonceRepository, Mockito.never()).trySave(Mockito.any()); + Mockito.verifyNoInteractions(securityEventRepository); + } + + @Test + void shouldReclaimExpiredExistingNonceRecord() { + ReplayNonceRepository replayNonceRepository = Mockito.mock(ReplayNonceRepository.class); + SecurityEventRepository securityEventRepository = Mockito.mock(SecurityEventRepository.class); + ReplayNonceEntity expired = persistedNonce("OPENAPI", "demo-app", "nonce-expired", + "POST", "/openapi/v1/demo", "abc123"); + expired.setId(1001L); + expired.setExpiresAt(LocalDateTime.of(2026, 4, 9, 9, 59)); + Mockito.when(replayNonceRepository.findByScopeAndPrincipalIdAndNonce("OPENAPI", "demo-app", "nonce-expired")) + .thenReturn(Optional.of(expired), Optional.empty()); + Mockito.when(replayNonceRepository.trySave(Mockito.any(ReplayNonceEntity.class))).thenReturn(true); + + ReplayProtectionService service = new ReplayProtectionServiceImpl( + replayNonceRepository, + securityEventRepository, + FIXED_CLOCK + ); + + ReplayCheckRequest request = request(ReplayScope.OPENAPI, "demo-app", "nonce-expired", FIXED_REQUEST_TIMESTAMP, + "POST", "/openapi/v1/demo", "abc123"); + + ReplayCheckResult result = service.check(request); + + Assertions.assertEquals(ReplayCheckStatus.CLAIMED, result.getStatus()); + Assertions.assertFalse(result.isReplayed()); + Assertions.assertFalse(result.isMismatch()); + Assertions.assertFalse(result.isSecurityEventRecorded()); + Mockito.verify(replayNonceRepository).deleteById(1001L); + Mockito.verify(replayNonceRepository).trySave(Mockito.any(ReplayNonceEntity.class)); + Mockito.verifyNoInteractions(securityEventRepository); + } + + @Test + void shouldMarkMismatchAndRecordSecurityEventForDifferentFingerprint() { + ReplayNonceRepository replayNonceRepository = Mockito.mock(ReplayNonceRepository.class); + SecurityEventRepository securityEventRepository = Mockito.mock(SecurityEventRepository.class); + ReplayNonceEntity existing = persistedNonce("INTERNAL_API", "session-001", "nonce-dup", + "PUT", "/api/v1/device", "body-1"); + Mockito.when(replayNonceRepository.findByScopeAndPrincipalIdAndNonce("INTERNAL_API", "session-001", "nonce-dup")) + .thenReturn(Optional.of(existing)); + Mockito.doNothing().when(securityEventRepository).save(Mockito.any(SecurityEventEntity.class)); + + ReplayProtectionService service = new ReplayProtectionServiceImpl( + replayNonceRepository, + securityEventRepository, + FIXED_CLOCK + ); + + ReplayCheckRequest request = request(ReplayScope.INTERNAL_API, "session-001", "nonce-dup", FIXED_REQUEST_TIMESTAMP, + "PUT", "/api/v1/device/config", "body-2"); + + ReplayCheckResult result = service.check(request); + + Assertions.assertEquals(ReplayCheckStatus.MISMATCH, result.getStatus()); + Assertions.assertTrue(result.isReplayed()); + Assertions.assertTrue(result.isMismatch()); + Assertions.assertTrue(result.isSecurityEventRecorded()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SecurityEventEntity.class); + Mockito.verify(securityEventRepository).save(captor.capture()); + SecurityEventEntity event = captor.getValue(); + Assertions.assertEquals(SecurityEventType.REPLAY_MISMATCH.name(), event.getEventType()); + Assertions.assertEquals("INTERNAL_API", event.getScope()); + Assertions.assertEquals("session-001", event.getPrincipalId()); + Assertions.assertEquals("PUT", event.getRequestMethod()); + Assertions.assertEquals("/api/v1/device/config", event.getRequestPath()); + Assertions.assertEquals("nonce-dup", event.getNonce()); + Assertions.assertEquals("127.0.0.1", event.getRemoteIp()); + } + + @Test + void shouldRejectExpiredTimestampBeforeClaim() { + ReplayNonceRepository replayNonceRepository = Mockito.mock(ReplayNonceRepository.class); + SecurityEventRepository securityEventRepository = Mockito.mock(SecurityEventRepository.class); + ReplayProtectionService service = new ReplayProtectionServiceImpl( + replayNonceRepository, + securityEventRepository, + FIXED_CLOCK + ); + + ReplayCheckRequest request = request(ReplayScope.OPENAPI, "demo-app", "nonce-old", EXPIRED_REQUEST_TIMESTAMP, + "POST", "/openapi/v1/demo", "abc123"); + + Assertions.assertThrows(ReplayProtectionException.class, () -> service.check(request)); + Mockito.verifyNoInteractions(replayNonceRepository); + Mockito.verifyNoInteractions(securityEventRepository); + } + + @Test + void shouldFailClosedWhenClaimPersistenceFails() { + ReplayNonceRepository replayNonceRepository = Mockito.mock(ReplayNonceRepository.class); + SecurityEventRepository securityEventRepository = Mockito.mock(SecurityEventRepository.class); + Mockito.when(replayNonceRepository.findByScopeAndPrincipalIdAndNonce("OPENAPI", "demo-app", "nonce-001")) + .thenReturn(Optional.empty()); + Mockito.when(replayNonceRepository.trySave(Mockito.any(ReplayNonceEntity.class))) + .thenThrow(new IllegalStateException("db down")); + + ReplayProtectionService service = new ReplayProtectionServiceImpl( + replayNonceRepository, + securityEventRepository, + FIXED_CLOCK + ); + + ReplayCheckRequest request = request(ReplayScope.OPENAPI, "demo-app", "nonce-001", FIXED_REQUEST_TIMESTAMP, + "POST", "/openapi/v1/demo", "abc123"); + + Assertions.assertThrows(ReplayProtectionException.class, () -> service.check(request)); + } + + @Test + void shouldHonorConfiguredOpenApiSkewWindow() { + ReplayNonceRepository replayNonceRepository = Mockito.mock(ReplayNonceRepository.class); + SecurityEventRepository securityEventRepository = Mockito.mock(SecurityEventRepository.class); + Mockito.when(replayNonceRepository.findByScopeAndPrincipalIdAndNonce("OPENAPI", "demo-app", "nonce-600")) + .thenReturn(Optional.empty()); + Mockito.when(replayNonceRepository.trySave(Mockito.any(ReplayNonceEntity.class))).thenReturn(true); + TmsSecurityProperties securityProperties = securityProperties(600L); + + ReplayProtectionService service = new ReplayProtectionServiceImpl( + replayNonceRepository, + securityEventRepository, + securityProperties, + FIXED_CLOCK + ); + + ReplayCheckRequest request = request(ReplayScope.OPENAPI, "demo-app", "nonce-600", FIXED_REQUEST_TIMESTAMP - 500L, + "POST", "/openapi/v1/demo", "abc123"); + + ReplayCheckResult result = service.check(request); + + Assertions.assertEquals(ReplayCheckStatus.CLAIMED, result.getStatus()); + Assertions.assertEquals(LocalDateTime.of(2026, 4, 9, 10, 20), result.getExpiresAt()); + } + + private static ReplayCheckRequest request( + ReplayScope scope, + String principalId, + String nonce, + long requestTimestampSeconds, + String requestMethod, + String requestPath, + String bodyHash + ) { + ReplayCheckRequest request = new ReplayCheckRequest(); + request.setScope(scope); + request.setPrincipalId(principalId); + request.setNonce(nonce); + request.setRequestTimestamp(requestTimestampSeconds); + request.setRequestMethod(requestMethod); + request.setRequestPath(requestPath); + request.setBodyHash(bodyHash); + request.setRemoteIp("127.0.0.1"); + request.setUserAgent("JUnit"); + return request; + } + + private static ReplayNonceEntity persistedNonce( + String scope, + String principalId, + String nonce, + String requestMethod, + String requestPath, + String bodyHash + ) { + ReplayNonceEntity entity = new ReplayNonceEntity(); + entity.setScope(scope); + entity.setPrincipalId(principalId); + entity.setNonce(nonce); + entity.setRequestTimestamp(FIXED_REQUEST_TIMESTAMP); + entity.setRequestMethod(requestMethod); + entity.setRequestPath(requestPath); + entity.setBodyHash(bodyHash); + entity.setRemoteIp("127.0.0.1"); + entity.setUserAgent("JUnit"); + entity.setExpiresAt(LocalDateTime.of(2026, 4, 9, 10, 10)); + return entity; + } + + private static TmsSecurityProperties securityProperties(long openApiSkewSeconds) { + TmsSecurityProperties securityProperties = new TmsSecurityProperties(); + securityProperties.getOpenapi().setTimestampSkewSeconds(openApiSkewSeconds); + return securityProperties; + } +} diff --git a/src/test/java/com/cisd/tms/security/internal/InternalApiAuthInterceptorTest.java b/src/test/java/com/cisd/tms/security/internal/InternalApiAuthInterceptorTest.java deleted file mode 100644 index 220da9a..0000000 --- a/src/test/java/com/cisd/tms/security/internal/InternalApiAuthInterceptorTest.java +++ /dev/null @@ -1,153 +0,0 @@ -package com.cisd.tms.security.internal; - -import com.cisd.tms.common.config.properties.TmsSecurityProperties; -import com.cisd.tms.modules.auth.entity.AuthSessionEntity; -import com.cisd.tms.modules.auth.repository.AuthSessionRepository; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.time.Clock; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; - -class InternalApiAuthInterceptorTest { - - private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-03-23T02:00:00Z"), ZoneOffset.UTC); - - @Test - void shouldRejectRequestWhenSessionTokenIsMissing() throws Exception { - InternalApiAuthInterceptor interceptor = new InternalApiAuthInterceptor( - new InMemoryAuthSessionRepository(), - new ObjectMapper(), - new TmsSecurityProperties(), - FIXED_CLOCK - ); - MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/device/info"); - MockHttpServletResponse response = new MockHttpServletResponse(); - - boolean allowed = interceptor.preHandle(request, response, new Object()); - - Assertions.assertFalse(allowed); - Assertions.assertEquals(401, response.getStatus()); - Assertions.assertTrue(response.getContentAsString().contains("session token missing")); - } - - @Test - void shouldRejectRequestWhenSessionHasExpiredByIdleTimeout() throws Exception { - InMemoryAuthSessionRepository sessions = new InMemoryAuthSessionRepository(); - sessions.save(session("token-expired", "KEY_ADMIN", "FULL", - LocalDateTime.of(2026, 3, 23, 1, 45), - LocalDateTime.of(2026, 3, 23, 1, 49), - LocalDateTime.of(2026, 3, 23, 1, 59))); - - InternalApiAuthInterceptor interceptor = new InternalApiAuthInterceptor( - sessions, - new ObjectMapper(), - new TmsSecurityProperties(), - FIXED_CLOCK - ); - MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/device/info"); - request.addHeader("Authorization", "Bearer token-expired"); - MockHttpServletResponse response = new MockHttpServletResponse(); - - boolean allowed = interceptor.preHandle(request, response, new Object()); - - Assertions.assertFalse(allowed); - Assertions.assertEquals(401, response.getStatus()); - Assertions.assertTrue(response.getContentAsString().contains("session expired")); - } - - @Test - void shouldAllowRequestAndRefreshLastActiveAtForValidSession() throws Exception { - InMemoryAuthSessionRepository sessions = new InMemoryAuthSessionRepository(); - sessions.save(session("token-valid", "AUDIT_ADMIN", "LIMITED", - LocalDateTime.of(2026, 3, 23, 1, 55), - LocalDateTime.of(2026, 3, 23, 1, 58), - LocalDateTime.of(2026, 3, 23, 2, 8))); - - InternalApiAuthInterceptor interceptor = new InternalApiAuthInterceptor( - sessions, - new ObjectMapper(), - new TmsSecurityProperties(), - FIXED_CLOCK - ); - MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/device/info"); - request.addHeader("Authorization", "Bearer token-valid"); - MockHttpServletResponse response = new MockHttpServletResponse(); - - boolean allowed = interceptor.preHandle(request, response, new Object()); - - Assertions.assertTrue(allowed); - Assertions.assertEquals("AUDIT_ADMIN", request.getAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE)); - Assertions.assertEquals("LIMITED", request.getAttribute(InternalApiAuthInterceptor.ATTR_AUTH_LEVEL)); - AuthSessionEntity updated = sessions.findBySessionToken("token-valid").orElseThrow(); - Assertions.assertEquals(LocalDateTime.of(2026, 3, 23, 2, 0), updated.getLastActiveAt()); - Assertions.assertEquals(LocalDateTime.of(2026, 3, 23, 2, 10), updated.getExpiresAt()); - } - - @Test - void shouldBypassSessionValidationWhenInternalAuthIsDisabled() throws Exception { - TmsSecurityProperties securityProperties = new TmsSecurityProperties(); - securityProperties.getInternalAuth().setEnabled(false); - - InternalApiAuthInterceptor interceptor = new InternalApiAuthInterceptor( - new InMemoryAuthSessionRepository(), - new ObjectMapper(), - securityProperties, - FIXED_CLOCK - ); - MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/device/info"); - MockHttpServletResponse response = new MockHttpServletResponse(); - - boolean allowed = interceptor.preHandle(request, response, new Object()); - - Assertions.assertTrue(allowed); - Assertions.assertEquals("KEY_ADMIN", request.getAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE)); - Assertions.assertEquals("FULL", request.getAttribute(InternalApiAuthInterceptor.ATTR_AUTH_LEVEL)); - Assertions.assertEquals("DEBUG-BYPASS", request.getAttribute(InternalApiAuthInterceptor.ATTR_SESSION_TOKEN)); - } - - private static AuthSessionEntity session( - String token, - String roleCode, - String authLevel, - LocalDateTime issuedAt, - LocalDateTime lastActiveAt, - LocalDateTime expiresAt - ) { - AuthSessionEntity entity = new AuthSessionEntity(); - entity.setId((long) (token.hashCode() & Integer.MAX_VALUE)); - entity.setSessionToken(token); - entity.setRoleCode(roleCode); - entity.setAuthLevel(authLevel); - entity.setIssuedAt(issuedAt); - entity.setLastActiveAt(lastActiveAt); - entity.setExpiresAt(expiresAt); - return entity; - } - - private static class InMemoryAuthSessionRepository implements AuthSessionRepository { - private final Map store = new ConcurrentHashMap<>(); - - @Override - public Optional findBySessionToken(String sessionToken) { - return Optional.ofNullable(store.get(sessionToken)); - } - - @Override - public void save(AuthSessionEntity entity) { - store.put(entity.getSessionToken(), entity); - } - - @Override - public void update(AuthSessionEntity entity) { - store.put(entity.getSessionToken(), entity); - } - } -} diff --git a/src/test/java/com/cisd/tms/security/internal/InternalApiReplayInterceptorTest.java b/src/test/java/com/cisd/tms/security/internal/InternalApiReplayInterceptorTest.java new file mode 100644 index 0000000..6583a71 --- /dev/null +++ b/src/test/java/com/cisd/tms/security/internal/InternalApiReplayInterceptorTest.java @@ -0,0 +1,180 @@ +package com.cisd.tms.security.internal; + +import com.cisd.tms.modules.security.replay.dto.ReplayCheckRequest; +import com.cisd.tms.modules.security.replay.dto.ReplayCheckResult; +import com.cisd.tms.modules.security.replay.enums.ReplayCheckStatus; +import com.cisd.tms.modules.security.replay.enums.ReplayScope; +import com.cisd.tms.modules.security.replay.exception.ReplayProtectionException; +import com.cisd.tms.modules.security.replay.service.ReplayProtectionService; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.method.HandlerMethod; + +class InternalApiReplayInterceptorTest { + + private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-04-09T10:00:00Z"), ZoneOffset.UTC); + private static final long FIXED_REQUEST_TIMESTAMP = 1775728800L; + + @Test + void shouldAllowReplayProtectedRequestAndHashBody() throws Exception { + ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class); + Mockito.when(replayProtectionService.check(Mockito.any(ReplayCheckRequest.class))).thenReturn(claimedResult()); + InternalApiReplayInterceptor interceptor = new InternalApiReplayInterceptor( + replayProtectionService, + new ObjectMapper(), + FIXED_CLOCK + ); + MockHttpServletRequest sourceRequest = new MockHttpServletRequest("POST", "/api/v1/init/tasks/task-001/execute"); + sourceRequest.setContentType("application/json"); + sourceRequest.setContent("{\"taskId\":\"task-001\"}".getBytes(StandardCharsets.UTF_8)); + sourceRequest.addHeader("X-Request-Timestamp", Long.toString(FIXED_REQUEST_TIMESTAMP)); + sourceRequest.addHeader("X-Request-Nonce", "nonce-001"); + sourceRequest.addHeader("User-Agent", "JUnit"); + sourceRequest.setRemoteAddr("127.0.0.1"); + 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.assertTrue(allowed); + ArgumentCaptor captor = ArgumentCaptor.forClass(ReplayCheckRequest.class); + Mockito.verify(replayProtectionService).check(captor.capture()); + ReplayCheckRequest replayRequest = captor.getValue(); + Assertions.assertEquals(ReplayScope.INTERNAL_API, replayRequest.getScope()); + Assertions.assertEquals("session-001", replayRequest.getPrincipalId()); + Assertions.assertEquals("nonce-001", replayRequest.getNonce()); + Assertions.assertEquals(FIXED_REQUEST_TIMESTAMP, replayRequest.getRequestTimestamp()); + Assertions.assertEquals("POST", replayRequest.getRequestMethod()); + Assertions.assertEquals("/api/v1/init/tasks/task-001/execute", replayRequest.getRequestPath()); + Assertions.assertEquals(sha256Hex("{\"taskId\":\"task-001\"}"), replayRequest.getBodyHash()); + } + + @Test + void shouldSkipHandlersWithoutReplayProtection() throws Exception { + ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class); + InternalApiReplayInterceptor interceptor = new InternalApiReplayInterceptor( + replayProtectionService, + new ObjectMapper(), + FIXED_CLOCK + ); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/device/info"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean allowed = interceptor.preHandle(request, response, protectedHandler("unprotected")); + + Assertions.assertTrue(allowed); + Mockito.verifyNoInteractions(replayProtectionService); + } + + @Test + void shouldRejectProtectedRequestWhenReplayHeadersAreMissing() throws Exception { + ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class); + InternalApiReplayInterceptor interceptor = new InternalApiReplayInterceptor( + replayProtectionService, + new ObjectMapper(), + FIXED_CLOCK + ); + MockHttpServletRequest sourceRequest = new MockHttpServletRequest("POST", "/api/v1/init/tasks/task-001/execute"); + 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("missing replay protection headers")); + Mockito.verifyNoInteractions(replayProtectionService); + } + + @Test + void shouldRejectProtectedRequestWhenReplayIsDetected() throws Exception { + ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class); + ReplayCheckResult replayed = new ReplayCheckResult(); + replayed.setStatus(ReplayCheckStatus.REPLAYED); + replayed.setReplayed(true); + Mockito.when(replayProtectionService.check(Mockito.any(ReplayCheckRequest.class))).thenReturn(replayed); + InternalApiReplayInterceptor interceptor = new InternalApiReplayInterceptor( + replayProtectionService, + 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)); + 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(409, response.getStatus()); + Assertions.assertTrue(response.getContentAsString().contains("replayed nonce")); + } + + @Test + void shouldFailClosedWhenReplayProtectionErrors() throws Exception { + ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class); + Mockito.when(replayProtectionService.check(Mockito.any(ReplayCheckRequest.class))) + .thenThrow(new ReplayProtectionException("db down")); + InternalApiReplayInterceptor interceptor = new InternalApiReplayInterceptor( + replayProtectionService, + 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)); + 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(503, response.getStatus()); + Assertions.assertTrue(response.getContentAsString().contains("replay protection unavailable")); + } + + private static HandlerMethod protectedHandler(String methodName) throws NoSuchMethodException { + return new HandlerMethod(new ReplayProtectedController(), ReplayProtectedController.class.getMethod(methodName)); + } + + private static ReplayCheckResult claimedResult() { + ReplayCheckResult result = new ReplayCheckResult(); + result.setStatus(ReplayCheckStatus.CLAIMED); + result.setReplayed(false); + return result; + } + + private static String sha256Hex(String value) throws Exception { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder builder = new StringBuilder(digest.length * 2); + for (byte b : digest) { + builder.append(String.format("%02x", b)); + } + return builder.toString(); + } + + static class ReplayProtectedController { + + @ReplayProtected + public void execute() { + } + + public void unprotected() { + } + } +} diff --git a/src/test/java/com/cisd/tms/security/internal/ReplayProtectedEndpointsTest.java b/src/test/java/com/cisd/tms/security/internal/ReplayProtectedEndpointsTest.java new file mode 100644 index 0000000..f7524a2 --- /dev/null +++ b/src/test/java/com/cisd/tms/security/internal/ReplayProtectedEndpointsTest.java @@ -0,0 +1,112 @@ +package com.cisd.tms.security.internal; + +import com.cisd.tms.modules.auth.controller.AuthAdminController; +import com.cisd.tms.modules.auth.controller.AuthController; +import com.cisd.tms.modules.device.controller.CryptoCardController; +import com.cisd.tms.modules.device.controller.DeviceController; +import com.cisd.tms.modules.device.controller.IpWhitelistController; +import com.cisd.tms.modules.device.controller.NetworkConfigController; +import com.cisd.tms.modules.device.controller.TimeConfigController; +import com.cisd.tms.modules.init.controller.InitController; +import com.cisd.tms.modules.mk.controller.LmkController; +import com.cisd.tms.modules.upgrade.controller.UpgradeController; +import java.lang.reflect.Method; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.core.annotation.AnnotatedElementUtils; + +class ReplayProtectedEndpointsTest { + + @Test + void shouldProtectSelectedInitActions() throws Exception { + Assertions.assertNotNull(annotation(InitController.class, "createTask", com.cisd.tms.modules.init.dto.InitPreviewRequest.class)); + Assertions.assertNotNull(annotation(InitController.class, "executeTask", String.class)); + Assertions.assertNotNull(annotation(InitController.class, "createResetTask")); + Assertions.assertNotNull(annotation(InitController.class, "executeResetTask", String.class)); + } + + @Test + void shouldProtectSelectedUpgradeActions() throws Exception { + Assertions.assertNotNull(annotation(UpgradeController.class, "uploadPackage", org.springframework.web.multipart.MultipartFile.class)); + Assertions.assertNotNull(annotation(UpgradeController.class, "create", com.cisd.tms.modules.upgrade.dto.UpgradeCreateTaskRequest.class)); + Assertions.assertNotNull(annotation(UpgradeController.class, "execute", String.class)); + Assertions.assertNotNull(annotation(UpgradeController.class, "rollback", String.class)); + } + + @Test + void shouldProtectSensitiveControllersAndMethods() throws Exception { + Assertions.assertNotNull(annotation(AuthController.class, "changePassword", + com.cisd.tms.modules.auth.dto.ChangePasswordRequest.class, jakarta.servlet.http.HttpServletRequest.class)); + Assertions.assertNotNull(annotation(DeviceController.class, "restart", jakarta.servlet.http.HttpServletRequest.class)); + + Assertions.assertNotNull(AnnotatedElementUtils.findMergedAnnotation(AuthAdminController.class, ReplayProtected.class)); + Assertions.assertNotNull(AnnotatedElementUtils.findMergedAnnotation(TimeConfigController.class, ReplayProtected.class)); + Assertions.assertNotNull(AnnotatedElementUtils.findMergedAnnotation(CryptoCardController.class, ReplayProtected.class)); + } + + @Test + void shouldOnlyProtectWriteActionsOnMixedReadWriteControllers() throws Exception { + Assertions.assertNull(AnnotatedElementUtils.findMergedAnnotation(NetworkConfigController.class, ReplayProtected.class)); + Assertions.assertNull(AnnotatedElementUtils.findMergedAnnotation(IpWhitelistController.class, ReplayProtected.class)); + Assertions.assertNull(AnnotatedElementUtils.findMergedAnnotation(LmkController.class, ReplayProtected.class)); + + Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "setIpv4Config", + com.cisd.tms.modules.device.dto.network.Ipv4ConfigRequest.class)); + Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "setIpv6Config", + com.cisd.tms.modules.device.dto.network.Ipv6ConfigRequest.class)); + Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "createBond", + com.cisd.tms.modules.device.dto.network.BondCreateRequest.class)); + Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "deleteBond", String.class)); + Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "addSlavesTOBond", + com.cisd.tms.modules.device.dto.network.BondAddSlavesRequest.class)); + Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "removeSlaveFromBond", + com.cisd.tms.modules.device.dto.network.BondRemoveSlaveRequest.class)); + Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "setBondMode", + com.cisd.tms.modules.device.dto.network.BondModifyModeRequest.class)); + Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "setDefaultRoute", + com.cisd.tms.modules.device.dto.network.SetDefaultRouteRequest.class)); + Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "addStaticRoute", + com.cisd.tms.modules.device.dto.network.AddStaticRouteRequest.class)); + Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "deleteDefaultRoute", + com.cisd.tms.modules.device.dto.network.DeleteDefaultRouteRequest.class)); + Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "deleteStaticRoute", + com.cisd.tms.modules.device.dto.network.DeleteStaticRouteRequest.class)); + + Assertions.assertNull(effectiveAnnotation(NetworkConfigController.class, "getNetworkInfo")); + Assertions.assertNull(effectiveAnnotation(NetworkConfigController.class, "getIpv4Info", String.class)); + Assertions.assertNull(effectiveAnnotation(NetworkConfigController.class, "getIpv6Info", String.class)); + Assertions.assertNull(effectiveAnnotation(NetworkConfigController.class, "getAllBondName")); + Assertions.assertNull(effectiveAnnotation(NetworkConfigController.class, "getBondSlaves", String.class)); + Assertions.assertNull(effectiveAnnotation(NetworkConfigController.class, "getBondMode", String.class)); + Assertions.assertNull(effectiveAnnotation(NetworkConfigController.class, "getRoutingTable")); + + Assertions.assertNotNull(effectiveAnnotation(IpWhitelistController.class, "addWhitelist", + com.cisd.tms.modules.device.dto.network.IpWhitelistRequest.class)); + Assertions.assertNotNull(effectiveAnnotation(IpWhitelistController.class, "updateWhitelist", + com.cisd.tms.modules.device.dto.network.IpWhitelistRequest.class)); + Assertions.assertNotNull(effectiveAnnotation(IpWhitelistController.class, "deleteWhitelist", Long.class)); + Assertions.assertNull(effectiveAnnotation(IpWhitelistController.class, "getWhitelistPage", + com.cisd.tms.modules.device.dto.network.IpWhitelistRequest.class)); + + Assertions.assertNotNull(effectiveAnnotation(LmkController.class, "initMasterKey")); + Assertions.assertNotNull(effectiveAnnotation(LmkController.class, "destroyMasterKey")); + Assertions.assertNotNull(effectiveAnnotation(LmkController.class, "recoverLmkEx", + com.cisd.tms.modules.mk.dto.MasterKeyRecoverPacketsRequest.class)); + Assertions.assertNull(effectiveAnnotation(LmkController.class, "queryMasterKeyStatus")); + Assertions.assertNull(effectiveAnnotation(LmkController.class, "signIk", String.class)); + } + + private static ReplayProtected annotation(Class type, String methodName, Class... parameterTypes) throws Exception { + Method method = type.getMethod(methodName, parameterTypes); + return AnnotatedElementUtils.findMergedAnnotation(method, ReplayProtected.class); + } + + private static ReplayProtected effectiveAnnotation(Class type, String methodName, Class... parameterTypes) throws Exception { + Method method = type.getMethod(methodName, parameterTypes); + ReplayProtected methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, ReplayProtected.class); + if (methodAnnotation != null) { + return methodAnnotation; + } + return AnnotatedElementUtils.findMergedAnnotation(type, ReplayProtected.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 new file mode 100644 index 0000000..f0a6dad --- /dev/null +++ b/src/test/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptorTest.java @@ -0,0 +1,183 @@ +package com.cisd.tms.security.openapi; + +import com.cisd.tms.common.config.properties.TmsSecurityProperties; +import com.cisd.tms.common.util.HmacUtil; +import com.cisd.tms.modules.security.replay.dto.ReplayCheckRequest; +import com.cisd.tms.modules.security.replay.dto.ReplayCheckResult; +import com.cisd.tms.modules.security.replay.enums.ReplayCheckStatus; +import com.cisd.tms.modules.security.replay.enums.ReplayScope; +import com.cisd.tms.modules.security.replay.exception.ReplayProtectionException; +import com.cisd.tms.modules.security.replay.service.ReplayProtectionService; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +class OpenApiSignAuthInterceptorTest { + + private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-04-09T10:00:00Z"), ZoneOffset.UTC); + private static final long FIXED_NOW_SECONDS = 1775728800L; + + @Test + void shouldPassConfiguredSkewToReplayProtectionServiceAfterSignatureValidation() throws Exception { + TmsSecurityProperties securityProperties = securityProperties("demo-app", "demo-secret", 600L); + ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class); + Mockito.when(replayProtectionService.check(Mockito.any(ReplayCheckRequest.class))) + .thenReturn(claimedResult()); + OpenApiSignAuthInterceptor interceptor = new OpenApiSignAuthInterceptor( + securityProperties, + replayProtectionService, + new ObjectMapper(), + FIXED_CLOCK + ); + MockHttpServletRequest request = signedRequest( + "demo-app", + "demo-secret", + FIXED_NOW_SECONDS - 500L, + "nonce-001", + "POST", + "/openapi/v1/demo" + ); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean allowed = interceptor.preHandle(request, response, new Object()); + + Assertions.assertTrue(allowed); + ArgumentCaptor captor = ArgumentCaptor.forClass(ReplayCheckRequest.class); + Mockito.verify(replayProtectionService).check(captor.capture()); + ReplayCheckRequest replayRequest = captor.getValue(); + Assertions.assertEquals(ReplayScope.OPENAPI, replayRequest.getScope()); + Assertions.assertEquals("demo-app", replayRequest.getPrincipalId()); + Assertions.assertEquals("nonce-001", replayRequest.getNonce()); + Assertions.assertEquals(FIXED_NOW_SECONDS - 500L, replayRequest.getRequestTimestamp()); + Assertions.assertEquals("POST", replayRequest.getRequestMethod()); + Assertions.assertEquals("/openapi/v1/demo", replayRequest.getRequestPath()); + } + + @Test + void shouldRejectInvalidSignatureWithoutCallingReplayProtectionService() throws Exception { + TmsSecurityProperties securityProperties = securityProperties("demo-app", "demo-secret", 300L); + ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class); + OpenApiSignAuthInterceptor interceptor = new OpenApiSignAuthInterceptor( + securityProperties, + replayProtectionService, + new ObjectMapper(), + FIXED_CLOCK + ); + MockHttpServletRequest request = signedRequest( + "demo-app", + "wrong-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(401, response.getStatus()); + Assertions.assertTrue(response.getContentAsString().contains("signature invalid")); + Mockito.verifyNoInteractions(replayProtectionService); + } + + @Test + void shouldRejectWhenReplayProtectionReportsReplay() throws Exception { + TmsSecurityProperties securityProperties = securityProperties("demo-app", "demo-secret", 300L); + ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class); + ReplayCheckResult replayed = new ReplayCheckResult(); + replayed.setStatus(ReplayCheckStatus.REPLAYED); + replayed.setReplayed(true); + Mockito.when(replayProtectionService.check(Mockito.any(ReplayCheckRequest.class))).thenReturn(replayed); + 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(401, response.getStatus()); + Assertions.assertTrue(response.getContentAsString().contains("replayed nonce")); + } + + @Test + void shouldFailClosedWhenReplayProtectionServiceErrors() 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(new ReplayProtectionException("db down")); + 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(401, response.getStatus()); + Assertions.assertTrue(response.getContentAsString().contains("replay protection unavailable")); + } + + private static TmsSecurityProperties securityProperties(String appId, String secret, long skewSeconds) { + TmsSecurityProperties securityProperties = new TmsSecurityProperties(); + securityProperties.getOpenapi().setTimestampSkewSeconds(skewSeconds); + securityProperties.getOpenapi().getClients().put(appId, secret); + return securityProperties; + } + + private static MockHttpServletRequest signedRequest( + String appId, + String secret, + long timestamp, + String nonce, + String method, + String path + ) { + MockHttpServletRequest request = new MockHttpServletRequest(method, path); + request.addHeader("X-App-Id", appId); + request.addHeader("X-Timestamp", Long.toString(timestamp)); + request.addHeader("X-Nonce", nonce); + request.addHeader("X-Signature", HmacUtil.hmacSha256Hex(secret, appId + "\n" + timestamp + "\n" + nonce)); + request.addHeader("User-Agent", "JUnit"); + request.setRemoteAddr("127.0.0.1"); + return request; + } + + private static ReplayCheckResult claimedResult() { + ReplayCheckResult result = new ReplayCheckResult(); + result.setStatus(ReplayCheckStatus.CLAIMED); + result.setReplayed(false); + result.setMismatch(false); + return result; + } +}