From 2f81beb0d888b4eed66d707ceef5e90d31b2fd0c Mon Sep 17 00:00:00 2001 From: waner Date: Wed, 8 Apr 2026 11:10:26 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E5=8D=87=E7=BA=A7=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/application.yml.example | 4 +++ .../exception/GlobalExceptionHandler.java | 7 ++++ .../upgrade/controller/UpgradeController.java | 7 ++-- .../upgrade/executor/UpgradeTaskRunner.java | 15 ++++++--- src/main/resources/application.yml | 7 +++- .../exception/GlobalExceptionHandlerTest.java | 27 ++++++++++++++++ .../MultipartUploadConfigurationTest.java | 32 +++++++++++++++++++ .../executor/UpgradeTaskRunnerTest.java | 26 +++++++++++++++ 8 files changed, 115 insertions(+), 10 deletions(-) create mode 100644 src/test/java/com/cisd/tms/common/exception/GlobalExceptionHandlerTest.java create mode 100644 src/test/java/com/cisd/tms/config/MultipartUploadConfigurationTest.java diff --git a/config/application.yml.example b/config/application.yml.example index 43f9abe..7e2c3c9 100644 --- a/config/application.yml.example +++ b/config/application.yml.example @@ -1,6 +1,10 @@ spring: application: name: tms-framework + servlet: + multipart: + max-file-size: 104857600 + max-request-size: 104857600 server: port: 8080 diff --git a/src/main/java/com/cisd/tms/common/exception/GlobalExceptionHandler.java b/src/main/java/com/cisd/tms/common/exception/GlobalExceptionHandler.java index 5d6e4b7..162310f 100644 --- a/src/main/java/com/cisd/tms/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/cisd/tms/common/exception/GlobalExceptionHandler.java @@ -11,6 +11,7 @@ import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MaxUploadSizeExceededException; @RestControllerAdvice public class GlobalExceptionHandler { @@ -45,6 +46,12 @@ public class GlobalExceptionHandler { return ApiResponse.fail(ErrorCode.VALIDATE_FAILED.getCode(), ex.getMessage()).withPath(request.getRequestURI()); } + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ApiResponse handleMaxUploadSizeExceeded(MaxUploadSizeExceededException ex, HttpServletRequest request) { + return ApiResponse.fail(ErrorCode.VALIDATE_FAILED.getCode(), "upload file size exceeds limit") + .withPath(request.getRequestURI()); + } + @ExceptionHandler(PcieCryptoException.class) public ApiResponse handleCryptoCardException(PcieCryptoException ex, HttpServletRequest request) { return ApiResponse.fail(ErrorCode.CRYPTO_CARD_ERROR.getCode(), ex.getMessage()) diff --git a/src/main/java/com/cisd/tms/modules/upgrade/controller/UpgradeController.java b/src/main/java/com/cisd/tms/modules/upgrade/controller/UpgradeController.java index ea7b9e6..c95a677 100644 --- a/src/main/java/com/cisd/tms/modules/upgrade/controller/UpgradeController.java +++ b/src/main/java/com/cisd/tms/modules/upgrade/controller/UpgradeController.java @@ -15,6 +15,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -26,17 +27,13 @@ import org.springframework.web.multipart.MultipartFile; @RestController @RequestMapping("/api/v1") +@RequiredArgsConstructor @Tag(name = "离线升级管理", description = "离线升级包上传、预检、任务创建、执行和升级记录查询接口") public class UpgradeController { private final UpgradeService upgradeService; private final FileService fileService; - public UpgradeController(UpgradeService upgradeService, FileService fileService) { - this.upgradeService = upgradeService; - this.fileService = fileService; - } - @PostMapping("/upgrade-packages") @Operation(summary = "上传离线升级包", description = "上传离线升级包,返回 fileId 供升级预检和任务创建使用。") public ApiResponse uploadPackage( diff --git a/src/main/java/com/cisd/tms/modules/upgrade/executor/UpgradeTaskRunner.java b/src/main/java/com/cisd/tms/modules/upgrade/executor/UpgradeTaskRunner.java index 9dbbd9f..649acc5 100644 --- a/src/main/java/com/cisd/tms/modules/upgrade/executor/UpgradeTaskRunner.java +++ b/src/main/java/com/cisd/tms/modules/upgrade/executor/UpgradeTaskRunner.java @@ -98,8 +98,9 @@ public class UpgradeTaskRunner { } DeviceSoftwareVersionEntity versionEntity = new DeviceSoftwareVersionEntity(); - versionEntity.setComponentCode(task.getTaskType()); - versionEntity.setComponentName(componentName(task.getTaskType())); + String componentCode = componentCode(task.getTaskType()); + versionEntity.setComponentCode(componentCode); + versionEntity.setComponentName(componentName(componentCode)); versionEntity.setCurrentVersion(task.getTargetVersion()); versionEntity.setSourceType("UPGRADE"); versionEntity.setDetectedAt(LocalDateTime.now()); @@ -211,12 +212,18 @@ public class UpgradeTaskRunner { private String componentName(String componentCode) { return switch (trim(componentCode)) { case "TMS" -> "设备管理软件"; - case "RECEIVER" -> "标准收发器"; - case "FIRMWARE" -> "固件"; + case "APP" -> "标准收发器"; default -> componentCode; }; } + private String componentCode(String taskType) { + return switch (trim(taskType)) { + case "RECEIVER" -> "APP"; + default -> trim(taskType); + }; + } + private String trim(String value) { return value == null ? "" : value.trim(); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index def075e..b834c8d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,6 +2,11 @@ spring: application: # Spring 应用名;用于日志、注册中心标识等。 name: tms-framework + servlet: + multipart: + # Spring/Tomcat multipart 解析上限;必须不小于业务上传上限,否则请求会在进入 Controller 前被拒绝。 + max-file-size: ${TMS_FILE_STORAGE_MAX_FILE_SIZE_BYTES:104857600} + max-request-size: ${TMS_FILE_STORAGE_MAX_FILE_SIZE_BYTES:104857600} profiles: # 默认激活环境;可通过 --spring.profiles.active=prod 覆盖。 active: dev @@ -173,7 +178,7 @@ tms: # 升级任务统一日志目录。 log-dir: ${TMS_UPGRADE_LOG_DIR:/home/tms/tmp/tms-upgrade-logs} # 升级包验签使用的公钥 PEM 文件路径;为空时升级预检会拒绝通过。 - signature-public-key-pem-path: ${TMS_UPGRADE_SIGNATURE_PUBLIC_KEY_PEM_PATH:} + signature-public-key-pem-path: ${TMS_UPGRADE_SIGNATURE_PUBLIC_KEY_PEM_PATH:/home/tms/cert/public_key.pem} mk: init-identify: # SDFE_InitIdentify 旧 PIN(Base64 编码);主密钥初始化接口会读取该值。 diff --git a/src/test/java/com/cisd/tms/common/exception/GlobalExceptionHandlerTest.java b/src/test/java/com/cisd/tms/common/exception/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..fd47f10 --- /dev/null +++ b/src/test/java/com/cisd/tms/common/exception/GlobalExceptionHandlerTest.java @@ -0,0 +1,27 @@ +package com.cisd.tms.common.exception; + +import com.cisd.tms.common.api.ApiResponse; +import com.cisd.tms.common.enums.ErrorCode; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.multipart.MaxUploadSizeExceededException; + +class GlobalExceptionHandlerTest { + + @Test + void shouldHandleMultipartMaxUploadSizeExceededAsValidationFailure() { + GlobalExceptionHandler handler = new GlobalExceptionHandler(); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/upgrade-packages"); + + ApiResponse response = handler.handleMaxUploadSizeExceeded( + new MaxUploadSizeExceededException(104857600), + request + ); + + Assertions.assertFalse(response.isSuccess()); + Assertions.assertEquals(ErrorCode.VALIDATE_FAILED.getCode(), response.getCode()); + Assertions.assertEquals("upload file size exceeds limit", response.getMsg()); + Assertions.assertEquals("/api/v1/upgrade-packages", response.getPath()); + } +} diff --git a/src/test/java/com/cisd/tms/config/MultipartUploadConfigurationTest.java b/src/test/java/com/cisd/tms/config/MultipartUploadConfigurationTest.java new file mode 100644 index 0000000..e202a99 --- /dev/null +++ b/src/test/java/com/cisd/tms/config/MultipartUploadConfigurationTest.java @@ -0,0 +1,32 @@ +package com.cisd.tms.config; + +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.core.env.PropertySource; +import org.springframework.core.io.FileSystemResource; + +class MultipartUploadConfigurationTest { + + @Test + void shouldConfigureSpringMultipartLimitsForLargeUpgradePackages() throws Exception { + FileSystemResource resource = new FileSystemResource(Path.of("src/main/resources/application.yml")); + List> propertySources = new YamlPropertySourceLoader().load("application", resource); + + Object maxFileSize = findProperty(propertySources, "spring.servlet.multipart.max-file-size"); + Object maxRequestSize = findProperty(propertySources, "spring.servlet.multipart.max-request-size"); + + Assertions.assertEquals("${TMS_FILE_STORAGE_MAX_FILE_SIZE_BYTES:104857600}", maxFileSize); + Assertions.assertEquals("${TMS_FILE_STORAGE_MAX_FILE_SIZE_BYTES:104857600}", maxRequestSize); + } + + private Object findProperty(List> propertySources, String name) { + return propertySources.stream() + .filter(source -> source.containsProperty(name)) + .findFirst() + .map(source -> source.getProperty(name)) + .orElse(null); + } +} diff --git a/src/test/java/com/cisd/tms/modules/upgrade/executor/UpgradeTaskRunnerTest.java b/src/test/java/com/cisd/tms/modules/upgrade/executor/UpgradeTaskRunnerTest.java index ce2fca1..92283a6 100644 --- a/src/test/java/com/cisd/tms/modules/upgrade/executor/UpgradeTaskRunnerTest.java +++ b/src/test/java/com/cisd/tms/modules/upgrade/executor/UpgradeTaskRunnerTest.java @@ -56,6 +56,32 @@ class UpgradeTaskRunnerTest { Assertions.assertTrue(log.indexOf("execute") < log.indexOf("verify")); } + @Test + void shouldUpdateAppSoftwareVersionWhenReceiverUpgradeSucceeds() throws Exception { + Path tempDir = Files.createTempDirectory("upgrade-runner-test"); + FileRecordEntity fileRecord = buildPackage(tempDir, false, "#!/bin/sh\necho execute\nexit 0\n", false); + InMemoryFileRecordRepository fileRepository = new InMemoryFileRecordRepository(); + fileRepository.save(fileRecord); + InMemoryUpgradeTaskRepository taskRepository = new InMemoryUpgradeTaskRepository(); + InMemoryDeviceSoftwareVersionRepository versionRepository = new InMemoryDeviceSoftwareVersionRepository(); + + UpgradeTaskRunner runner = new UpgradeTaskRunner( + taskRepository, + fileRepository, + versionRepository, + stagingService(tempDir.resolve("staging"), tempDir.resolve("logs")), + properties(tempDir.resolve("staging"), tempDir.resolve("logs")), + objectMapper + ); + + runner.run(task("UPG-001-R", fileRecord.getFileId(), "RECEIVER", "V1.4.0")); + + Assertions.assertEquals("SUCCESS", taskRepository.status); + Assertions.assertNull(versionRepository.store.get("RECEIVER")); + Assertions.assertEquals("V1.4.0", versionRepository.store.get("APP").getCurrentVersion()); + Assertions.assertEquals("标准收发器", versionRepository.store.get("APP").getComponentName()); + } + @Test void shouldMarkTaskFailedWhenExecuteScriptFails() throws Exception { Path tempDir = Files.createTempDirectory("upgrade-runner-test"); From 3d28f841153eef5eefa64ab3f0eb0155c9040f7d Mon Sep 17 00:00:00 2001 From: waner Date: Wed, 8 Apr 2026 15:32:56 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:app=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/DeviceProfileServiceImpl.java | 4 ++-- .../impl/DeviceRuntimeStatusServiceImpl.java | 10 +++++----- .../upgrade/enums/UpgradeTargetComponent.java | 3 +++ .../upgrade/enums/UpgradeTaskStatus.java | 3 +++ .../upgrade/executor/UpgradeTaskRunner.java | 11 ++++++----- .../service/UpgradePackageService.java | 5 +++++ .../upgrade/service/UpgradeService.java | 9 +++++++++ .../SoftUpgradePackageSignatureVerifier.java | 5 ++++- .../support/UpgradePackageStagingService.java | 1 + .../support/UpgradeVersionComparator.java | 1 + .../db/migration/V1__tms_schema_full.sql | 2 +- ...oftware_version_rename_app_to_receiver.sql | 19 +++++++++++++++++++ .../service/DeviceProfileServiceTest.java | 6 +++--- .../DeviceRuntimeStatusServiceTest.java | 8 ++++---- .../executor/UpgradeTaskRunnerTest.java | 8 ++++---- 15 files changed, 70 insertions(+), 25 deletions(-) create mode 100644 src/main/resources/db/migration/V4__device_software_version_rename_app_to_receiver.sql diff --git a/src/main/java/com/cisd/tms/modules/device/service/impl/DeviceProfileServiceImpl.java b/src/main/java/com/cisd/tms/modules/device/service/impl/DeviceProfileServiceImpl.java index a26bae3..e8023e6 100644 --- a/src/main/java/com/cisd/tms/modules/device/service/impl/DeviceProfileServiceImpl.java +++ b/src/main/java/com/cisd/tms/modules/device/service/impl/DeviceProfileServiceImpl.java @@ -83,9 +83,9 @@ public class DeviceProfileServiceImpl implements DeviceProfileService { if ("ENTERPRISE".equals(productType) || "INDIRECT".equals(productType)) { addSoftwareItem(items, "TIDB", "数据库", cached("TIDB")); addSoftwareItem(items, "NGINX", "Nginx", cached("NGINX")); - addSoftwareItem(items, "APP", appName(productType), firstNonBlank(cached("APP"), cisdPresetProperties.getVersion())); + addSoftwareItem(items, "RECEIVER", appName(productType), firstNonBlank(cached("RECEIVER"), cisdPresetProperties.getVersion())); } else if ("DIRECT".equals(productType)) { - addSoftwareItem(items, "APP", appName(productType), firstNonBlank(cached("APP"), cisdPresetProperties.getVersion())); + addSoftwareItem(items, "RECEIVER", appName(productType), firstNonBlank(cached("RECEIVER"), cisdPresetProperties.getVersion())); } return items; } diff --git a/src/main/java/com/cisd/tms/modules/device/service/impl/DeviceRuntimeStatusServiceImpl.java b/src/main/java/com/cisd/tms/modules/device/service/impl/DeviceRuntimeStatusServiceImpl.java index 7164abf..3b099da 100644 --- a/src/main/java/com/cisd/tms/modules/device/service/impl/DeviceRuntimeStatusServiceImpl.java +++ b/src/main/java/com/cisd/tms/modules/device/service/impl/DeviceRuntimeStatusServiceImpl.java @@ -114,17 +114,17 @@ public class DeviceRuntimeStatusServiceImpl implements DeviceRuntimeStatusServic items.add(buildSoftwareItem("TLQ", "消息队列", cached("TLQ"), initialized && isTlqEnabled(mqType), softwareRuntimeProbe::isTlqRunning)); items.add(buildSoftwareItem("TIDB", "数据库", cached("TIDB"), initialized && isTidbEnabled(productType), softwareRuntimeProbe::isTidbRunning)); items.add(buildSoftwareItem("NGINX", "Nginx", cached("NGINX"), initialized && isNginxEnabled(productType), softwareRuntimeProbe::isNginxRunning)); - items.add(buildAppItem(productType, initialized)); + items.add(buildReceiverItem(productType, initialized)); items.add(buildSoftwareItem("TMS", "设备管理软件", firstNonBlank(cached("TMS"), systemSoftwareVersionProbe.tmsVersion(), cisdPresetProperties.getVersion()), true, softwareRuntimeProbe::isTmsRunning)); return items; } - private SoftwareRuntimeStatusItem buildAppItem(String productType, boolean initialized) { + private SoftwareRuntimeStatusItem buildReceiverItem(String productType, boolean initialized) { boolean standard = "ENTERPRISE".equals(productType) || "INDIRECT".equals(productType); BooleanSupplier supplier = standard ? softwareRuntimeProbe::isStandardAppRunning : softwareRuntimeProbe::isDirectAppRunning; - String name = "应用软件"; - String displayName = firstNonBlank(cached("APP"), appName(productType)); - return buildSoftwareItem("APP", name, displayName, initialized, supplier); + String name = "标准收发器"; + String displayName = firstNonBlank(cached("RECEIVER"), appName(productType)); + return buildSoftwareItem("RECEIVER", name, displayName, initialized, supplier); } private SoftwareRuntimeStatusItem buildSoftwareItem( diff --git a/src/main/java/com/cisd/tms/modules/upgrade/enums/UpgradeTargetComponent.java b/src/main/java/com/cisd/tms/modules/upgrade/enums/UpgradeTargetComponent.java index 28cffd9..03e0058 100644 --- a/src/main/java/com/cisd/tms/modules/upgrade/enums/UpgradeTargetComponent.java +++ b/src/main/java/com/cisd/tms/modules/upgrade/enums/UpgradeTargetComponent.java @@ -1,5 +1,8 @@ package com.cisd.tms.modules.upgrade.enums; +/** + * 升级任务类型。具体应用名称由升级包脚本处理,后端不按应用名拆分。 + */ public enum UpgradeTargetComponent { TMS, RECEIVER, diff --git a/src/main/java/com/cisd/tms/modules/upgrade/enums/UpgradeTaskStatus.java b/src/main/java/com/cisd/tms/modules/upgrade/enums/UpgradeTaskStatus.java index e6f5e65..dec641b 100644 --- a/src/main/java/com/cisd/tms/modules/upgrade/enums/UpgradeTaskStatus.java +++ b/src/main/java/com/cisd/tms/modules/upgrade/enums/UpgradeTaskStatus.java @@ -1,5 +1,8 @@ package com.cisd.tms.modules.upgrade.enums; +/** + * 升级任务主状态。脚本级细分状态当前通过日志体现,尚未独立落表。 + */ public enum UpgradeTaskStatus { PENDING_CONFIRM, RUNNING, diff --git a/src/main/java/com/cisd/tms/modules/upgrade/executor/UpgradeTaskRunner.java b/src/main/java/com/cisd/tms/modules/upgrade/executor/UpgradeTaskRunner.java index 649acc5..9728b90 100644 --- a/src/main/java/com/cisd/tms/modules/upgrade/executor/UpgradeTaskRunner.java +++ b/src/main/java/com/cisd/tms/modules/upgrade/executor/UpgradeTaskRunner.java @@ -58,6 +58,7 @@ public class UpgradeTaskRunner { UpgradeManifest manifest = objectMapper.readValue(Files.readString(stagedDir.resolve("manifest.json")), UpgradeManifest.class); Path root = stagedDir.normalize().toAbsolutePath(); + // 包内脚本按固定阶段执行;可选阶段未声明时跳过,execute 阶段必须存在。 int precheckCode = runOptionalScript("precheck", root, manifest.getEntrypoints() == null ? "" : manifest.getEntrypoints().getPrecheck(), task, logPath); if (precheckCode != 0) { upgradeTaskRepository.updateStatus( @@ -97,6 +98,7 @@ public class UpgradeTaskRunner { return; } + // 只有 execute 和 verify 都成功后,才认为升级成功并回写设备软件版本表。 DeviceSoftwareVersionEntity versionEntity = new DeviceSoftwareVersionEntity(); String componentCode = componentCode(task.getTaskType()); versionEntity.setComponentCode(componentCode); @@ -147,6 +149,7 @@ public class UpgradeTaskRunner { if (rollbackPath.isEmpty()) { throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade rollback script not found"); } + // 回滚不自动触发,只有外部调用 rollback 接口后才执行包内 rollback 脚本。 int exitCode = runRequiredScript("rollback", stagedDir.normalize().toAbsolutePath(), rollbackPath, task, logPath); if (exitCode != 0) { upgradeTaskRepository.updateStatus( @@ -198,6 +201,7 @@ public class UpgradeTaskRunner { private int runRequiredScript(String phase, Path stagedDir, String scriptPath, UpgradeTaskEntity task, Path logPath) throws IOException, InterruptedException { Path resolved = stagedDir.resolve(trim(scriptPath)).normalize().toAbsolutePath(); + // 防止脚本路径通过 ../ 跳出升级包解压目录。 if (!resolved.startsWith(stagedDir) || !Files.exists(resolved)) { throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade " + phase + " script not found"); } @@ -212,16 +216,13 @@ public class UpgradeTaskRunner { private String componentName(String componentCode) { return switch (trim(componentCode)) { case "TMS" -> "设备管理软件"; - case "APP" -> "标准收发器"; + case "RECEIVER" -> "标准收发器"; default -> componentCode; }; } private String componentCode(String taskType) { - return switch (trim(taskType)) { - case "RECEIVER" -> "APP"; - default -> trim(taskType); - }; + return trim(taskType); } private String trim(String value) { diff --git a/src/main/java/com/cisd/tms/modules/upgrade/service/UpgradePackageService.java b/src/main/java/com/cisd/tms/modules/upgrade/service/UpgradePackageService.java index 98f21a1..6f4c0c7 100644 --- a/src/main/java/com/cisd/tms/modules/upgrade/service/UpgradePackageService.java +++ b/src/main/java/com/cisd/tms/modules/upgrade/service/UpgradePackageService.java @@ -51,11 +51,14 @@ public class UpgradePackageService { public UpgradePreviewResponse preview(String fileId) { StagedUpgradePackage stagedPackage = stagePackage(fileId); UpgradeManifest manifest = stagedPackage.manifest(); + // 预检顺序保持“结构/产品/签名/脚本/版本”,先挡住不属于当前设备或不可信的包。 validateManifest(manifest); validateProductType(manifest.getProductType()); + // 签名对象固定为 manifest.json 原始字节,具体算法由 UpgradePackageSignatureVerifier 实现。 signatureVerifier.verify(manifest, stagedPackage.manifestPath(), stagedPackage.signaturePath()); validateExecuteScript(stagedPackage.stagedDir(), manifest); + // 当前版本来自设备软件版本表;空版本允许继续预检,由前端展示“未记录版本”。 String currentVersion = currentVersion(manifest.getTaskType()); String targetVersion = normalize(manifest.getVersion()); @@ -101,6 +104,7 @@ public class UpgradePackageService { throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade package file not found on disk"); } + // 同一个 fileId 每次预检都会重新解压,确保读取的是当前上传文件内容。 Path stagedDir = upgradePackageStagingService.stage(record.getFileId(), packagePath); Path manifestPath = stagedDir.resolve("manifest.json"); Path signaturePath = stagedDir.resolve("signature.sig"); @@ -142,6 +146,7 @@ public class UpgradePackageService { private void validateExecuteScript(Path stagedDir, UpgradeManifest manifest) { Path executePath = stagedDir.resolve(normalize(manifest.getEntrypoints().getExecute())).normalize().toAbsolutePath(); + // execute 路径必须在 staging 目录内,避免 manifest 声明外部系统脚本。 if (!executePath.startsWith(stagedDir.normalize().toAbsolutePath()) || !Files.exists(executePath)) { throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade execute script not found"); } diff --git a/src/main/java/com/cisd/tms/modules/upgrade/service/UpgradeService.java b/src/main/java/com/cisd/tms/modules/upgrade/service/UpgradeService.java index 45e6944..bd2451c 100644 --- a/src/main/java/com/cisd/tms/modules/upgrade/service/UpgradeService.java +++ b/src/main/java/com/cisd/tms/modules/upgrade/service/UpgradeService.java @@ -43,6 +43,10 @@ public class UpgradeService { private final UpgradeTaskRunner upgradeTaskRunner; private final UpgradeProperties upgradeProperties; private final Executor backgroundExecutor; + /** + * 进程内执行互斥表:避免同一任务被前端重复点击后并发启动。 + * 跨进程/重启后的互斥仍以数据库中的 RUNNING 任务为准。 + */ private final ConcurrentMap> activeExecutions; @Autowired @@ -93,6 +97,7 @@ public class UpgradeService { if (request == null || request.getFileId() == null || request.getFileId().isBlank()) { throw new IllegalArgumentException("fileId must not be blank"); } + // 创建任务前重新预检一次,避免用户上传后替换文件或设备版本状态发生变化。 UpgradePreviewResponse preview = upgradePackageService.preview(request.getFileId()); if (!preview.isAllowed()) { throw new BizException(ErrorCode.BIZ_ERROR.getCode(), blankAs(preview.getBlockReason(), "upgrade preview not allowed")); @@ -131,6 +136,7 @@ public class UpgradeService { public UpgradeTaskResponse executeTask(String taskId) { UpgradeTaskEntity task = loadTask(taskId); + // 第一版按一体机单机串行升级处理,避免同时升级 TMS、收发器或固件造成状态不可控。 UpgradeTaskEntity runningTask = upgradeTaskRepository.findRunningTask().orElse(null); if (runningTask != null && !runningTask.getTaskId().equals(taskId)) { throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "another upgrade task is running"); @@ -144,6 +150,7 @@ public class UpgradeService { CompletableFuture future = CompletableFuture.runAsync(() -> { try { + // 后台执行重新加载任务,确保使用已落库的最新状态和日志路径。 upgradeTaskRunner.run(loadTask(taskId)); } finally { activeExecutions.remove(taskId); @@ -155,6 +162,7 @@ public class UpgradeService { public UpgradeTaskResponse rollbackTask(String taskId) { UpgradeTaskEntity task = loadTask(taskId); + // 回滚能力由升级包声明;TMS 不假定所有包都能安全回滚。 UpgradeManifest manifest = upgradePackageService.loadManifest(task.getPackageFileId()); if (manifest.getEntrypoints() == null || trim(manifest.getEntrypoints().getRollback()).isEmpty()) { throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade rollback script not found"); @@ -205,6 +213,7 @@ public class UpgradeService { } Path path = Path.of(logPath).normalize().toAbsolutePath(); Path allowedRoot = Path.of(trim(upgradeProperties.getLogDir())).normalize().toAbsolutePath(); + // 日志路径必须落在升级日志根目录下,防止通过篡改任务记录读取任意文件。 if (!path.startsWith(allowedRoot)) { throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade log path is out of allowed directory"); } diff --git a/src/main/java/com/cisd/tms/modules/upgrade/support/SoftUpgradePackageSignatureVerifier.java b/src/main/java/com/cisd/tms/modules/upgrade/support/SoftUpgradePackageSignatureVerifier.java index 57c0d4f..8e26d75 100644 --- a/src/main/java/com/cisd/tms/modules/upgrade/support/SoftUpgradePackageSignatureVerifier.java +++ b/src/main/java/com/cisd/tms/modules/upgrade/support/SoftUpgradePackageSignatureVerifier.java @@ -44,6 +44,7 @@ public class SoftUpgradePackageSignatureVerifier implements UpgradePackageSignat throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade package signature file not found"); } + // 第一版升级包仅对 manifest.json 做离线软验签,避免在包内脚本执行前信任未授权包。 PublicKey publicKey = loadPublicKey(); byte[] manifestBytes = readBytes(manifestPath, "failed to read manifest for signature verification"); byte[] signatureBytes = loadSignature(signaturePath); @@ -74,6 +75,7 @@ public class SoftUpgradePackageSignatureVerifier implements UpgradePackageSignat String pemContent = Files.readString(pemPath, StandardCharsets.UTF_8); try (Reader reader = new StringReader(pemContent); PEMParser parser = new PEMParser(reader)) { Object parsed = parser.readObject(); + // 约定使用 X.509 SubjectPublicKeyInfo 格式的 PUBLIC KEY PEM。 if (!(parsed instanceof SubjectPublicKeyInfo publicKeyInfo)) { throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade signature public key pem is invalid"); } @@ -97,12 +99,13 @@ public class SoftUpgradePackageSignatureVerifier implements UpgradePackageSignat String text = new String(raw, StandardCharsets.UTF_8).trim(); if (!text.isEmpty()) { try { + // 兼容联调阶段生成的 Base64 文本签名;生产包可直接使用二进制签名。 byte[] decoded = Base64.getDecoder().decode(text); if (decoded.length > 0) { return decoded; } } catch (IllegalArgumentException ignored) { - // Keep compatibility with raw binary signature files. + // 非 Base64 文本时按原始二进制签名处理。 } } return raw; diff --git a/src/main/java/com/cisd/tms/modules/upgrade/support/UpgradePackageStagingService.java b/src/main/java/com/cisd/tms/modules/upgrade/support/UpgradePackageStagingService.java index 1893515..7026100 100644 --- a/src/main/java/com/cisd/tms/modules/upgrade/support/UpgradePackageStagingService.java +++ b/src/main/java/com/cisd/tms/modules/upgrade/support/UpgradePackageStagingService.java @@ -39,6 +39,7 @@ public class UpgradePackageStagingService { ZipEntry entry; while ((entry = zipInputStream.getNextEntry()) != null) { Path target = targetDir.resolve(entry.getName()).normalize().toAbsolutePath(); + // 防止恶意 zip 条目通过 ../ 写出 staging 目录。 if (!target.startsWith(targetDir)) { throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "invalid upgrade package entry"); } diff --git a/src/main/java/com/cisd/tms/modules/upgrade/support/UpgradeVersionComparator.java b/src/main/java/com/cisd/tms/modules/upgrade/support/UpgradeVersionComparator.java index b7ca7c7..f58556a 100644 --- a/src/main/java/com/cisd/tms/modules/upgrade/support/UpgradeVersionComparator.java +++ b/src/main/java/com/cisd/tms/modules/upgrade/support/UpgradeVersionComparator.java @@ -25,6 +25,7 @@ public class UpgradeVersionComparator { String value = version == null ? "" : version.trim(); List parts = new ArrayList<>(); StringBuilder current = new StringBuilder(); + // 只提取版本号中的数字段,兼容 V1.2.3、1.2.3-build4 等简单格式。 for (int i = 0; i < value.length(); i++) { char ch = value.charAt(i); if (Character.isDigit(ch)) { diff --git a/src/main/resources/db/migration/V1__tms_schema_full.sql b/src/main/resources/db/migration/V1__tms_schema_full.sql index 8a59d14..10e18e0 100644 --- a/src/main/resources/db/migration/V1__tms_schema_full.sql +++ b/src/main/resources/db/migration/V1__tms_schema_full.sql @@ -110,7 +110,7 @@ VALUES (1002, 'TLQ', '消息队列', 'V8.1.11', 'MANUAL', NOW(), '出厂预置'), (1003, 'TIDB', '数据库', 'V8.1.0', 'MANUAL', NOW(), '出厂预置'), (1004, 'NGINX', 'Nginx', 'V1.20', 'MANUAL', NOW(), '出厂预置'), - (1005, 'APP', '标准收发器企业版', 'V1.3.2', 'MANUAL', NOW(), '出厂预置'), + (1005, 'RECEIVER', '标准收发器', 'V1.3.2', 'MANUAL', NOW(), '出厂预置'), (1006, 'TMS', 'CISD终端管理服务', 'V1.0.0', 'MANUAL', NOW(), '出厂预置') ON DUPLICATE KEY UPDATE component_name = VALUES(component_name), diff --git a/src/main/resources/db/migration/V4__device_software_version_rename_app_to_receiver.sql b/src/main/resources/db/migration/V4__device_software_version_rename_app_to_receiver.sql new file mode 100644 index 0000000..1ba42d9 --- /dev/null +++ b/src/main/resources/db/migration/V4__device_software_version_rename_app_to_receiver.sql @@ -0,0 +1,19 @@ +-- 组件版本表统一使用 RECEIVER 表示标准收发器,废弃旧编码 APP。 +-- 若环境中已存在 RECEIVER 行,则保留 RECEIVER 并删除旧 APP,避免唯一键冲突。 +DELETE FROM tms_device_software_version +WHERE component_code = 'APP' + AND EXISTS ( + SELECT 1 + FROM (SELECT id FROM tms_device_software_version WHERE component_code = 'RECEIVER') receiver_row + ); + +UPDATE tms_device_software_version +SET component_code = 'RECEIVER', + component_name = '标准收发器', + update_time = CURRENT_TIMESTAMP +WHERE component_code = 'APP'; + +UPDATE tms_device_software_version +SET component_name = '标准收发器', + update_time = CURRENT_TIMESTAMP +WHERE component_code = 'RECEIVER'; diff --git a/src/test/java/com/cisd/tms/modules/device/service/DeviceProfileServiceTest.java b/src/test/java/com/cisd/tms/modules/device/service/DeviceProfileServiceTest.java index 5c33f8c..790219e 100644 --- a/src/test/java/com/cisd/tms/modules/device/service/DeviceProfileServiceTest.java +++ b/src/test/java/com/cisd/tms/modules/device/service/DeviceProfileServiceTest.java @@ -47,7 +47,7 @@ class DeviceProfileServiceTest { versionRepository.save(component("TLQ", "消息队列", "tonglink/Q V8.1.11")); versionRepository.save(component("TIDB", "数据库", "TiDB V8.1.0")); versionRepository.save(component("NGINX", "Nginx", "Nginx V1.20")); - versionRepository.save(component("APP", "标准收发器间参版", "标准收发器间参版 V1.3.2")); + versionRepository.save(component("RECEIVER", "标准收发器间参版", "标准收发器间参版 V1.3.2")); DeviceProfileService service = new DeviceProfileServiceImpl( initService, @@ -73,7 +73,7 @@ class DeviceProfileServiceTest { Assertions.assertEquals("32GB", response.getHardware().getMemoryTotal()); Assertions.assertEquals("953.87GB", response.getHardware().getDiskTotal()); Assertions.assertEquals( - List.of("OS", "TMS", "RABBITMQ", "TLQ", "TIDB", "NGINX", "APP"), + List.of("OS", "TMS", "RABBITMQ", "TLQ", "TIDB", "NGINX", "RECEIVER"), response.getSoftware().stream().map(DeviceProfileSoftwareItem::getComponentCode).toList() ); } @@ -156,7 +156,7 @@ class DeviceProfileServiceTest { DeviceProfileResponse response = service.profile(); Assertions.assertEquals( - List.of("OS", "TMS", "RABBITMQ", "TIDB", "NGINX", "APP"), + List.of("OS", "TMS", "RABBITMQ", "TIDB", "NGINX", "RECEIVER"), response.getSoftware().stream().map(DeviceProfileSoftwareItem::getComponentCode).toList() ); DeviceProfileSoftwareItem rabbitmq = response.getSoftware().stream() diff --git a/src/test/java/com/cisd/tms/modules/device/service/DeviceRuntimeStatusServiceTest.java b/src/test/java/com/cisd/tms/modules/device/service/DeviceRuntimeStatusServiceTest.java index c941df9..470aac3 100644 --- a/src/test/java/com/cisd/tms/modules/device/service/DeviceRuntimeStatusServiceTest.java +++ b/src/test/java/com/cisd/tms/modules/device/service/DeviceRuntimeStatusServiceTest.java @@ -48,7 +48,7 @@ class DeviceRuntimeStatusServiceTest { versions.save(component("TLQ", "消息队列", "tonglink/Q V8.1.11")); versions.save(component("TIDB", "数据库", "TiDB V8.1.0")); versions.save(component("NGINX", "Nginx", "Nginx V1.20")); - versions.save(component("APP", "标准收发器企业版", "标准收发器企业版 V1.3.2")); + versions.save(component("RECEIVER", "标准收发器企业版", "标准收发器企业版 V1.3.2")); DeviceRuntimeStatusService service = new DeviceRuntimeStatusServiceImpl( initService, @@ -78,7 +78,7 @@ class DeviceRuntimeStatusServiceTest { assertSoftwareStatus(response.getSoftware(), "TLQ", "NOT_INSTALLED"); assertSoftwareStatus(response.getSoftware(), "TIDB", "NORMAL"); assertSoftwareStatus(response.getSoftware(), "NGINX", "ABNORMAL"); - assertSoftwareStatus(response.getSoftware(), "APP", "NORMAL"); + assertSoftwareStatus(response.getSoftware(), "RECEIVER", "NORMAL"); assertSoftwareStatus(response.getSoftware(), "TMS", "NORMAL"); } @@ -100,7 +100,7 @@ class DeviceRuntimeStatusServiceTest { assertSoftwareStatus(response.getSoftware(), "TLQ", "NOT_INSTALLED"); assertSoftwareStatus(response.getSoftware(), "TIDB", "NOT_INSTALLED"); assertSoftwareStatus(response.getSoftware(), "NGINX", "NOT_INSTALLED"); - assertSoftwareStatus(response.getSoftware(), "APP", "NOT_INSTALLED"); + assertSoftwareStatus(response.getSoftware(), "RECEIVER", "NOT_INSTALLED"); assertSoftwareStatus(response.getSoftware(), "TMS", "NORMAL"); } @@ -130,7 +130,7 @@ class DeviceRuntimeStatusServiceTest { assertSoftwareStatus(response.getSoftware(), "TLQ", "UNKNOWN"); assertSoftwareStatus(response.getSoftware(), "TIDB", "UNKNOWN"); assertSoftwareStatus(response.getSoftware(), "NGINX", "UNKNOWN"); - assertSoftwareStatus(response.getSoftware(), "APP", "UNKNOWN"); + assertSoftwareStatus(response.getSoftware(), "RECEIVER", "UNKNOWN"); assertSoftwareStatus(response.getSoftware(), "TMS", "UNKNOWN"); } diff --git a/src/test/java/com/cisd/tms/modules/upgrade/executor/UpgradeTaskRunnerTest.java b/src/test/java/com/cisd/tms/modules/upgrade/executor/UpgradeTaskRunnerTest.java index 92283a6..ef6b92c 100644 --- a/src/test/java/com/cisd/tms/modules/upgrade/executor/UpgradeTaskRunnerTest.java +++ b/src/test/java/com/cisd/tms/modules/upgrade/executor/UpgradeTaskRunnerTest.java @@ -57,7 +57,7 @@ class UpgradeTaskRunnerTest { } @Test - void shouldUpdateAppSoftwareVersionWhenReceiverUpgradeSucceeds() throws Exception { + void shouldUpdateReceiverSoftwareVersionWhenReceiverUpgradeSucceeds() throws Exception { Path tempDir = Files.createTempDirectory("upgrade-runner-test"); FileRecordEntity fileRecord = buildPackage(tempDir, false, "#!/bin/sh\necho execute\nexit 0\n", false); InMemoryFileRecordRepository fileRepository = new InMemoryFileRecordRepository(); @@ -77,9 +77,9 @@ class UpgradeTaskRunnerTest { runner.run(task("UPG-001-R", fileRecord.getFileId(), "RECEIVER", "V1.4.0")); Assertions.assertEquals("SUCCESS", taskRepository.status); - Assertions.assertNull(versionRepository.store.get("RECEIVER")); - Assertions.assertEquals("V1.4.0", versionRepository.store.get("APP").getCurrentVersion()); - Assertions.assertEquals("标准收发器", versionRepository.store.get("APP").getComponentName()); + Assertions.assertNull(versionRepository.store.get("APP")); + Assertions.assertEquals("V1.4.0", versionRepository.store.get("RECEIVER").getCurrentVersion()); + Assertions.assertEquals("标准收发器", versionRepository.store.get("RECEIVER").getComponentName()); } @Test From 8f4f5f47740dbedeb2bb5058ca85f40eaaa71d49 Mon Sep 17 00:00:00 2001 From: waner Date: Thu, 9 Apr 2026 15:14:49 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E9=98=B2=E9=87=8D?= =?UTF-8?q?=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-04-09-replay-protection-plan.md | 461 ++++++++++++++++++ pom.xml | 7 + .../java/com/cisd/tms/TmsApplication.java | 2 + .../cisd/tms/common/config/WebMvcConfig.java | 35 ++ .../exception/GlobalExceptionHandler.java | 8 + .../auth/controller/AuthAdminController.java | 2 + .../auth/controller/AuthController.java | 2 + .../controller/CryptoCardController.java | 2 + .../device/controller/DeviceController.java | 2 + .../controller/IpWhitelistController.java | 6 +- .../controller/NetworkConfigController.java | 12 + .../controller/TimeConfigController.java | 2 + .../init/controller/InitController.java | 5 + .../modules/mk/controller/LmkController.java | 4 + .../replay/dto/ReplayCheckRequest.java | 88 ++++ .../replay/dto/ReplayCheckResult.java | 53 ++ .../replay/entity/ReplayNonceEntity.java | 100 ++++ .../replay/entity/SecurityEventEntity.java | 81 +++ .../replay/enums/ReplayCheckStatus.java | 7 + .../security/replay/enums/ReplayScope.java | 6 + .../replay/enums/SecurityEventType.java | 5 + .../exception/ReplayProtectionException.java | 12 + .../replay/job/ReplayNonceCleanupJob.java | 32 ++ .../replay/mapper/ReplayNonceMapper.java | 75 +++ .../replay/mapper/SecurityEventMapper.java | 9 + .../repository/ReplayNonceRepository.java | 20 + .../repository/SecurityEventRepository.java | 8 + .../impl/ReplayNonceRepositoryImpl.java | 54 ++ .../impl/SecurityEventRepositoryImpl.java | 21 + .../service/ReplayProtectionService.java | 9 + .../impl/ReplayProtectionServiceImpl.java | 230 +++++++++ .../upgrade/controller/UpgradeController.java | 5 + .../CachedBodyHttpServletRequest.java | 69 +++ .../internal/InternalApiAuthInterceptor.java | 24 +- .../InternalApiReplayInterceptor.java | 145 ++++++ .../security/internal/ReplayProtected.java | 11 + .../security/openapi/OpenApiReplayGuard.java | 40 -- .../openapi/OpenApiSignAuthInterceptor.java | 60 ++- .../db/migration/V1__tms_schema_full.sql | 39 ++ .../V5__add_replay_protection_tables.sql | 38 ++ .../com/cisd/tms/TmsApplicationTests.java | 12 + .../replay/ReplayNonceRepositoryTest.java | 134 +++++ .../replay/job/ReplayNonceCleanupJobTest.java | 27 + .../service/ReplayProtectionServiceTest.java | 283 +++++++++++ .../InternalApiAuthInterceptorTest.java | 153 ------ .../InternalApiReplayInterceptorTest.java | 180 +++++++ .../ReplayProtectedEndpointsTest.java | 112 +++++ .../OpenApiSignAuthInterceptorTest.java | 183 +++++++ 48 files changed, 2643 insertions(+), 232 deletions(-) create mode 100644 docs/plans/2026-04-09-replay-protection-plan.md create mode 100644 src/main/java/com/cisd/tms/modules/security/replay/dto/ReplayCheckRequest.java create mode 100644 src/main/java/com/cisd/tms/modules/security/replay/dto/ReplayCheckResult.java create mode 100644 src/main/java/com/cisd/tms/modules/security/replay/entity/ReplayNonceEntity.java create mode 100644 src/main/java/com/cisd/tms/modules/security/replay/entity/SecurityEventEntity.java create mode 100644 src/main/java/com/cisd/tms/modules/security/replay/enums/ReplayCheckStatus.java create mode 100644 src/main/java/com/cisd/tms/modules/security/replay/enums/ReplayScope.java create mode 100644 src/main/java/com/cisd/tms/modules/security/replay/enums/SecurityEventType.java create mode 100644 src/main/java/com/cisd/tms/modules/security/replay/exception/ReplayProtectionException.java create mode 100644 src/main/java/com/cisd/tms/modules/security/replay/job/ReplayNonceCleanupJob.java create mode 100644 src/main/java/com/cisd/tms/modules/security/replay/mapper/ReplayNonceMapper.java create mode 100644 src/main/java/com/cisd/tms/modules/security/replay/mapper/SecurityEventMapper.java create mode 100644 src/main/java/com/cisd/tms/modules/security/replay/repository/ReplayNonceRepository.java create mode 100644 src/main/java/com/cisd/tms/modules/security/replay/repository/SecurityEventRepository.java create mode 100644 src/main/java/com/cisd/tms/modules/security/replay/repository/impl/ReplayNonceRepositoryImpl.java create mode 100644 src/main/java/com/cisd/tms/modules/security/replay/repository/impl/SecurityEventRepositoryImpl.java create mode 100644 src/main/java/com/cisd/tms/modules/security/replay/service/ReplayProtectionService.java create mode 100644 src/main/java/com/cisd/tms/modules/security/replay/service/impl/ReplayProtectionServiceImpl.java create mode 100644 src/main/java/com/cisd/tms/security/internal/CachedBodyHttpServletRequest.java create mode 100644 src/main/java/com/cisd/tms/security/internal/InternalApiReplayInterceptor.java create mode 100644 src/main/java/com/cisd/tms/security/internal/ReplayProtected.java delete mode 100644 src/main/java/com/cisd/tms/security/openapi/OpenApiReplayGuard.java create mode 100644 src/main/resources/db/migration/V5__add_replay_protection_tables.sql create mode 100644 src/test/java/com/cisd/tms/modules/security/replay/ReplayNonceRepositoryTest.java create mode 100644 src/test/java/com/cisd/tms/modules/security/replay/job/ReplayNonceCleanupJobTest.java create mode 100644 src/test/java/com/cisd/tms/modules/security/replay/service/ReplayProtectionServiceTest.java delete mode 100644 src/test/java/com/cisd/tms/security/internal/InternalApiAuthInterceptorTest.java create mode 100644 src/test/java/com/cisd/tms/security/internal/InternalApiReplayInterceptorTest.java create mode 100644 src/test/java/com/cisd/tms/security/internal/ReplayProtectedEndpointsTest.java create mode 100644 src/test/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptorTest.java diff --git a/docs/plans/2026-04-09-replay-protection-plan.md b/docs/plans/2026-04-09-replay-protection-plan.md new file mode 100644 index 0000000..b861cb9 --- /dev/null +++ b/docs/plans/2026-04-09-replay-protection-plan.md @@ -0,0 +1,461 @@ +# Replay Protection Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add TiDB-backed replay protection for `/openapi/**` and selected sensitive internal `/api/**` write/execute endpoints. + +**Architecture:** Replace the current in-memory OpenAPI nonce guard with a database-backed replay nonce service, then add an internal replay interceptor that only protects annotated high-risk handlers. Persist nonce claims in TiDB with a unique key, record replay-related security events, and reject protected requests when replay validation cannot be completed. + +**Tech Stack:** Spring Boot 3, MyBatis-Plus, Flyway SQL migration, JUnit 5, Mockito, MockMvc. + +--- + +### Task 1: Add Replay Nonce and Security Event Schema + +**Files:** +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/resources/db/migration/V5__add_replay_protection_tables.sql` +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/resources/db/migration/V1__tms_schema_full.sql` +- Test: `/Users/waner/Work/CISD/文档/tms-framework/src/test/java/com/cisd/tms/TmsApplicationTests.java` + +**Step 1: Write the failing schema assertions** + +Add assertions that `V1__tms_schema_full.sql` or the new migration defines: +- `tms_replay_nonce` +- `tms_security_event` +- unique key on `(scope, principal_id, nonce)` +- index on `expires_at` + +**Step 2: Run test to verify it fails** + +Run: `mvn -q -Dtest=TmsApplicationTests test` + +Expected: FAIL because replay protection tables do not exist in schema artifacts yet. + +**Step 3: Write minimal implementation** + +Create `V5__add_replay_protection_tables.sql` with: +- `tms_replay_nonce` +- `tms_security_event` + +Update `V1__tms_schema_full.sql` so fresh-init environments also include the same final-state tables. + +**Step 4: Run test to verify it passes** + +Run: `mvn -q -Dtest=TmsApplicationTests test` + +Expected: PASS for schema assertions. + +**Step 5: Commit** + +```bash +git add src/main/resources/db/migration/V1__tms_schema_full.sql src/main/resources/db/migration/V5__add_replay_protection_tables.sql src/test/java/com/cisd/tms/TmsApplicationTests.java +git commit -m "feat: add replay protection schema" +``` + +### Task 2: Add Replay Persistence Models and Repositories + +**Files:** +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/entity/ReplayNonceEntity.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/entity/SafetyEventEntity.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/mapper/ReplayNonceMapper.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/mapper/SecurityEventMapper.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/repository/ReplayNonceRepository.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/repository/SecurityEventRepository.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/repository/impl/ReplayNonceRepositoryImpl.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/repository/impl/SecurityEventRepositoryImpl.java` +- Test: `/Users/waner/Work/CISD/文档/tms-framework/src/test/java/com/cisd/tms/modules/security/replay/ReplayNonceRepositoryTest.java` + +**Step 1: Write the failing repository test** + +Add tests for: +- saving a nonce record +- detecting duplicate `(scope, principalId, nonce)` +- saving a security event + +**Step 2: Run test to verify it fails** + +Run: `mvn -q -Dtest=ReplayNonceRepositoryTest test` + +Expected: FAIL because replay persistence classes do not exist. + +**Step 3: Write minimal implementation** + +Create entities, mappers, repositories, and repository impls consistent with the existing `modules/*/repository/impl` pattern. + +Represent: +- `scope` +- `principalId` +- `nonce` +- `requestTimestamp` +- `requestMethod` +- `requestPath` +- `bodyHash` +- `remoteIp` +- `userAgent` +- `expiresAt` + +**Step 4: Run test to verify it passes** + +Run: `mvn -q -Dtest=ReplayNonceRepositoryTest test` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/main/java/com/cisd/tms/modules/security/replay src/test/java/com/cisd/tms/modules/security/replay/ReplayNonceRepositoryTest.java +git commit -m "feat: add replay persistence layer" +``` + +### Task 3: Add Replay Protection Service and Domain Types + +**Files:** +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/service/ReplayNonceService.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/service/impl/ReplayNonceServiceImpl.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/dto/ReplayCheckRequest.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/dto/ReplayCheckResult.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/enums/ReplayScope.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/enums/SafetyEventType.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/exception/ReplayAttackException.java` +- Test: `/Users/waner/Work/CISD/文档/tms-framework/src/test/java/com/cisd/tms/modules/security/replay/ReplayNonceServiceTest.java` + +**Step 1: Write the failing service test** + +Add tests for: +- first nonce claim succeeds +- duplicate nonce claim returns replay result +- duplicate nonce with different path/body hash marks mismatch +- expired timestamp is rejected before claim +- repository failure causes protected request to fail closed + +**Step 2: Run test to verify it fails** + +Run: `mvn -q -Dtest=ReplayNonceServiceTest test` + +Expected: FAIL because replay service does not exist. + +**Step 3: Write minimal implementation** + +Implement a service that: +- validates timestamp skew +- attempts nonce claim via repository +- on duplicate, compares request fingerprint +- writes security events +- throws a dedicated replay exception for interceptor handling + +Keep timestamp comparison logic in one place so both OpenAPI and internal interceptors share the same rule. + +**Step 4: Run test to verify it passes** + +Run: `mvn -q -Dtest=ReplayNonceServiceTest test` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/main/java/com/cisd/tms/modules/security/replay src/test/java/com/cisd/tms/modules/security/replay/ReplayNonceServiceTest.java +git commit -m "feat: add replay protection service" +``` + +### Task 4: Replace OpenAPI In-Memory Replay Guard + +**Files:** +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptor.java` +- Delete: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/security/openapi/OpenApiReplayGuard.java` +- Test: `/Users/waner/Work/CISD/文档/tms-framework/src/test/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptorTest.java` + +**Step 1: Write the failing interceptor tests** + +Add tests for: +- valid request passes and claims nonce +- duplicate nonce is rejected +- timestamp expired is rejected +- invalid signature is rejected before nonce claim +- duplicate nonce with same appId but different body hash is treated as replay mismatch + +**Step 2: Run test to verify it fails** + +Run: `mvn -q -Dtest=OpenApiSignAuthInterceptorTest test` + +Expected: FAIL because interceptor still uses `OpenApiReplayGuard`. + +**Step 3: Write minimal implementation** + +Refactor `OpenApiSignAuthInterceptor` to: +- compute `bodyHash` +- validate timestamp +- validate signature +- call `ReplayNonceService` + +Remove `OpenApiReplayGuard`. + +Do not change the existing request header names in this task. + +**Step 4: Run test to verify it passes** + +Run: `mvn -q -Dtest=OpenApiSignAuthInterceptorTest test` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/main/java/com/cisd/tms/security/openapi src/test/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptorTest.java +git commit -m "feat: persist openapi replay protection" +``` + +### Task 5: Add Internal Replay Annotation and Interceptor + +**Files:** +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/annotation/ReplayProtected.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/security/internal/InternalReplayProtectionInterceptor.java` +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/common/config/WebMvcConfig.java` +- Test: `/Users/waner/Work/CISD/文档/tms-framework/src/test/java/com/cisd/tms/security/internal/InternalReplayProtectionInterceptorTest.java` + +**Step 1: Write the failing interceptor tests** + +Add tests for: +- unannotated internal handler is ignored +- annotated handler requires `X-Request-Timestamp` +- annotated handler requires `X-Request-Nonce` +- annotated handler claims nonce with current session token +- replayed annotated request is rejected + +**Step 2: Run test to verify it fails** + +Run: `mvn -q -Dtest=InternalReplayProtectionInterceptorTest test` + +Expected: FAIL because annotation and interceptor do not exist. + +**Step 3: Write minimal implementation** + +Create: +- `@ReplayProtected` +- internal interceptor that runs after session auth +- registration in `WebMvcConfig` + +The interceptor should use: +- `InternalApiAuthInterceptor.ATTR_SESSION_TOKEN` +- `X-Request-Timestamp` +- `X-Request-Nonce` + +Reject protected requests when session token is missing or replay validation fails. + +**Step 4: Run test to verify it passes** + +Run: `mvn -q -Dtest=InternalReplayProtectionInterceptorTest test` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/main/java/com/cisd/tms/modules/security/replay/annotation/ReplayProtected.java src/main/java/com/cisd/tms/security/internal/InternalReplayProtectionInterceptor.java src/main/java/com/cisd/tms/common/config/WebMvcConfig.java src/test/java/com/cisd/tms/security/internal/InternalReplayProtectionInterceptorTest.java +git commit -m "feat: add internal replay interceptor" +``` + +### Task 6: Annotate First-Batch Sensitive Internal Endpoints + +**Files:** +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/init/controller/InitController.java` +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/upgrade/controller/UpgradeController.java` +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/auth/controller/AuthController.java` +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/auth/controller/AuthAdminController.java` +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/device/controller/TimeConfigController.java` +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/device/controller/NetworkConfigController.java` +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/device/controller/IpWhitelistController.java` +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/device/controller/DeviceController.java` +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/mk/controller/LmkController.java` +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/device/controller/CryptoCardController.java` +- Test: `/Users/waner/Work/CISD/文档/tms-framework/src/test/java/com/cisd/tms/modules/upgrade/controller/UpgradeControllerTest.java` +- Test: `/Users/waner/Work/CISD/文档/tms-framework/src/test/java/com/cisd/tms/modules/auth/controller/AuthControllerTest.java` + +**Step 1: Write the failing controller coverage tests** + +Add tests for: +- protected endpoints reject missing replay headers +- login/captcha endpoints remain unprotected +- a representative protected endpoint accepts replay headers when replay service allows it + +**Step 2: Run test to verify it fails** + +Run: `mvn -q -Dtest=UpgradeControllerTest,AuthControllerTest test` + +Expected: FAIL because first-batch endpoints are not annotated. + +**Step 3: Write minimal implementation** + +Annotate only: +- init/create/execute/reset-create/reset-execute +- upgrade/create/execute/rollback +- logout/change-password +- all auth admin POST methods +- time/network/whitelist/device restart writes +- all `LmkController` write methods +- selected `CryptoCardController` write-state methods + +Do not annotate: +- login +- captcha +- random issuance +- GET endpoints +- pure preview endpoints + +**Step 4: Run test to verify it passes** + +Run: `mvn -q -Dtest=UpgradeControllerTest,AuthControllerTest test` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/main/java/com/cisd/tms/modules/init/controller/InitController.java src/main/java/com/cisd/tms/modules/upgrade/controller/UpgradeController.java src/main/java/com/cisd/tms/modules/auth/controller/AuthController.java src/main/java/com/cisd/tms/modules/auth/controller/AuthAdminController.java src/main/java/com/cisd/tms/modules/device/controller/TimeConfigController.java src/main/java/com/cisd/tms/modules/device/controller/NetworkConfigController.java src/main/java/com/cisd/tms/modules/device/controller/IpWhitelistController.java src/main/java/com/cisd/tms/modules/device/controller/DeviceController.java src/main/java/com/cisd/tms/modules/mk/controller/LmkController.java src/main/java/com/cisd/tms/modules/device/controller/CryptoCardController.java src/test/java/com/cisd/tms/modules/upgrade/controller/UpgradeControllerTest.java src/test/java/com/cisd/tms/modules/auth/controller/AuthControllerTest.java +git commit -m "feat: protect sensitive internal endpoints from replay" +``` + +### Task 7: Map Replay Exceptions and Add Structured Responses + +**Files:** +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/common/exception/GlobalExceptionHandler.java` +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/common/enums/ErrorCode.java` +- Test: `/Users/waner/Work/CISD/文档/tms-framework/src/test/java/com/cisd/tms/common/exception/GlobalExceptionHandlerTest.java` + +**Step 1: Write the failing exception handler test** + +Add tests for: +- replay attack exception returns expected code/message +- timestamp expired exception returns expected code/message + +**Step 2: Run test to verify it fails** + +Run: `mvn -q -Dtest=GlobalExceptionHandlerTest test` + +Expected: FAIL because replay exceptions are not mapped yet. + +**Step 3: Write minimal implementation** + +Add replay-related error codes and map: +- replay detected +- request timestamp expired +- replay guard unavailable + +Keep response shape consistent with existing `ApiResponse.fail(...).withPath(...)`. + +**Step 4: Run test to verify it passes** + +Run: `mvn -q -Dtest=GlobalExceptionHandlerTest test` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/main/java/com/cisd/tms/common/exception/GlobalExceptionHandler.java src/main/java/com/cisd/tms/common/enums/ErrorCode.java src/test/java/com/cisd/tms/common/exception/GlobalExceptionHandlerTest.java +git commit -m "feat: expose replay protection errors" +``` + +### Task 8: Add Replay Nonce Cleanup Job + +**Files:** +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/config/ReplayProtectionProperties.java` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/security/replay/job/ReplayNonceCleanupJob.java` +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/src/main/resources/application.yml` +- Test: `/Users/waner/Work/CISD/文档/tms-framework/src/test/java/com/cisd/tms/modules/security/replay/ReplayNonceCleanupJobTest.java` + +**Step 1: Write the failing cleanup job test** + +Add tests for: +- deleting expired rows in small batches +- leaving non-expired rows untouched + +**Step 2: Run test to verify it fails** + +Run: `mvn -q -Dtest=ReplayNonceCleanupJobTest test` + +Expected: FAIL because cleanup job does not exist. + +**Step 3: Write minimal implementation** + +Add: +- replay protection properties +- scheduled cleanup job +- config defaults for ttl and delete batch size + +Use modest defaults such as: +- `ttl-seconds: 600` +- `cleanup-batch-size: 1000` + +**Step 4: Run test to verify it passes** + +Run: `mvn -q -Dtest=ReplayNonceCleanupJobTest test` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/main/java/com/cisd/tms/modules/security/replay/config src/main/java/com/cisd/tms/modules/security/replay/job src/main/resources/application.yml src/test/java/com/cisd/tms/modules/security/replay/ReplayNonceCleanupJobTest.java +git commit -m "feat: add replay nonce cleanup job" +``` + +### Task 9: Run Focused Verification + +**Files:** +- Test: `/Users/waner/Work/CISD/文档/tms-framework/src/test/java/com/cisd/tms/modules/security/replay/ReplayNonceRepositoryTest.java` +- Test: `/Users/waner/Work/CISD/文档/tms-framework/src/test/java/com/cisd/tms/modules/security/replay/ReplayNonceServiceTest.java` +- Test: `/Users/waner/Work/CISD/文档/tms-framework/src/test/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptorTest.java` +- Test: `/Users/waner/Work/CISD/文档/tms-framework/src/test/java/com/cisd/tms/security/internal/InternalReplayProtectionInterceptorTest.java` +- Test: `/Users/waner/Work/CISD/文档/tms-framework/src/test/java/com/cisd/tms/common/exception/GlobalExceptionHandlerTest.java` + +**Step 1: Run focused replay tests** + +Run: `mvn -q -Dtest=ReplayNonceRepositoryTest,ReplayNonceServiceTest,OpenApiSignAuthInterceptorTest,InternalReplayProtectionInterceptorTest,GlobalExceptionHandlerTest test` + +Expected: PASS + +**Step 2: Run representative controller tests** + +Run: `mvn -q -Dtest=UpgradeControllerTest,AuthControllerTest test` + +Expected: PASS + +**Step 3: Run compile check** + +Run: `mvn -q -DskipTests compile` + +Expected: PASS + +**Step 4: Commit** + +```bash +git add -A +git commit -m "test: verify replay protection implementation" +``` + +### Task 10: Update Docs and Integration Guidance + +**Files:** +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/README.md` +- Modify: `/Users/waner/Work/CISD/文档/tms-framework/docs/openapi/auth-mk-frontend-integration.md` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/docs/plans/2026-04-09-replay-protection-design.md` +- Add: `/Users/waner/Work/CISD/文档/tms-framework/docs/plans/2026-04-09-replay-protection-plan.md` + +**Step 1: Document OpenAPI replay headers** + +Update README and integration docs to describe: +- OpenAPI replay protection flow +- internal sensitive API replay headers +- expected replay failure behavior + +**Step 2: Verify docs are consistent with actual endpoint scope** + +Review the protected endpoint list against controller annotations. + +**Step 3: Commit** + +```bash +git add README.md docs/openapi/auth-mk-frontend-integration.md docs/plans/2026-04-09-replay-protection-design.md docs/plans/2026-04-09-replay-protection-plan.md +git commit -m "docs: add replay protection guidance" +``` diff --git a/pom.xml b/pom.xml index 43d1d20..d891a32 100644 --- a/pom.xml +++ b/pom.xml @@ -95,6 +95,13 @@ 1.83 + + + + com.google.code.gson + gson + 2.13.2 + diff --git a/src/main/java/com/cisd/tms/TmsApplication.java b/src/main/java/com/cisd/tms/TmsApplication.java index 239bb5b..9a5a26f 100644 --- a/src/main/java/com/cisd/tms/TmsApplication.java +++ b/src/main/java/com/cisd/tms/TmsApplication.java @@ -3,9 +3,11 @@ package com.cisd.tms; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @ConfigurationPropertiesScan +@EnableScheduling public class TmsApplication { public static void main(String[] args) { diff --git a/src/main/java/com/cisd/tms/common/config/WebMvcConfig.java b/src/main/java/com/cisd/tms/common/config/WebMvcConfig.java index 566a4f0..5e127a7 100644 --- a/src/main/java/com/cisd/tms/common/config/WebMvcConfig.java +++ b/src/main/java/com/cisd/tms/common/config/WebMvcConfig.java @@ -2,6 +2,8 @@ package com.cisd.tms.common.config; import com.cisd.tms.modules.auth.security.InternalAuthorizationInterceptor; import com.cisd.tms.security.internal.InternalApiAuthInterceptor; +import com.cisd.tms.security.internal.InternalApiReplayInterceptor; +import com.cisd.tms.security.internal.CachedBodyHttpServletRequest; import com.cisd.tms.security.openapi.OpenApiSignAuthInterceptor; import com.cisd.tms.common.util.TraceIdUtil; import jakarta.servlet.FilterChain; @@ -21,15 +23,18 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; public class WebMvcConfig implements WebMvcConfigurer { private final InternalApiAuthInterceptor internalApiAuthInterceptor; + private final InternalApiReplayInterceptor internalApiReplayInterceptor; private final InternalAuthorizationInterceptor internalAuthorizationInterceptor; private final OpenApiSignAuthInterceptor openApiSignAuthInterceptor; public WebMvcConfig( InternalApiAuthInterceptor internalApiAuthInterceptor, + InternalApiReplayInterceptor internalApiReplayInterceptor, InternalAuthorizationInterceptor internalAuthorizationInterceptor, OpenApiSignAuthInterceptor openApiSignAuthInterceptor ) { this.internalApiAuthInterceptor = internalApiAuthInterceptor; + this.internalApiReplayInterceptor = internalApiReplayInterceptor; this.internalAuthorizationInterceptor = internalAuthorizationInterceptor; this.openApiSignAuthInterceptor = openApiSignAuthInterceptor; } @@ -55,6 +60,33 @@ public class WebMvcConfig implements WebMvcConfigurer { }; } + @Bean + public OncePerRequestFilter cachedBodyRequestFilter() { + return new OncePerRequestFilter() { + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + if (shouldWrap(request)) { + filterChain.doFilter(new CachedBodyHttpServletRequest(request), response); + return; + } + filterChain.doFilter(request, response); + } + + private boolean shouldWrap(HttpServletRequest request) { + String requestUri = request.getRequestURI(); + if (requestUri == null || !requestUri.startsWith("/api/")) { + return false; + } + if (request instanceof CachedBodyHttpServletRequest) { + return false; + } + String contentType = request.getContentType(); + return contentType == null || !contentType.toLowerCase().startsWith("multipart/"); + } + }; + } + @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(internalApiAuthInterceptor) @@ -67,6 +99,9 @@ public class WebMvcConfig implements WebMvcConfigurer { "/api/v1/auth/captcha" ); + registry.addInterceptor(internalApiReplayInterceptor) + .addPathPatterns("/api/**"); + registry.addInterceptor(internalAuthorizationInterceptor) .addPathPatterns("/api/**"); diff --git a/src/main/java/com/cisd/tms/common/exception/GlobalExceptionHandler.java b/src/main/java/com/cisd/tms/common/exception/GlobalExceptionHandler.java index 162310f..85c1de1 100644 --- a/src/main/java/com/cisd/tms/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/cisd/tms/common/exception/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ package com.cisd.tms.common.exception; import com.cisd.tms.common.api.ApiResponse; import com.cisd.tms.common.enums.ErrorCode; import com.cisd.tms.integration.crypto.pcie.PcieCryptoException; +import com.cisd.tms.modules.security.replay.exception.ReplayProtectionException; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.ConstraintViolationException; import org.slf4j.Logger; @@ -58,6 +59,13 @@ public class GlobalExceptionHandler { .withPath(request.getRequestURI()); } + @ExceptionHandler(ReplayProtectionException.class) + public ApiResponse handleReplayProtectionException(ReplayProtectionException ex, HttpServletRequest request) { + log.warn("Replay protection failed", ex); + return ApiResponse.fail(ErrorCode.INTERNAL_ERROR.getCode(), "replay protection unavailable") + .withPath(request.getRequestURI()); + } + @ExceptionHandler(Exception.class) public ApiResponse handleException(Exception ex, HttpServletRequest request) { log.error("Unhandled exception", ex); diff --git a/src/main/java/com/cisd/tms/modules/auth/controller/AuthAdminController.java b/src/main/java/com/cisd/tms/modules/auth/controller/AuthAdminController.java index 4ea2145..bcbabb9 100644 --- a/src/main/java/com/cisd/tms/modules/auth/controller/AuthAdminController.java +++ b/src/main/java/com/cisd/tms/modules/auth/controller/AuthAdminController.java @@ -8,6 +8,7 @@ import com.cisd.tms.modules.auth.service.AuthAdminService; import com.cisd.tms.modules.mk.dto.UKeySignDTO; import com.cisd.tms.modules.mk.dto.UKeySignResult; import com.cisd.tms.security.internal.InternalApiAuthInterceptor; +import com.cisd.tms.security.internal.ReplayProtected; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -23,6 +24,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/v1/auth") @RequiredArgsConstructor +@ReplayProtected @Tag(name = "认证管理", description = "角色启用、密码重置与 UKey 绑定接口") public class AuthAdminController { diff --git a/src/main/java/com/cisd/tms/modules/auth/controller/AuthController.java b/src/main/java/com/cisd/tms/modules/auth/controller/AuthController.java index 6a28c3f..95f7c40 100644 --- a/src/main/java/com/cisd/tms/modules/auth/controller/AuthController.java +++ b/src/main/java/com/cisd/tms/modules/auth/controller/AuthController.java @@ -11,6 +11,7 @@ import com.cisd.tms.modules.auth.dto.UkeyLoginRandomResponse; import com.cisd.tms.modules.auth.dto.UkeyLoginRequest; import com.cisd.tms.modules.auth.service.AuthService; import com.cisd.tms.security.internal.InternalApiAuthInterceptor; +import com.cisd.tms.security.internal.ReplayProtected; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -72,6 +73,7 @@ public class AuthController { @PostMapping("/change-password") @Operation(summary = "修改当前角色口令", description = "基于当前会话校验并更新当前角色口令。") + @ReplayProtected public ApiResponse changePassword(@Valid @RequestBody ChangePasswordRequest request, HttpServletRequest httpRequest) { authService.changePassword( (String) httpRequest.getAttribute(InternalApiAuthInterceptor.ATTR_SESSION_TOKEN), diff --git a/src/main/java/com/cisd/tms/modules/device/controller/CryptoCardController.java b/src/main/java/com/cisd/tms/modules/device/controller/CryptoCardController.java index b7dc2a7..fa9b85d 100644 --- a/src/main/java/com/cisd/tms/modules/device/controller/CryptoCardController.java +++ b/src/main/java/com/cisd/tms/modules/device/controller/CryptoCardController.java @@ -36,6 +36,7 @@ import com.cisd.tms.integration.crypto.pcie.model.SymmetricKekCryptoRequest; import com.cisd.tms.integration.crypto.pcie.model.StandardKeyPairResult; import com.cisd.tms.integration.crypto.pcie.service.PcieDeviceStatusFormatter; import com.cisd.tms.integration.crypto.pcie.service.PcieCryptoService; +import com.cisd.tms.security.internal.ReplayProtected; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -61,6 +62,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/v1/device/crypto") @RequiredArgsConstructor +@ReplayProtected @Tag(name = "密码卡调试", description = "密码卡设备调试、密钥操作和文件管理接口") public class CryptoCardController { diff --git a/src/main/java/com/cisd/tms/modules/device/controller/DeviceController.java b/src/main/java/com/cisd/tms/modules/device/controller/DeviceController.java index 3792b9c..9bda776 100644 --- a/src/main/java/com/cisd/tms/modules/device/controller/DeviceController.java +++ b/src/main/java/com/cisd/tms/modules/device/controller/DeviceController.java @@ -9,6 +9,7 @@ import com.cisd.tms.modules.device.service.DeviceProfileService; import com.cisd.tms.modules.device.service.DeviceRuntimeStatusService; import com.cisd.tms.modules.device.service.DeviceService; import com.cisd.tms.security.internal.InternalApiAuthInterceptor; +import com.cisd.tms.security.internal.ReplayProtected; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -55,6 +56,7 @@ public class DeviceController { @PostMapping("/restart") @Operation(summary = "重启设备服务", description = "受理设备重启请求,并记录触发操作的内部用户。") + @ReplayProtected public ApiResponse restart(HttpServletRequest request) { String operator = (String) request.getAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE); return ApiResponse.success(deviceService.restart(operator)); diff --git a/src/main/java/com/cisd/tms/modules/device/controller/IpWhitelistController.java b/src/main/java/com/cisd/tms/modules/device/controller/IpWhitelistController.java index b542410..789da7f 100644 --- a/src/main/java/com/cisd/tms/modules/device/controller/IpWhitelistController.java +++ b/src/main/java/com/cisd/tms/modules/device/controller/IpWhitelistController.java @@ -5,6 +5,7 @@ import com.cisd.tms.common.api.ApiResponse; import com.cisd.tms.modules.device.dto.network.IpWhitelistResponse; import com.cisd.tms.modules.device.service.IpWhitelistService; import com.cisd.tms.modules.device.dto.network.IpWhitelistRequest; +import com.cisd.tms.security.internal.ReplayProtected; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.beans.factory.annotation.Autowired; @@ -21,6 +22,7 @@ public class IpWhitelistController { @PostMapping("/add") @Operation(summary = "添加IP白名单", description = "新增一条IP白名单记录") + @ReplayProtected public ApiResponse addWhitelist(@RequestBody IpWhitelistRequest req) { ipWhitelistService.addWhitelist(req); return ApiResponse.success("添加成功"); @@ -29,6 +31,7 @@ public class IpWhitelistController { @PostMapping("/update") @Operation(summary = "更新IP白名单", description = "根据ID更新IP白名单信息") + @ReplayProtected public ApiResponse updateWhitelist(@RequestBody IpWhitelistRequest req) { ipWhitelistService.updateWhitelist(req); return ApiResponse.success("更新成功"); @@ -36,6 +39,7 @@ public class IpWhitelistController { @PostMapping("/delete/{id}") @Operation(summary = "删除IP白名单", description = "根据ID删除指定IP白名单") + @ReplayProtected public ApiResponse deleteWhitelist(@PathVariable Long id) { ipWhitelistService.deleteWhitelist(id); return ApiResponse.success("删除成功"); @@ -48,4 +52,4 @@ public class IpWhitelistController { IPage page = ipWhitelistService.getWhitelistPage(req); return ApiResponse.success(page); } -} \ No newline at end of file +} diff --git a/src/main/java/com/cisd/tms/modules/device/controller/NetworkConfigController.java b/src/main/java/com/cisd/tms/modules/device/controller/NetworkConfigController.java index 805e0ea..4b36294 100644 --- a/src/main/java/com/cisd/tms/modules/device/controller/NetworkConfigController.java +++ b/src/main/java/com/cisd/tms/modules/device/controller/NetworkConfigController.java @@ -4,6 +4,7 @@ package com.cisd.tms.modules.device.controller; import com.cisd.tms.common.api.ApiResponse; import com.cisd.tms.modules.device.dto.network.*; import com.cisd.tms.modules.device.service.NetworkConfigService; +import com.cisd.tms.security.internal.ReplayProtected; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.web.bind.annotation.*; @@ -36,6 +37,7 @@ public class NetworkConfigController { @Operation(summary = "设置IPv4配置", description = "为指定网络接口配置IPv4地址、掩码、网关等信息") @PostMapping("/ipv4-config/set") + @ReplayProtected public ApiResponse setIpv4Config(@RequestBody Ipv4ConfigRequest req) { String resultMessage = networkConfigService.setIpv4Config(req); return ApiResponse.success(resultMessage); @@ -50,6 +52,7 @@ public class NetworkConfigController { @Operation(summary = "设置IPv6配置", description = "为指定网络接口配置IPv6地址、前缀长度、网关等信息") @PostMapping("/ipv6-config/set") + @ReplayProtected public ApiResponse setIpv6Config(@RequestBody Ipv6ConfigRequest req) { String resultMessage = networkConfigService.setIpv6Config(req); return ApiResponse.success(resultMessage); @@ -63,6 +66,7 @@ public class NetworkConfigController { @Operation(summary = "创建Bond", description = "创建一个新的Bond聚合接口,需指定名称、模式和从属接口") @PostMapping("/bond/create") + @ReplayProtected public ApiResponse createBond(@RequestBody BondCreateRequest req) { String resultMessage = networkConfigService.createBond(req); return ApiResponse.success(resultMessage); @@ -71,6 +75,7 @@ public class NetworkConfigController { @Operation(summary = "删除Bond", description = "根据Bond名称删除指定的聚合接口") @PostMapping("/bond/delete/{bondName}") + @ReplayProtected public ApiResponse deleteBond(@PathVariable("bondName") String bondName){ String resultMessage = networkConfigService.deleteBond(bondName); return ApiResponse.success(resultMessage); @@ -78,6 +83,7 @@ public class NetworkConfigController { @Operation(summary = "添加从属接口到Bond", description = "向指定Bond中添加一个或多个从属网络接口") @PostMapping("/bond-slave/add") + @ReplayProtected public ApiResponse addSlavesTOBond(@RequestBody BondAddSlavesRequest req) { String resultMessage = networkConfigService.addSlavesTOBond(req); return ApiResponse.success(resultMessage); @@ -85,6 +91,7 @@ public class NetworkConfigController { @Operation(summary = "从Bond中移除从属接口", description = "从指定Bond中移除一个或多个从属网络接口") @PostMapping("/bond-slave/remove") + @ReplayProtected public ApiResponse removeSlaveFromBond(@RequestBody BondRemoveSlaveRequest req) { String resultMessage = networkConfigService.removeSlaveFromBond(req); return ApiResponse.success(resultMessage); @@ -112,6 +119,7 @@ public class NetworkConfigController { @Operation(summary = "设置Bond模式", description = "修改指定Bond的绑定模式") @PostMapping("/bond/mode/set") + @ReplayProtected public ApiResponse setBondMode(@RequestBody BondModifyModeRequest req) { String resultMessage = networkConfigService.setBondMode(req); @@ -127,6 +135,7 @@ public class NetworkConfigController { @Operation(summary = "设置默认路由", description = "配置或修改系统的默认网关路由") @PostMapping("/routes/default/set") + @ReplayProtected public ApiResponse setDefaultRoute(@RequestBody SetDefaultRouteRequest req) { String resultMessage = networkConfigService.setDefaultRoute(req); return ApiResponse.success(resultMessage); @@ -134,6 +143,7 @@ public class NetworkConfigController { @Operation(summary = "添加静态路由", description = "新增一条静态路由规则") @PostMapping("/routes/static/add") + @ReplayProtected public ApiResponse addStaticRoute(@RequestBody AddStaticRouteRequest req) { String resultMessage = networkConfigService.addStaticRoute(req); return ApiResponse.success(resultMessage); @@ -141,6 +151,7 @@ public class NetworkConfigController { @Operation(summary = "删除默认路由", description = "删除指定的默认路由") @PostMapping("/routes/default/delete") + @ReplayProtected public ApiResponse deleteDefaultRoute(@RequestBody DeleteDefaultRouteRequest req) { String resultMessage = networkConfigService.deleteDefaultRoute(req); return ApiResponse.success(resultMessage); @@ -148,6 +159,7 @@ public class NetworkConfigController { @Operation(summary = "删除静态路由", description = "删除指定的静态路由") @PostMapping("/routes/static/delete") + @ReplayProtected public ApiResponse deleteStaticRoute(@RequestBody DeleteStaticRouteRequest req) { String resultMessage = networkConfigService.deleteStaticRoute(req); return ApiResponse.success(resultMessage); diff --git a/src/main/java/com/cisd/tms/modules/device/controller/TimeConfigController.java b/src/main/java/com/cisd/tms/modules/device/controller/TimeConfigController.java index 0d546f6..0b013b1 100644 --- a/src/main/java/com/cisd/tms/modules/device/controller/TimeConfigController.java +++ b/src/main/java/com/cisd/tms/modules/device/controller/TimeConfigController.java @@ -5,6 +5,7 @@ import com.cisd.tms.common.api.ApiResponse; import com.cisd.tms.common.enums.ErrorCode; import com.cisd.tms.modules.device.dto.TimeConfigRequest; import com.cisd.tms.modules.device.service.TimeConfigService; +import com.cisd.tms.security.internal.ReplayProtected; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.PostMapping; @@ -15,6 +16,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/v1/device") @RequiredArgsConstructor +@ReplayProtected @Tag(name = "device-time-config-controller") public class TimeConfigController { diff --git a/src/main/java/com/cisd/tms/modules/init/controller/InitController.java b/src/main/java/com/cisd/tms/modules/init/controller/InitController.java index c3daaa2..6090f1f 100644 --- a/src/main/java/com/cisd/tms/modules/init/controller/InitController.java +++ b/src/main/java/com/cisd/tms/modules/init/controller/InitController.java @@ -13,6 +13,7 @@ import com.cisd.tms.modules.init.dto.InitTaskStepResponse; import com.cisd.tms.modules.init.dto.ResetCreateTaskResponse; import com.cisd.tms.modules.init.dto.ResetPreviewResponse; import com.cisd.tms.modules.init.service.InitService; +import com.cisd.tms.security.internal.ReplayProtected; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -64,6 +65,7 @@ public class InitController { */ @PostMapping("/tasks") @Operation(summary = "创建初始化任务", description = "根据初始化请求生成任务和步骤记录,任务初始状态为 PENDING。") + @ReplayProtected public ApiResponse createTask(@Valid @RequestBody InitPreviewRequest request) { return ApiResponse.success(initService.createTask(request)); } @@ -112,6 +114,7 @@ public class InitController { */ @PostMapping("/tasks/{taskId}/execute") @Operation(summary = "执行初始化任务", description = "异步受理初始化任务执行请求,立即返回当前任务快照,实际步骤在后台继续运行。") + @ReplayProtected public ApiResponse executeTask( @Parameter(description = "初始化任务唯一标识") @PathVariable String taskId @@ -127,12 +130,14 @@ public class InitController { @PostMapping("/reset/tasks") @Operation(summary = "创建重置任务", description = "自动读取当前设备有效初始化快照生成重置任务和步骤记录,任务初始状态为 PENDING。") + @ReplayProtected public ApiResponse createResetTask() { return ApiResponse.success(initService.createResetTask()); } @PostMapping("/reset/tasks/{taskId}/execute") @Operation(summary = "执行重置任务", description = "异步受理重置任务执行请求,立即返回当前任务快照,实际步骤在后台继续运行。") + @ReplayProtected public ApiResponse executeResetTask( @Parameter(description = "重置任务唯一标识") @PathVariable String taskId diff --git a/src/main/java/com/cisd/tms/modules/mk/controller/LmkController.java b/src/main/java/com/cisd/tms/modules/mk/controller/LmkController.java index e6e90e1..7a8ffd4 100644 --- a/src/main/java/com/cisd/tms/modules/mk/controller/LmkController.java +++ b/src/main/java/com/cisd/tms/modules/mk/controller/LmkController.java @@ -6,6 +6,7 @@ import com.cisd.tms.modules.mk.dto.MasterKeyRecoverPacketsRequest; import com.cisd.tms.modules.mk.dto.MasterKeyStateResult; import com.cisd.tms.modules.mk.enums.MasterKeyStatus; import com.cisd.tms.modules.mk.service.LmkService; +import com.cisd.tms.security.internal.ReplayProtected; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -28,12 +29,14 @@ public class LmkController { @PostMapping("/masterKey/init") @Operation(summary = "初始化主密钥", description = "执行密码卡初始化识别、主密钥生成加载、内部密钥生成,并返回主密钥状态与校验值。") + @ReplayProtected public ApiResponse initMasterKey() { return ApiResponse.success(lmkService.initMasterKey()); } @PostMapping("/masterKey/destroy") @Operation(summary = "销毁主密钥", description = "销毁当前密码卡中的主密钥,失败直接抛出异常。") + @ReplayProtected public ApiResponse destroyMasterKey() { //销毁主密钥、内部密钥、用户密钥 lmkService.destroyMasterKey(); @@ -53,6 +56,7 @@ public class LmkController { */ @PostMapping("/masterKey/recover") @Operation(summary = "恢复全部密钥", description = "请求体需传入三份按 componentIndex 标识的分量包。三份分量包必须分别对应 componentIndex=1、2、3。服务端会依次恢复 LMK、IK 和用户密钥。") + @ReplayProtected public ApiResponse recoverLmkEx(@Valid @RequestBody MasterKeyRecoverPacketsRequest request) { lmkService.recoverKeyPackets(request.getPackets()); return ApiResponse.success(); diff --git a/src/main/java/com/cisd/tms/modules/security/replay/dto/ReplayCheckRequest.java b/src/main/java/com/cisd/tms/modules/security/replay/dto/ReplayCheckRequest.java new file mode 100644 index 0000000..8601953 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/dto/ReplayCheckRequest.java @@ -0,0 +1,88 @@ +package com.cisd.tms.modules.security.replay.dto; + +import com.cisd.tms.modules.security.replay.enums.ReplayScope; + +public class ReplayCheckRequest { + + private ReplayScope scope; + private String principalId; + private String nonce; + private long requestTimestamp; + private String requestMethod; + private String requestPath; + private String bodyHash; + private String remoteIp; + private String userAgent; + + public ReplayScope getScope() { + return scope; + } + + public void setScope(ReplayScope scope) { + this.scope = scope; + } + + public String getPrincipalId() { + return principalId; + } + + public void setPrincipalId(String principalId) { + this.principalId = principalId; + } + + public String getNonce() { + return nonce; + } + + public void setNonce(String nonce) { + this.nonce = nonce; + } + + public long getRequestTimestamp() { + return requestTimestamp; + } + + public void setRequestTimestamp(long requestTimestamp) { + this.requestTimestamp = requestTimestamp; + } + + public String getRequestMethod() { + return requestMethod; + } + + public void setRequestMethod(String requestMethod) { + this.requestMethod = requestMethod; + } + + public String getRequestPath() { + return requestPath; + } + + public void setRequestPath(String requestPath) { + this.requestPath = requestPath; + } + + public String getBodyHash() { + return bodyHash; + } + + public void setBodyHash(String bodyHash) { + this.bodyHash = bodyHash; + } + + public String getRemoteIp() { + return remoteIp; + } + + public void setRemoteIp(String remoteIp) { + this.remoteIp = remoteIp; + } + + public String getUserAgent() { + return userAgent; + } + + public void setUserAgent(String userAgent) { + this.userAgent = userAgent; + } +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/dto/ReplayCheckResult.java b/src/main/java/com/cisd/tms/modules/security/replay/dto/ReplayCheckResult.java new file mode 100644 index 0000000..46e630e --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/dto/ReplayCheckResult.java @@ -0,0 +1,53 @@ +package com.cisd.tms.modules.security.replay.dto; + +import com.cisd.tms.modules.security.replay.enums.ReplayCheckStatus; +import java.time.LocalDateTime; + +public class ReplayCheckResult { + + private ReplayCheckStatus status; + private boolean replayed; + private boolean mismatch; + private boolean securityEventRecorded; + private LocalDateTime expiresAt; + + public ReplayCheckStatus getStatus() { + return status; + } + + public void setStatus(ReplayCheckStatus status) { + this.status = status; + } + + public boolean isReplayed() { + return replayed; + } + + public void setReplayed(boolean replayed) { + this.replayed = replayed; + } + + public boolean isMismatch() { + return mismatch; + } + + public void setMismatch(boolean mismatch) { + this.mismatch = mismatch; + } + + public boolean isSecurityEventRecorded() { + return securityEventRecorded; + } + + public void setSecurityEventRecorded(boolean securityEventRecorded) { + this.securityEventRecorded = securityEventRecorded; + } + + public LocalDateTime getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(LocalDateTime expiresAt) { + this.expiresAt = expiresAt; + } +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/entity/ReplayNonceEntity.java b/src/main/java/com/cisd/tms/modules/security/replay/entity/ReplayNonceEntity.java new file mode 100644 index 0000000..0675dc7 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/entity/ReplayNonceEntity.java @@ -0,0 +1,100 @@ +package com.cisd.tms.modules.security.replay.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.cisd.tms.infrastructure.persistence.entity.BaseEntity; +import java.time.LocalDateTime; + +@TableName("tms_replay_nonce") +public class ReplayNonceEntity extends BaseEntity { + + private String scope; + private String principalId; + private String nonce; + private Long requestTimestamp; + private String requestMethod; + private String requestPath; + private String bodyHash; + private String remoteIp; + private String userAgent; + private LocalDateTime expiresAt; + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public String getPrincipalId() { + return principalId; + } + + public void setPrincipalId(String principalId) { + this.principalId = principalId; + } + + public String getNonce() { + return nonce; + } + + public void setNonce(String nonce) { + this.nonce = nonce; + } + + public Long getRequestTimestamp() { + return requestTimestamp; + } + + public void setRequestTimestamp(Long requestTimestamp) { + this.requestTimestamp = requestTimestamp; + } + + public String getRequestMethod() { + return requestMethod; + } + + public void setRequestMethod(String requestMethod) { + this.requestMethod = requestMethod; + } + + public String getRequestPath() { + return requestPath; + } + + public void setRequestPath(String requestPath) { + this.requestPath = requestPath; + } + + public String getBodyHash() { + return bodyHash; + } + + public void setBodyHash(String bodyHash) { + this.bodyHash = bodyHash; + } + + public String getRemoteIp() { + return remoteIp; + } + + public void setRemoteIp(String remoteIp) { + this.remoteIp = remoteIp; + } + + public String getUserAgent() { + return userAgent; + } + + public void setUserAgent(String userAgent) { + this.userAgent = userAgent; + } + + public LocalDateTime getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(LocalDateTime expiresAt) { + this.expiresAt = expiresAt; + } +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/entity/SecurityEventEntity.java b/src/main/java/com/cisd/tms/modules/security/replay/entity/SecurityEventEntity.java new file mode 100644 index 0000000..1b1273a --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/entity/SecurityEventEntity.java @@ -0,0 +1,81 @@ +package com.cisd.tms.modules.security.replay.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.cisd.tms.infrastructure.persistence.entity.BaseEntity; + +@TableName("tms_security_event") +public class SecurityEventEntity extends BaseEntity { + + private String eventType; + private String scope; + private String principalId; + private String requestMethod; + private String requestPath; + private String nonce; + private String remoteIp; + private String detailJson; + + public String getEventType() { + return eventType; + } + + public void setEventType(String eventType) { + this.eventType = eventType; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public String getPrincipalId() { + return principalId; + } + + public void setPrincipalId(String principalId) { + this.principalId = principalId; + } + + public String getRequestMethod() { + return requestMethod; + } + + public void setRequestMethod(String requestMethod) { + this.requestMethod = requestMethod; + } + + public String getRequestPath() { + return requestPath; + } + + public void setRequestPath(String requestPath) { + this.requestPath = requestPath; + } + + public String getNonce() { + return nonce; + } + + public void setNonce(String nonce) { + this.nonce = nonce; + } + + public String getRemoteIp() { + return remoteIp; + } + + public void setRemoteIp(String remoteIp) { + this.remoteIp = remoteIp; + } + + public String getDetailJson() { + return detailJson; + } + + public void setDetailJson(String detailJson) { + this.detailJson = detailJson; + } +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/enums/ReplayCheckStatus.java b/src/main/java/com/cisd/tms/modules/security/replay/enums/ReplayCheckStatus.java new file mode 100644 index 0000000..13d2631 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/enums/ReplayCheckStatus.java @@ -0,0 +1,7 @@ +package com.cisd.tms.modules.security.replay.enums; + +public enum ReplayCheckStatus { + CLAIMED, + REPLAYED, + MISMATCH +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/enums/ReplayScope.java b/src/main/java/com/cisd/tms/modules/security/replay/enums/ReplayScope.java new file mode 100644 index 0000000..442155e --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/enums/ReplayScope.java @@ -0,0 +1,6 @@ +package com.cisd.tms.modules.security.replay.enums; + +public enum ReplayScope { + OPENAPI, + INTERNAL_API +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/enums/SecurityEventType.java b/src/main/java/com/cisd/tms/modules/security/replay/enums/SecurityEventType.java new file mode 100644 index 0000000..d5ef0b4 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/enums/SecurityEventType.java @@ -0,0 +1,5 @@ +package com.cisd.tms.modules.security.replay.enums; + +public enum SecurityEventType { + REPLAY_MISMATCH +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/exception/ReplayProtectionException.java b/src/main/java/com/cisd/tms/modules/security/replay/exception/ReplayProtectionException.java new file mode 100644 index 0000000..1b38c90 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/exception/ReplayProtectionException.java @@ -0,0 +1,12 @@ +package com.cisd.tms.modules.security.replay.exception; + +public class ReplayProtectionException extends RuntimeException { + + public ReplayProtectionException(String message) { + super(message); + } + + public ReplayProtectionException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/job/ReplayNonceCleanupJob.java b/src/main/java/com/cisd/tms/modules/security/replay/job/ReplayNonceCleanupJob.java new file mode 100644 index 0000000..6b6d557 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/job/ReplayNonceCleanupJob.java @@ -0,0 +1,32 @@ +package com.cisd.tms.modules.security.replay.job; + +import com.cisd.tms.modules.security.replay.repository.ReplayNonceRepository; +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +public class ReplayNonceCleanupJob { + + private static final int DELETE_BATCH_LIMIT = 1000; + + private final ReplayNonceRepository replayNonceRepository; + private final Clock clock; + + public ReplayNonceCleanupJob(ReplayNonceRepository replayNonceRepository, Clock clock) { + this.replayNonceRepository = replayNonceRepository; + this.clock = clock; + } + + @Scheduled(fixedDelay = 60000L) + public void cleanupExpiredNonces() { + // 分批清理过期 nonce,避免单次删除过多记录导致数据库抖动。 + replayNonceRepository.deleteExpiredBefore(nowUtc(), DELETE_BATCH_LIMIT); + } + + private LocalDateTime nowUtc() { + return LocalDateTime.ofInstant(clock.instant(), ZoneOffset.UTC); + } +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/mapper/ReplayNonceMapper.java b/src/main/java/com/cisd/tms/modules/security/replay/mapper/ReplayNonceMapper.java new file mode 100644 index 0000000..d2a514d --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/mapper/ReplayNonceMapper.java @@ -0,0 +1,75 @@ +package com.cisd.tms.modules.security.replay.mapper; + +import com.cisd.tms.infrastructure.persistence.mapper.BaseMapperX; +import com.cisd.tms.modules.security.replay.entity.ReplayNonceEntity; +import java.time.LocalDateTime; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Result; +import org.apache.ibatis.annotations.Results; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ReplayNonceMapper extends BaseMapperX { + + @Select(""" + SELECT id, + scope, + principal_id, + nonce, + request_timestamp, + request_method, + request_path, + body_hash, + remote_ip, + user_agent, + expires_at, + create_time, + update_time + FROM tms_replay_nonce + WHERE scope = #{scope} + AND principal_id = #{principalId} + AND nonce = #{nonce} + LIMIT 1 + """) + @Results(id = "ReplayNonceResultMap", value = { + @Result(column = "id", property = "id", id = true), + @Result(column = "scope", property = "scope"), + @Result(column = "principal_id", property = "principalId"), + @Result(column = "nonce", property = "nonce"), + @Result(column = "request_timestamp", property = "requestTimestamp"), + @Result(column = "request_method", property = "requestMethod"), + @Result(column = "request_path", property = "requestPath"), + @Result(column = "body_hash", property = "bodyHash"), + @Result(column = "remote_ip", property = "remoteIp"), + @Result(column = "user_agent", property = "userAgent"), + @Result(column = "expires_at", property = "expiresAt"), + @Result(column = "create_time", property = "createTime"), + @Result(column = "update_time", property = "updateTime") + }) + ReplayNonceEntity selectByScopeAndPrincipalIdAndNonce( + @Param("scope") String scope, + @Param("principalId") String principalId, + @Param("nonce") String nonce + ); + + @Delete(""" + DELETE FROM tms_replay_nonce + WHERE scope = #{scope} + AND principal_id = #{principalId} + AND nonce = #{nonce} + """) + int deleteByScopeAndPrincipalIdAndNonce( + @Param("scope") String scope, + @Param("principalId") String principalId, + @Param("nonce") String nonce + ); + + @Delete(""" + DELETE FROM tms_replay_nonce + WHERE expires_at < #{cutoff} + LIMIT #{limit} + """) + int deleteExpiredBefore(@Param("cutoff") LocalDateTime cutoff, @Param("limit") int limit); +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/mapper/SecurityEventMapper.java b/src/main/java/com/cisd/tms/modules/security/replay/mapper/SecurityEventMapper.java new file mode 100644 index 0000000..b73990e --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/mapper/SecurityEventMapper.java @@ -0,0 +1,9 @@ +package com.cisd.tms.modules.security.replay.mapper; + +import com.cisd.tms.infrastructure.persistence.mapper.BaseMapperX; +import com.cisd.tms.modules.security.replay.entity.SecurityEventEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SecurityEventMapper extends BaseMapperX { +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/repository/ReplayNonceRepository.java b/src/main/java/com/cisd/tms/modules/security/replay/repository/ReplayNonceRepository.java new file mode 100644 index 0000000..6684151 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/repository/ReplayNonceRepository.java @@ -0,0 +1,20 @@ +package com.cisd.tms.modules.security.replay.repository; + +import com.cisd.tms.modules.security.replay.entity.ReplayNonceEntity; +import java.time.LocalDateTime; +import java.util.Optional; + +public interface ReplayNonceRepository { + + void save(ReplayNonceEntity entity); + + boolean trySave(ReplayNonceEntity entity); + + Optional findByScopeAndPrincipalIdAndNonce(String scope, String principalId, String nonce); + + int deleteById(Long id); + + int deleteByScopeAndPrincipalIdAndNonce(String scope, String principalId, String nonce); + + int deleteExpiredBefore(LocalDateTime cutoff, int limit); +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/repository/SecurityEventRepository.java b/src/main/java/com/cisd/tms/modules/security/replay/repository/SecurityEventRepository.java new file mode 100644 index 0000000..2fd7708 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/repository/SecurityEventRepository.java @@ -0,0 +1,8 @@ +package com.cisd.tms.modules.security.replay.repository; + +import com.cisd.tms.modules.security.replay.entity.SecurityEventEntity; + +public interface SecurityEventRepository { + + void save(SecurityEventEntity entity); +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/repository/impl/ReplayNonceRepositoryImpl.java b/src/main/java/com/cisd/tms/modules/security/replay/repository/impl/ReplayNonceRepositoryImpl.java new file mode 100644 index 0000000..c4a1af0 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/repository/impl/ReplayNonceRepositoryImpl.java @@ -0,0 +1,54 @@ +package com.cisd.tms.modules.security.replay.repository.impl; + +import com.cisd.tms.modules.security.replay.entity.ReplayNonceEntity; +import com.cisd.tms.modules.security.replay.mapper.ReplayNonceMapper; +import com.cisd.tms.modules.security.replay.repository.ReplayNonceRepository; +import java.time.LocalDateTime; +import java.util.Optional; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Repository; + +@Repository +public class ReplayNonceRepositoryImpl implements ReplayNonceRepository { + + private final ReplayNonceMapper replayNonceMapper; + + public ReplayNonceRepositoryImpl(ReplayNonceMapper replayNonceMapper) { + this.replayNonceMapper = replayNonceMapper; + } + + @Override + public void save(ReplayNonceEntity entity) { + replayNonceMapper.insert(entity); + } + + @Override + public boolean trySave(ReplayNonceEntity entity) { + try { + replayNonceMapper.insert(entity); + return true; + } catch (DataIntegrityViolationException ex) { + return false; + } + } + + @Override + public Optional findByScopeAndPrincipalIdAndNonce(String scope, String principalId, String nonce) { + return Optional.ofNullable(replayNonceMapper.selectByScopeAndPrincipalIdAndNonce(scope, principalId, nonce)); + } + + @Override + public int deleteById(Long id) { + return replayNonceMapper.deleteById(id); + } + + @Override + public int deleteByScopeAndPrincipalIdAndNonce(String scope, String principalId, String nonce) { + return replayNonceMapper.deleteByScopeAndPrincipalIdAndNonce(scope, principalId, nonce); + } + + @Override + public int deleteExpiredBefore(LocalDateTime cutoff, int limit) { + return replayNonceMapper.deleteExpiredBefore(cutoff, limit); + } +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/repository/impl/SecurityEventRepositoryImpl.java b/src/main/java/com/cisd/tms/modules/security/replay/repository/impl/SecurityEventRepositoryImpl.java new file mode 100644 index 0000000..6dd078b --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/repository/impl/SecurityEventRepositoryImpl.java @@ -0,0 +1,21 @@ +package com.cisd.tms.modules.security.replay.repository.impl; + +import com.cisd.tms.modules.security.replay.entity.SecurityEventEntity; +import com.cisd.tms.modules.security.replay.mapper.SecurityEventMapper; +import com.cisd.tms.modules.security.replay.repository.SecurityEventRepository; +import org.springframework.stereotype.Repository; + +@Repository +public class SecurityEventRepositoryImpl implements SecurityEventRepository { + + private final SecurityEventMapper securityEventMapper; + + public SecurityEventRepositoryImpl(SecurityEventMapper securityEventMapper) { + this.securityEventMapper = securityEventMapper; + } + + @Override + public void save(SecurityEventEntity entity) { + securityEventMapper.insert(entity); + } +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/service/ReplayProtectionService.java b/src/main/java/com/cisd/tms/modules/security/replay/service/ReplayProtectionService.java new file mode 100644 index 0000000..dded2c7 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/service/ReplayProtectionService.java @@ -0,0 +1,9 @@ +package com.cisd.tms.modules.security.replay.service; + +import com.cisd.tms.modules.security.replay.dto.ReplayCheckRequest; +import com.cisd.tms.modules.security.replay.dto.ReplayCheckResult; + +public interface ReplayProtectionService { + + ReplayCheckResult check(ReplayCheckRequest request); +} diff --git a/src/main/java/com/cisd/tms/modules/security/replay/service/impl/ReplayProtectionServiceImpl.java b/src/main/java/com/cisd/tms/modules/security/replay/service/impl/ReplayProtectionServiceImpl.java new file mode 100644 index 0000000..dc6c02d --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/security/replay/service/impl/ReplayProtectionServiceImpl.java @@ -0,0 +1,230 @@ +package com.cisd.tms.modules.security.replay.service.impl; + +import com.cisd.tms.common.config.properties.TmsSecurityProperties; +import com.cisd.tms.modules.security.replay.dto.ReplayCheckRequest; +import com.cisd.tms.modules.security.replay.dto.ReplayCheckResult; +import com.cisd.tms.modules.security.replay.entity.ReplayNonceEntity; +import com.cisd.tms.modules.security.replay.entity.SecurityEventEntity; +import com.cisd.tms.modules.security.replay.enums.ReplayCheckStatus; +import com.cisd.tms.modules.security.replay.enums.ReplayScope; +import com.cisd.tms.modules.security.replay.enums.SecurityEventType; +import com.cisd.tms.modules.security.replay.exception.ReplayProtectionException; +import com.cisd.tms.modules.security.replay.repository.ReplayNonceRepository; +import com.cisd.tms.modules.security.replay.repository.SecurityEventRepository; +import com.cisd.tms.modules.security.replay.service.ReplayProtectionService; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Objects; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class ReplayProtectionServiceImpl implements ReplayProtectionService { + + private static final long DEFAULT_ALLOWED_SKEW_SECONDS = 300L; + + private final ReplayNonceRepository replayNonceRepository; + private final SecurityEventRepository securityEventRepository; + private final TmsSecurityProperties securityProperties; + private final Clock clock; + + @Autowired + public ReplayProtectionServiceImpl( + ReplayNonceRepository replayNonceRepository, + SecurityEventRepository securityEventRepository, + TmsSecurityProperties securityProperties, + Clock clock + ) { + this.replayNonceRepository = replayNonceRepository; + this.securityEventRepository = securityEventRepository; + this.securityProperties = securityProperties; + this.clock = clock; + } + + public ReplayProtectionServiceImpl( + ReplayNonceRepository replayNonceRepository, + SecurityEventRepository securityEventRepository, + Clock clock + ) { + this(replayNonceRepository, securityEventRepository, new TmsSecurityProperties(), clock); + } + + @Override + public ReplayCheckResult check(ReplayCheckRequest request) { + validateRequest(request); + long nowSeconds = clock.instant().getEpochSecond(); + long allowedSkewSeconds = allowedSkewSeconds(request.getScope()); + ensureTimestampWithinWindow(request.getRequestTimestamp(), nowSeconds, allowedSkewSeconds); + + String scope = request.getScope().name(); + String principalId = normalize(request.getPrincipalId()); + String nonce = normalize(request.getNonce()); + + var existing = replayNonceRepository.findByScopeAndPrincipalIdAndNonce(scope, principalId, nonce); + if (existing.isPresent()) { + if (isExpired(existing.get(), nowSeconds)) { + // 过期记录即使清理任务尚未来得及删除,也不应永久阻塞新的合法请求。 + reclaimExpiredNonce(existing.get()); + existing = replayNonceRepository.findByScopeAndPrincipalIdAndNonce(scope, principalId, nonce); + } + } + if (existing.isPresent()) { + return buildReplayResult(request, existing.get()); + } + + ReplayNonceEntity entity = buildReplayEntity(request, nowSeconds, allowedSkewSeconds); + try { + if (replayNonceRepository.trySave(entity)) { + return buildClaimedResult(entity); + } + } catch (RuntimeException ex) { + throw new ReplayProtectionException("failed to persist replay nonce", ex); + } + + var reloaded = replayNonceRepository.findByScopeAndPrincipalIdAndNonce(scope, principalId, nonce); + if (reloaded.isEmpty()) { + throw new ReplayProtectionException("replay nonce claim failed without existing record"); + } + return buildReplayResult(request, reloaded.get()); + } + + private void reclaimExpiredNonce(ReplayNonceEntity expiredEntity) { + if (expiredEntity.getId() == null) { + throw new ReplayProtectionException("expired replay nonce id is missing"); + } + try { + replayNonceRepository.deleteById(expiredEntity.getId()); + } catch (RuntimeException ex) { + throw new ReplayProtectionException("failed to remove expired replay nonce", ex); + } + } + + private void validateRequest(ReplayCheckRequest request) { + if (request == null) { + throw new ReplayProtectionException("replay request must not be null"); + } + if (request.getScope() == null) { + throw new ReplayProtectionException("replay scope is required"); + } + if (isBlank(request.getPrincipalId())) { + throw new ReplayProtectionException("principal id is required"); + } + if (isBlank(request.getNonce())) { + throw new ReplayProtectionException("nonce is required"); + } + if (request.getRequestTimestamp() <= 0L) { + throw new ReplayProtectionException("request timestamp is required"); + } + } + + private void ensureTimestampWithinWindow(long requestTimestamp, long nowSeconds, long allowedSkewSeconds) { + if (Math.abs(nowSeconds - requestTimestamp) > allowedSkewSeconds) { + throw new ReplayProtectionException("request timestamp is outside allowed window"); + } + } + + private ReplayCheckResult buildClaimedResult(ReplayNonceEntity entity) { + ReplayCheckResult result = new ReplayCheckResult(); + result.setStatus(ReplayCheckStatus.CLAIMED); + result.setReplayed(false); + result.setMismatch(false); + result.setSecurityEventRecorded(false); + result.setExpiresAt(entity.getExpiresAt()); + return result; + } + + private ReplayCheckResult buildReplayResult(ReplayCheckRequest request, ReplayNonceEntity existing) { + boolean mismatch = !sameFingerprint(request, existing); + if (mismatch) { + // 同一 nonce 命中但请求指纹不同,按更高风险事件记录,便于后续审计或告警。 + recordSecurityEvent(request); + } + + ReplayCheckResult result = new ReplayCheckResult(); + result.setStatus(mismatch ? ReplayCheckStatus.MISMATCH : ReplayCheckStatus.REPLAYED); + result.setReplayed(true); + result.setMismatch(mismatch); + result.setSecurityEventRecorded(mismatch); + result.setExpiresAt(existing.getExpiresAt()); + return result; + } + + private void recordSecurityEvent(ReplayCheckRequest request) { + SecurityEventEntity event = new SecurityEventEntity(); + event.setEventType(SecurityEventType.REPLAY_MISMATCH.name()); + event.setScope(request.getScope().name()); + event.setPrincipalId(normalize(request.getPrincipalId())); + event.setRequestMethod(normalize(request.getRequestMethod())); + event.setRequestPath(normalize(request.getRequestPath())); + event.setNonce(normalize(request.getNonce())); + event.setRemoteIp(normalize(request.getRemoteIp())); + event.setDetailJson(buildMismatchDetail(request)); + + try { + securityEventRepository.save(event); + } catch (RuntimeException ex) { + throw new ReplayProtectionException("failed to persist security event", ex); + } + } + + private String buildMismatchDetail(ReplayCheckRequest request) { + return "request fingerprint mismatch: scope=" + request.getScope().name() + + ", method=" + normalize(request.getRequestMethod()) + + ", path=" + normalize(request.getRequestPath()) + + ", bodyHash=" + normalize(request.getBodyHash()); + } + + private ReplayNonceEntity buildReplayEntity(ReplayCheckRequest request, long nowSeconds, long allowedSkewSeconds) { + ReplayNonceEntity entity = new ReplayNonceEntity(); + entity.setScope(request.getScope().name()); + entity.setPrincipalId(normalize(request.getPrincipalId())); + entity.setNonce(normalize(request.getNonce())); + entity.setRequestTimestamp(request.getRequestTimestamp()); + entity.setRequestMethod(normalize(request.getRequestMethod())); + entity.setRequestPath(normalize(request.getRequestPath())); + entity.setBodyHash(normalize(request.getBodyHash())); + entity.setRemoteIp(normalize(request.getRemoteIp())); + entity.setUserAgent(normalize(request.getUserAgent())); + entity.setExpiresAt(LocalDateTime.ofInstant(Instant.ofEpochSecond(nowSeconds + allowedSkewSeconds * 2L), ZoneOffset.UTC)); + return entity; + } + + private long allowedSkewSeconds(ReplayScope scope) { + // OpenAPI 允许使用配置时间窗,内部管理端走固定默认值,避免由调用方自行放宽窗口。 + if (scope == ReplayScope.OPENAPI) { + long configuredSkewSeconds = securityProperties.getOpenapi().getTimestampSkewSeconds(); + if (configuredSkewSeconds > 0L) { + return configuredSkewSeconds; + } + } + return DEFAULT_ALLOWED_SKEW_SECONDS; + } + + private boolean sameFingerprint(ReplayCheckRequest request, ReplayNonceEntity existing) { + return same(normalize(request.getRequestMethod()), normalize(existing.getRequestMethod())) + && same(normalize(request.getRequestPath()), normalize(existing.getRequestPath())) + && same(normalize(request.getBodyHash()), normalize(existing.getBodyHash())); + } + + private boolean same(String left, String right) { + return Objects.equals(left, right); + } + + private boolean isExpired(ReplayNonceEntity entity, long nowSeconds) { + if (entity.getExpiresAt() == null) { + return false; + } + LocalDateTime now = LocalDateTime.ofInstant(Instant.ofEpochSecond(nowSeconds), ZoneOffset.UTC); + return !entity.getExpiresAt().isAfter(now); + } + + private String normalize(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/com/cisd/tms/modules/upgrade/controller/UpgradeController.java b/src/main/java/com/cisd/tms/modules/upgrade/controller/UpgradeController.java index c95a677..c2de5df 100644 --- a/src/main/java/com/cisd/tms/modules/upgrade/controller/UpgradeController.java +++ b/src/main/java/com/cisd/tms/modules/upgrade/controller/UpgradeController.java @@ -11,6 +11,7 @@ import com.cisd.tms.modules.upgrade.dto.UpgradePreviewResponse; import com.cisd.tms.modules.upgrade.dto.UpgradeTaskPageResponse; import com.cisd.tms.modules.upgrade.dto.UpgradeTaskResponse; import com.cisd.tms.modules.upgrade.service.UpgradeService; +import com.cisd.tms.security.internal.ReplayProtected; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -36,6 +37,7 @@ public class UpgradeController { @PostMapping("/upgrade-packages") @Operation(summary = "上传离线升级包", description = "上传离线升级包,返回 fileId 供升级预检和任务创建使用。") + @ReplayProtected public ApiResponse uploadPackage( @Parameter(description = "待上传的离线升级包") @RequestParam("file") MultipartFile file @@ -51,12 +53,14 @@ public class UpgradeController { @PostMapping("/upgrades") @Operation(summary = "创建离线升级任务", description = "根据 fileId 创建离线升级任务,状态初始为 PENDING_CONFIRM。") + @ReplayProtected public ApiResponse create(@Valid @RequestBody UpgradeCreateTaskRequest request) { return ApiResponse.success(upgradeService.createTask(request)); } @PostMapping("/upgrades/{taskId}/execute") @Operation(summary = "执行离线升级任务", description = "异步受理升级执行请求,立即返回任务当前快照。") + @ReplayProtected public ApiResponse execute( @Parameter(description = "升级任务号") @PathVariable String taskId @@ -66,6 +70,7 @@ public class UpgradeController { @PostMapping("/upgrades/{taskId}/rollback") @Operation(summary = "回滚离线升级任务", description = "异步受理升级回滚请求,立即返回任务当前快照。") + @ReplayProtected public ApiResponse rollback( @Parameter(description = "升级任务号") @PathVariable String taskId diff --git a/src/main/java/com/cisd/tms/security/internal/CachedBodyHttpServletRequest.java b/src/main/java/com/cisd/tms/security/internal/CachedBodyHttpServletRequest.java new file mode 100644 index 0000000..b45564a --- /dev/null +++ b/src/main/java/com/cisd/tms/security/internal/CachedBodyHttpServletRequest.java @@ -0,0 +1,69 @@ +package com.cisd.tms.security.internal; + +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import org.springframework.util.StreamUtils; + +public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { + + private final byte[] cachedBody; + + public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException { + super(request); + this.cachedBody = StreamUtils.copyToByteArray(request.getInputStream()); + } + + public byte[] getCachedBody() { + return cachedBody.clone(); + } + + @Override + public ServletInputStream getInputStream() { + return new CachedBodyServletInputStream(cachedBody); + } + + @Override + public BufferedReader getReader() { + Charset charset = getCharacterEncoding() == null + ? StandardCharsets.UTF_8 + : Charset.forName(getCharacterEncoding()); + return new BufferedReader(new InputStreamReader(getInputStream(), charset)); + } + + private static class CachedBodyServletInputStream extends ServletInputStream { + + private final ByteArrayInputStream inputStream; + + private CachedBodyServletInputStream(byte[] cachedBody) { + this.inputStream = new ByteArrayInputStream(cachedBody); + } + + @Override + public boolean isFinished() { + return inputStream.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener readListener) { + throw new UnsupportedOperationException("ReadListener is not supported"); + } + + @Override + public int read() { + return inputStream.read(); + } + } +} diff --git a/src/main/java/com/cisd/tms/security/internal/InternalApiAuthInterceptor.java b/src/main/java/com/cisd/tms/security/internal/InternalApiAuthInterceptor.java index 0726bad..f6421fc 100644 --- a/src/main/java/com/cisd/tms/security/internal/InternalApiAuthInterceptor.java +++ b/src/main/java/com/cisd/tms/security/internal/InternalApiAuthInterceptor.java @@ -13,12 +13,15 @@ import java.io.IOException; import java.time.Clock; import java.time.LocalDateTime; import java.time.ZoneOffset; + +import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.web.cors.CorsUtils; import org.springframework.web.servlet.HandlerInterceptor; @Component +@RequiredArgsConstructor public class InternalApiAuthInterceptor implements HandlerInterceptor { public static final String ATTR_ROLE_CODE = "CURRENT_ROLE_CODE"; @@ -33,27 +36,6 @@ public class InternalApiAuthInterceptor implements HandlerInterceptor { private final ObjectMapper objectMapper; private final Clock clock; - @Autowired - public InternalApiAuthInterceptor( - AuthSessionRepository authSessionRepository, - ObjectMapper objectMapper, - TmsSecurityProperties securityProperties - ) { - this(authSessionRepository, objectMapper, securityProperties, Clock.systemUTC()); - } - - InternalApiAuthInterceptor( - AuthSessionRepository authSessionRepository, - ObjectMapper objectMapper, - TmsSecurityProperties securityProperties, - Clock clock - ) { - this.authSessionRepository = authSessionRepository; - this.securityProperties = securityProperties; - this.objectMapper = objectMapper; - this.clock = clock; - } - @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (CorsUtils.isPreFlightRequest(request)) { diff --git a/src/main/java/com/cisd/tms/security/internal/InternalApiReplayInterceptor.java b/src/main/java/com/cisd/tms/security/internal/InternalApiReplayInterceptor.java new file mode 100644 index 0000000..e0f677e --- /dev/null +++ b/src/main/java/com/cisd/tms/security/internal/InternalApiReplayInterceptor.java @@ -0,0 +1,145 @@ +package com.cisd.tms.security.internal; + +import com.cisd.tms.common.api.ApiResponse; +import com.cisd.tms.common.enums.ErrorCode; +import com.cisd.tms.common.util.HttpResponseUtil; +import com.cisd.tms.modules.security.replay.dto.ReplayCheckRequest; +import com.cisd.tms.modules.security.replay.dto.ReplayCheckResult; +import com.cisd.tms.modules.security.replay.enums.ReplayScope; +import com.cisd.tms.modules.security.replay.exception.ReplayProtectionException; +import com.cisd.tms.modules.security.replay.service.ReplayProtectionService; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Clock; +import lombok.RequiredArgsConstructor; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import org.springframework.web.cors.CorsUtils; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +@RequiredArgsConstructor +public class InternalApiReplayInterceptor implements HandlerInterceptor { + + static final String TIMESTAMP_HEADER = "X-Request-Timestamp"; + static final String NONCE_HEADER = "X-Request-Nonce"; + + private final ReplayProtectionService replayProtectionService; + private final ObjectMapper objectMapper; + private final Clock clock; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + if (CorsUtils.isPreFlightRequest(request) || !(handler instanceof HandlerMethod handlerMethod)) { + return true; + } + // 只有显式标记的敏感接口才启用防重放,避免把普通查询接口一并拦住。 + if (findAnnotation(handlerMethod, ReplayProtected.class) == null) { + return true; + } + + String sessionToken = (String) request.getAttribute(InternalApiAuthInterceptor.ATTR_SESSION_TOKEN); + if (isBlank(sessionToken)) { + writeJson(response, HttpServletResponse.SC_UNAUTHORIZED, ErrorCode.UNAUTHORIZED.getCode(), "session token missing"); + return false; + } + + String timestamp = trimHeader(request, TIMESTAMP_HEADER); + String nonce = trimHeader(request, NONCE_HEADER); + if (isBlank(timestamp) || isBlank(nonce)) { + writeJson(response, HttpServletResponse.SC_BAD_REQUEST, ErrorCode.BAD_REQUEST.getCode(), "missing replay protection headers"); + return false; + } + + long requestTimestamp; + try { + requestTimestamp = Long.parseLong(timestamp); + } catch (NumberFormatException ex) { + writeJson(response, HttpServletResponse.SC_BAD_REQUEST, ErrorCode.BAD_REQUEST.getCode(), "invalid replay timestamp"); + return false; + } + + ReplayCheckRequest replayRequest = buildReplayRequest(request, sessionToken, nonce, requestTimestamp); + try { + ReplayCheckResult result = replayProtectionService.check(replayRequest); + if (result.isReplayed()) { + writeJson(response, HttpServletResponse.SC_CONFLICT, ErrorCode.UNAUTHORIZED.getCode(), "replayed nonce"); + return false; + } + } catch (ReplayProtectionException ex) { + writeJson(response, HttpServletResponse.SC_SERVICE_UNAVAILABLE, ErrorCode.INTERNAL_ERROR.getCode(), + "replay protection unavailable"); + return false; + } + + return true; + } + + private ReplayCheckRequest buildReplayRequest( + HttpServletRequest request, + String sessionToken, + String nonce, + long requestTimestamp + ) { + // 内部接口使用会话 token 作为主体标识,并把请求体哈希纳入指纹,识别同 nonce 的不同内容重放。 + ReplayCheckRequest replayRequest = new ReplayCheckRequest(); + replayRequest.setScope(ReplayScope.INTERNAL_API); + replayRequest.setPrincipalId(sessionToken); + replayRequest.setNonce(nonce); + replayRequest.setRequestTimestamp(requestTimestamp); + replayRequest.setRequestMethod(request.getMethod()); + replayRequest.setRequestPath(request.getRequestURI()); + replayRequest.setBodyHash(bodyHash(request)); + replayRequest.setRemoteIp(request.getRemoteAddr()); + replayRequest.setUserAgent(trimHeader(request, "User-Agent")); + return replayRequest; + } + + private String bodyHash(HttpServletRequest request) { + if (!(request instanceof CachedBodyHttpServletRequest cachedRequest)) { + return null; + } + byte[] body = cachedRequest.getCachedBody(); + if (body.length == 0) { + return null; + } + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(body); + StringBuilder builder = new StringBuilder(digest.length * 2); + for (byte b : digest) { + builder.append(String.format("%02x", b)); + } + return builder.toString(); + } catch (Exception ex) { + throw new ReplayProtectionException("failed to hash request body", ex); + } + } + + @Nullable + private T findAnnotation(HandlerMethod handlerMethod, Class annotationType) { + T methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(handlerMethod.getMethod(), annotationType); + if (methodAnnotation != null) { + return methodAnnotation; + } + return AnnotatedElementUtils.findMergedAnnotation(handlerMethod.getBeanType(), annotationType); + } + + private String trimHeader(HttpServletRequest request, String name) { + String value = request.getHeader(name); + return value == null ? null : value.trim(); + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private void writeJson(HttpServletResponse response, int status, int code, String message) throws IOException { + HttpResponseUtil.writeJson(response, status, ApiResponse.fail(code, message), objectMapper); + } +} diff --git a/src/main/java/com/cisd/tms/security/internal/ReplayProtected.java b/src/main/java/com/cisd/tms/security/internal/ReplayProtected.java new file mode 100644 index 0000000..0b64358 --- /dev/null +++ b/src/main/java/com/cisd/tms/security/internal/ReplayProtected.java @@ -0,0 +1,11 @@ +package com.cisd.tms.security.internal; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ReplayProtected { +} diff --git a/src/main/java/com/cisd/tms/security/openapi/OpenApiReplayGuard.java b/src/main/java/com/cisd/tms/security/openapi/OpenApiReplayGuard.java deleted file mode 100644 index 8c9ec95..0000000 --- a/src/main/java/com/cisd/tms/security/openapi/OpenApiReplayGuard.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.cisd.tms.security.openapi; - -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicLong; -import org.springframework.stereotype.Component; - -@Component -public class OpenApiReplayGuard { - - private final Map nonceExpireSeconds = new ConcurrentHashMap<>(); - private final AtomicLong cleanupCounter = new AtomicLong(0); - - public boolean tryAcquire(String appId, String nonce, long nowSeconds, long ttlSeconds) { - if (ttlSeconds <= 0) { - ttlSeconds = 300; - } - - String key = appId + ":" + nonce; - long expiry = nowSeconds + ttlSeconds; - AtomicBoolean accepted = new AtomicBoolean(false); - - nonceExpireSeconds.compute(key, (k, existingExpiry) -> { - if (existingExpiry == null || existingExpiry < nowSeconds) { - accepted.set(true); - return expiry; - } - accepted.set(false); - return existingExpiry; - }); - - // Opportunistic cleanup to keep memory growth under control. - if (cleanupCounter.incrementAndGet() % 1000 == 0) { - nonceExpireSeconds.entrySet().removeIf(e -> e.getValue() < nowSeconds); - } - - return accepted.get(); - } -} diff --git a/src/main/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptor.java b/src/main/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptor.java index a0cca50..639c1ba 100644 --- a/src/main/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptor.java +++ b/src/main/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptor.java @@ -5,19 +5,28 @@ import com.cisd.tms.common.config.properties.TmsSecurityProperties; import com.cisd.tms.common.enums.ErrorCode; import com.cisd.tms.common.util.HmacUtil; import com.cisd.tms.common.util.HttpResponseUtil; +import com.cisd.tms.modules.security.replay.dto.ReplayCheckRequest; +import com.cisd.tms.modules.security.replay.dto.ReplayCheckResult; +import com.cisd.tms.modules.security.replay.enums.ReplayScope; +import com.cisd.tms.modules.security.replay.exception.ReplayProtectionException; +import com.cisd.tms.modules.security.replay.service.ReplayProtectionService; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; +import java.time.Clock; import java.time.Instant; import java.util.Map; + +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.web.cors.CorsUtils; import org.springframework.web.servlet.HandlerInterceptor; @Component +@RequiredArgsConstructor public class OpenApiSignAuthInterceptor implements HandlerInterceptor { private static final String APP_ID_HEADER = "X-App-Id"; @@ -26,18 +35,9 @@ public class OpenApiSignAuthInterceptor implements HandlerInterceptor { private static final String SIGNATURE_HEADER = "X-Signature"; private final TmsSecurityProperties securityProperties; - private final OpenApiReplayGuard replayGuard; + private final ReplayProtectionService replayProtectionService; private final ObjectMapper objectMapper; - - public OpenApiSignAuthInterceptor( - TmsSecurityProperties securityProperties, - OpenApiReplayGuard replayGuard, - ObjectMapper objectMapper - ) { - this.securityProperties = securityProperties; - this.replayGuard = replayGuard; - this.objectMapper = objectMapper; - } + private final Clock clock; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { @@ -70,18 +70,13 @@ public class OpenApiSignAuthInterceptor implements HandlerInterceptor { return false; } - long now = Instant.now().getEpochSecond(); + long now = Instant.ofEpochMilli(clock.millis()).getEpochSecond(); long skew = securityProperties.getOpenapi().getTimestampSkewSeconds(); if (Math.abs(now - requestEpoch) > skew) { writeUnauthorized(response, "timestamp expired"); return false; } - if (!replayGuard.tryAcquire(appId, nonce, now, skew * 2)) { - writeUnauthorized(response, "replayed nonce"); - return false; - } - String content = appId + "\n" + timestamp + "\n" + nonce; String expected = HmacUtil.hmacSha256Hex(appSecret, content); @@ -90,9 +85,40 @@ public class OpenApiSignAuthInterceptor implements HandlerInterceptor { return false; } + try { + // 先完成签名校验,再占用 nonce,避免伪造请求提前消耗合法 nonce。 + ReplayCheckResult result = replayProtectionService.check(buildReplayRequest(request, appId, nonce, requestEpoch)); + if (result.isReplayed()) { + writeUnauthorized(response, "replayed nonce"); + return false; + } + } catch (ReplayProtectionException ex) { + writeUnauthorized(response, "replay protection unavailable"); + return false; + } + return true; } + private ReplayCheckRequest buildReplayRequest( + HttpServletRequest request, + String appId, + String nonce, + long requestEpoch + ) { + // OpenAPI 当前把 appId、时间戳、nonce 作为防重放主键输入,并额外记录方法、路径等上下文。 + ReplayCheckRequest replayRequest = new ReplayCheckRequest(); + replayRequest.setScope(ReplayScope.OPENAPI); + replayRequest.setPrincipalId(appId); + replayRequest.setNonce(nonce); + replayRequest.setRequestTimestamp(requestEpoch); + replayRequest.setRequestMethod(request.getMethod()); + replayRequest.setRequestPath(request.getRequestURI()); + replayRequest.setRemoteIp(request.getRemoteAddr()); + replayRequest.setUserAgent(trimHeader(request, "User-Agent")); + return replayRequest; + } + private boolean secureEquals(String expected, String actual) { return MessageDigest.isEqual( expected.getBytes(StandardCharsets.UTF_8), diff --git a/src/main/resources/db/migration/V1__tms_schema_full.sql b/src/main/resources/db/migration/V1__tms_schema_full.sql index 10e18e0..8605c2a 100644 --- a/src/main/resources/db/migration/V1__tms_schema_full.sql +++ b/src/main/resources/db/migration/V1__tms_schema_full.sql @@ -234,6 +234,45 @@ CREATE TABLE IF NOT EXISTS tms_auth_audit_log ( KEY idx_tms_auth_audit_log_role_code (role_code) ); +-- ----------------------------------------------------------------------------- +-- 防重放与安全事件 +-- ----------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS tms_replay_nonce ( + id BIGINT PRIMARY KEY, + scope VARCHAR(32) NOT NULL COMMENT '防重放作用域:OPENAPI / INTERNAL_API', + principal_id VARCHAR(128) NOT NULL COMMENT '主体标识:appId / sessionToken', + nonce VARCHAR(128) NOT NULL, + request_timestamp BIGINT NOT NULL, + request_method VARCHAR(16) NULL, + request_path VARCHAR(256) NULL, + body_hash VARCHAR(128) NULL, + remote_ip VARCHAR(64) NULL, + user_agent VARCHAR(512) NULL, + expires_at DATETIME(3) NOT NULL, + create_time DATETIME(3) NOT NULL, + update_time DATETIME(3) NOT NULL, + UNIQUE KEY uk_tms_replay_nonce_scope_principal_nonce (scope, principal_id, nonce), + KEY idx_tms_replay_nonce_expires_at (expires_at), + KEY idx_tms_replay_nonce_principal_time (principal_id, create_time) +); + +CREATE TABLE IF NOT EXISTS tms_security_event ( + id BIGINT PRIMARY KEY, + event_type VARCHAR(64) NOT NULL, + scope VARCHAR(32) NOT NULL, + principal_id VARCHAR(128) NULL, + request_method VARCHAR(16) NULL, + request_path VARCHAR(256) NULL, + nonce VARCHAR(128) NULL, + remote_ip VARCHAR(64) NULL, + detail_json JSON NULL, + create_time DATETIME(3) NOT NULL, + update_time DATETIME(3) NOT NULL, + KEY idx_tms_security_event_event_type (event_type), + KEY idx_tms_security_event_scope_principal (scope, principal_id) +); + INSERT IGNORE INTO tms_role_account ( id, role_code, diff --git a/src/main/resources/db/migration/V5__add_replay_protection_tables.sql b/src/main/resources/db/migration/V5__add_replay_protection_tables.sql new file mode 100644 index 0000000..ef9f4be --- /dev/null +++ b/src/main/resources/db/migration/V5__add_replay_protection_tables.sql @@ -0,0 +1,38 @@ +-- ----------------------------------------------------------------------------- +-- Replay protection tables +-- ----------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS tms_replay_nonce ( + id BIGINT PRIMARY KEY, + scope VARCHAR(32) NOT NULL COMMENT '防重放作用域:OPENAPI / INTERNAL_API', + principal_id VARCHAR(128) NOT NULL COMMENT '主体标识:appId / sessionToken', + nonce VARCHAR(128) NOT NULL, + request_timestamp BIGINT NOT NULL, + request_method VARCHAR(16) NULL, + request_path VARCHAR(256) NULL, + body_hash VARCHAR(128) NULL, + remote_ip VARCHAR(64) NULL, + user_agent VARCHAR(512) NULL, + expires_at DATETIME(3) NOT NULL, + create_time DATETIME(3) NOT NULL, + update_time DATETIME(3) NOT NULL, + UNIQUE KEY uk_tms_replay_nonce_scope_principal_nonce (scope, principal_id, nonce), + KEY idx_tms_replay_nonce_expires_at (expires_at), + KEY idx_tms_replay_nonce_principal_time (principal_id, create_time) +); + +CREATE TABLE IF NOT EXISTS tms_security_event ( + id BIGINT PRIMARY KEY, + event_type VARCHAR(64) NOT NULL, + scope VARCHAR(32) NOT NULL, + principal_id VARCHAR(128) NULL, + request_method VARCHAR(16) NULL, + request_path VARCHAR(256) NULL, + nonce VARCHAR(128) NULL, + remote_ip VARCHAR(64) NULL, + detail_json JSON NULL, + create_time DATETIME(3) NOT NULL, + update_time DATETIME(3) NOT NULL, + KEY idx_tms_security_event_event_type (event_type), + KEY idx_tms_security_event_scope_principal (scope, principal_id) +); diff --git a/src/test/java/com/cisd/tms/TmsApplicationTests.java b/src/test/java/com/cisd/tms/TmsApplicationTests.java index caf1096..42f47e9 100644 --- a/src/test/java/com/cisd/tms/TmsApplicationTests.java +++ b/src/test/java/com/cisd/tms/TmsApplicationTests.java @@ -54,12 +54,17 @@ class TmsApplicationTests { @Test void shouldCreateAuthRoleTablesAndSeedFixedRoleAccounts() { ClassPathResource migration = new ClassPathResource("db/migration/V1__tms_schema_full.sql"); + ClassPathResource replayMigration = new ClassPathResource("db/migration/V5__add_replay_protection_tables.sql"); org.junit.jupiter.api.Assertions.assertTrue(migration.exists()); + org.junit.jupiter.api.Assertions.assertTrue(replayMigration.exists()); String sql = org.junit.jupiter.api.Assertions.assertDoesNotThrow( () -> new String(migration.getInputStream().readAllBytes(), StandardCharsets.UTF_8) ); + String replaySql = org.junit.jupiter.api.Assertions.assertDoesNotThrow( + () -> new String(replayMigration.getInputStream().readAllBytes(), StandardCharsets.UTF_8) + ); org.junit.jupiter.api.Assertions.assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS tms_role_account")); org.junit.jupiter.api.Assertions.assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS tms_role_ukey_binding")); @@ -72,5 +77,12 @@ class TmsApplicationTests { org.junit.jupiter.api.Assertions.assertTrue(sql.contains("'KEY_ADMIN'")); org.junit.jupiter.api.Assertions.assertTrue(sql.contains("'ACTIVE'")); org.junit.jupiter.api.Assertions.assertTrue(sql.contains("'UNENABLED'")); + org.junit.jupiter.api.Assertions.assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS tms_replay_nonce")); + org.junit.jupiter.api.Assertions.assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS tms_security_event")); + + org.junit.jupiter.api.Assertions.assertTrue(replaySql.contains("CREATE TABLE IF NOT EXISTS tms_replay_nonce")); + org.junit.jupiter.api.Assertions.assertTrue(replaySql.contains("CREATE TABLE IF NOT EXISTS tms_security_event")); + org.junit.jupiter.api.Assertions.assertTrue(replaySql.contains("UNIQUE KEY uk_tms_replay_nonce_scope_principal_nonce (scope, principal_id, nonce)")); + org.junit.jupiter.api.Assertions.assertTrue(replaySql.contains("KEY idx_tms_replay_nonce_expires_at (expires_at)")); } } diff --git a/src/test/java/com/cisd/tms/modules/security/replay/ReplayNonceRepositoryTest.java b/src/test/java/com/cisd/tms/modules/security/replay/ReplayNonceRepositoryTest.java new file mode 100644 index 0000000..02bdd12 --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/security/replay/ReplayNonceRepositoryTest.java @@ -0,0 +1,134 @@ +package com.cisd.tms.modules.security.replay; + +import com.cisd.tms.modules.security.replay.entity.ReplayNonceEntity; +import com.cisd.tms.modules.security.replay.entity.SecurityEventEntity; +import com.cisd.tms.modules.security.replay.mapper.ReplayNonceMapper; +import com.cisd.tms.modules.security.replay.mapper.SecurityEventMapper; +import com.cisd.tms.modules.security.replay.repository.ReplayNonceRepository; +import com.cisd.tms.modules.security.replay.repository.SecurityEventRepository; +import com.cisd.tms.modules.security.replay.repository.impl.ReplayNonceRepositoryImpl; +import com.cisd.tms.modules.security.replay.repository.impl.SecurityEventRepositoryImpl; +import java.time.LocalDateTime; +import java.lang.reflect.Method; +import java.util.Optional; +import org.apache.ibatis.annotations.Delete; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.apache.ibatis.annotations.Select; + +class ReplayNonceRepositoryTest { + + @Test + void shouldSaveNonceAndLookupExistingNonce() { + ReplayNonceMapper mapper = Mockito.mock(ReplayNonceMapper.class); + ReplayNonceRepository repository = new ReplayNonceRepositoryImpl(mapper); + ReplayNonceEntity entity = replayNonce("OPENAPI", "demo-app", "nonce-001"); + Mockito.when(mapper.selectByScopeAndPrincipalIdAndNonce("OPENAPI", "demo-app", "nonce-001")) + .thenReturn(entity); + + repository.save(entity); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ReplayNonceEntity.class); + Mockito.verify(mapper).insert(captor.capture()); + Assertions.assertEquals("OPENAPI", captor.getValue().getScope()); + Assertions.assertEquals("demo-app", captor.getValue().getPrincipalId()); + Assertions.assertEquals("nonce-001", captor.getValue().getNonce()); + + Optional found = repository.findByScopeAndPrincipalIdAndNonce("OPENAPI", "demo-app", "nonce-001"); + Mockito.verify(mapper).selectByScopeAndPrincipalIdAndNonce("OPENAPI", "demo-app", "nonce-001"); + Assertions.assertTrue(found.isPresent()); + Assertions.assertEquals("nonce-001", found.orElseThrow().getNonce()); + } + + @Test + void shouldSaveSecurityEvent() { + SecurityEventMapper mapper = Mockito.mock(SecurityEventMapper.class); + SecurityEventRepository repository = new SecurityEventRepositoryImpl(mapper); + SecurityEventEntity entity = securityEvent("REPLAY_DETECTED", "INTERNAL_API", "token-001"); + + repository.save(entity); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SecurityEventEntity.class); + Mockito.verify(mapper).insert(captor.capture()); + Assertions.assertEquals("REPLAY_DETECTED", captor.getValue().getEventType()); + Assertions.assertEquals("INTERNAL_API", captor.getValue().getScope()); + Assertions.assertEquals("token-001", captor.getValue().getPrincipalId()); + } + + @Test + void shouldRepresentDuplicateNonceByLookingUpExistingRecord() { + ReplayNonceMapper mapper = Mockito.mock(ReplayNonceMapper.class); + ReplayNonceRepository repository = new ReplayNonceRepositoryImpl(mapper); + ReplayNonceEntity existing = replayNonce("INTERNAL_API", "token-001", "nonce-dup"); + Mockito.when(mapper.selectByScopeAndPrincipalIdAndNonce("INTERNAL_API", "token-001", "nonce-dup")) + .thenReturn(existing); + + Optional duplicate = repository.findByScopeAndPrincipalIdAndNonce( + "INTERNAL_API", "token-001", "nonce-dup"); + + Assertions.assertTrue(duplicate.isPresent()); + Assertions.assertEquals("nonce-dup", duplicate.orElseThrow().getNonce()); + Assertions.assertEquals("token-001", duplicate.orElseThrow().getPrincipalId()); + } + + @Test + void shouldDeclareSqlMappingForNonceLookup() throws NoSuchMethodException { + Method method = ReplayNonceMapper.class.getMethod( + "selectByScopeAndPrincipalIdAndNonce", String.class, String.class, String.class); + + Assertions.assertTrue(method.isAnnotationPresent(Select.class)); + } + + @Test + void shouldDeleteExpiredNoncesBeforeCutoff() { + ReplayNonceMapper mapper = Mockito.mock(ReplayNonceMapper.class); + ReplayNonceRepository repository = new ReplayNonceRepositoryImpl(mapper); + LocalDateTime cutoff = LocalDateTime.of(2026, 4, 9, 10, 30, 0); + Mockito.when(mapper.deleteExpiredBefore(cutoff, 1000)).thenReturn(7); + + int deletedCount = repository.deleteExpiredBefore(cutoff, 1000); + + Mockito.verify(mapper).deleteExpiredBefore(cutoff, 1000); + Assertions.assertEquals(7, deletedCount); + } + + @Test + void shouldDeclareSqlMappingForExpiredNonceDeletion() throws NoSuchMethodException { + Method method = ReplayNonceMapper.class.getMethod( + "deleteExpiredBefore", LocalDateTime.class, int.class); + + Assertions.assertTrue(method.isAnnotationPresent(Delete.class)); + } + + private static ReplayNonceEntity replayNonce(String scope, String principalId, String nonce) { + ReplayNonceEntity entity = new ReplayNonceEntity(); + entity.setScope(scope); + entity.setPrincipalId(principalId); + entity.setNonce(nonce); + entity.setRequestTimestamp(1740000000L); + entity.setRequestMethod("POST"); + entity.setRequestPath("/api/v1/demo"); + entity.setBodyHash("abc123"); + entity.setRemoteIp("127.0.0.1"); + entity.setUserAgent("JUnit"); + entity.setExpiresAt(LocalDateTime.of(2026, 4, 9, 10, 0, 0)); + return entity; + } + + private static SecurityEventEntity securityEvent(String eventType, String scope, String principalId) { + SecurityEventEntity entity = new SecurityEventEntity(); + entity.setEventType(eventType); + entity.setScope(scope); + entity.setPrincipalId(principalId); + entity.setRequestMethod("POST"); + entity.setRequestPath("/api/v1/demo"); + entity.setNonce("nonce-001"); + entity.setRemoteIp("127.0.0.1"); + entity.setDetailJson("{\"reason\":\"replay\"}"); + entity.setCreateTime(LocalDateTime.of(2026, 4, 9, 10, 0, 0)); + entity.setUpdateTime(LocalDateTime.of(2026, 4, 9, 10, 0, 0)); + return entity; + } +} diff --git a/src/test/java/com/cisd/tms/modules/security/replay/job/ReplayNonceCleanupJobTest.java b/src/test/java/com/cisd/tms/modules/security/replay/job/ReplayNonceCleanupJobTest.java new file mode 100644 index 0000000..2f1840f --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/security/replay/job/ReplayNonceCleanupJobTest.java @@ -0,0 +1,27 @@ +package com.cisd.tms.modules.security.replay.job; + +import com.cisd.tms.modules.security.replay.repository.ReplayNonceRepository; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class ReplayNonceCleanupJobTest { + + private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-04-09T10:30:00Z"), ZoneOffset.UTC); + + @Test + void shouldDeleteExpiredNoncesWithCurrentUtcTimeAndBatchLimit() { + ReplayNonceRepository replayNonceRepository = Mockito.mock(ReplayNonceRepository.class); + ReplayNonceCleanupJob cleanupJob = new ReplayNonceCleanupJob(replayNonceRepository, FIXED_CLOCK); + + cleanupJob.cleanupExpiredNonces(); + + Mockito.verify(replayNonceRepository).deleteExpiredBefore( + LocalDateTime.of(2026, 4, 9, 10, 30, 0), + 1000 + ); + } +} diff --git a/src/test/java/com/cisd/tms/modules/security/replay/service/ReplayProtectionServiceTest.java b/src/test/java/com/cisd/tms/modules/security/replay/service/ReplayProtectionServiceTest.java new file mode 100644 index 0000000..a7fc19e --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/security/replay/service/ReplayProtectionServiceTest.java @@ -0,0 +1,283 @@ +package com.cisd.tms.modules.security.replay.service; + +import com.cisd.tms.common.config.properties.TmsSecurityProperties; +import com.cisd.tms.modules.security.replay.dto.ReplayCheckRequest; +import com.cisd.tms.modules.security.replay.dto.ReplayCheckResult; +import com.cisd.tms.modules.security.replay.entity.ReplayNonceEntity; +import com.cisd.tms.modules.security.replay.entity.SecurityEventEntity; +import com.cisd.tms.modules.security.replay.enums.ReplayCheckStatus; +import com.cisd.tms.modules.security.replay.enums.ReplayScope; +import com.cisd.tms.modules.security.replay.enums.SecurityEventType; +import com.cisd.tms.modules.security.replay.exception.ReplayProtectionException; +import com.cisd.tms.modules.security.replay.repository.ReplayNonceRepository; +import com.cisd.tms.modules.security.replay.repository.SecurityEventRepository; +import com.cisd.tms.modules.security.replay.service.impl.ReplayProtectionServiceImpl; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Optional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +class ReplayProtectionServiceTest { + + private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-04-09T10:00:00Z"), ZoneOffset.UTC); + private static final long FIXED_REQUEST_TIMESTAMP = 1775728800L; + private static final long EXPIRED_REQUEST_TIMESTAMP = 1775728440L; + + @Test + void shouldClaimFirstNonceAndPersistRecord() { + ReplayNonceRepository replayNonceRepository = Mockito.mock(ReplayNonceRepository.class); + SecurityEventRepository securityEventRepository = Mockito.mock(SecurityEventRepository.class); + Mockito.when(replayNonceRepository.findByScopeAndPrincipalIdAndNonce("OPENAPI", "demo-app", "nonce-001")) + .thenReturn(Optional.empty()); + Mockito.when(replayNonceRepository.trySave(Mockito.any(ReplayNonceEntity.class))).thenReturn(true); + + ReplayProtectionService service = new ReplayProtectionServiceImpl( + replayNonceRepository, + securityEventRepository, + FIXED_CLOCK + ); + + ReplayCheckRequest request = request(ReplayScope.OPENAPI, "demo-app", "nonce-001", FIXED_REQUEST_TIMESTAMP, + "POST", "/openapi/v1/demo", "abc123"); + + ReplayCheckResult result = service.check(request); + + Assertions.assertEquals(ReplayCheckStatus.CLAIMED, result.getStatus()); + Assertions.assertFalse(result.isReplayed()); + Assertions.assertFalse(result.isMismatch()); + Assertions.assertFalse(result.isSecurityEventRecorded()); + Assertions.assertEquals(LocalDateTime.of(2026, 4, 9, 10, 10), result.getExpiresAt()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ReplayNonceEntity.class); + Mockito.verify(replayNonceRepository).trySave(captor.capture()); + ReplayNonceEntity saved = captor.getValue(); + Assertions.assertEquals("OPENAPI", saved.getScope()); + Assertions.assertEquals("demo-app", saved.getPrincipalId()); + Assertions.assertEquals("nonce-001", saved.getNonce()); + Assertions.assertEquals(FIXED_REQUEST_TIMESTAMP, saved.getRequestTimestamp()); + Assertions.assertEquals("POST", saved.getRequestMethod()); + Assertions.assertEquals("/openapi/v1/demo", saved.getRequestPath()); + Assertions.assertEquals("abc123", saved.getBodyHash()); + Assertions.assertEquals("127.0.0.1", saved.getRemoteIp()); + Assertions.assertEquals("JUnit", saved.getUserAgent()); + Assertions.assertEquals(LocalDateTime.of(2026, 4, 9, 10, 10), saved.getExpiresAt()); + Mockito.verifyNoInteractions(securityEventRepository); + } + + @Test + void shouldReturnReplayResultForDuplicateNonce() { + ReplayNonceRepository replayNonceRepository = Mockito.mock(ReplayNonceRepository.class); + SecurityEventRepository securityEventRepository = Mockito.mock(SecurityEventRepository.class); + ReplayNonceEntity existing = persistedNonce("INTERNAL_API", "session-001", "nonce-dup", + "PUT", "/api/v1/device", "body-1"); + Mockito.when(replayNonceRepository.findByScopeAndPrincipalIdAndNonce("INTERNAL_API", "session-001", "nonce-dup")) + .thenReturn(Optional.of(existing)); + + ReplayProtectionService service = new ReplayProtectionServiceImpl( + replayNonceRepository, + securityEventRepository, + FIXED_CLOCK + ); + + ReplayCheckRequest request = request(ReplayScope.INTERNAL_API, "session-001", "nonce-dup", FIXED_REQUEST_TIMESTAMP, + "PUT", "/api/v1/device", "body-1"); + + ReplayCheckResult result = service.check(request); + + Assertions.assertEquals(ReplayCheckStatus.REPLAYED, result.getStatus()); + Assertions.assertTrue(result.isReplayed()); + Assertions.assertFalse(result.isMismatch()); + Assertions.assertFalse(result.isSecurityEventRecorded()); + Mockito.verify(replayNonceRepository, Mockito.never()).trySave(Mockito.any()); + Mockito.verifyNoInteractions(securityEventRepository); + } + + @Test + void shouldReclaimExpiredExistingNonceRecord() { + ReplayNonceRepository replayNonceRepository = Mockito.mock(ReplayNonceRepository.class); + SecurityEventRepository securityEventRepository = Mockito.mock(SecurityEventRepository.class); + ReplayNonceEntity expired = persistedNonce("OPENAPI", "demo-app", "nonce-expired", + "POST", "/openapi/v1/demo", "abc123"); + expired.setId(1001L); + expired.setExpiresAt(LocalDateTime.of(2026, 4, 9, 9, 59)); + Mockito.when(replayNonceRepository.findByScopeAndPrincipalIdAndNonce("OPENAPI", "demo-app", "nonce-expired")) + .thenReturn(Optional.of(expired), Optional.empty()); + Mockito.when(replayNonceRepository.trySave(Mockito.any(ReplayNonceEntity.class))).thenReturn(true); + + ReplayProtectionService service = new ReplayProtectionServiceImpl( + replayNonceRepository, + securityEventRepository, + FIXED_CLOCK + ); + + ReplayCheckRequest request = request(ReplayScope.OPENAPI, "demo-app", "nonce-expired", FIXED_REQUEST_TIMESTAMP, + "POST", "/openapi/v1/demo", "abc123"); + + ReplayCheckResult result = service.check(request); + + Assertions.assertEquals(ReplayCheckStatus.CLAIMED, result.getStatus()); + Assertions.assertFalse(result.isReplayed()); + Assertions.assertFalse(result.isMismatch()); + Assertions.assertFalse(result.isSecurityEventRecorded()); + Mockito.verify(replayNonceRepository).deleteById(1001L); + Mockito.verify(replayNonceRepository).trySave(Mockito.any(ReplayNonceEntity.class)); + Mockito.verifyNoInteractions(securityEventRepository); + } + + @Test + void shouldMarkMismatchAndRecordSecurityEventForDifferentFingerprint() { + ReplayNonceRepository replayNonceRepository = Mockito.mock(ReplayNonceRepository.class); + SecurityEventRepository securityEventRepository = Mockito.mock(SecurityEventRepository.class); + ReplayNonceEntity existing = persistedNonce("INTERNAL_API", "session-001", "nonce-dup", + "PUT", "/api/v1/device", "body-1"); + Mockito.when(replayNonceRepository.findByScopeAndPrincipalIdAndNonce("INTERNAL_API", "session-001", "nonce-dup")) + .thenReturn(Optional.of(existing)); + Mockito.doNothing().when(securityEventRepository).save(Mockito.any(SecurityEventEntity.class)); + + ReplayProtectionService service = new ReplayProtectionServiceImpl( + replayNonceRepository, + securityEventRepository, + FIXED_CLOCK + ); + + ReplayCheckRequest request = request(ReplayScope.INTERNAL_API, "session-001", "nonce-dup", FIXED_REQUEST_TIMESTAMP, + "PUT", "/api/v1/device/config", "body-2"); + + ReplayCheckResult result = service.check(request); + + Assertions.assertEquals(ReplayCheckStatus.MISMATCH, result.getStatus()); + Assertions.assertTrue(result.isReplayed()); + Assertions.assertTrue(result.isMismatch()); + Assertions.assertTrue(result.isSecurityEventRecorded()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SecurityEventEntity.class); + Mockito.verify(securityEventRepository).save(captor.capture()); + SecurityEventEntity event = captor.getValue(); + Assertions.assertEquals(SecurityEventType.REPLAY_MISMATCH.name(), event.getEventType()); + Assertions.assertEquals("INTERNAL_API", event.getScope()); + Assertions.assertEquals("session-001", event.getPrincipalId()); + Assertions.assertEquals("PUT", event.getRequestMethod()); + Assertions.assertEquals("/api/v1/device/config", event.getRequestPath()); + Assertions.assertEquals("nonce-dup", event.getNonce()); + Assertions.assertEquals("127.0.0.1", event.getRemoteIp()); + } + + @Test + void shouldRejectExpiredTimestampBeforeClaim() { + ReplayNonceRepository replayNonceRepository = Mockito.mock(ReplayNonceRepository.class); + SecurityEventRepository securityEventRepository = Mockito.mock(SecurityEventRepository.class); + ReplayProtectionService service = new ReplayProtectionServiceImpl( + replayNonceRepository, + securityEventRepository, + FIXED_CLOCK + ); + + ReplayCheckRequest request = request(ReplayScope.OPENAPI, "demo-app", "nonce-old", EXPIRED_REQUEST_TIMESTAMP, + "POST", "/openapi/v1/demo", "abc123"); + + Assertions.assertThrows(ReplayProtectionException.class, () -> service.check(request)); + Mockito.verifyNoInteractions(replayNonceRepository); + Mockito.verifyNoInteractions(securityEventRepository); + } + + @Test + void shouldFailClosedWhenClaimPersistenceFails() { + ReplayNonceRepository replayNonceRepository = Mockito.mock(ReplayNonceRepository.class); + SecurityEventRepository securityEventRepository = Mockito.mock(SecurityEventRepository.class); + Mockito.when(replayNonceRepository.findByScopeAndPrincipalIdAndNonce("OPENAPI", "demo-app", "nonce-001")) + .thenReturn(Optional.empty()); + Mockito.when(replayNonceRepository.trySave(Mockito.any(ReplayNonceEntity.class))) + .thenThrow(new IllegalStateException("db down")); + + ReplayProtectionService service = new ReplayProtectionServiceImpl( + replayNonceRepository, + securityEventRepository, + FIXED_CLOCK + ); + + ReplayCheckRequest request = request(ReplayScope.OPENAPI, "demo-app", "nonce-001", FIXED_REQUEST_TIMESTAMP, + "POST", "/openapi/v1/demo", "abc123"); + + Assertions.assertThrows(ReplayProtectionException.class, () -> service.check(request)); + } + + @Test + void shouldHonorConfiguredOpenApiSkewWindow() { + ReplayNonceRepository replayNonceRepository = Mockito.mock(ReplayNonceRepository.class); + SecurityEventRepository securityEventRepository = Mockito.mock(SecurityEventRepository.class); + Mockito.when(replayNonceRepository.findByScopeAndPrincipalIdAndNonce("OPENAPI", "demo-app", "nonce-600")) + .thenReturn(Optional.empty()); + Mockito.when(replayNonceRepository.trySave(Mockito.any(ReplayNonceEntity.class))).thenReturn(true); + TmsSecurityProperties securityProperties = securityProperties(600L); + + ReplayProtectionService service = new ReplayProtectionServiceImpl( + replayNonceRepository, + securityEventRepository, + securityProperties, + FIXED_CLOCK + ); + + ReplayCheckRequest request = request(ReplayScope.OPENAPI, "demo-app", "nonce-600", FIXED_REQUEST_TIMESTAMP - 500L, + "POST", "/openapi/v1/demo", "abc123"); + + ReplayCheckResult result = service.check(request); + + Assertions.assertEquals(ReplayCheckStatus.CLAIMED, result.getStatus()); + Assertions.assertEquals(LocalDateTime.of(2026, 4, 9, 10, 20), result.getExpiresAt()); + } + + private static ReplayCheckRequest request( + ReplayScope scope, + String principalId, + String nonce, + long requestTimestampSeconds, + String requestMethod, + String requestPath, + String bodyHash + ) { + ReplayCheckRequest request = new ReplayCheckRequest(); + request.setScope(scope); + request.setPrincipalId(principalId); + request.setNonce(nonce); + request.setRequestTimestamp(requestTimestampSeconds); + request.setRequestMethod(requestMethod); + request.setRequestPath(requestPath); + request.setBodyHash(bodyHash); + request.setRemoteIp("127.0.0.1"); + request.setUserAgent("JUnit"); + return request; + } + + private static ReplayNonceEntity persistedNonce( + String scope, + String principalId, + String nonce, + String requestMethod, + String requestPath, + String bodyHash + ) { + ReplayNonceEntity entity = new ReplayNonceEntity(); + entity.setScope(scope); + entity.setPrincipalId(principalId); + entity.setNonce(nonce); + entity.setRequestTimestamp(FIXED_REQUEST_TIMESTAMP); + entity.setRequestMethod(requestMethod); + entity.setRequestPath(requestPath); + entity.setBodyHash(bodyHash); + entity.setRemoteIp("127.0.0.1"); + entity.setUserAgent("JUnit"); + entity.setExpiresAt(LocalDateTime.of(2026, 4, 9, 10, 10)); + return entity; + } + + private static TmsSecurityProperties securityProperties(long openApiSkewSeconds) { + TmsSecurityProperties securityProperties = new TmsSecurityProperties(); + securityProperties.getOpenapi().setTimestampSkewSeconds(openApiSkewSeconds); + return securityProperties; + } +} diff --git a/src/test/java/com/cisd/tms/security/internal/InternalApiAuthInterceptorTest.java b/src/test/java/com/cisd/tms/security/internal/InternalApiAuthInterceptorTest.java deleted file mode 100644 index 220da9a..0000000 --- a/src/test/java/com/cisd/tms/security/internal/InternalApiAuthInterceptorTest.java +++ /dev/null @@ -1,153 +0,0 @@ -package com.cisd.tms.security.internal; - -import com.cisd.tms.common.config.properties.TmsSecurityProperties; -import com.cisd.tms.modules.auth.entity.AuthSessionEntity; -import com.cisd.tms.modules.auth.repository.AuthSessionRepository; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.time.Clock; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; - -class InternalApiAuthInterceptorTest { - - private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-03-23T02:00:00Z"), ZoneOffset.UTC); - - @Test - void shouldRejectRequestWhenSessionTokenIsMissing() throws Exception { - InternalApiAuthInterceptor interceptor = new InternalApiAuthInterceptor( - new InMemoryAuthSessionRepository(), - new ObjectMapper(), - new TmsSecurityProperties(), - FIXED_CLOCK - ); - MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/device/info"); - MockHttpServletResponse response = new MockHttpServletResponse(); - - boolean allowed = interceptor.preHandle(request, response, new Object()); - - Assertions.assertFalse(allowed); - Assertions.assertEquals(401, response.getStatus()); - Assertions.assertTrue(response.getContentAsString().contains("session token missing")); - } - - @Test - void shouldRejectRequestWhenSessionHasExpiredByIdleTimeout() throws Exception { - InMemoryAuthSessionRepository sessions = new InMemoryAuthSessionRepository(); - sessions.save(session("token-expired", "KEY_ADMIN", "FULL", - LocalDateTime.of(2026, 3, 23, 1, 45), - LocalDateTime.of(2026, 3, 23, 1, 49), - LocalDateTime.of(2026, 3, 23, 1, 59))); - - InternalApiAuthInterceptor interceptor = new InternalApiAuthInterceptor( - sessions, - new ObjectMapper(), - new TmsSecurityProperties(), - FIXED_CLOCK - ); - MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/device/info"); - request.addHeader("Authorization", "Bearer token-expired"); - MockHttpServletResponse response = new MockHttpServletResponse(); - - boolean allowed = interceptor.preHandle(request, response, new Object()); - - Assertions.assertFalse(allowed); - Assertions.assertEquals(401, response.getStatus()); - Assertions.assertTrue(response.getContentAsString().contains("session expired")); - } - - @Test - void shouldAllowRequestAndRefreshLastActiveAtForValidSession() throws Exception { - InMemoryAuthSessionRepository sessions = new InMemoryAuthSessionRepository(); - sessions.save(session("token-valid", "AUDIT_ADMIN", "LIMITED", - LocalDateTime.of(2026, 3, 23, 1, 55), - LocalDateTime.of(2026, 3, 23, 1, 58), - LocalDateTime.of(2026, 3, 23, 2, 8))); - - InternalApiAuthInterceptor interceptor = new InternalApiAuthInterceptor( - sessions, - new ObjectMapper(), - new TmsSecurityProperties(), - FIXED_CLOCK - ); - MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/device/info"); - request.addHeader("Authorization", "Bearer token-valid"); - MockHttpServletResponse response = new MockHttpServletResponse(); - - boolean allowed = interceptor.preHandle(request, response, new Object()); - - Assertions.assertTrue(allowed); - Assertions.assertEquals("AUDIT_ADMIN", request.getAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE)); - Assertions.assertEquals("LIMITED", request.getAttribute(InternalApiAuthInterceptor.ATTR_AUTH_LEVEL)); - AuthSessionEntity updated = sessions.findBySessionToken("token-valid").orElseThrow(); - Assertions.assertEquals(LocalDateTime.of(2026, 3, 23, 2, 0), updated.getLastActiveAt()); - Assertions.assertEquals(LocalDateTime.of(2026, 3, 23, 2, 10), updated.getExpiresAt()); - } - - @Test - void shouldBypassSessionValidationWhenInternalAuthIsDisabled() throws Exception { - TmsSecurityProperties securityProperties = new TmsSecurityProperties(); - securityProperties.getInternalAuth().setEnabled(false); - - InternalApiAuthInterceptor interceptor = new InternalApiAuthInterceptor( - new InMemoryAuthSessionRepository(), - new ObjectMapper(), - securityProperties, - FIXED_CLOCK - ); - MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/device/info"); - MockHttpServletResponse response = new MockHttpServletResponse(); - - boolean allowed = interceptor.preHandle(request, response, new Object()); - - Assertions.assertTrue(allowed); - Assertions.assertEquals("KEY_ADMIN", request.getAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE)); - Assertions.assertEquals("FULL", request.getAttribute(InternalApiAuthInterceptor.ATTR_AUTH_LEVEL)); - Assertions.assertEquals("DEBUG-BYPASS", request.getAttribute(InternalApiAuthInterceptor.ATTR_SESSION_TOKEN)); - } - - private static AuthSessionEntity session( - String token, - String roleCode, - String authLevel, - LocalDateTime issuedAt, - LocalDateTime lastActiveAt, - LocalDateTime expiresAt - ) { - AuthSessionEntity entity = new AuthSessionEntity(); - entity.setId((long) (token.hashCode() & Integer.MAX_VALUE)); - entity.setSessionToken(token); - entity.setRoleCode(roleCode); - entity.setAuthLevel(authLevel); - entity.setIssuedAt(issuedAt); - entity.setLastActiveAt(lastActiveAt); - entity.setExpiresAt(expiresAt); - return entity; - } - - private static class InMemoryAuthSessionRepository implements AuthSessionRepository { - private final Map store = new ConcurrentHashMap<>(); - - @Override - public Optional findBySessionToken(String sessionToken) { - return Optional.ofNullable(store.get(sessionToken)); - } - - @Override - public void save(AuthSessionEntity entity) { - store.put(entity.getSessionToken(), entity); - } - - @Override - public void update(AuthSessionEntity entity) { - store.put(entity.getSessionToken(), entity); - } - } -} diff --git a/src/test/java/com/cisd/tms/security/internal/InternalApiReplayInterceptorTest.java b/src/test/java/com/cisd/tms/security/internal/InternalApiReplayInterceptorTest.java new file mode 100644 index 0000000..6583a71 --- /dev/null +++ b/src/test/java/com/cisd/tms/security/internal/InternalApiReplayInterceptorTest.java @@ -0,0 +1,180 @@ +package com.cisd.tms.security.internal; + +import com.cisd.tms.modules.security.replay.dto.ReplayCheckRequest; +import com.cisd.tms.modules.security.replay.dto.ReplayCheckResult; +import com.cisd.tms.modules.security.replay.enums.ReplayCheckStatus; +import com.cisd.tms.modules.security.replay.enums.ReplayScope; +import com.cisd.tms.modules.security.replay.exception.ReplayProtectionException; +import com.cisd.tms.modules.security.replay.service.ReplayProtectionService; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.method.HandlerMethod; + +class InternalApiReplayInterceptorTest { + + private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-04-09T10:00:00Z"), ZoneOffset.UTC); + private static final long FIXED_REQUEST_TIMESTAMP = 1775728800L; + + @Test + void shouldAllowReplayProtectedRequestAndHashBody() throws Exception { + ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class); + Mockito.when(replayProtectionService.check(Mockito.any(ReplayCheckRequest.class))).thenReturn(claimedResult()); + InternalApiReplayInterceptor interceptor = new InternalApiReplayInterceptor( + replayProtectionService, + new ObjectMapper(), + FIXED_CLOCK + ); + MockHttpServletRequest sourceRequest = new MockHttpServletRequest("POST", "/api/v1/init/tasks/task-001/execute"); + sourceRequest.setContentType("application/json"); + sourceRequest.setContent("{\"taskId\":\"task-001\"}".getBytes(StandardCharsets.UTF_8)); + sourceRequest.addHeader("X-Request-Timestamp", Long.toString(FIXED_REQUEST_TIMESTAMP)); + sourceRequest.addHeader("X-Request-Nonce", "nonce-001"); + sourceRequest.addHeader("User-Agent", "JUnit"); + sourceRequest.setRemoteAddr("127.0.0.1"); + CachedBodyHttpServletRequest request = new CachedBodyHttpServletRequest(sourceRequest); + request.setAttribute(InternalApiAuthInterceptor.ATTR_SESSION_TOKEN, "session-001"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean allowed = interceptor.preHandle(request, response, protectedHandler("execute")); + + Assertions.assertTrue(allowed); + ArgumentCaptor captor = ArgumentCaptor.forClass(ReplayCheckRequest.class); + Mockito.verify(replayProtectionService).check(captor.capture()); + ReplayCheckRequest replayRequest = captor.getValue(); + Assertions.assertEquals(ReplayScope.INTERNAL_API, replayRequest.getScope()); + Assertions.assertEquals("session-001", replayRequest.getPrincipalId()); + Assertions.assertEquals("nonce-001", replayRequest.getNonce()); + Assertions.assertEquals(FIXED_REQUEST_TIMESTAMP, replayRequest.getRequestTimestamp()); + Assertions.assertEquals("POST", replayRequest.getRequestMethod()); + Assertions.assertEquals("/api/v1/init/tasks/task-001/execute", replayRequest.getRequestPath()); + Assertions.assertEquals(sha256Hex("{\"taskId\":\"task-001\"}"), replayRequest.getBodyHash()); + } + + @Test + void shouldSkipHandlersWithoutReplayProtection() throws Exception { + ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class); + InternalApiReplayInterceptor interceptor = new InternalApiReplayInterceptor( + replayProtectionService, + new ObjectMapper(), + FIXED_CLOCK + ); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/device/info"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean allowed = interceptor.preHandle(request, response, protectedHandler("unprotected")); + + Assertions.assertTrue(allowed); + Mockito.verifyNoInteractions(replayProtectionService); + } + + @Test + void shouldRejectProtectedRequestWhenReplayHeadersAreMissing() throws Exception { + ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class); + InternalApiReplayInterceptor interceptor = new InternalApiReplayInterceptor( + replayProtectionService, + new ObjectMapper(), + FIXED_CLOCK + ); + MockHttpServletRequest sourceRequest = new MockHttpServletRequest("POST", "/api/v1/init/tasks/task-001/execute"); + CachedBodyHttpServletRequest request = new CachedBodyHttpServletRequest(sourceRequest); + request.setAttribute(InternalApiAuthInterceptor.ATTR_SESSION_TOKEN, "session-001"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean allowed = interceptor.preHandle(request, response, protectedHandler("execute")); + + Assertions.assertFalse(allowed); + Assertions.assertEquals(400, response.getStatus()); + Assertions.assertTrue(response.getContentAsString().contains("missing replay protection headers")); + Mockito.verifyNoInteractions(replayProtectionService); + } + + @Test + void shouldRejectProtectedRequestWhenReplayIsDetected() throws Exception { + ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class); + ReplayCheckResult replayed = new ReplayCheckResult(); + replayed.setStatus(ReplayCheckStatus.REPLAYED); + replayed.setReplayed(true); + Mockito.when(replayProtectionService.check(Mockito.any(ReplayCheckRequest.class))).thenReturn(replayed); + InternalApiReplayInterceptor interceptor = new InternalApiReplayInterceptor( + replayProtectionService, + new ObjectMapper(), + FIXED_CLOCK + ); + MockHttpServletRequest sourceRequest = new MockHttpServletRequest("POST", "/api/v1/init/tasks/task-001/execute"); + sourceRequest.addHeader("X-Request-Timestamp", Long.toString(FIXED_REQUEST_TIMESTAMP)); + sourceRequest.addHeader("X-Request-Nonce", "nonce-001"); + CachedBodyHttpServletRequest request = new CachedBodyHttpServletRequest(sourceRequest); + request.setAttribute(InternalApiAuthInterceptor.ATTR_SESSION_TOKEN, "session-001"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean allowed = interceptor.preHandle(request, response, protectedHandler("execute")); + + Assertions.assertFalse(allowed); + Assertions.assertEquals(409, response.getStatus()); + Assertions.assertTrue(response.getContentAsString().contains("replayed nonce")); + } + + @Test + void shouldFailClosedWhenReplayProtectionErrors() throws Exception { + ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class); + Mockito.when(replayProtectionService.check(Mockito.any(ReplayCheckRequest.class))) + .thenThrow(new ReplayProtectionException("db down")); + InternalApiReplayInterceptor interceptor = new InternalApiReplayInterceptor( + replayProtectionService, + new ObjectMapper(), + FIXED_CLOCK + ); + MockHttpServletRequest sourceRequest = new MockHttpServletRequest("POST", "/api/v1/init/tasks/task-001/execute"); + sourceRequest.addHeader("X-Request-Timestamp", Long.toString(FIXED_REQUEST_TIMESTAMP)); + sourceRequest.addHeader("X-Request-Nonce", "nonce-001"); + CachedBodyHttpServletRequest request = new CachedBodyHttpServletRequest(sourceRequest); + request.setAttribute(InternalApiAuthInterceptor.ATTR_SESSION_TOKEN, "session-001"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean allowed = interceptor.preHandle(request, response, protectedHandler("execute")); + + Assertions.assertFalse(allowed); + Assertions.assertEquals(503, response.getStatus()); + Assertions.assertTrue(response.getContentAsString().contains("replay protection unavailable")); + } + + private static HandlerMethod protectedHandler(String methodName) throws NoSuchMethodException { + return new HandlerMethod(new ReplayProtectedController(), ReplayProtectedController.class.getMethod(methodName)); + } + + private static ReplayCheckResult claimedResult() { + ReplayCheckResult result = new ReplayCheckResult(); + result.setStatus(ReplayCheckStatus.CLAIMED); + result.setReplayed(false); + return result; + } + + private static String sha256Hex(String value) throws Exception { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder builder = new StringBuilder(digest.length * 2); + for (byte b : digest) { + builder.append(String.format("%02x", b)); + } + return builder.toString(); + } + + static class ReplayProtectedController { + + @ReplayProtected + public void execute() { + } + + public void unprotected() { + } + } +} diff --git a/src/test/java/com/cisd/tms/security/internal/ReplayProtectedEndpointsTest.java b/src/test/java/com/cisd/tms/security/internal/ReplayProtectedEndpointsTest.java new file mode 100644 index 0000000..f7524a2 --- /dev/null +++ b/src/test/java/com/cisd/tms/security/internal/ReplayProtectedEndpointsTest.java @@ -0,0 +1,112 @@ +package com.cisd.tms.security.internal; + +import com.cisd.tms.modules.auth.controller.AuthAdminController; +import com.cisd.tms.modules.auth.controller.AuthController; +import com.cisd.tms.modules.device.controller.CryptoCardController; +import com.cisd.tms.modules.device.controller.DeviceController; +import com.cisd.tms.modules.device.controller.IpWhitelistController; +import com.cisd.tms.modules.device.controller.NetworkConfigController; +import com.cisd.tms.modules.device.controller.TimeConfigController; +import com.cisd.tms.modules.init.controller.InitController; +import com.cisd.tms.modules.mk.controller.LmkController; +import com.cisd.tms.modules.upgrade.controller.UpgradeController; +import java.lang.reflect.Method; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.core.annotation.AnnotatedElementUtils; + +class ReplayProtectedEndpointsTest { + + @Test + void shouldProtectSelectedInitActions() throws Exception { + Assertions.assertNotNull(annotation(InitController.class, "createTask", com.cisd.tms.modules.init.dto.InitPreviewRequest.class)); + Assertions.assertNotNull(annotation(InitController.class, "executeTask", String.class)); + Assertions.assertNotNull(annotation(InitController.class, "createResetTask")); + Assertions.assertNotNull(annotation(InitController.class, "executeResetTask", String.class)); + } + + @Test + void shouldProtectSelectedUpgradeActions() throws Exception { + Assertions.assertNotNull(annotation(UpgradeController.class, "uploadPackage", org.springframework.web.multipart.MultipartFile.class)); + Assertions.assertNotNull(annotation(UpgradeController.class, "create", com.cisd.tms.modules.upgrade.dto.UpgradeCreateTaskRequest.class)); + Assertions.assertNotNull(annotation(UpgradeController.class, "execute", String.class)); + Assertions.assertNotNull(annotation(UpgradeController.class, "rollback", String.class)); + } + + @Test + void shouldProtectSensitiveControllersAndMethods() throws Exception { + Assertions.assertNotNull(annotation(AuthController.class, "changePassword", + com.cisd.tms.modules.auth.dto.ChangePasswordRequest.class, jakarta.servlet.http.HttpServletRequest.class)); + Assertions.assertNotNull(annotation(DeviceController.class, "restart", jakarta.servlet.http.HttpServletRequest.class)); + + Assertions.assertNotNull(AnnotatedElementUtils.findMergedAnnotation(AuthAdminController.class, ReplayProtected.class)); + Assertions.assertNotNull(AnnotatedElementUtils.findMergedAnnotation(TimeConfigController.class, ReplayProtected.class)); + Assertions.assertNotNull(AnnotatedElementUtils.findMergedAnnotation(CryptoCardController.class, ReplayProtected.class)); + } + + @Test + void shouldOnlyProtectWriteActionsOnMixedReadWriteControllers() throws Exception { + Assertions.assertNull(AnnotatedElementUtils.findMergedAnnotation(NetworkConfigController.class, ReplayProtected.class)); + Assertions.assertNull(AnnotatedElementUtils.findMergedAnnotation(IpWhitelistController.class, ReplayProtected.class)); + Assertions.assertNull(AnnotatedElementUtils.findMergedAnnotation(LmkController.class, ReplayProtected.class)); + + Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "setIpv4Config", + com.cisd.tms.modules.device.dto.network.Ipv4ConfigRequest.class)); + Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "setIpv6Config", + com.cisd.tms.modules.device.dto.network.Ipv6ConfigRequest.class)); + Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "createBond", + com.cisd.tms.modules.device.dto.network.BondCreateRequest.class)); + Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "deleteBond", String.class)); + Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "addSlavesTOBond", + com.cisd.tms.modules.device.dto.network.BondAddSlavesRequest.class)); + Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "removeSlaveFromBond", + com.cisd.tms.modules.device.dto.network.BondRemoveSlaveRequest.class)); + Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "setBondMode", + com.cisd.tms.modules.device.dto.network.BondModifyModeRequest.class)); + Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "setDefaultRoute", + com.cisd.tms.modules.device.dto.network.SetDefaultRouteRequest.class)); + Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "addStaticRoute", + com.cisd.tms.modules.device.dto.network.AddStaticRouteRequest.class)); + Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "deleteDefaultRoute", + com.cisd.tms.modules.device.dto.network.DeleteDefaultRouteRequest.class)); + Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "deleteStaticRoute", + com.cisd.tms.modules.device.dto.network.DeleteStaticRouteRequest.class)); + + Assertions.assertNull(effectiveAnnotation(NetworkConfigController.class, "getNetworkInfo")); + Assertions.assertNull(effectiveAnnotation(NetworkConfigController.class, "getIpv4Info", String.class)); + Assertions.assertNull(effectiveAnnotation(NetworkConfigController.class, "getIpv6Info", String.class)); + Assertions.assertNull(effectiveAnnotation(NetworkConfigController.class, "getAllBondName")); + Assertions.assertNull(effectiveAnnotation(NetworkConfigController.class, "getBondSlaves", String.class)); + Assertions.assertNull(effectiveAnnotation(NetworkConfigController.class, "getBondMode", String.class)); + Assertions.assertNull(effectiveAnnotation(NetworkConfigController.class, "getRoutingTable")); + + Assertions.assertNotNull(effectiveAnnotation(IpWhitelistController.class, "addWhitelist", + com.cisd.tms.modules.device.dto.network.IpWhitelistRequest.class)); + Assertions.assertNotNull(effectiveAnnotation(IpWhitelistController.class, "updateWhitelist", + com.cisd.tms.modules.device.dto.network.IpWhitelistRequest.class)); + Assertions.assertNotNull(effectiveAnnotation(IpWhitelistController.class, "deleteWhitelist", Long.class)); + Assertions.assertNull(effectiveAnnotation(IpWhitelistController.class, "getWhitelistPage", + com.cisd.tms.modules.device.dto.network.IpWhitelistRequest.class)); + + Assertions.assertNotNull(effectiveAnnotation(LmkController.class, "initMasterKey")); + Assertions.assertNotNull(effectiveAnnotation(LmkController.class, "destroyMasterKey")); + Assertions.assertNotNull(effectiveAnnotation(LmkController.class, "recoverLmkEx", + com.cisd.tms.modules.mk.dto.MasterKeyRecoverPacketsRequest.class)); + Assertions.assertNull(effectiveAnnotation(LmkController.class, "queryMasterKeyStatus")); + Assertions.assertNull(effectiveAnnotation(LmkController.class, "signIk", String.class)); + } + + private static ReplayProtected annotation(Class type, String methodName, Class... parameterTypes) throws Exception { + Method method = type.getMethod(methodName, parameterTypes); + return AnnotatedElementUtils.findMergedAnnotation(method, ReplayProtected.class); + } + + private static ReplayProtected effectiveAnnotation(Class type, String methodName, Class... parameterTypes) throws Exception { + Method method = type.getMethod(methodName, parameterTypes); + ReplayProtected methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, ReplayProtected.class); + if (methodAnnotation != null) { + return methodAnnotation; + } + return AnnotatedElementUtils.findMergedAnnotation(type, ReplayProtected.class); + } +} diff --git a/src/test/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptorTest.java b/src/test/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptorTest.java new file mode 100644 index 0000000..f0a6dad --- /dev/null +++ b/src/test/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptorTest.java @@ -0,0 +1,183 @@ +package com.cisd.tms.security.openapi; + +import com.cisd.tms.common.config.properties.TmsSecurityProperties; +import com.cisd.tms.common.util.HmacUtil; +import com.cisd.tms.modules.security.replay.dto.ReplayCheckRequest; +import com.cisd.tms.modules.security.replay.dto.ReplayCheckResult; +import com.cisd.tms.modules.security.replay.enums.ReplayCheckStatus; +import com.cisd.tms.modules.security.replay.enums.ReplayScope; +import com.cisd.tms.modules.security.replay.exception.ReplayProtectionException; +import com.cisd.tms.modules.security.replay.service.ReplayProtectionService; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +class OpenApiSignAuthInterceptorTest { + + private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-04-09T10:00:00Z"), ZoneOffset.UTC); + private static final long FIXED_NOW_SECONDS = 1775728800L; + + @Test + void shouldPassConfiguredSkewToReplayProtectionServiceAfterSignatureValidation() throws Exception { + TmsSecurityProperties securityProperties = securityProperties("demo-app", "demo-secret", 600L); + ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class); + Mockito.when(replayProtectionService.check(Mockito.any(ReplayCheckRequest.class))) + .thenReturn(claimedResult()); + OpenApiSignAuthInterceptor interceptor = new OpenApiSignAuthInterceptor( + securityProperties, + replayProtectionService, + new ObjectMapper(), + FIXED_CLOCK + ); + MockHttpServletRequest request = signedRequest( + "demo-app", + "demo-secret", + FIXED_NOW_SECONDS - 500L, + "nonce-001", + "POST", + "/openapi/v1/demo" + ); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean allowed = interceptor.preHandle(request, response, new Object()); + + Assertions.assertTrue(allowed); + ArgumentCaptor captor = ArgumentCaptor.forClass(ReplayCheckRequest.class); + Mockito.verify(replayProtectionService).check(captor.capture()); + ReplayCheckRequest replayRequest = captor.getValue(); + Assertions.assertEquals(ReplayScope.OPENAPI, replayRequest.getScope()); + Assertions.assertEquals("demo-app", replayRequest.getPrincipalId()); + Assertions.assertEquals("nonce-001", replayRequest.getNonce()); + Assertions.assertEquals(FIXED_NOW_SECONDS - 500L, replayRequest.getRequestTimestamp()); + Assertions.assertEquals("POST", replayRequest.getRequestMethod()); + Assertions.assertEquals("/openapi/v1/demo", replayRequest.getRequestPath()); + } + + @Test + void shouldRejectInvalidSignatureWithoutCallingReplayProtectionService() throws Exception { + TmsSecurityProperties securityProperties = securityProperties("demo-app", "demo-secret", 300L); + ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class); + OpenApiSignAuthInterceptor interceptor = new OpenApiSignAuthInterceptor( + securityProperties, + replayProtectionService, + new ObjectMapper(), + FIXED_CLOCK + ); + MockHttpServletRequest request = signedRequest( + "demo-app", + "wrong-secret", + FIXED_NOW_SECONDS, + "nonce-001", + "POST", + "/openapi/v1/demo" + ); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean allowed = interceptor.preHandle(request, response, new Object()); + + Assertions.assertFalse(allowed); + Assertions.assertEquals(401, response.getStatus()); + Assertions.assertTrue(response.getContentAsString().contains("signature invalid")); + Mockito.verifyNoInteractions(replayProtectionService); + } + + @Test + void shouldRejectWhenReplayProtectionReportsReplay() throws Exception { + TmsSecurityProperties securityProperties = securityProperties("demo-app", "demo-secret", 300L); + ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class); + ReplayCheckResult replayed = new ReplayCheckResult(); + replayed.setStatus(ReplayCheckStatus.REPLAYED); + replayed.setReplayed(true); + Mockito.when(replayProtectionService.check(Mockito.any(ReplayCheckRequest.class))).thenReturn(replayed); + OpenApiSignAuthInterceptor interceptor = new OpenApiSignAuthInterceptor( + securityProperties, + replayProtectionService, + new ObjectMapper(), + FIXED_CLOCK + ); + MockHttpServletRequest request = signedRequest( + "demo-app", + "demo-secret", + FIXED_NOW_SECONDS, + "nonce-001", + "POST", + "/openapi/v1/demo" + ); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean allowed = interceptor.preHandle(request, response, new Object()); + + Assertions.assertFalse(allowed); + Assertions.assertEquals(401, response.getStatus()); + Assertions.assertTrue(response.getContentAsString().contains("replayed nonce")); + } + + @Test + void shouldFailClosedWhenReplayProtectionServiceErrors() throws Exception { + TmsSecurityProperties securityProperties = securityProperties("demo-app", "demo-secret", 300L); + ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class); + Mockito.when(replayProtectionService.check(Mockito.any(ReplayCheckRequest.class))) + .thenThrow(new ReplayProtectionException("db down")); + OpenApiSignAuthInterceptor interceptor = new OpenApiSignAuthInterceptor( + securityProperties, + replayProtectionService, + new ObjectMapper(), + FIXED_CLOCK + ); + MockHttpServletRequest request = signedRequest( + "demo-app", + "demo-secret", + FIXED_NOW_SECONDS, + "nonce-001", + "POST", + "/openapi/v1/demo" + ); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean allowed = interceptor.preHandle(request, response, new Object()); + + Assertions.assertFalse(allowed); + Assertions.assertEquals(401, response.getStatus()); + Assertions.assertTrue(response.getContentAsString().contains("replay protection unavailable")); + } + + private static TmsSecurityProperties securityProperties(String appId, String secret, long skewSeconds) { + TmsSecurityProperties securityProperties = new TmsSecurityProperties(); + securityProperties.getOpenapi().setTimestampSkewSeconds(skewSeconds); + securityProperties.getOpenapi().getClients().put(appId, secret); + return securityProperties; + } + + private static MockHttpServletRequest signedRequest( + String appId, + String secret, + long timestamp, + String nonce, + String method, + String path + ) { + MockHttpServletRequest request = new MockHttpServletRequest(method, path); + request.addHeader("X-App-Id", appId); + request.addHeader("X-Timestamp", Long.toString(timestamp)); + request.addHeader("X-Nonce", nonce); + request.addHeader("X-Signature", HmacUtil.hmacSha256Hex(secret, appId + "\n" + timestamp + "\n" + nonce)); + request.addHeader("User-Agent", "JUnit"); + request.setRemoteAddr("127.0.0.1"); + return request; + } + + private static ReplayCheckResult claimedResult() { + ReplayCheckResult result = new ReplayCheckResult(); + result.setStatus(ReplayCheckStatus.CLAIMED); + result.setReplayed(false); + result.setMismatch(false); + return result; + } +}