接口防重放
This commit is contained in:
@@ -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"
|
||||||
|
```
|
||||||
@@ -95,6 +95,13 @@
|
|||||||
<version>1.83</version>
|
<version>1.83</version>
|
||||||
<!-- <scope>compile</scope>-->
|
<!-- <scope>compile</scope>-->
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Source: https://mvnrepository.com/artifact/com.google.code.gson/gson -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.code.gson</groupId>
|
||||||
|
<artifactId>gson</artifactId>
|
||||||
|
<version>2.13.2</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package com.cisd.tms;
|
|||||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@ConfigurationPropertiesScan
|
@ConfigurationPropertiesScan
|
||||||
|
@EnableScheduling
|
||||||
public class TmsApplication {
|
public class TmsApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package com.cisd.tms.common.config;
|
|||||||
|
|
||||||
import com.cisd.tms.modules.auth.security.InternalAuthorizationInterceptor;
|
import com.cisd.tms.modules.auth.security.InternalAuthorizationInterceptor;
|
||||||
import com.cisd.tms.security.internal.InternalApiAuthInterceptor;
|
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.security.openapi.OpenApiSignAuthInterceptor;
|
||||||
import com.cisd.tms.common.util.TraceIdUtil;
|
import com.cisd.tms.common.util.TraceIdUtil;
|
||||||
import jakarta.servlet.FilterChain;
|
import jakarta.servlet.FilterChain;
|
||||||
@@ -21,15 +23,18 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
|||||||
public class WebMvcConfig implements WebMvcConfigurer {
|
public class WebMvcConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
private final InternalApiAuthInterceptor internalApiAuthInterceptor;
|
private final InternalApiAuthInterceptor internalApiAuthInterceptor;
|
||||||
|
private final InternalApiReplayInterceptor internalApiReplayInterceptor;
|
||||||
private final InternalAuthorizationInterceptor internalAuthorizationInterceptor;
|
private final InternalAuthorizationInterceptor internalAuthorizationInterceptor;
|
||||||
private final OpenApiSignAuthInterceptor openApiSignAuthInterceptor;
|
private final OpenApiSignAuthInterceptor openApiSignAuthInterceptor;
|
||||||
|
|
||||||
public WebMvcConfig(
|
public WebMvcConfig(
|
||||||
InternalApiAuthInterceptor internalApiAuthInterceptor,
|
InternalApiAuthInterceptor internalApiAuthInterceptor,
|
||||||
|
InternalApiReplayInterceptor internalApiReplayInterceptor,
|
||||||
InternalAuthorizationInterceptor internalAuthorizationInterceptor,
|
InternalAuthorizationInterceptor internalAuthorizationInterceptor,
|
||||||
OpenApiSignAuthInterceptor openApiSignAuthInterceptor
|
OpenApiSignAuthInterceptor openApiSignAuthInterceptor
|
||||||
) {
|
) {
|
||||||
this.internalApiAuthInterceptor = internalApiAuthInterceptor;
|
this.internalApiAuthInterceptor = internalApiAuthInterceptor;
|
||||||
|
this.internalApiReplayInterceptor = internalApiReplayInterceptor;
|
||||||
this.internalAuthorizationInterceptor = internalAuthorizationInterceptor;
|
this.internalAuthorizationInterceptor = internalAuthorizationInterceptor;
|
||||||
this.openApiSignAuthInterceptor = openApiSignAuthInterceptor;
|
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
|
@Override
|
||||||
public void addInterceptors(InterceptorRegistry registry) {
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
registry.addInterceptor(internalApiAuthInterceptor)
|
registry.addInterceptor(internalApiAuthInterceptor)
|
||||||
@@ -67,6 +99,9 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
|||||||
"/api/v1/auth/captcha"
|
"/api/v1/auth/captcha"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
registry.addInterceptor(internalApiReplayInterceptor)
|
||||||
|
.addPathPatterns("/api/**");
|
||||||
|
|
||||||
registry.addInterceptor(internalAuthorizationInterceptor)
|
registry.addInterceptor(internalAuthorizationInterceptor)
|
||||||
.addPathPatterns("/api/**");
|
.addPathPatterns("/api/**");
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.cisd.tms.common.exception;
|
|||||||
import com.cisd.tms.common.api.ApiResponse;
|
import com.cisd.tms.common.api.ApiResponse;
|
||||||
import com.cisd.tms.common.enums.ErrorCode;
|
import com.cisd.tms.common.enums.ErrorCode;
|
||||||
import com.cisd.tms.integration.crypto.pcie.PcieCryptoException;
|
import com.cisd.tms.integration.crypto.pcie.PcieCryptoException;
|
||||||
|
import com.cisd.tms.modules.security.replay.exception.ReplayProtectionException;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.validation.ConstraintViolationException;
|
import jakarta.validation.ConstraintViolationException;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -58,6 +59,13 @@ public class GlobalExceptionHandler {
|
|||||||
.withPath(request.getRequestURI());
|
.withPath(request.getRequestURI());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(ReplayProtectionException.class)
|
||||||
|
public ApiResponse<Void> 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)
|
@ExceptionHandler(Exception.class)
|
||||||
public ApiResponse<Void> handleException(Exception ex, HttpServletRequest request) {
|
public ApiResponse<Void> handleException(Exception ex, HttpServletRequest request) {
|
||||||
log.error("Unhandled exception", ex);
|
log.error("Unhandled exception", ex);
|
||||||
|
|||||||
@@ -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.UKeySignDTO;
|
||||||
import com.cisd.tms.modules.mk.dto.UKeySignResult;
|
import com.cisd.tms.modules.mk.dto.UKeySignResult;
|
||||||
import com.cisd.tms.security.internal.InternalApiAuthInterceptor;
|
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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
@@ -23,6 +24,7 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/auth")
|
@RequestMapping("/api/v1/auth")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@ReplayProtected
|
||||||
@Tag(name = "认证管理", description = "角色启用、密码重置与 UKey 绑定接口")
|
@Tag(name = "认证管理", description = "角色启用、密码重置与 UKey 绑定接口")
|
||||||
public class AuthAdminController {
|
public class AuthAdminController {
|
||||||
|
|
||||||
|
|||||||
@@ -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.dto.UkeyLoginRequest;
|
||||||
import com.cisd.tms.modules.auth.service.AuthService;
|
import com.cisd.tms.modules.auth.service.AuthService;
|
||||||
import com.cisd.tms.security.internal.InternalApiAuthInterceptor;
|
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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
@@ -72,6 +73,7 @@ public class AuthController {
|
|||||||
|
|
||||||
@PostMapping("/change-password")
|
@PostMapping("/change-password")
|
||||||
@Operation(summary = "修改当前角色口令", description = "基于当前会话校验并更新当前角色口令。")
|
@Operation(summary = "修改当前角色口令", description = "基于当前会话校验并更新当前角色口令。")
|
||||||
|
@ReplayProtected
|
||||||
public ApiResponse<Void> changePassword(@Valid @RequestBody ChangePasswordRequest request, HttpServletRequest httpRequest) {
|
public ApiResponse<Void> changePassword(@Valid @RequestBody ChangePasswordRequest request, HttpServletRequest httpRequest) {
|
||||||
authService.changePassword(
|
authService.changePassword(
|
||||||
(String) httpRequest.getAttribute(InternalApiAuthInterceptor.ATTR_SESSION_TOKEN),
|
(String) httpRequest.getAttribute(InternalApiAuthInterceptor.ATTR_SESSION_TOKEN),
|
||||||
|
|||||||
@@ -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.model.StandardKeyPairResult;
|
||||||
import com.cisd.tms.integration.crypto.pcie.service.PcieDeviceStatusFormatter;
|
import com.cisd.tms.integration.crypto.pcie.service.PcieDeviceStatusFormatter;
|
||||||
import com.cisd.tms.integration.crypto.pcie.service.PcieCryptoService;
|
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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
@@ -61,6 +62,7 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/device/crypto")
|
@RequestMapping("/api/v1/device/crypto")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@ReplayProtected
|
||||||
@Tag(name = "密码卡调试", description = "密码卡设备调试、密钥操作和文件管理接口")
|
@Tag(name = "密码卡调试", description = "密码卡设备调试、密钥操作和文件管理接口")
|
||||||
public class CryptoCardController {
|
public class CryptoCardController {
|
||||||
|
|
||||||
|
|||||||
@@ -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.DeviceRuntimeStatusService;
|
||||||
import com.cisd.tms.modules.device.service.DeviceService;
|
import com.cisd.tms.modules.device.service.DeviceService;
|
||||||
import com.cisd.tms.security.internal.InternalApiAuthInterceptor;
|
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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
@@ -55,6 +56,7 @@ public class DeviceController {
|
|||||||
|
|
||||||
@PostMapping("/restart")
|
@PostMapping("/restart")
|
||||||
@Operation(summary = "重启设备服务", description = "受理设备重启请求,并记录触发操作的内部用户。")
|
@Operation(summary = "重启设备服务", description = "受理设备重启请求,并记录触发操作的内部用户。")
|
||||||
|
@ReplayProtected
|
||||||
public ApiResponse<DeviceActionResponse> restart(HttpServletRequest request) {
|
public ApiResponse<DeviceActionResponse> restart(HttpServletRequest request) {
|
||||||
String operator = (String) request.getAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE);
|
String operator = (String) request.getAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE);
|
||||||
return ApiResponse.success(deviceService.restart(operator));
|
return ApiResponse.success(deviceService.restart(operator));
|
||||||
|
|||||||
@@ -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.dto.network.IpWhitelistResponse;
|
||||||
import com.cisd.tms.modules.device.service.IpWhitelistService;
|
import com.cisd.tms.modules.device.service.IpWhitelistService;
|
||||||
import com.cisd.tms.modules.device.dto.network.IpWhitelistRequest;
|
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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -21,6 +22,7 @@ public class IpWhitelistController {
|
|||||||
|
|
||||||
@PostMapping("/add")
|
@PostMapping("/add")
|
||||||
@Operation(summary = "添加IP白名单", description = "新增一条IP白名单记录")
|
@Operation(summary = "添加IP白名单", description = "新增一条IP白名单记录")
|
||||||
|
@ReplayProtected
|
||||||
public ApiResponse<String> addWhitelist(@RequestBody IpWhitelistRequest req) {
|
public ApiResponse<String> addWhitelist(@RequestBody IpWhitelistRequest req) {
|
||||||
ipWhitelistService.addWhitelist(req);
|
ipWhitelistService.addWhitelist(req);
|
||||||
return ApiResponse.success("添加成功");
|
return ApiResponse.success("添加成功");
|
||||||
@@ -29,6 +31,7 @@ public class IpWhitelistController {
|
|||||||
|
|
||||||
@PostMapping("/update")
|
@PostMapping("/update")
|
||||||
@Operation(summary = "更新IP白名单", description = "根据ID更新IP白名单信息")
|
@Operation(summary = "更新IP白名单", description = "根据ID更新IP白名单信息")
|
||||||
|
@ReplayProtected
|
||||||
public ApiResponse<String> updateWhitelist(@RequestBody IpWhitelistRequest req) {
|
public ApiResponse<String> updateWhitelist(@RequestBody IpWhitelistRequest req) {
|
||||||
ipWhitelistService.updateWhitelist(req);
|
ipWhitelistService.updateWhitelist(req);
|
||||||
return ApiResponse.success("更新成功");
|
return ApiResponse.success("更新成功");
|
||||||
@@ -36,6 +39,7 @@ public class IpWhitelistController {
|
|||||||
|
|
||||||
@PostMapping("/delete/{id}")
|
@PostMapping("/delete/{id}")
|
||||||
@Operation(summary = "删除IP白名单", description = "根据ID删除指定IP白名单")
|
@Operation(summary = "删除IP白名单", description = "根据ID删除指定IP白名单")
|
||||||
|
@ReplayProtected
|
||||||
public ApiResponse<String> deleteWhitelist(@PathVariable Long id) {
|
public ApiResponse<String> deleteWhitelist(@PathVariable Long id) {
|
||||||
ipWhitelistService.deleteWhitelist(id);
|
ipWhitelistService.deleteWhitelist(id);
|
||||||
return ApiResponse.success("删除成功");
|
return ApiResponse.success("删除成功");
|
||||||
@@ -48,4 +52,4 @@ public class IpWhitelistController {
|
|||||||
IPage<IpWhitelistResponse> page = ipWhitelistService.getWhitelistPage(req);
|
IPage<IpWhitelistResponse> page = ipWhitelistService.getWhitelistPage(req);
|
||||||
return ApiResponse.success(page);
|
return ApiResponse.success(page);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package com.cisd.tms.modules.device.controller;
|
|||||||
import com.cisd.tms.common.api.ApiResponse;
|
import com.cisd.tms.common.api.ApiResponse;
|
||||||
import com.cisd.tms.modules.device.dto.network.*;
|
import com.cisd.tms.modules.device.dto.network.*;
|
||||||
import com.cisd.tms.modules.device.service.NetworkConfigService;
|
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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@@ -36,6 +37,7 @@ public class NetworkConfigController {
|
|||||||
|
|
||||||
@Operation(summary = "设置IPv4配置", description = "为指定网络接口配置IPv4地址、掩码、网关等信息")
|
@Operation(summary = "设置IPv4配置", description = "为指定网络接口配置IPv4地址、掩码、网关等信息")
|
||||||
@PostMapping("/ipv4-config/set")
|
@PostMapping("/ipv4-config/set")
|
||||||
|
@ReplayProtected
|
||||||
public ApiResponse<String> setIpv4Config(@RequestBody Ipv4ConfigRequest req) {
|
public ApiResponse<String> setIpv4Config(@RequestBody Ipv4ConfigRequest req) {
|
||||||
String resultMessage = networkConfigService.setIpv4Config(req);
|
String resultMessage = networkConfigService.setIpv4Config(req);
|
||||||
return ApiResponse.success(resultMessage);
|
return ApiResponse.success(resultMessage);
|
||||||
@@ -50,6 +52,7 @@ public class NetworkConfigController {
|
|||||||
|
|
||||||
@Operation(summary = "设置IPv6配置", description = "为指定网络接口配置IPv6地址、前缀长度、网关等信息")
|
@Operation(summary = "设置IPv6配置", description = "为指定网络接口配置IPv6地址、前缀长度、网关等信息")
|
||||||
@PostMapping("/ipv6-config/set")
|
@PostMapping("/ipv6-config/set")
|
||||||
|
@ReplayProtected
|
||||||
public ApiResponse<String> setIpv6Config(@RequestBody Ipv6ConfigRequest req) {
|
public ApiResponse<String> setIpv6Config(@RequestBody Ipv6ConfigRequest req) {
|
||||||
String resultMessage = networkConfigService.setIpv6Config(req);
|
String resultMessage = networkConfigService.setIpv6Config(req);
|
||||||
return ApiResponse.success(resultMessage);
|
return ApiResponse.success(resultMessage);
|
||||||
@@ -63,6 +66,7 @@ public class NetworkConfigController {
|
|||||||
|
|
||||||
@Operation(summary = "创建Bond", description = "创建一个新的Bond聚合接口,需指定名称、模式和从属接口")
|
@Operation(summary = "创建Bond", description = "创建一个新的Bond聚合接口,需指定名称、模式和从属接口")
|
||||||
@PostMapping("/bond/create")
|
@PostMapping("/bond/create")
|
||||||
|
@ReplayProtected
|
||||||
public ApiResponse<String> createBond(@RequestBody BondCreateRequest req) {
|
public ApiResponse<String> createBond(@RequestBody BondCreateRequest req) {
|
||||||
String resultMessage = networkConfigService.createBond(req);
|
String resultMessage = networkConfigService.createBond(req);
|
||||||
return ApiResponse.success(resultMessage);
|
return ApiResponse.success(resultMessage);
|
||||||
@@ -71,6 +75,7 @@ public class NetworkConfigController {
|
|||||||
|
|
||||||
@Operation(summary = "删除Bond", description = "根据Bond名称删除指定的聚合接口")
|
@Operation(summary = "删除Bond", description = "根据Bond名称删除指定的聚合接口")
|
||||||
@PostMapping("/bond/delete/{bondName}")
|
@PostMapping("/bond/delete/{bondName}")
|
||||||
|
@ReplayProtected
|
||||||
public ApiResponse<String> deleteBond(@PathVariable("bondName") String bondName){
|
public ApiResponse<String> deleteBond(@PathVariable("bondName") String bondName){
|
||||||
String resultMessage = networkConfigService.deleteBond(bondName);
|
String resultMessage = networkConfigService.deleteBond(bondName);
|
||||||
return ApiResponse.success(resultMessage);
|
return ApiResponse.success(resultMessage);
|
||||||
@@ -78,6 +83,7 @@ public class NetworkConfigController {
|
|||||||
|
|
||||||
@Operation(summary = "添加从属接口到Bond", description = "向指定Bond中添加一个或多个从属网络接口")
|
@Operation(summary = "添加从属接口到Bond", description = "向指定Bond中添加一个或多个从属网络接口")
|
||||||
@PostMapping("/bond-slave/add")
|
@PostMapping("/bond-slave/add")
|
||||||
|
@ReplayProtected
|
||||||
public ApiResponse<String> addSlavesTOBond(@RequestBody BondAddSlavesRequest req) {
|
public ApiResponse<String> addSlavesTOBond(@RequestBody BondAddSlavesRequest req) {
|
||||||
String resultMessage = networkConfigService.addSlavesTOBond(req);
|
String resultMessage = networkConfigService.addSlavesTOBond(req);
|
||||||
return ApiResponse.success(resultMessage);
|
return ApiResponse.success(resultMessage);
|
||||||
@@ -85,6 +91,7 @@ public class NetworkConfigController {
|
|||||||
|
|
||||||
@Operation(summary = "从Bond中移除从属接口", description = "从指定Bond中移除一个或多个从属网络接口")
|
@Operation(summary = "从Bond中移除从属接口", description = "从指定Bond中移除一个或多个从属网络接口")
|
||||||
@PostMapping("/bond-slave/remove")
|
@PostMapping("/bond-slave/remove")
|
||||||
|
@ReplayProtected
|
||||||
public ApiResponse<String> removeSlaveFromBond(@RequestBody BondRemoveSlaveRequest req) {
|
public ApiResponse<String> removeSlaveFromBond(@RequestBody BondRemoveSlaveRequest req) {
|
||||||
String resultMessage = networkConfigService.removeSlaveFromBond(req);
|
String resultMessage = networkConfigService.removeSlaveFromBond(req);
|
||||||
return ApiResponse.success(resultMessage);
|
return ApiResponse.success(resultMessage);
|
||||||
@@ -112,6 +119,7 @@ public class NetworkConfigController {
|
|||||||
|
|
||||||
@Operation(summary = "设置Bond模式", description = "修改指定Bond的绑定模式")
|
@Operation(summary = "设置Bond模式", description = "修改指定Bond的绑定模式")
|
||||||
@PostMapping("/bond/mode/set")
|
@PostMapping("/bond/mode/set")
|
||||||
|
@ReplayProtected
|
||||||
public ApiResponse<String> setBondMode(@RequestBody BondModifyModeRequest req) {
|
public ApiResponse<String> setBondMode(@RequestBody BondModifyModeRequest req) {
|
||||||
|
|
||||||
String resultMessage = networkConfigService.setBondMode(req);
|
String resultMessage = networkConfigService.setBondMode(req);
|
||||||
@@ -127,6 +135,7 @@ public class NetworkConfigController {
|
|||||||
|
|
||||||
@Operation(summary = "设置默认路由", description = "配置或修改系统的默认网关路由")
|
@Operation(summary = "设置默认路由", description = "配置或修改系统的默认网关路由")
|
||||||
@PostMapping("/routes/default/set")
|
@PostMapping("/routes/default/set")
|
||||||
|
@ReplayProtected
|
||||||
public ApiResponse<String> setDefaultRoute(@RequestBody SetDefaultRouteRequest req) {
|
public ApiResponse<String> setDefaultRoute(@RequestBody SetDefaultRouteRequest req) {
|
||||||
String resultMessage = networkConfigService.setDefaultRoute(req);
|
String resultMessage = networkConfigService.setDefaultRoute(req);
|
||||||
return ApiResponse.success(resultMessage);
|
return ApiResponse.success(resultMessage);
|
||||||
@@ -134,6 +143,7 @@ public class NetworkConfigController {
|
|||||||
|
|
||||||
@Operation(summary = "添加静态路由", description = "新增一条静态路由规则")
|
@Operation(summary = "添加静态路由", description = "新增一条静态路由规则")
|
||||||
@PostMapping("/routes/static/add")
|
@PostMapping("/routes/static/add")
|
||||||
|
@ReplayProtected
|
||||||
public ApiResponse<String> addStaticRoute(@RequestBody AddStaticRouteRequest req) {
|
public ApiResponse<String> addStaticRoute(@RequestBody AddStaticRouteRequest req) {
|
||||||
String resultMessage = networkConfigService.addStaticRoute(req);
|
String resultMessage = networkConfigService.addStaticRoute(req);
|
||||||
return ApiResponse.success(resultMessage);
|
return ApiResponse.success(resultMessage);
|
||||||
@@ -141,6 +151,7 @@ public class NetworkConfigController {
|
|||||||
|
|
||||||
@Operation(summary = "删除默认路由", description = "删除指定的默认路由")
|
@Operation(summary = "删除默认路由", description = "删除指定的默认路由")
|
||||||
@PostMapping("/routes/default/delete")
|
@PostMapping("/routes/default/delete")
|
||||||
|
@ReplayProtected
|
||||||
public ApiResponse<String> deleteDefaultRoute(@RequestBody DeleteDefaultRouteRequest req) {
|
public ApiResponse<String> deleteDefaultRoute(@RequestBody DeleteDefaultRouteRequest req) {
|
||||||
String resultMessage = networkConfigService.deleteDefaultRoute(req);
|
String resultMessage = networkConfigService.deleteDefaultRoute(req);
|
||||||
return ApiResponse.success(resultMessage);
|
return ApiResponse.success(resultMessage);
|
||||||
@@ -148,6 +159,7 @@ public class NetworkConfigController {
|
|||||||
|
|
||||||
@Operation(summary = "删除静态路由", description = "删除指定的静态路由")
|
@Operation(summary = "删除静态路由", description = "删除指定的静态路由")
|
||||||
@PostMapping("/routes/static/delete")
|
@PostMapping("/routes/static/delete")
|
||||||
|
@ReplayProtected
|
||||||
public ApiResponse<String> deleteStaticRoute(@RequestBody DeleteStaticRouteRequest req) {
|
public ApiResponse<String> deleteStaticRoute(@RequestBody DeleteStaticRouteRequest req) {
|
||||||
String resultMessage = networkConfigService.deleteStaticRoute(req);
|
String resultMessage = networkConfigService.deleteStaticRoute(req);
|
||||||
return ApiResponse.success(resultMessage);
|
return ApiResponse.success(resultMessage);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.cisd.tms.common.api.ApiResponse;
|
|||||||
import com.cisd.tms.common.enums.ErrorCode;
|
import com.cisd.tms.common.enums.ErrorCode;
|
||||||
import com.cisd.tms.modules.device.dto.TimeConfigRequest;
|
import com.cisd.tms.modules.device.dto.TimeConfigRequest;
|
||||||
import com.cisd.tms.modules.device.service.TimeConfigService;
|
import com.cisd.tms.modules.device.service.TimeConfigService;
|
||||||
|
import com.cisd.tms.security.internal.ReplayProtected;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
@@ -15,6 +16,7 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/device")
|
@RequestMapping("/api/v1/device")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@ReplayProtected
|
||||||
@Tag(name = "device-time-config-controller")
|
@Tag(name = "device-time-config-controller")
|
||||||
public class TimeConfigController {
|
public class TimeConfigController {
|
||||||
|
|
||||||
|
|||||||
@@ -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.ResetCreateTaskResponse;
|
||||||
import com.cisd.tms.modules.init.dto.ResetPreviewResponse;
|
import com.cisd.tms.modules.init.dto.ResetPreviewResponse;
|
||||||
import com.cisd.tms.modules.init.service.InitService;
|
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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
@@ -64,6 +65,7 @@ public class InitController {
|
|||||||
*/
|
*/
|
||||||
@PostMapping("/tasks")
|
@PostMapping("/tasks")
|
||||||
@Operation(summary = "创建初始化任务", description = "根据初始化请求生成任务和步骤记录,任务初始状态为 PENDING。")
|
@Operation(summary = "创建初始化任务", description = "根据初始化请求生成任务和步骤记录,任务初始状态为 PENDING。")
|
||||||
|
@ReplayProtected
|
||||||
public ApiResponse<InitCreateTaskResponse> createTask(@Valid @RequestBody InitPreviewRequest request) {
|
public ApiResponse<InitCreateTaskResponse> createTask(@Valid @RequestBody InitPreviewRequest request) {
|
||||||
return ApiResponse.success(initService.createTask(request));
|
return ApiResponse.success(initService.createTask(request));
|
||||||
}
|
}
|
||||||
@@ -112,6 +114,7 @@ public class InitController {
|
|||||||
*/
|
*/
|
||||||
@PostMapping("/tasks/{taskId}/execute")
|
@PostMapping("/tasks/{taskId}/execute")
|
||||||
@Operation(summary = "执行初始化任务", description = "异步受理初始化任务执行请求,立即返回当前任务快照,实际步骤在后台继续运行。")
|
@Operation(summary = "执行初始化任务", description = "异步受理初始化任务执行请求,立即返回当前任务快照,实际步骤在后台继续运行。")
|
||||||
|
@ReplayProtected
|
||||||
public ApiResponse<InitTaskExecuteResponse> executeTask(
|
public ApiResponse<InitTaskExecuteResponse> executeTask(
|
||||||
@Parameter(description = "初始化任务唯一标识")
|
@Parameter(description = "初始化任务唯一标识")
|
||||||
@PathVariable String taskId
|
@PathVariable String taskId
|
||||||
@@ -127,12 +130,14 @@ public class InitController {
|
|||||||
|
|
||||||
@PostMapping("/reset/tasks")
|
@PostMapping("/reset/tasks")
|
||||||
@Operation(summary = "创建重置任务", description = "自动读取当前设备有效初始化快照生成重置任务和步骤记录,任务初始状态为 PENDING。")
|
@Operation(summary = "创建重置任务", description = "自动读取当前设备有效初始化快照生成重置任务和步骤记录,任务初始状态为 PENDING。")
|
||||||
|
@ReplayProtected
|
||||||
public ApiResponse<ResetCreateTaskResponse> createResetTask() {
|
public ApiResponse<ResetCreateTaskResponse> createResetTask() {
|
||||||
return ApiResponse.success(initService.createResetTask());
|
return ApiResponse.success(initService.createResetTask());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/reset/tasks/{taskId}/execute")
|
@PostMapping("/reset/tasks/{taskId}/execute")
|
||||||
@Operation(summary = "执行重置任务", description = "异步受理重置任务执行请求,立即返回当前任务快照,实际步骤在后台继续运行。")
|
@Operation(summary = "执行重置任务", description = "异步受理重置任务执行请求,立即返回当前任务快照,实际步骤在后台继续运行。")
|
||||||
|
@ReplayProtected
|
||||||
public ApiResponse<InitTaskExecuteResponse> executeResetTask(
|
public ApiResponse<InitTaskExecuteResponse> executeResetTask(
|
||||||
@Parameter(description = "重置任务唯一标识")
|
@Parameter(description = "重置任务唯一标识")
|
||||||
@PathVariable String taskId
|
@PathVariable String taskId
|
||||||
|
|||||||
@@ -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.dto.MasterKeyStateResult;
|
||||||
import com.cisd.tms.modules.mk.enums.MasterKeyStatus;
|
import com.cisd.tms.modules.mk.enums.MasterKeyStatus;
|
||||||
import com.cisd.tms.modules.mk.service.LmkService;
|
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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
@@ -28,12 +29,14 @@ public class LmkController {
|
|||||||
|
|
||||||
@PostMapping("/masterKey/init")
|
@PostMapping("/masterKey/init")
|
||||||
@Operation(summary = "初始化主密钥", description = "执行密码卡初始化识别、主密钥生成加载、内部密钥生成,并返回主密钥状态与校验值。")
|
@Operation(summary = "初始化主密钥", description = "执行密码卡初始化识别、主密钥生成加载、内部密钥生成,并返回主密钥状态与校验值。")
|
||||||
|
@ReplayProtected
|
||||||
public ApiResponse<MasterKeyStateResult> initMasterKey() {
|
public ApiResponse<MasterKeyStateResult> initMasterKey() {
|
||||||
return ApiResponse.success(lmkService.initMasterKey());
|
return ApiResponse.success(lmkService.initMasterKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/masterKey/destroy")
|
@PostMapping("/masterKey/destroy")
|
||||||
@Operation(summary = "销毁主密钥", description = "销毁当前密码卡中的主密钥,失败直接抛出异常。")
|
@Operation(summary = "销毁主密钥", description = "销毁当前密码卡中的主密钥,失败直接抛出异常。")
|
||||||
|
@ReplayProtected
|
||||||
public ApiResponse<Void> destroyMasterKey() {
|
public ApiResponse<Void> destroyMasterKey() {
|
||||||
//销毁主密钥、内部密钥、用户密钥
|
//销毁主密钥、内部密钥、用户密钥
|
||||||
lmkService.destroyMasterKey();
|
lmkService.destroyMasterKey();
|
||||||
@@ -53,6 +56,7 @@ public class LmkController {
|
|||||||
*/
|
*/
|
||||||
@PostMapping("/masterKey/recover")
|
@PostMapping("/masterKey/recover")
|
||||||
@Operation(summary = "恢复全部密钥", description = "请求体需传入三份按 componentIndex 标识的分量包。三份分量包必须分别对应 componentIndex=1、2、3。服务端会依次恢复 LMK、IK 和用户密钥。")
|
@Operation(summary = "恢复全部密钥", description = "请求体需传入三份按 componentIndex 标识的分量包。三份分量包必须分别对应 componentIndex=1、2、3。服务端会依次恢复 LMK、IK 和用户密钥。")
|
||||||
|
@ReplayProtected
|
||||||
public ApiResponse<Void> recoverLmkEx(@Valid @RequestBody MasterKeyRecoverPacketsRequest request) {
|
public ApiResponse<Void> recoverLmkEx(@Valid @RequestBody MasterKeyRecoverPacketsRequest request) {
|
||||||
lmkService.recoverKeyPackets(request.getPackets());
|
lmkService.recoverKeyPackets(request.getPackets());
|
||||||
return ApiResponse.success();
|
return ApiResponse.success();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.cisd.tms.modules.security.replay.enums;
|
||||||
|
|
||||||
|
public enum ReplayCheckStatus {
|
||||||
|
CLAIMED,
|
||||||
|
REPLAYED,
|
||||||
|
MISMATCH
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.cisd.tms.modules.security.replay.enums;
|
||||||
|
|
||||||
|
public enum ReplayScope {
|
||||||
|
OPENAPI,
|
||||||
|
INTERNAL_API
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.cisd.tms.modules.security.replay.enums;
|
||||||
|
|
||||||
|
public enum SecurityEventType {
|
||||||
|
REPLAY_MISMATCH
|
||||||
|
}
|
||||||
+12
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ReplayNonceEntity> {
|
||||||
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
@@ -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<SecurityEventEntity> {
|
||||||
|
}
|
||||||
+20
@@ -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<ReplayNonceEntity> 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);
|
||||||
|
}
|
||||||
+8
@@ -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);
|
||||||
|
}
|
||||||
+54
@@ -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<ReplayNonceEntity> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -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);
|
||||||
|
}
|
||||||
+230
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import com.cisd.tms.modules.upgrade.dto.UpgradePreviewResponse;
|
|||||||
import com.cisd.tms.modules.upgrade.dto.UpgradeTaskPageResponse;
|
import com.cisd.tms.modules.upgrade.dto.UpgradeTaskPageResponse;
|
||||||
import com.cisd.tms.modules.upgrade.dto.UpgradeTaskResponse;
|
import com.cisd.tms.modules.upgrade.dto.UpgradeTaskResponse;
|
||||||
import com.cisd.tms.modules.upgrade.service.UpgradeService;
|
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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
@@ -36,6 +37,7 @@ public class UpgradeController {
|
|||||||
|
|
||||||
@PostMapping("/upgrade-packages")
|
@PostMapping("/upgrade-packages")
|
||||||
@Operation(summary = "上传离线升级包", description = "上传离线升级包,返回 fileId 供升级预检和任务创建使用。")
|
@Operation(summary = "上传离线升级包", description = "上传离线升级包,返回 fileId 供升级预检和任务创建使用。")
|
||||||
|
@ReplayProtected
|
||||||
public ApiResponse<FileUploadResponse> uploadPackage(
|
public ApiResponse<FileUploadResponse> uploadPackage(
|
||||||
@Parameter(description = "待上传的离线升级包")
|
@Parameter(description = "待上传的离线升级包")
|
||||||
@RequestParam("file") MultipartFile file
|
@RequestParam("file") MultipartFile file
|
||||||
@@ -51,12 +53,14 @@ public class UpgradeController {
|
|||||||
|
|
||||||
@PostMapping("/upgrades")
|
@PostMapping("/upgrades")
|
||||||
@Operation(summary = "创建离线升级任务", description = "根据 fileId 创建离线升级任务,状态初始为 PENDING_CONFIRM。")
|
@Operation(summary = "创建离线升级任务", description = "根据 fileId 创建离线升级任务,状态初始为 PENDING_CONFIRM。")
|
||||||
|
@ReplayProtected
|
||||||
public ApiResponse<UpgradeCreateTaskResponse> create(@Valid @RequestBody UpgradeCreateTaskRequest request) {
|
public ApiResponse<UpgradeCreateTaskResponse> create(@Valid @RequestBody UpgradeCreateTaskRequest request) {
|
||||||
return ApiResponse.success(upgradeService.createTask(request));
|
return ApiResponse.success(upgradeService.createTask(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/upgrades/{taskId}/execute")
|
@PostMapping("/upgrades/{taskId}/execute")
|
||||||
@Operation(summary = "执行离线升级任务", description = "异步受理升级执行请求,立即返回任务当前快照。")
|
@Operation(summary = "执行离线升级任务", description = "异步受理升级执行请求,立即返回任务当前快照。")
|
||||||
|
@ReplayProtected
|
||||||
public ApiResponse<UpgradeTaskResponse> execute(
|
public ApiResponse<UpgradeTaskResponse> execute(
|
||||||
@Parameter(description = "升级任务号")
|
@Parameter(description = "升级任务号")
|
||||||
@PathVariable String taskId
|
@PathVariable String taskId
|
||||||
@@ -66,6 +70,7 @@ public class UpgradeController {
|
|||||||
|
|
||||||
@PostMapping("/upgrades/{taskId}/rollback")
|
@PostMapping("/upgrades/{taskId}/rollback")
|
||||||
@Operation(summary = "回滚离线升级任务", description = "异步受理升级回滚请求,立即返回任务当前快照。")
|
@Operation(summary = "回滚离线升级任务", description = "异步受理升级回滚请求,立即返回任务当前快照。")
|
||||||
|
@ReplayProtected
|
||||||
public ApiResponse<UpgradeTaskResponse> rollback(
|
public ApiResponse<UpgradeTaskResponse> rollback(
|
||||||
@Parameter(description = "升级任务号")
|
@Parameter(description = "升级任务号")
|
||||||
@PathVariable String taskId
|
@PathVariable String taskId
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,12 +13,15 @@ import java.io.IOException;
|
|||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.cors.CorsUtils;
|
import org.springframework.web.cors.CorsUtils;
|
||||||
import org.springframework.web.servlet.HandlerInterceptor;
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class InternalApiAuthInterceptor implements HandlerInterceptor {
|
public class InternalApiAuthInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
public static final String ATTR_ROLE_CODE = "CURRENT_ROLE_CODE";
|
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 ObjectMapper objectMapper;
|
||||||
private final Clock clock;
|
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
|
@Override
|
||||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||||
if (CorsUtils.isPreFlightRequest(request)) {
|
if (CorsUtils.isPreFlightRequest(request)) {
|
||||||
|
|||||||
@@ -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 extends java.lang.annotation.Annotation> T findAnnotation(HandlerMethod handlerMethod, Class<T> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
}
|
||||||
@@ -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<String, Long> 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,19 +5,28 @@ import com.cisd.tms.common.config.properties.TmsSecurityProperties;
|
|||||||
import com.cisd.tms.common.enums.ErrorCode;
|
import com.cisd.tms.common.enums.ErrorCode;
|
||||||
import com.cisd.tms.common.util.HmacUtil;
|
import com.cisd.tms.common.util.HmacUtil;
|
||||||
import com.cisd.tms.common.util.HttpResponseUtil;
|
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 com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
|
import java.time.Clock;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.cors.CorsUtils;
|
import org.springframework.web.cors.CorsUtils;
|
||||||
import org.springframework.web.servlet.HandlerInterceptor;
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class OpenApiSignAuthInterceptor implements HandlerInterceptor {
|
public class OpenApiSignAuthInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
private static final String APP_ID_HEADER = "X-App-Id";
|
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 static final String SIGNATURE_HEADER = "X-Signature";
|
||||||
|
|
||||||
private final TmsSecurityProperties securityProperties;
|
private final TmsSecurityProperties securityProperties;
|
||||||
private final OpenApiReplayGuard replayGuard;
|
private final ReplayProtectionService replayProtectionService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final Clock clock;
|
||||||
public OpenApiSignAuthInterceptor(
|
|
||||||
TmsSecurityProperties securityProperties,
|
|
||||||
OpenApiReplayGuard replayGuard,
|
|
||||||
ObjectMapper objectMapper
|
|
||||||
) {
|
|
||||||
this.securityProperties = securityProperties;
|
|
||||||
this.replayGuard = replayGuard;
|
|
||||||
this.objectMapper = objectMapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||||
@@ -70,18 +70,13 @@ public class OpenApiSignAuthInterceptor implements HandlerInterceptor {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
long now = Instant.now().getEpochSecond();
|
long now = Instant.ofEpochMilli(clock.millis()).getEpochSecond();
|
||||||
long skew = securityProperties.getOpenapi().getTimestampSkewSeconds();
|
long skew = securityProperties.getOpenapi().getTimestampSkewSeconds();
|
||||||
if (Math.abs(now - requestEpoch) > skew) {
|
if (Math.abs(now - requestEpoch) > skew) {
|
||||||
writeUnauthorized(response, "timestamp expired");
|
writeUnauthorized(response, "timestamp expired");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!replayGuard.tryAcquire(appId, nonce, now, skew * 2)) {
|
|
||||||
writeUnauthorized(response, "replayed nonce");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
String content = appId + "\n" + timestamp + "\n" + nonce;
|
String content = appId + "\n" + timestamp + "\n" + nonce;
|
||||||
String expected = HmacUtil.hmacSha256Hex(appSecret, content);
|
String expected = HmacUtil.hmacSha256Hex(appSecret, content);
|
||||||
|
|
||||||
@@ -90,9 +85,40 @@ public class OpenApiSignAuthInterceptor implements HandlerInterceptor {
|
|||||||
return false;
|
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;
|
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) {
|
private boolean secureEquals(String expected, String actual) {
|
||||||
return MessageDigest.isEqual(
|
return MessageDigest.isEqual(
|
||||||
expected.getBytes(StandardCharsets.UTF_8),
|
expected.getBytes(StandardCharsets.UTF_8),
|
||||||
|
|||||||
@@ -234,6 +234,45 @@ CREATE TABLE IF NOT EXISTS tms_auth_audit_log (
|
|||||||
KEY idx_tms_auth_audit_log_role_code (role_code)
|
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 (
|
INSERT IGNORE INTO tms_role_account (
|
||||||
id,
|
id,
|
||||||
role_code,
|
role_code,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -54,12 +54,17 @@ class TmsApplicationTests {
|
|||||||
@Test
|
@Test
|
||||||
void shouldCreateAuthRoleTablesAndSeedFixedRoleAccounts() {
|
void shouldCreateAuthRoleTablesAndSeedFixedRoleAccounts() {
|
||||||
ClassPathResource migration = new ClassPathResource("db/migration/V1__tms_schema_full.sql");
|
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(migration.exists());
|
||||||
|
org.junit.jupiter.api.Assertions.assertTrue(replayMigration.exists());
|
||||||
|
|
||||||
String sql = org.junit.jupiter.api.Assertions.assertDoesNotThrow(
|
String sql = org.junit.jupiter.api.Assertions.assertDoesNotThrow(
|
||||||
() -> new String(migration.getInputStream().readAllBytes(), StandardCharsets.UTF_8)
|
() -> 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_account"));
|
||||||
org.junit.jupiter.api.Assertions.assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS tms_role_ukey_binding"));
|
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("'KEY_ADMIN'"));
|
||||||
org.junit.jupiter.api.Assertions.assertTrue(sql.contains("'ACTIVE'"));
|
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("'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)"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<ReplayNonceEntity> 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<ReplayNonceEntity> 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<SecurityEventEntity> 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<ReplayNonceEntity> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+283
@@ -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<ReplayNonceEntity> 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<SecurityEventEntity> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, AuthSessionEntity> store = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Optional<AuthSessionEntity> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<ReplayCheckRequest> 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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ReplayCheckRequest> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user