diff --git a/config/application.yml.example b/config/application.yml.example
index 43f9abe..7e2c3c9 100644
--- a/config/application.yml.example
+++ b/config/application.yml.example
@@ -1,6 +1,10 @@
spring:
application:
name: tms-framework
+ servlet:
+ multipart:
+ max-file-size: 104857600
+ max-request-size: 104857600
server:
port: 8080
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 5d6e4b7..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;
@@ -11,6 +12,7 @@ import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.multipart.MaxUploadSizeExceededException;
@RestControllerAdvice
public class GlobalExceptionHandler {
@@ -45,12 +47,25 @@ public class GlobalExceptionHandler {
return ApiResponse.fail(ErrorCode.VALIDATE_FAILED.getCode(), ex.getMessage()).withPath(request.getRequestURI());
}
+ @ExceptionHandler(MaxUploadSizeExceededException.class)
+ public ApiResponse handleMaxUploadSizeExceeded(MaxUploadSizeExceededException ex, HttpServletRequest request) {
+ return ApiResponse.fail(ErrorCode.VALIDATE_FAILED.getCode(), "upload file size exceeds limit")
+ .withPath(request.getRequestURI());
+ }
+
@ExceptionHandler(PcieCryptoException.class)
public ApiResponse handleCryptoCardException(PcieCryptoException ex, HttpServletRequest request) {
return ApiResponse.fail(ErrorCode.CRYPTO_CARD_ERROR.getCode(), ex.getMessage())
.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 f67e189..cc08dde 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();
@@ -49,4 +53,4 @@ public class IpWhitelistController {
IPage page = ipWhitelistService.getWhitelistPage(pageNum, pageSize);
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 0e8deb9..bd1789c 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) {
networkConfigService.setIpv4Config(req);
return ApiResponse.success();
@@ -50,6 +52,7 @@ public class NetworkConfigController {
@Operation(summary = "设置IPv6配置", description = "为指定网络接口配置IPv6地址、前缀长度、网关等信息")
@PostMapping("/ipv6-config/set")
+ @ReplayProtected
public ApiResponse setIpv6Config(@RequestBody Ipv6ConfigRequest req) {
networkConfigService.setIpv6Config(req);
return ApiResponse.success();
@@ -63,6 +66,7 @@ public class NetworkConfigController {
@Operation(summary = "创建Bond", description = "创建一个新的Bond聚合接口,需指定名称、模式和从属接口")
@PostMapping("/bond/create")
+ @ReplayProtected
public ApiResponse createBond(@RequestBody BondCreateRequest req) {
networkConfigService.createBond(req);
return ApiResponse.success();
@@ -71,6 +75,7 @@ public class NetworkConfigController {
@Operation(summary = "删除Bond", description = "根据Bond名称删除指定的聚合接口")
@PostMapping("/bond/delete/{bondName}")
+ @ReplayProtected
public ApiResponse deleteBond(@PathVariable("bondName") String bondName){
networkConfigService.deleteBond(bondName);
return ApiResponse.success();
@@ -78,6 +83,7 @@ public class NetworkConfigController {
@Operation(summary = "添加从属接口到Bond", description = "向指定Bond中添加一个或多个从属网络接口")
@PostMapping("/bond-slave/add")
+ @ReplayProtected
public ApiResponse addSlavesTOBond(@RequestBody BondAddSlavesRequest req) {
networkConfigService.addSlavesTOBond(req);
return ApiResponse.success();
@@ -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,8 +119,8 @@ public class NetworkConfigController {
@Operation(summary = "设置Bond模式", description = "修改指定Bond的绑定模式")
@PostMapping("/bond/mode/set")
+ @ReplayProtected
public ApiResponse setBondMode(@RequestBody BondModifyModeRequest req) {
-
networkConfigService.setBondMode(req);
return ApiResponse.success();
}
@@ -127,6 +134,7 @@ public class NetworkConfigController {
@Operation(summary = "设置默认路由", description = "配置或修改系统的默认网关路由")
@PostMapping("/routes/default/set")
+ @ReplayProtected
public ApiResponse setDefaultRoute(@RequestBody SetDefaultRouteRequest req) {
networkConfigService.setDefaultRoute(req);
return ApiResponse.success();
@@ -134,6 +142,7 @@ public class NetworkConfigController {
@Operation(summary = "添加静态路由", description = "新增一条静态路由规则")
@PostMapping("/routes/static/add")
+ @ReplayProtected
public ApiResponse addStaticRoute(@RequestBody AddStaticRouteRequest req) {
networkConfigService.addStaticRoute(req);
return ApiResponse.success();
@@ -141,6 +150,7 @@ public class NetworkConfigController {
@Operation(summary = "删除默认路由", description = "删除指定的默认路由")
@PostMapping("/routes/default/delete")
+ @ReplayProtected
public ApiResponse deleteDefaultRoute(@RequestBody DeleteDefaultRouteRequest req) {
networkConfigService.deleteDefaultRoute(req);
return ApiResponse.success();
@@ -148,6 +158,7 @@ public class NetworkConfigController {
@Operation(summary = "删除静态路由", description = "删除指定的静态路由")
@PostMapping("/routes/static/delete")
+ @ReplayProtected
public ApiResponse deleteStaticRoute(@RequestBody DeleteStaticRouteRequest req) {
networkConfigService.deleteStaticRoute(req);
return ApiResponse.success();
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/device/service/impl/DeviceProfileServiceImpl.java b/src/main/java/com/cisd/tms/modules/device/service/impl/DeviceProfileServiceImpl.java
index a26bae3..e8023e6 100644
--- a/src/main/java/com/cisd/tms/modules/device/service/impl/DeviceProfileServiceImpl.java
+++ b/src/main/java/com/cisd/tms/modules/device/service/impl/DeviceProfileServiceImpl.java
@@ -83,9 +83,9 @@ public class DeviceProfileServiceImpl implements DeviceProfileService {
if ("ENTERPRISE".equals(productType) || "INDIRECT".equals(productType)) {
addSoftwareItem(items, "TIDB", "数据库", cached("TIDB"));
addSoftwareItem(items, "NGINX", "Nginx", cached("NGINX"));
- addSoftwareItem(items, "APP", appName(productType), firstNonBlank(cached("APP"), cisdPresetProperties.getVersion()));
+ addSoftwareItem(items, "RECEIVER", appName(productType), firstNonBlank(cached("RECEIVER"), cisdPresetProperties.getVersion()));
} else if ("DIRECT".equals(productType)) {
- addSoftwareItem(items, "APP", appName(productType), firstNonBlank(cached("APP"), cisdPresetProperties.getVersion()));
+ addSoftwareItem(items, "RECEIVER", appName(productType), firstNonBlank(cached("RECEIVER"), cisdPresetProperties.getVersion()));
}
return items;
}
diff --git a/src/main/java/com/cisd/tms/modules/device/service/impl/DeviceRuntimeStatusServiceImpl.java b/src/main/java/com/cisd/tms/modules/device/service/impl/DeviceRuntimeStatusServiceImpl.java
index 7164abf..3b099da 100644
--- a/src/main/java/com/cisd/tms/modules/device/service/impl/DeviceRuntimeStatusServiceImpl.java
+++ b/src/main/java/com/cisd/tms/modules/device/service/impl/DeviceRuntimeStatusServiceImpl.java
@@ -114,17 +114,17 @@ public class DeviceRuntimeStatusServiceImpl implements DeviceRuntimeStatusServic
items.add(buildSoftwareItem("TLQ", "消息队列", cached("TLQ"), initialized && isTlqEnabled(mqType), softwareRuntimeProbe::isTlqRunning));
items.add(buildSoftwareItem("TIDB", "数据库", cached("TIDB"), initialized && isTidbEnabled(productType), softwareRuntimeProbe::isTidbRunning));
items.add(buildSoftwareItem("NGINX", "Nginx", cached("NGINX"), initialized && isNginxEnabled(productType), softwareRuntimeProbe::isNginxRunning));
- items.add(buildAppItem(productType, initialized));
+ items.add(buildReceiverItem(productType, initialized));
items.add(buildSoftwareItem("TMS", "设备管理软件", firstNonBlank(cached("TMS"), systemSoftwareVersionProbe.tmsVersion(), cisdPresetProperties.getVersion()), true, softwareRuntimeProbe::isTmsRunning));
return items;
}
- private SoftwareRuntimeStatusItem buildAppItem(String productType, boolean initialized) {
+ private SoftwareRuntimeStatusItem buildReceiverItem(String productType, boolean initialized) {
boolean standard = "ENTERPRISE".equals(productType) || "INDIRECT".equals(productType);
BooleanSupplier supplier = standard ? softwareRuntimeProbe::isStandardAppRunning : softwareRuntimeProbe::isDirectAppRunning;
- String name = "应用软件";
- String displayName = firstNonBlank(cached("APP"), appName(productType));
- return buildSoftwareItem("APP", name, displayName, initialized, supplier);
+ String name = "标准收发器";
+ String displayName = firstNonBlank(cached("RECEIVER"), appName(productType));
+ return buildSoftwareItem("RECEIVER", name, displayName, initialized, supplier);
}
private SoftwareRuntimeStatusItem buildSoftwareItem(
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 ea7b9e6..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,10 +11,12 @@ 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;
import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -26,19 +28,16 @@ import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/v1")
+@RequiredArgsConstructor
@Tag(name = "离线升级管理", description = "离线升级包上传、预检、任务创建、执行和升级记录查询接口")
public class UpgradeController {
private final UpgradeService upgradeService;
private final FileService fileService;
- public UpgradeController(UpgradeService upgradeService, FileService fileService) {
- this.upgradeService = upgradeService;
- this.fileService = fileService;
- }
-
@PostMapping("/upgrade-packages")
@Operation(summary = "上传离线升级包", description = "上传离线升级包,返回 fileId 供升级预检和任务创建使用。")
+ @ReplayProtected
public ApiResponse uploadPackage(
@Parameter(description = "待上传的离线升级包")
@RequestParam("file") MultipartFile file
@@ -54,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
@@ -69,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/modules/upgrade/enums/UpgradeTargetComponent.java b/src/main/java/com/cisd/tms/modules/upgrade/enums/UpgradeTargetComponent.java
index 28cffd9..03e0058 100644
--- a/src/main/java/com/cisd/tms/modules/upgrade/enums/UpgradeTargetComponent.java
+++ b/src/main/java/com/cisd/tms/modules/upgrade/enums/UpgradeTargetComponent.java
@@ -1,5 +1,8 @@
package com.cisd.tms.modules.upgrade.enums;
+/**
+ * 升级任务类型。具体应用名称由升级包脚本处理,后端不按应用名拆分。
+ */
public enum UpgradeTargetComponent {
TMS,
RECEIVER,
diff --git a/src/main/java/com/cisd/tms/modules/upgrade/enums/UpgradeTaskStatus.java b/src/main/java/com/cisd/tms/modules/upgrade/enums/UpgradeTaskStatus.java
index e6f5e65..dec641b 100644
--- a/src/main/java/com/cisd/tms/modules/upgrade/enums/UpgradeTaskStatus.java
+++ b/src/main/java/com/cisd/tms/modules/upgrade/enums/UpgradeTaskStatus.java
@@ -1,5 +1,8 @@
package com.cisd.tms.modules.upgrade.enums;
+/**
+ * 升级任务主状态。脚本级细分状态当前通过日志体现,尚未独立落表。
+ */
public enum UpgradeTaskStatus {
PENDING_CONFIRM,
RUNNING,
diff --git a/src/main/java/com/cisd/tms/modules/upgrade/executor/UpgradeTaskRunner.java b/src/main/java/com/cisd/tms/modules/upgrade/executor/UpgradeTaskRunner.java
index 9dbbd9f..9728b90 100644
--- a/src/main/java/com/cisd/tms/modules/upgrade/executor/UpgradeTaskRunner.java
+++ b/src/main/java/com/cisd/tms/modules/upgrade/executor/UpgradeTaskRunner.java
@@ -58,6 +58,7 @@ public class UpgradeTaskRunner {
UpgradeManifest manifest = objectMapper.readValue(Files.readString(stagedDir.resolve("manifest.json")), UpgradeManifest.class);
Path root = stagedDir.normalize().toAbsolutePath();
+ // 包内脚本按固定阶段执行;可选阶段未声明时跳过,execute 阶段必须存在。
int precheckCode = runOptionalScript("precheck", root, manifest.getEntrypoints() == null ? "" : manifest.getEntrypoints().getPrecheck(), task, logPath);
if (precheckCode != 0) {
upgradeTaskRepository.updateStatus(
@@ -97,9 +98,11 @@ public class UpgradeTaskRunner {
return;
}
+ // 只有 execute 和 verify 都成功后,才认为升级成功并回写设备软件版本表。
DeviceSoftwareVersionEntity versionEntity = new DeviceSoftwareVersionEntity();
- versionEntity.setComponentCode(task.getTaskType());
- versionEntity.setComponentName(componentName(task.getTaskType()));
+ String componentCode = componentCode(task.getTaskType());
+ versionEntity.setComponentCode(componentCode);
+ versionEntity.setComponentName(componentName(componentCode));
versionEntity.setCurrentVersion(task.getTargetVersion());
versionEntity.setSourceType("UPGRADE");
versionEntity.setDetectedAt(LocalDateTime.now());
@@ -146,6 +149,7 @@ public class UpgradeTaskRunner {
if (rollbackPath.isEmpty()) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade rollback script not found");
}
+ // 回滚不自动触发,只有外部调用 rollback 接口后才执行包内 rollback 脚本。
int exitCode = runRequiredScript("rollback", stagedDir.normalize().toAbsolutePath(), rollbackPath, task, logPath);
if (exitCode != 0) {
upgradeTaskRepository.updateStatus(
@@ -197,6 +201,7 @@ public class UpgradeTaskRunner {
private int runRequiredScript(String phase, Path stagedDir, String scriptPath, UpgradeTaskEntity task, Path logPath) throws IOException, InterruptedException {
Path resolved = stagedDir.resolve(trim(scriptPath)).normalize().toAbsolutePath();
+ // 防止脚本路径通过 ../ 跳出升级包解压目录。
if (!resolved.startsWith(stagedDir) || !Files.exists(resolved)) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade " + phase + " script not found");
}
@@ -212,11 +217,14 @@ public class UpgradeTaskRunner {
return switch (trim(componentCode)) {
case "TMS" -> "设备管理软件";
case "RECEIVER" -> "标准收发器";
- case "FIRMWARE" -> "固件";
default -> componentCode;
};
}
+ private String componentCode(String taskType) {
+ return trim(taskType);
+ }
+
private String trim(String value) {
return value == null ? "" : value.trim();
}
diff --git a/src/main/java/com/cisd/tms/modules/upgrade/service/UpgradePackageService.java b/src/main/java/com/cisd/tms/modules/upgrade/service/UpgradePackageService.java
index 98f21a1..6f4c0c7 100644
--- a/src/main/java/com/cisd/tms/modules/upgrade/service/UpgradePackageService.java
+++ b/src/main/java/com/cisd/tms/modules/upgrade/service/UpgradePackageService.java
@@ -51,11 +51,14 @@ public class UpgradePackageService {
public UpgradePreviewResponse preview(String fileId) {
StagedUpgradePackage stagedPackage = stagePackage(fileId);
UpgradeManifest manifest = stagedPackage.manifest();
+ // 预检顺序保持“结构/产品/签名/脚本/版本”,先挡住不属于当前设备或不可信的包。
validateManifest(manifest);
validateProductType(manifest.getProductType());
+ // 签名对象固定为 manifest.json 原始字节,具体算法由 UpgradePackageSignatureVerifier 实现。
signatureVerifier.verify(manifest, stagedPackage.manifestPath(), stagedPackage.signaturePath());
validateExecuteScript(stagedPackage.stagedDir(), manifest);
+ // 当前版本来自设备软件版本表;空版本允许继续预检,由前端展示“未记录版本”。
String currentVersion = currentVersion(manifest.getTaskType());
String targetVersion = normalize(manifest.getVersion());
@@ -101,6 +104,7 @@ public class UpgradePackageService {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade package file not found on disk");
}
+ // 同一个 fileId 每次预检都会重新解压,确保读取的是当前上传文件内容。
Path stagedDir = upgradePackageStagingService.stage(record.getFileId(), packagePath);
Path manifestPath = stagedDir.resolve("manifest.json");
Path signaturePath = stagedDir.resolve("signature.sig");
@@ -142,6 +146,7 @@ public class UpgradePackageService {
private void validateExecuteScript(Path stagedDir, UpgradeManifest manifest) {
Path executePath = stagedDir.resolve(normalize(manifest.getEntrypoints().getExecute())).normalize().toAbsolutePath();
+ // execute 路径必须在 staging 目录内,避免 manifest 声明外部系统脚本。
if (!executePath.startsWith(stagedDir.normalize().toAbsolutePath()) || !Files.exists(executePath)) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade execute script not found");
}
diff --git a/src/main/java/com/cisd/tms/modules/upgrade/service/UpgradeService.java b/src/main/java/com/cisd/tms/modules/upgrade/service/UpgradeService.java
index 45e6944..bd2451c 100644
--- a/src/main/java/com/cisd/tms/modules/upgrade/service/UpgradeService.java
+++ b/src/main/java/com/cisd/tms/modules/upgrade/service/UpgradeService.java
@@ -43,6 +43,10 @@ public class UpgradeService {
private final UpgradeTaskRunner upgradeTaskRunner;
private final UpgradeProperties upgradeProperties;
private final Executor backgroundExecutor;
+ /**
+ * 进程内执行互斥表:避免同一任务被前端重复点击后并发启动。
+ * 跨进程/重启后的互斥仍以数据库中的 RUNNING 任务为准。
+ */
private final ConcurrentMap> activeExecutions;
@Autowired
@@ -93,6 +97,7 @@ public class UpgradeService {
if (request == null || request.getFileId() == null || request.getFileId().isBlank()) {
throw new IllegalArgumentException("fileId must not be blank");
}
+ // 创建任务前重新预检一次,避免用户上传后替换文件或设备版本状态发生变化。
UpgradePreviewResponse preview = upgradePackageService.preview(request.getFileId());
if (!preview.isAllowed()) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), blankAs(preview.getBlockReason(), "upgrade preview not allowed"));
@@ -131,6 +136,7 @@ public class UpgradeService {
public UpgradeTaskResponse executeTask(String taskId) {
UpgradeTaskEntity task = loadTask(taskId);
+ // 第一版按一体机单机串行升级处理,避免同时升级 TMS、收发器或固件造成状态不可控。
UpgradeTaskEntity runningTask = upgradeTaskRepository.findRunningTask().orElse(null);
if (runningTask != null && !runningTask.getTaskId().equals(taskId)) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "another upgrade task is running");
@@ -144,6 +150,7 @@ public class UpgradeService {
CompletableFuture future = CompletableFuture.runAsync(() -> {
try {
+ // 后台执行重新加载任务,确保使用已落库的最新状态和日志路径。
upgradeTaskRunner.run(loadTask(taskId));
} finally {
activeExecutions.remove(taskId);
@@ -155,6 +162,7 @@ public class UpgradeService {
public UpgradeTaskResponse rollbackTask(String taskId) {
UpgradeTaskEntity task = loadTask(taskId);
+ // 回滚能力由升级包声明;TMS 不假定所有包都能安全回滚。
UpgradeManifest manifest = upgradePackageService.loadManifest(task.getPackageFileId());
if (manifest.getEntrypoints() == null || trim(manifest.getEntrypoints().getRollback()).isEmpty()) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade rollback script not found");
@@ -205,6 +213,7 @@ public class UpgradeService {
}
Path path = Path.of(logPath).normalize().toAbsolutePath();
Path allowedRoot = Path.of(trim(upgradeProperties.getLogDir())).normalize().toAbsolutePath();
+ // 日志路径必须落在升级日志根目录下,防止通过篡改任务记录读取任意文件。
if (!path.startsWith(allowedRoot)) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade log path is out of allowed directory");
}
diff --git a/src/main/java/com/cisd/tms/modules/upgrade/support/SoftUpgradePackageSignatureVerifier.java b/src/main/java/com/cisd/tms/modules/upgrade/support/SoftUpgradePackageSignatureVerifier.java
index 57c0d4f..8e26d75 100644
--- a/src/main/java/com/cisd/tms/modules/upgrade/support/SoftUpgradePackageSignatureVerifier.java
+++ b/src/main/java/com/cisd/tms/modules/upgrade/support/SoftUpgradePackageSignatureVerifier.java
@@ -44,6 +44,7 @@ public class SoftUpgradePackageSignatureVerifier implements UpgradePackageSignat
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade package signature file not found");
}
+ // 第一版升级包仅对 manifest.json 做离线软验签,避免在包内脚本执行前信任未授权包。
PublicKey publicKey = loadPublicKey();
byte[] manifestBytes = readBytes(manifestPath, "failed to read manifest for signature verification");
byte[] signatureBytes = loadSignature(signaturePath);
@@ -74,6 +75,7 @@ public class SoftUpgradePackageSignatureVerifier implements UpgradePackageSignat
String pemContent = Files.readString(pemPath, StandardCharsets.UTF_8);
try (Reader reader = new StringReader(pemContent); PEMParser parser = new PEMParser(reader)) {
Object parsed = parser.readObject();
+ // 约定使用 X.509 SubjectPublicKeyInfo 格式的 PUBLIC KEY PEM。
if (!(parsed instanceof SubjectPublicKeyInfo publicKeyInfo)) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade signature public key pem is invalid");
}
@@ -97,12 +99,13 @@ public class SoftUpgradePackageSignatureVerifier implements UpgradePackageSignat
String text = new String(raw, StandardCharsets.UTF_8).trim();
if (!text.isEmpty()) {
try {
+ // 兼容联调阶段生成的 Base64 文本签名;生产包可直接使用二进制签名。
byte[] decoded = Base64.getDecoder().decode(text);
if (decoded.length > 0) {
return decoded;
}
} catch (IllegalArgumentException ignored) {
- // Keep compatibility with raw binary signature files.
+ // 非 Base64 文本时按原始二进制签名处理。
}
}
return raw;
diff --git a/src/main/java/com/cisd/tms/modules/upgrade/support/UpgradePackageStagingService.java b/src/main/java/com/cisd/tms/modules/upgrade/support/UpgradePackageStagingService.java
index 1893515..7026100 100644
--- a/src/main/java/com/cisd/tms/modules/upgrade/support/UpgradePackageStagingService.java
+++ b/src/main/java/com/cisd/tms/modules/upgrade/support/UpgradePackageStagingService.java
@@ -39,6 +39,7 @@ public class UpgradePackageStagingService {
ZipEntry entry;
while ((entry = zipInputStream.getNextEntry()) != null) {
Path target = targetDir.resolve(entry.getName()).normalize().toAbsolutePath();
+ // 防止恶意 zip 条目通过 ../ 写出 staging 目录。
if (!target.startsWith(targetDir)) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "invalid upgrade package entry");
}
diff --git a/src/main/java/com/cisd/tms/modules/upgrade/support/UpgradeVersionComparator.java b/src/main/java/com/cisd/tms/modules/upgrade/support/UpgradeVersionComparator.java
index b7ca7c7..f58556a 100644
--- a/src/main/java/com/cisd/tms/modules/upgrade/support/UpgradeVersionComparator.java
+++ b/src/main/java/com/cisd/tms/modules/upgrade/support/UpgradeVersionComparator.java
@@ -25,6 +25,7 @@ public class UpgradeVersionComparator {
String value = version == null ? "" : version.trim();
List parts = new ArrayList<>();
StringBuilder current = new StringBuilder();
+ // 只提取版本号中的数字段,兼容 V1.2.3、1.2.3-build4 等简单格式。
for (int i = 0; i < value.length(); i++) {
char ch = value.charAt(i);
if (Character.isDigit(ch)) {
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/application.yml b/src/main/resources/application.yml
index def075e..b834c8d 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -2,6 +2,11 @@ spring:
application:
# Spring 应用名;用于日志、注册中心标识等。
name: tms-framework
+ servlet:
+ multipart:
+ # Spring/Tomcat multipart 解析上限;必须不小于业务上传上限,否则请求会在进入 Controller 前被拒绝。
+ max-file-size: ${TMS_FILE_STORAGE_MAX_FILE_SIZE_BYTES:104857600}
+ max-request-size: ${TMS_FILE_STORAGE_MAX_FILE_SIZE_BYTES:104857600}
profiles:
# 默认激活环境;可通过 --spring.profiles.active=prod 覆盖。
active: dev
@@ -173,7 +178,7 @@ tms:
# 升级任务统一日志目录。
log-dir: ${TMS_UPGRADE_LOG_DIR:/home/tms/tmp/tms-upgrade-logs}
# 升级包验签使用的公钥 PEM 文件路径;为空时升级预检会拒绝通过。
- signature-public-key-pem-path: ${TMS_UPGRADE_SIGNATURE_PUBLIC_KEY_PEM_PATH:}
+ signature-public-key-pem-path: ${TMS_UPGRADE_SIGNATURE_PUBLIC_KEY_PEM_PATH:/home/tms/cert/public_key.pem}
mk:
init-identify:
# SDFE_InitIdentify 旧 PIN(Base64 编码);主密钥初始化接口会读取该值。
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 0226641..97ff413 100644
--- a/src/main/resources/db/migration/V1__tms_schema_full.sql
+++ b/src/main/resources/db/migration/V1__tms_schema_full.sql
@@ -110,7 +110,7 @@ VALUES
(1002, 'TLQ', '消息队列', 'V8.1.11', 'MANUAL', NOW(), '出厂预置'),
(1003, 'TIDB', '数据库', 'V8.1.0', 'MANUAL', NOW(), '出厂预置'),
(1004, 'NGINX', 'Nginx', 'V1.20', 'MANUAL', NOW(), '出厂预置'),
- (1005, 'APP', '标准收发器企业版', 'V1.3.2', 'MANUAL', NOW(), '出厂预置'),
+ (1005, 'RECEIVER', '标准收发器', 'V1.3.2', 'MANUAL', NOW(), '出厂预置'),
(1006, 'TMS', 'CISD终端管理服务', 'V1.0.0', 'MANUAL', NOW(), '出厂预置')
ON DUPLICATE KEY UPDATE
component_name = VALUES(component_name),
@@ -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/V4__device_software_version_rename_app_to_receiver.sql b/src/main/resources/db/migration/V4__device_software_version_rename_app_to_receiver.sql
new file mode 100644
index 0000000..1ba42d9
--- /dev/null
+++ b/src/main/resources/db/migration/V4__device_software_version_rename_app_to_receiver.sql
@@ -0,0 +1,19 @@
+-- 组件版本表统一使用 RECEIVER 表示标准收发器,废弃旧编码 APP。
+-- 若环境中已存在 RECEIVER 行,则保留 RECEIVER 并删除旧 APP,避免唯一键冲突。
+DELETE FROM tms_device_software_version
+WHERE component_code = 'APP'
+ AND EXISTS (
+ SELECT 1
+ FROM (SELECT id FROM tms_device_software_version WHERE component_code = 'RECEIVER') receiver_row
+ );
+
+UPDATE tms_device_software_version
+SET component_code = 'RECEIVER',
+ component_name = '标准收发器',
+ update_time = CURRENT_TIMESTAMP
+WHERE component_code = 'APP';
+
+UPDATE tms_device_software_version
+SET component_name = '标准收发器',
+ update_time = CURRENT_TIMESTAMP
+WHERE component_code = 'RECEIVER';
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/common/exception/GlobalExceptionHandlerTest.java b/src/test/java/com/cisd/tms/common/exception/GlobalExceptionHandlerTest.java
new file mode 100644
index 0000000..fd47f10
--- /dev/null
+++ b/src/test/java/com/cisd/tms/common/exception/GlobalExceptionHandlerTest.java
@@ -0,0 +1,27 @@
+package com.cisd.tms.common.exception;
+
+import com.cisd.tms.common.api.ApiResponse;
+import com.cisd.tms.common.enums.ErrorCode;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.web.multipart.MaxUploadSizeExceededException;
+
+class GlobalExceptionHandlerTest {
+
+ @Test
+ void shouldHandleMultipartMaxUploadSizeExceededAsValidationFailure() {
+ GlobalExceptionHandler handler = new GlobalExceptionHandler();
+ MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/upgrade-packages");
+
+ ApiResponse response = handler.handleMaxUploadSizeExceeded(
+ new MaxUploadSizeExceededException(104857600),
+ request
+ );
+
+ Assertions.assertFalse(response.isSuccess());
+ Assertions.assertEquals(ErrorCode.VALIDATE_FAILED.getCode(), response.getCode());
+ Assertions.assertEquals("upload file size exceeds limit", response.getMsg());
+ Assertions.assertEquals("/api/v1/upgrade-packages", response.getPath());
+ }
+}
diff --git a/src/test/java/com/cisd/tms/config/MultipartUploadConfigurationTest.java b/src/test/java/com/cisd/tms/config/MultipartUploadConfigurationTest.java
new file mode 100644
index 0000000..e202a99
--- /dev/null
+++ b/src/test/java/com/cisd/tms/config/MultipartUploadConfigurationTest.java
@@ -0,0 +1,32 @@
+package com.cisd.tms.config;
+
+import java.nio.file.Path;
+import java.util.List;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.env.YamlPropertySourceLoader;
+import org.springframework.core.env.PropertySource;
+import org.springframework.core.io.FileSystemResource;
+
+class MultipartUploadConfigurationTest {
+
+ @Test
+ void shouldConfigureSpringMultipartLimitsForLargeUpgradePackages() throws Exception {
+ FileSystemResource resource = new FileSystemResource(Path.of("src/main/resources/application.yml"));
+ List> propertySources = new YamlPropertySourceLoader().load("application", resource);
+
+ Object maxFileSize = findProperty(propertySources, "spring.servlet.multipart.max-file-size");
+ Object maxRequestSize = findProperty(propertySources, "spring.servlet.multipart.max-request-size");
+
+ Assertions.assertEquals("${TMS_FILE_STORAGE_MAX_FILE_SIZE_BYTES:104857600}", maxFileSize);
+ Assertions.assertEquals("${TMS_FILE_STORAGE_MAX_FILE_SIZE_BYTES:104857600}", maxRequestSize);
+ }
+
+ private Object findProperty(List> propertySources, String name) {
+ return propertySources.stream()
+ .filter(source -> source.containsProperty(name))
+ .findFirst()
+ .map(source -> source.getProperty(name))
+ .orElse(null);
+ }
+}
diff --git a/src/test/java/com/cisd/tms/modules/device/service/DeviceProfileServiceTest.java b/src/test/java/com/cisd/tms/modules/device/service/DeviceProfileServiceTest.java
index 5c33f8c..790219e 100644
--- a/src/test/java/com/cisd/tms/modules/device/service/DeviceProfileServiceTest.java
+++ b/src/test/java/com/cisd/tms/modules/device/service/DeviceProfileServiceTest.java
@@ -47,7 +47,7 @@ class DeviceProfileServiceTest {
versionRepository.save(component("TLQ", "消息队列", "tonglink/Q V8.1.11"));
versionRepository.save(component("TIDB", "数据库", "TiDB V8.1.0"));
versionRepository.save(component("NGINX", "Nginx", "Nginx V1.20"));
- versionRepository.save(component("APP", "标准收发器间参版", "标准收发器间参版 V1.3.2"));
+ versionRepository.save(component("RECEIVER", "标准收发器间参版", "标准收发器间参版 V1.3.2"));
DeviceProfileService service = new DeviceProfileServiceImpl(
initService,
@@ -73,7 +73,7 @@ class DeviceProfileServiceTest {
Assertions.assertEquals("32GB", response.getHardware().getMemoryTotal());
Assertions.assertEquals("953.87GB", response.getHardware().getDiskTotal());
Assertions.assertEquals(
- List.of("OS", "TMS", "RABBITMQ", "TLQ", "TIDB", "NGINX", "APP"),
+ List.of("OS", "TMS", "RABBITMQ", "TLQ", "TIDB", "NGINX", "RECEIVER"),
response.getSoftware().stream().map(DeviceProfileSoftwareItem::getComponentCode).toList()
);
}
@@ -156,7 +156,7 @@ class DeviceProfileServiceTest {
DeviceProfileResponse response = service.profile();
Assertions.assertEquals(
- List.of("OS", "TMS", "RABBITMQ", "TIDB", "NGINX", "APP"),
+ List.of("OS", "TMS", "RABBITMQ", "TIDB", "NGINX", "RECEIVER"),
response.getSoftware().stream().map(DeviceProfileSoftwareItem::getComponentCode).toList()
);
DeviceProfileSoftwareItem rabbitmq = response.getSoftware().stream()
diff --git a/src/test/java/com/cisd/tms/modules/device/service/DeviceRuntimeStatusServiceTest.java b/src/test/java/com/cisd/tms/modules/device/service/DeviceRuntimeStatusServiceTest.java
index c941df9..470aac3 100644
--- a/src/test/java/com/cisd/tms/modules/device/service/DeviceRuntimeStatusServiceTest.java
+++ b/src/test/java/com/cisd/tms/modules/device/service/DeviceRuntimeStatusServiceTest.java
@@ -48,7 +48,7 @@ class DeviceRuntimeStatusServiceTest {
versions.save(component("TLQ", "消息队列", "tonglink/Q V8.1.11"));
versions.save(component("TIDB", "数据库", "TiDB V8.1.0"));
versions.save(component("NGINX", "Nginx", "Nginx V1.20"));
- versions.save(component("APP", "标准收发器企业版", "标准收发器企业版 V1.3.2"));
+ versions.save(component("RECEIVER", "标准收发器企业版", "标准收发器企业版 V1.3.2"));
DeviceRuntimeStatusService service = new DeviceRuntimeStatusServiceImpl(
initService,
@@ -78,7 +78,7 @@ class DeviceRuntimeStatusServiceTest {
assertSoftwareStatus(response.getSoftware(), "TLQ", "NOT_INSTALLED");
assertSoftwareStatus(response.getSoftware(), "TIDB", "NORMAL");
assertSoftwareStatus(response.getSoftware(), "NGINX", "ABNORMAL");
- assertSoftwareStatus(response.getSoftware(), "APP", "NORMAL");
+ assertSoftwareStatus(response.getSoftware(), "RECEIVER", "NORMAL");
assertSoftwareStatus(response.getSoftware(), "TMS", "NORMAL");
}
@@ -100,7 +100,7 @@ class DeviceRuntimeStatusServiceTest {
assertSoftwareStatus(response.getSoftware(), "TLQ", "NOT_INSTALLED");
assertSoftwareStatus(response.getSoftware(), "TIDB", "NOT_INSTALLED");
assertSoftwareStatus(response.getSoftware(), "NGINX", "NOT_INSTALLED");
- assertSoftwareStatus(response.getSoftware(), "APP", "NOT_INSTALLED");
+ assertSoftwareStatus(response.getSoftware(), "RECEIVER", "NOT_INSTALLED");
assertSoftwareStatus(response.getSoftware(), "TMS", "NORMAL");
}
@@ -130,7 +130,7 @@ class DeviceRuntimeStatusServiceTest {
assertSoftwareStatus(response.getSoftware(), "TLQ", "UNKNOWN");
assertSoftwareStatus(response.getSoftware(), "TIDB", "UNKNOWN");
assertSoftwareStatus(response.getSoftware(), "NGINX", "UNKNOWN");
- assertSoftwareStatus(response.getSoftware(), "APP", "UNKNOWN");
+ assertSoftwareStatus(response.getSoftware(), "RECEIVER", "UNKNOWN");
assertSoftwareStatus(response.getSoftware(), "TMS", "UNKNOWN");
}
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/modules/upgrade/executor/UpgradeTaskRunnerTest.java b/src/test/java/com/cisd/tms/modules/upgrade/executor/UpgradeTaskRunnerTest.java
index ce2fca1..ef6b92c 100644
--- a/src/test/java/com/cisd/tms/modules/upgrade/executor/UpgradeTaskRunnerTest.java
+++ b/src/test/java/com/cisd/tms/modules/upgrade/executor/UpgradeTaskRunnerTest.java
@@ -56,6 +56,32 @@ class UpgradeTaskRunnerTest {
Assertions.assertTrue(log.indexOf("execute") < log.indexOf("verify"));
}
+ @Test
+ void shouldUpdateReceiverSoftwareVersionWhenReceiverUpgradeSucceeds() throws Exception {
+ Path tempDir = Files.createTempDirectory("upgrade-runner-test");
+ FileRecordEntity fileRecord = buildPackage(tempDir, false, "#!/bin/sh\necho execute\nexit 0\n", false);
+ InMemoryFileRecordRepository fileRepository = new InMemoryFileRecordRepository();
+ fileRepository.save(fileRecord);
+ InMemoryUpgradeTaskRepository taskRepository = new InMemoryUpgradeTaskRepository();
+ InMemoryDeviceSoftwareVersionRepository versionRepository = new InMemoryDeviceSoftwareVersionRepository();
+
+ UpgradeTaskRunner runner = new UpgradeTaskRunner(
+ taskRepository,
+ fileRepository,
+ versionRepository,
+ stagingService(tempDir.resolve("staging"), tempDir.resolve("logs")),
+ properties(tempDir.resolve("staging"), tempDir.resolve("logs")),
+ objectMapper
+ );
+
+ runner.run(task("UPG-001-R", fileRecord.getFileId(), "RECEIVER", "V1.4.0"));
+
+ Assertions.assertEquals("SUCCESS", taskRepository.status);
+ Assertions.assertNull(versionRepository.store.get("APP"));
+ Assertions.assertEquals("V1.4.0", versionRepository.store.get("RECEIVER").getCurrentVersion());
+ Assertions.assertEquals("标准收发器", versionRepository.store.get("RECEIVER").getComponentName());
+ }
+
@Test
void shouldMarkTaskFailedWhenExecuteScriptFails() throws Exception {
Path tempDir = Files.createTempDirectory("upgrade-runner-test");
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;
+ }
+}