解决网络配置接口冲突

This commit is contained in:
xydkj
2026-04-09 17:46:43 +08:00
66 changed files with 2818 additions and 258 deletions
@@ -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) {
@@ -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/**");
@@ -3,6 +3,7 @@ package com.cisd.tms.common.exception;
import com.cisd.tms.common.api.ApiResponse;
import com.cisd.tms.common.enums.ErrorCode;
import com.cisd.tms.integration.crypto.pcie.PcieCryptoException;
import com.cisd.tms.modules.security.replay.exception.ReplayProtectionException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import org.slf4j.Logger;
@@ -11,6 +12,7 @@ import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
@RestControllerAdvice
public class GlobalExceptionHandler {
@@ -45,12 +47,25 @@ public class GlobalExceptionHandler {
return ApiResponse.fail(ErrorCode.VALIDATE_FAILED.getCode(), ex.getMessage()).withPath(request.getRequestURI());
}
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ApiResponse<Void> 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<Void> handleCryptoCardException(PcieCryptoException ex, HttpServletRequest request) {
return ApiResponse.fail(ErrorCode.CRYPTO_CARD_ERROR.getCode(), ex.getMessage())
.withPath(request.getRequestURI());
}
@ExceptionHandler(ReplayProtectionException.class)
public ApiResponse<Void> handleReplayProtectionException(ReplayProtectionException ex, HttpServletRequest request) {
log.warn("Replay protection failed", ex);
return ApiResponse.fail(ErrorCode.INTERNAL_ERROR.getCode(), "replay protection unavailable")
.withPath(request.getRequestURI());
}
@ExceptionHandler(Exception.class)
public ApiResponse<Void> handleException(Exception ex, HttpServletRequest request) {
log.error("Unhandled exception", ex);
@@ -8,6 +8,7 @@ import com.cisd.tms.modules.auth.service.AuthAdminService;
import com.cisd.tms.modules.mk.dto.UKeySignDTO;
import com.cisd.tms.modules.mk.dto.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 {
@@ -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<Void> changePassword(@Valid @RequestBody ChangePasswordRequest request, HttpServletRequest httpRequest) {
authService.changePassword(
(String) httpRequest.getAttribute(InternalApiAuthInterceptor.ATTR_SESSION_TOKEN),
@@ -36,6 +36,7 @@ import com.cisd.tms.integration.crypto.pcie.model.SymmetricKekCryptoRequest;
import com.cisd.tms.integration.crypto.pcie.model.StandardKeyPairResult;
import com.cisd.tms.integration.crypto.pcie.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 {
@@ -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<DeviceActionResponse> restart(HttpServletRequest request) {
String operator = (String) request.getAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE);
return ApiResponse.success(deviceService.restart(operator));
@@ -5,6 +5,7 @@ import com.cisd.tms.common.api.ApiResponse;
import com.cisd.tms.modules.device.dto.network.IpWhitelistResponse;
import com.cisd.tms.modules.device.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<Void> 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<Void> 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<Void> deleteWhitelist(@PathVariable Long id) {
ipWhitelistService.deleteWhitelist(id);
return ApiResponse.success();
@@ -49,4 +53,4 @@ public class IpWhitelistController {
IPage<IpWhitelistResponse> page = ipWhitelistService.getWhitelistPage(pageNum, pageSize);
return ApiResponse.success(page);
}
}
}
@@ -4,6 +4,7 @@ package com.cisd.tms.modules.device.controller;
import com.cisd.tms.common.api.ApiResponse;
import com.cisd.tms.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<Void> setIpv4Config(@RequestBody Ipv4ConfigRequest req) {
networkConfigService.setIpv4Config(req);
return ApiResponse.success();
@@ -50,6 +52,7 @@ public class NetworkConfigController {
@Operation(summary = "设置IPv6配置", description = "为指定网络接口配置IPv6地址、前缀长度、网关等信息")
@PostMapping("/ipv6-config/set")
@ReplayProtected
public ApiResponse<Void> setIpv6Config(@RequestBody Ipv6ConfigRequest req) {
networkConfigService.setIpv6Config(req);
return ApiResponse.success();
@@ -63,6 +66,7 @@ public class NetworkConfigController {
@Operation(summary = "创建Bond", description = "创建一个新的Bond聚合接口,需指定名称、模式和从属接口")
@PostMapping("/bond/create")
@ReplayProtected
public ApiResponse<Void> createBond(@RequestBody BondCreateRequest req) {
networkConfigService.createBond(req);
return ApiResponse.success();
@@ -71,6 +75,7 @@ public class NetworkConfigController {
@Operation(summary = "删除Bond", description = "根据Bond名称删除指定的聚合接口")
@PostMapping("/bond/delete/{bondName}")
@ReplayProtected
public ApiResponse<Void> deleteBond(@PathVariable("bondName") String bondName){
networkConfigService.deleteBond(bondName);
return ApiResponse.success();
@@ -78,6 +83,7 @@ public class NetworkConfigController {
@Operation(summary = "添加从属接口到Bond", description = "向指定Bond中添加一个或多个从属网络接口")
@PostMapping("/bond-slave/add")
@ReplayProtected
public ApiResponse<Void> addSlavesTOBond(@RequestBody BondAddSlavesRequest req) {
networkConfigService.addSlavesTOBond(req);
return ApiResponse.success();
@@ -85,6 +91,7 @@ public class NetworkConfigController {
@Operation(summary = "从Bond中移除从属接口", description = "从指定Bond中移除一个或多个从属网络接口")
@PostMapping("/bond-slave/remove")
@ReplayProtected
public ApiResponse<String> removeSlaveFromBond(@RequestBody BondRemoveSlaveRequest req) {
String resultMessage = networkConfigService.removeSlaveFromBond(req);
return ApiResponse.success(resultMessage);
@@ -112,8 +119,8 @@ public class NetworkConfigController {
@Operation(summary = "设置Bond模式", description = "修改指定Bond的绑定模式")
@PostMapping("/bond/mode/set")
@ReplayProtected
public ApiResponse<Void> setBondMode(@RequestBody BondModifyModeRequest req) {
networkConfigService.setBondMode(req);
return ApiResponse.success();
}
@@ -127,6 +134,7 @@ public class NetworkConfigController {
@Operation(summary = "设置默认路由", description = "配置或修改系统的默认网关路由")
@PostMapping("/routes/default/set")
@ReplayProtected
public ApiResponse<Void> setDefaultRoute(@RequestBody SetDefaultRouteRequest req) {
networkConfigService.setDefaultRoute(req);
return ApiResponse.success();
@@ -134,6 +142,7 @@ public class NetworkConfigController {
@Operation(summary = "添加静态路由", description = "新增一条静态路由规则")
@PostMapping("/routes/static/add")
@ReplayProtected
public ApiResponse<Void> addStaticRoute(@RequestBody AddStaticRouteRequest req) {
networkConfigService.addStaticRoute(req);
return ApiResponse.success();
@@ -141,6 +150,7 @@ public class NetworkConfigController {
@Operation(summary = "删除默认路由", description = "删除指定的默认路由")
@PostMapping("/routes/default/delete")
@ReplayProtected
public ApiResponse<Void> deleteDefaultRoute(@RequestBody DeleteDefaultRouteRequest req) {
networkConfigService.deleteDefaultRoute(req);
return ApiResponse.success();
@@ -148,6 +158,7 @@ public class NetworkConfigController {
@Operation(summary = "删除静态路由", description = "删除指定的静态路由")
@PostMapping("/routes/static/delete")
@ReplayProtected
public ApiResponse<Void> deleteStaticRoute(@RequestBody DeleteStaticRouteRequest req) {
networkConfigService.deleteStaticRoute(req);
return ApiResponse.success();
@@ -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 {
@@ -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;
}
@@ -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(
@@ -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<InitCreateTaskResponse> 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<InitTaskExecuteResponse> executeTask(
@Parameter(description = "初始化任务唯一标识")
@PathVariable String taskId
@@ -127,12 +130,14 @@ public class InitController {
@PostMapping("/reset/tasks")
@Operation(summary = "创建重置任务", description = "自动读取当前设备有效初始化快照生成重置任务和步骤记录,任务初始状态为 PENDING。")
@ReplayProtected
public ApiResponse<ResetCreateTaskResponse> createResetTask() {
return ApiResponse.success(initService.createResetTask());
}
@PostMapping("/reset/tasks/{taskId}/execute")
@Operation(summary = "执行重置任务", description = "异步受理重置任务执行请求,立即返回当前任务快照,实际步骤在后台继续运行。")
@ReplayProtected
public ApiResponse<InitTaskExecuteResponse> executeResetTask(
@Parameter(description = "重置任务唯一标识")
@PathVariable String taskId
@@ -6,6 +6,7 @@ import com.cisd.tms.modules.mk.dto.MasterKeyRecoverPacketsRequest;
import com.cisd.tms.modules.mk.dto.MasterKeyStateResult;
import com.cisd.tms.modules.mk.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<MasterKeyStateResult> initMasterKey() {
return ApiResponse.success(lmkService.initMasterKey());
}
@PostMapping("/masterKey/destroy")
@Operation(summary = "销毁主密钥", description = "销毁当前密码卡中的主密钥,失败直接抛出异常。")
@ReplayProtected
public ApiResponse<Void> 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<Void> recoverLmkEx(@Valid @RequestBody MasterKeyRecoverPacketsRequest request) {
lmkService.recoverKeyPackets(request.getPackets());
return ApiResponse.success();
@@ -0,0 +1,88 @@
package com.cisd.tms.modules.security.replay.dto;
import com.cisd.tms.modules.security.replay.enums.ReplayScope;
public class ReplayCheckRequest {
private ReplayScope scope;
private String principalId;
private String nonce;
private long requestTimestamp;
private String requestMethod;
private String requestPath;
private String bodyHash;
private String remoteIp;
private String userAgent;
public ReplayScope getScope() {
return scope;
}
public void setScope(ReplayScope scope) {
this.scope = scope;
}
public String getPrincipalId() {
return principalId;
}
public void setPrincipalId(String principalId) {
this.principalId = principalId;
}
public String getNonce() {
return nonce;
}
public void setNonce(String nonce) {
this.nonce = nonce;
}
public long getRequestTimestamp() {
return requestTimestamp;
}
public void setRequestTimestamp(long requestTimestamp) {
this.requestTimestamp = requestTimestamp;
}
public String getRequestMethod() {
return requestMethod;
}
public void setRequestMethod(String requestMethod) {
this.requestMethod = requestMethod;
}
public String getRequestPath() {
return requestPath;
}
public void setRequestPath(String requestPath) {
this.requestPath = requestPath;
}
public String getBodyHash() {
return bodyHash;
}
public void setBodyHash(String bodyHash) {
this.bodyHash = bodyHash;
}
public String getRemoteIp() {
return remoteIp;
}
public void setRemoteIp(String remoteIp) {
this.remoteIp = remoteIp;
}
public String getUserAgent() {
return userAgent;
}
public void setUserAgent(String userAgent) {
this.userAgent = userAgent;
}
}
@@ -0,0 +1,53 @@
package com.cisd.tms.modules.security.replay.dto;
import com.cisd.tms.modules.security.replay.enums.ReplayCheckStatus;
import java.time.LocalDateTime;
public class ReplayCheckResult {
private ReplayCheckStatus status;
private boolean replayed;
private boolean mismatch;
private boolean securityEventRecorded;
private LocalDateTime expiresAt;
public ReplayCheckStatus getStatus() {
return status;
}
public void setStatus(ReplayCheckStatus status) {
this.status = status;
}
public boolean isReplayed() {
return replayed;
}
public void setReplayed(boolean replayed) {
this.replayed = replayed;
}
public boolean isMismatch() {
return mismatch;
}
public void setMismatch(boolean mismatch) {
this.mismatch = mismatch;
}
public boolean isSecurityEventRecorded() {
return securityEventRecorded;
}
public void setSecurityEventRecorded(boolean securityEventRecorded) {
this.securityEventRecorded = securityEventRecorded;
}
public LocalDateTime getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(LocalDateTime expiresAt) {
this.expiresAt = expiresAt;
}
}
@@ -0,0 +1,100 @@
package com.cisd.tms.modules.security.replay.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.cisd.tms.infrastructure.persistence.entity.BaseEntity;
import java.time.LocalDateTime;
@TableName("tms_replay_nonce")
public class ReplayNonceEntity extends BaseEntity {
private String scope;
private String principalId;
private String nonce;
private Long requestTimestamp;
private String requestMethod;
private String requestPath;
private String bodyHash;
private String remoteIp;
private String userAgent;
private LocalDateTime expiresAt;
public String getScope() {
return scope;
}
public void setScope(String scope) {
this.scope = scope;
}
public String getPrincipalId() {
return principalId;
}
public void setPrincipalId(String principalId) {
this.principalId = principalId;
}
public String getNonce() {
return nonce;
}
public void setNonce(String nonce) {
this.nonce = nonce;
}
public Long getRequestTimestamp() {
return requestTimestamp;
}
public void setRequestTimestamp(Long requestTimestamp) {
this.requestTimestamp = requestTimestamp;
}
public String getRequestMethod() {
return requestMethod;
}
public void setRequestMethod(String requestMethod) {
this.requestMethod = requestMethod;
}
public String getRequestPath() {
return requestPath;
}
public void setRequestPath(String requestPath) {
this.requestPath = requestPath;
}
public String getBodyHash() {
return bodyHash;
}
public void setBodyHash(String bodyHash) {
this.bodyHash = bodyHash;
}
public String getRemoteIp() {
return remoteIp;
}
public void setRemoteIp(String remoteIp) {
this.remoteIp = remoteIp;
}
public String getUserAgent() {
return userAgent;
}
public void setUserAgent(String userAgent) {
this.userAgent = userAgent;
}
public LocalDateTime getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(LocalDateTime expiresAt) {
this.expiresAt = expiresAt;
}
}
@@ -0,0 +1,81 @@
package com.cisd.tms.modules.security.replay.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.cisd.tms.infrastructure.persistence.entity.BaseEntity;
@TableName("tms_security_event")
public class SecurityEventEntity extends BaseEntity {
private String eventType;
private String scope;
private String principalId;
private String requestMethod;
private String requestPath;
private String nonce;
private String remoteIp;
private String detailJson;
public String getEventType() {
return eventType;
}
public void setEventType(String eventType) {
this.eventType = eventType;
}
public String getScope() {
return scope;
}
public void setScope(String scope) {
this.scope = scope;
}
public String getPrincipalId() {
return principalId;
}
public void setPrincipalId(String principalId) {
this.principalId = principalId;
}
public String getRequestMethod() {
return requestMethod;
}
public void setRequestMethod(String requestMethod) {
this.requestMethod = requestMethod;
}
public String getRequestPath() {
return requestPath;
}
public void setRequestPath(String requestPath) {
this.requestPath = requestPath;
}
public String getNonce() {
return nonce;
}
public void setNonce(String nonce) {
this.nonce = nonce;
}
public String getRemoteIp() {
return remoteIp;
}
public void setRemoteIp(String remoteIp) {
this.remoteIp = remoteIp;
}
public String getDetailJson() {
return detailJson;
}
public void setDetailJson(String detailJson) {
this.detailJson = detailJson;
}
}
@@ -0,0 +1,7 @@
package com.cisd.tms.modules.security.replay.enums;
public enum ReplayCheckStatus {
CLAIMED,
REPLAYED,
MISMATCH
}
@@ -0,0 +1,6 @@
package com.cisd.tms.modules.security.replay.enums;
public enum ReplayScope {
OPENAPI,
INTERNAL_API
}
@@ -0,0 +1,5 @@
package com.cisd.tms.modules.security.replay.enums;
public enum SecurityEventType {
REPLAY_MISMATCH
}
@@ -0,0 +1,12 @@
package com.cisd.tms.modules.security.replay.exception;
public class ReplayProtectionException extends RuntimeException {
public ReplayProtectionException(String message) {
super(message);
}
public ReplayProtectionException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,32 @@
package com.cisd.tms.modules.security.replay.job;
import com.cisd.tms.modules.security.replay.repository.ReplayNonceRepository;
import java.time.Clock;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class ReplayNonceCleanupJob {
private static final int DELETE_BATCH_LIMIT = 1000;
private final ReplayNonceRepository replayNonceRepository;
private final Clock clock;
public ReplayNonceCleanupJob(ReplayNonceRepository replayNonceRepository, Clock clock) {
this.replayNonceRepository = replayNonceRepository;
this.clock = clock;
}
@Scheduled(fixedDelay = 60000L)
public void cleanupExpiredNonces() {
// 分批清理过期 nonce,避免单次删除过多记录导致数据库抖动。
replayNonceRepository.deleteExpiredBefore(nowUtc(), DELETE_BATCH_LIMIT);
}
private LocalDateTime nowUtc() {
return LocalDateTime.ofInstant(clock.instant(), ZoneOffset.UTC);
}
}
@@ -0,0 +1,75 @@
package com.cisd.tms.modules.security.replay.mapper;
import com.cisd.tms.infrastructure.persistence.mapper.BaseMapperX;
import com.cisd.tms.modules.security.replay.entity.ReplayNonceEntity;
import java.time.LocalDateTime;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Result;
import org.apache.ibatis.annotations.Results;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface ReplayNonceMapper extends BaseMapperX<ReplayNonceEntity> {
@Select("""
SELECT id,
scope,
principal_id,
nonce,
request_timestamp,
request_method,
request_path,
body_hash,
remote_ip,
user_agent,
expires_at,
create_time,
update_time
FROM tms_replay_nonce
WHERE scope = #{scope}
AND principal_id = #{principalId}
AND nonce = #{nonce}
LIMIT 1
""")
@Results(id = "ReplayNonceResultMap", value = {
@Result(column = "id", property = "id", id = true),
@Result(column = "scope", property = "scope"),
@Result(column = "principal_id", property = "principalId"),
@Result(column = "nonce", property = "nonce"),
@Result(column = "request_timestamp", property = "requestTimestamp"),
@Result(column = "request_method", property = "requestMethod"),
@Result(column = "request_path", property = "requestPath"),
@Result(column = "body_hash", property = "bodyHash"),
@Result(column = "remote_ip", property = "remoteIp"),
@Result(column = "user_agent", property = "userAgent"),
@Result(column = "expires_at", property = "expiresAt"),
@Result(column = "create_time", property = "createTime"),
@Result(column = "update_time", property = "updateTime")
})
ReplayNonceEntity selectByScopeAndPrincipalIdAndNonce(
@Param("scope") String scope,
@Param("principalId") String principalId,
@Param("nonce") String nonce
);
@Delete("""
DELETE FROM tms_replay_nonce
WHERE scope = #{scope}
AND principal_id = #{principalId}
AND nonce = #{nonce}
""")
int deleteByScopeAndPrincipalIdAndNonce(
@Param("scope") String scope,
@Param("principalId") String principalId,
@Param("nonce") String nonce
);
@Delete("""
DELETE FROM tms_replay_nonce
WHERE expires_at < #{cutoff}
LIMIT #{limit}
""")
int deleteExpiredBefore(@Param("cutoff") LocalDateTime cutoff, @Param("limit") int limit);
}
@@ -0,0 +1,9 @@
package com.cisd.tms.modules.security.replay.mapper;
import com.cisd.tms.infrastructure.persistence.mapper.BaseMapperX;
import com.cisd.tms.modules.security.replay.entity.SecurityEventEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SecurityEventMapper extends BaseMapperX<SecurityEventEntity> {
}
@@ -0,0 +1,20 @@
package com.cisd.tms.modules.security.replay.repository;
import com.cisd.tms.modules.security.replay.entity.ReplayNonceEntity;
import java.time.LocalDateTime;
import java.util.Optional;
public interface ReplayNonceRepository {
void save(ReplayNonceEntity entity);
boolean trySave(ReplayNonceEntity entity);
Optional<ReplayNonceEntity> findByScopeAndPrincipalIdAndNonce(String scope, String principalId, String nonce);
int deleteById(Long id);
int deleteByScopeAndPrincipalIdAndNonce(String scope, String principalId, String nonce);
int deleteExpiredBefore(LocalDateTime cutoff, int limit);
}
@@ -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);
}
@@ -0,0 +1,54 @@
package com.cisd.tms.modules.security.replay.repository.impl;
import com.cisd.tms.modules.security.replay.entity.ReplayNonceEntity;
import com.cisd.tms.modules.security.replay.mapper.ReplayNonceMapper;
import com.cisd.tms.modules.security.replay.repository.ReplayNonceRepository;
import java.time.LocalDateTime;
import java.util.Optional;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Repository;
@Repository
public class ReplayNonceRepositoryImpl implements ReplayNonceRepository {
private final ReplayNonceMapper replayNonceMapper;
public ReplayNonceRepositoryImpl(ReplayNonceMapper replayNonceMapper) {
this.replayNonceMapper = replayNonceMapper;
}
@Override
public void save(ReplayNonceEntity entity) {
replayNonceMapper.insert(entity);
}
@Override
public boolean trySave(ReplayNonceEntity entity) {
try {
replayNonceMapper.insert(entity);
return true;
} catch (DataIntegrityViolationException ex) {
return false;
}
}
@Override
public Optional<ReplayNonceEntity> findByScopeAndPrincipalIdAndNonce(String scope, String principalId, String nonce) {
return Optional.ofNullable(replayNonceMapper.selectByScopeAndPrincipalIdAndNonce(scope, principalId, nonce));
}
@Override
public int deleteById(Long id) {
return replayNonceMapper.deleteById(id);
}
@Override
public int deleteByScopeAndPrincipalIdAndNonce(String scope, String principalId, String nonce) {
return replayNonceMapper.deleteByScopeAndPrincipalIdAndNonce(scope, principalId, nonce);
}
@Override
public int deleteExpiredBefore(LocalDateTime cutoff, int limit) {
return replayNonceMapper.deleteExpiredBefore(cutoff, limit);
}
}
@@ -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);
}
}
@@ -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);
}
@@ -0,0 +1,230 @@
package com.cisd.tms.modules.security.replay.service.impl;
import com.cisd.tms.common.config.properties.TmsSecurityProperties;
import com.cisd.tms.modules.security.replay.dto.ReplayCheckRequest;
import com.cisd.tms.modules.security.replay.dto.ReplayCheckResult;
import com.cisd.tms.modules.security.replay.entity.ReplayNonceEntity;
import com.cisd.tms.modules.security.replay.entity.SecurityEventEntity;
import com.cisd.tms.modules.security.replay.enums.ReplayCheckStatus;
import com.cisd.tms.modules.security.replay.enums.ReplayScope;
import com.cisd.tms.modules.security.replay.enums.SecurityEventType;
import com.cisd.tms.modules.security.replay.exception.ReplayProtectionException;
import com.cisd.tms.modules.security.replay.repository.ReplayNonceRepository;
import com.cisd.tms.modules.security.replay.repository.SecurityEventRepository;
import com.cisd.tms.modules.security.replay.service.ReplayProtectionService;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Objects;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class ReplayProtectionServiceImpl implements ReplayProtectionService {
private static final long DEFAULT_ALLOWED_SKEW_SECONDS = 300L;
private final ReplayNonceRepository replayNonceRepository;
private final SecurityEventRepository securityEventRepository;
private final TmsSecurityProperties securityProperties;
private final Clock clock;
@Autowired
public ReplayProtectionServiceImpl(
ReplayNonceRepository replayNonceRepository,
SecurityEventRepository securityEventRepository,
TmsSecurityProperties securityProperties,
Clock clock
) {
this.replayNonceRepository = replayNonceRepository;
this.securityEventRepository = securityEventRepository;
this.securityProperties = securityProperties;
this.clock = clock;
}
public ReplayProtectionServiceImpl(
ReplayNonceRepository replayNonceRepository,
SecurityEventRepository securityEventRepository,
Clock clock
) {
this(replayNonceRepository, securityEventRepository, new TmsSecurityProperties(), clock);
}
@Override
public ReplayCheckResult check(ReplayCheckRequest request) {
validateRequest(request);
long nowSeconds = clock.instant().getEpochSecond();
long allowedSkewSeconds = allowedSkewSeconds(request.getScope());
ensureTimestampWithinWindow(request.getRequestTimestamp(), nowSeconds, allowedSkewSeconds);
String scope = request.getScope().name();
String principalId = normalize(request.getPrincipalId());
String nonce = normalize(request.getNonce());
var existing = replayNonceRepository.findByScopeAndPrincipalIdAndNonce(scope, principalId, nonce);
if (existing.isPresent()) {
if (isExpired(existing.get(), nowSeconds)) {
// 过期记录即使清理任务尚未来得及删除,也不应永久阻塞新的合法请求。
reclaimExpiredNonce(existing.get());
existing = replayNonceRepository.findByScopeAndPrincipalIdAndNonce(scope, principalId, nonce);
}
}
if (existing.isPresent()) {
return buildReplayResult(request, existing.get());
}
ReplayNonceEntity entity = buildReplayEntity(request, nowSeconds, allowedSkewSeconds);
try {
if (replayNonceRepository.trySave(entity)) {
return buildClaimedResult(entity);
}
} catch (RuntimeException ex) {
throw new ReplayProtectionException("failed to persist replay nonce", ex);
}
var reloaded = replayNonceRepository.findByScopeAndPrincipalIdAndNonce(scope, principalId, nonce);
if (reloaded.isEmpty()) {
throw new ReplayProtectionException("replay nonce claim failed without existing record");
}
return buildReplayResult(request, reloaded.get());
}
private void reclaimExpiredNonce(ReplayNonceEntity expiredEntity) {
if (expiredEntity.getId() == null) {
throw new ReplayProtectionException("expired replay nonce id is missing");
}
try {
replayNonceRepository.deleteById(expiredEntity.getId());
} catch (RuntimeException ex) {
throw new ReplayProtectionException("failed to remove expired replay nonce", ex);
}
}
private void validateRequest(ReplayCheckRequest request) {
if (request == null) {
throw new ReplayProtectionException("replay request must not be null");
}
if (request.getScope() == null) {
throw new ReplayProtectionException("replay scope is required");
}
if (isBlank(request.getPrincipalId())) {
throw new ReplayProtectionException("principal id is required");
}
if (isBlank(request.getNonce())) {
throw new ReplayProtectionException("nonce is required");
}
if (request.getRequestTimestamp() <= 0L) {
throw new ReplayProtectionException("request timestamp is required");
}
}
private void ensureTimestampWithinWindow(long requestTimestamp, long nowSeconds, long allowedSkewSeconds) {
if (Math.abs(nowSeconds - requestTimestamp) > allowedSkewSeconds) {
throw new ReplayProtectionException("request timestamp is outside allowed window");
}
}
private ReplayCheckResult buildClaimedResult(ReplayNonceEntity entity) {
ReplayCheckResult result = new ReplayCheckResult();
result.setStatus(ReplayCheckStatus.CLAIMED);
result.setReplayed(false);
result.setMismatch(false);
result.setSecurityEventRecorded(false);
result.setExpiresAt(entity.getExpiresAt());
return result;
}
private ReplayCheckResult buildReplayResult(ReplayCheckRequest request, ReplayNonceEntity existing) {
boolean mismatch = !sameFingerprint(request, existing);
if (mismatch) {
// 同一 nonce 命中但请求指纹不同,按更高风险事件记录,便于后续审计或告警。
recordSecurityEvent(request);
}
ReplayCheckResult result = new ReplayCheckResult();
result.setStatus(mismatch ? ReplayCheckStatus.MISMATCH : ReplayCheckStatus.REPLAYED);
result.setReplayed(true);
result.setMismatch(mismatch);
result.setSecurityEventRecorded(mismatch);
result.setExpiresAt(existing.getExpiresAt());
return result;
}
private void recordSecurityEvent(ReplayCheckRequest request) {
SecurityEventEntity event = new SecurityEventEntity();
event.setEventType(SecurityEventType.REPLAY_MISMATCH.name());
event.setScope(request.getScope().name());
event.setPrincipalId(normalize(request.getPrincipalId()));
event.setRequestMethod(normalize(request.getRequestMethod()));
event.setRequestPath(normalize(request.getRequestPath()));
event.setNonce(normalize(request.getNonce()));
event.setRemoteIp(normalize(request.getRemoteIp()));
event.setDetailJson(buildMismatchDetail(request));
try {
securityEventRepository.save(event);
} catch (RuntimeException ex) {
throw new ReplayProtectionException("failed to persist security event", ex);
}
}
private String buildMismatchDetail(ReplayCheckRequest request) {
return "request fingerprint mismatch: scope=" + request.getScope().name()
+ ", method=" + normalize(request.getRequestMethod())
+ ", path=" + normalize(request.getRequestPath())
+ ", bodyHash=" + normalize(request.getBodyHash());
}
private ReplayNonceEntity buildReplayEntity(ReplayCheckRequest request, long nowSeconds, long allowedSkewSeconds) {
ReplayNonceEntity entity = new ReplayNonceEntity();
entity.setScope(request.getScope().name());
entity.setPrincipalId(normalize(request.getPrincipalId()));
entity.setNonce(normalize(request.getNonce()));
entity.setRequestTimestamp(request.getRequestTimestamp());
entity.setRequestMethod(normalize(request.getRequestMethod()));
entity.setRequestPath(normalize(request.getRequestPath()));
entity.setBodyHash(normalize(request.getBodyHash()));
entity.setRemoteIp(normalize(request.getRemoteIp()));
entity.setUserAgent(normalize(request.getUserAgent()));
entity.setExpiresAt(LocalDateTime.ofInstant(Instant.ofEpochSecond(nowSeconds + allowedSkewSeconds * 2L), ZoneOffset.UTC));
return entity;
}
private long allowedSkewSeconds(ReplayScope scope) {
// OpenAPI 允许使用配置时间窗,内部管理端走固定默认值,避免由调用方自行放宽窗口。
if (scope == ReplayScope.OPENAPI) {
long configuredSkewSeconds = securityProperties.getOpenapi().getTimestampSkewSeconds();
if (configuredSkewSeconds > 0L) {
return configuredSkewSeconds;
}
}
return DEFAULT_ALLOWED_SKEW_SECONDS;
}
private boolean sameFingerprint(ReplayCheckRequest request, ReplayNonceEntity existing) {
return same(normalize(request.getRequestMethod()), normalize(existing.getRequestMethod()))
&& same(normalize(request.getRequestPath()), normalize(existing.getRequestPath()))
&& same(normalize(request.getBodyHash()), normalize(existing.getBodyHash()));
}
private boolean same(String left, String right) {
return Objects.equals(left, right);
}
private boolean isExpired(ReplayNonceEntity entity, long nowSeconds) {
if (entity.getExpiresAt() == null) {
return false;
}
LocalDateTime now = LocalDateTime.ofInstant(Instant.ofEpochSecond(nowSeconds), ZoneOffset.UTC);
return !entity.getExpiresAt().isAfter(now);
}
private String normalize(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
}
@@ -11,10 +11,12 @@ import com.cisd.tms.modules.upgrade.dto.UpgradePreviewResponse;
import com.cisd.tms.modules.upgrade.dto.UpgradeTaskPageResponse;
import com.cisd.tms.modules.upgrade.dto.UpgradeTaskResponse;
import com.cisd.tms.modules.upgrade.service.UpgradeService;
import com.cisd.tms.security.internal.ReplayProtected;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -26,19 +28,16 @@ import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
@Tag(name = "离线升级管理", description = "离线升级包上传、预检、任务创建、执行和升级记录查询接口")
public class UpgradeController {
private final UpgradeService upgradeService;
private final FileService fileService;
public UpgradeController(UpgradeService upgradeService, FileService fileService) {
this.upgradeService = upgradeService;
this.fileService = fileService;
}
@PostMapping("/upgrade-packages")
@Operation(summary = "上传离线升级包", description = "上传离线升级包,返回 fileId 供升级预检和任务创建使用。")
@ReplayProtected
public ApiResponse<FileUploadResponse> uploadPackage(
@Parameter(description = "待上传的离线升级包")
@RequestParam("file") MultipartFile file
@@ -54,12 +53,14 @@ public class UpgradeController {
@PostMapping("/upgrades")
@Operation(summary = "创建离线升级任务", description = "根据 fileId 创建离线升级任务,状态初始为 PENDING_CONFIRM。")
@ReplayProtected
public ApiResponse<UpgradeCreateTaskResponse> create(@Valid @RequestBody UpgradeCreateTaskRequest request) {
return ApiResponse.success(upgradeService.createTask(request));
}
@PostMapping("/upgrades/{taskId}/execute")
@Operation(summary = "执行离线升级任务", description = "异步受理升级执行请求,立即返回任务当前快照。")
@ReplayProtected
public ApiResponse<UpgradeTaskResponse> execute(
@Parameter(description = "升级任务号")
@PathVariable String taskId
@@ -69,6 +70,7 @@ public class UpgradeController {
@PostMapping("/upgrades/{taskId}/rollback")
@Operation(summary = "回滚离线升级任务", description = "异步受理升级回滚请求,立即返回任务当前快照。")
@ReplayProtected
public ApiResponse<UpgradeTaskResponse> rollback(
@Parameter(description = "升级任务号")
@PathVariable String taskId
@@ -1,5 +1,8 @@
package com.cisd.tms.modules.upgrade.enums;
/**
* 升级任务类型。具体应用名称由升级包脚本处理,后端不按应用名拆分。
*/
public enum UpgradeTargetComponent {
TMS,
RECEIVER,
@@ -1,5 +1,8 @@
package com.cisd.tms.modules.upgrade.enums;
/**
* 升级任务主状态。脚本级细分状态当前通过日志体现,尚未独立落表。
*/
public enum UpgradeTaskStatus {
PENDING_CONFIRM,
RUNNING,
@@ -58,6 +58,7 @@ public class UpgradeTaskRunner {
UpgradeManifest manifest = objectMapper.readValue(Files.readString(stagedDir.resolve("manifest.json")), UpgradeManifest.class);
Path root = stagedDir.normalize().toAbsolutePath();
// 包内脚本按固定阶段执行;可选阶段未声明时跳过,execute 阶段必须存在。
int precheckCode = runOptionalScript("precheck", root, manifest.getEntrypoints() == null ? "" : manifest.getEntrypoints().getPrecheck(), task, logPath);
if (precheckCode != 0) {
upgradeTaskRepository.updateStatus(
@@ -97,9 +98,11 @@ public class UpgradeTaskRunner {
return;
}
// 只有 execute 和 verify 都成功后,才认为升级成功并回写设备软件版本表。
DeviceSoftwareVersionEntity versionEntity = new DeviceSoftwareVersionEntity();
versionEntity.setComponentCode(task.getTaskType());
versionEntity.setComponentName(componentName(task.getTaskType()));
String componentCode = componentCode(task.getTaskType());
versionEntity.setComponentCode(componentCode);
versionEntity.setComponentName(componentName(componentCode));
versionEntity.setCurrentVersion(task.getTargetVersion());
versionEntity.setSourceType("UPGRADE");
versionEntity.setDetectedAt(LocalDateTime.now());
@@ -146,6 +149,7 @@ public class UpgradeTaskRunner {
if (rollbackPath.isEmpty()) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade rollback script not found");
}
// 回滚不自动触发,只有外部调用 rollback 接口后才执行包内 rollback 脚本。
int exitCode = runRequiredScript("rollback", stagedDir.normalize().toAbsolutePath(), rollbackPath, task, logPath);
if (exitCode != 0) {
upgradeTaskRepository.updateStatus(
@@ -197,6 +201,7 @@ public class UpgradeTaskRunner {
private int runRequiredScript(String phase, Path stagedDir, String scriptPath, UpgradeTaskEntity task, Path logPath) throws IOException, InterruptedException {
Path resolved = stagedDir.resolve(trim(scriptPath)).normalize().toAbsolutePath();
// 防止脚本路径通过 ../ 跳出升级包解压目录。
if (!resolved.startsWith(stagedDir) || !Files.exists(resolved)) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade " + phase + " script not found");
}
@@ -212,11 +217,14 @@ public class UpgradeTaskRunner {
return switch (trim(componentCode)) {
case "TMS" -> "设备管理软件";
case "RECEIVER" -> "标准收发器";
case "FIRMWARE" -> "固件";
default -> componentCode;
};
}
private String componentCode(String taskType) {
return trim(taskType);
}
private String trim(String value) {
return value == null ? "" : value.trim();
}
@@ -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");
}
@@ -43,6 +43,10 @@ public class UpgradeService {
private final UpgradeTaskRunner upgradeTaskRunner;
private final UpgradeProperties upgradeProperties;
private final Executor backgroundExecutor;
/**
* 进程内执行互斥表:避免同一任务被前端重复点击后并发启动。
* 跨进程/重启后的互斥仍以数据库中的 RUNNING 任务为准。
*/
private final ConcurrentMap<String, CompletableFuture<Void>> 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<Void> 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");
}
@@ -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;
@@ -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");
}
@@ -25,6 +25,7 @@ public class UpgradeVersionComparator {
String value = version == null ? "" : version.trim();
List<Integer> 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)) {
@@ -0,0 +1,69 @@
package com.cisd.tms.security.internal;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import org.springframework.util.StreamUtils;
public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper {
private final byte[] cachedBody;
public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException {
super(request);
this.cachedBody = StreamUtils.copyToByteArray(request.getInputStream());
}
public byte[] getCachedBody() {
return cachedBody.clone();
}
@Override
public ServletInputStream getInputStream() {
return new CachedBodyServletInputStream(cachedBody);
}
@Override
public BufferedReader getReader() {
Charset charset = getCharacterEncoding() == null
? StandardCharsets.UTF_8
: Charset.forName(getCharacterEncoding());
return new BufferedReader(new InputStreamReader(getInputStream(), charset));
}
private static class CachedBodyServletInputStream extends ServletInputStream {
private final ByteArrayInputStream inputStream;
private CachedBodyServletInputStream(byte[] cachedBody) {
this.inputStream = new ByteArrayInputStream(cachedBody);
}
@Override
public boolean isFinished() {
return inputStream.available() == 0;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setReadListener(ReadListener readListener) {
throw new UnsupportedOperationException("ReadListener is not supported");
}
@Override
public int read() {
return inputStream.read();
}
}
}
@@ -13,12 +13,15 @@ import java.io.IOException;
import java.time.Clock;
import java.time.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)) {
@@ -0,0 +1,145 @@
package com.cisd.tms.security.internal;
import com.cisd.tms.common.api.ApiResponse;
import com.cisd.tms.common.enums.ErrorCode;
import com.cisd.tms.common.util.HttpResponseUtil;
import com.cisd.tms.modules.security.replay.dto.ReplayCheckRequest;
import com.cisd.tms.modules.security.replay.dto.ReplayCheckResult;
import com.cisd.tms.modules.security.replay.enums.ReplayScope;
import com.cisd.tms.modules.security.replay.exception.ReplayProtectionException;
import com.cisd.tms.modules.security.replay.service.ReplayProtectionService;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import lombok.RequiredArgsConstructor;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import org.springframework.web.cors.CorsUtils;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
@RequiredArgsConstructor
public class InternalApiReplayInterceptor implements HandlerInterceptor {
static final String TIMESTAMP_HEADER = "X-Request-Timestamp";
static final String NONCE_HEADER = "X-Request-Nonce";
private final ReplayProtectionService replayProtectionService;
private final ObjectMapper objectMapper;
private final Clock clock;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (CorsUtils.isPreFlightRequest(request) || !(handler instanceof HandlerMethod handlerMethod)) {
return true;
}
// 只有显式标记的敏感接口才启用防重放,避免把普通查询接口一并拦住。
if (findAnnotation(handlerMethod, ReplayProtected.class) == null) {
return true;
}
String sessionToken = (String) request.getAttribute(InternalApiAuthInterceptor.ATTR_SESSION_TOKEN);
if (isBlank(sessionToken)) {
writeJson(response, HttpServletResponse.SC_UNAUTHORIZED, ErrorCode.UNAUTHORIZED.getCode(), "session token missing");
return false;
}
String timestamp = trimHeader(request, TIMESTAMP_HEADER);
String nonce = trimHeader(request, NONCE_HEADER);
if (isBlank(timestamp) || isBlank(nonce)) {
writeJson(response, HttpServletResponse.SC_BAD_REQUEST, ErrorCode.BAD_REQUEST.getCode(), "missing replay protection headers");
return false;
}
long requestTimestamp;
try {
requestTimestamp = Long.parseLong(timestamp);
} catch (NumberFormatException ex) {
writeJson(response, HttpServletResponse.SC_BAD_REQUEST, ErrorCode.BAD_REQUEST.getCode(), "invalid replay timestamp");
return false;
}
ReplayCheckRequest replayRequest = buildReplayRequest(request, sessionToken, nonce, requestTimestamp);
try {
ReplayCheckResult result = replayProtectionService.check(replayRequest);
if (result.isReplayed()) {
writeJson(response, HttpServletResponse.SC_CONFLICT, ErrorCode.UNAUTHORIZED.getCode(), "replayed nonce");
return false;
}
} catch (ReplayProtectionException ex) {
writeJson(response, HttpServletResponse.SC_SERVICE_UNAVAILABLE, ErrorCode.INTERNAL_ERROR.getCode(),
"replay protection unavailable");
return false;
}
return true;
}
private ReplayCheckRequest buildReplayRequest(
HttpServletRequest request,
String sessionToken,
String nonce,
long requestTimestamp
) {
// 内部接口使用会话 token 作为主体标识,并把请求体哈希纳入指纹,识别同 nonce 的不同内容重放。
ReplayCheckRequest replayRequest = new ReplayCheckRequest();
replayRequest.setScope(ReplayScope.INTERNAL_API);
replayRequest.setPrincipalId(sessionToken);
replayRequest.setNonce(nonce);
replayRequest.setRequestTimestamp(requestTimestamp);
replayRequest.setRequestMethod(request.getMethod());
replayRequest.setRequestPath(request.getRequestURI());
replayRequest.setBodyHash(bodyHash(request));
replayRequest.setRemoteIp(request.getRemoteAddr());
replayRequest.setUserAgent(trimHeader(request, "User-Agent"));
return replayRequest;
}
private String bodyHash(HttpServletRequest request) {
if (!(request instanceof CachedBodyHttpServletRequest cachedRequest)) {
return null;
}
byte[] body = cachedRequest.getCachedBody();
if (body.length == 0) {
return null;
}
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(body);
StringBuilder builder = new StringBuilder(digest.length * 2);
for (byte b : digest) {
builder.append(String.format("%02x", b));
}
return builder.toString();
} catch (Exception ex) {
throw new ReplayProtectionException("failed to hash request body", ex);
}
}
@Nullable
private <T extends java.lang.annotation.Annotation> T findAnnotation(HandlerMethod handlerMethod, Class<T> annotationType) {
T methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(handlerMethod.getMethod(), annotationType);
if (methodAnnotation != null) {
return methodAnnotation;
}
return AnnotatedElementUtils.findMergedAnnotation(handlerMethod.getBeanType(), annotationType);
}
private String trimHeader(HttpServletRequest request, String name) {
String value = request.getHeader(name);
return value == null ? null : value.trim();
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
private void writeJson(HttpServletResponse response, int status, int code, String message) throws IOException {
HttpResponseUtil.writeJson(response, status, ApiResponse.fail(code, message), objectMapper);
}
}
@@ -0,0 +1,11 @@
package com.cisd.tms.security.internal;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ReplayProtected {
}
@@ -1,40 +0,0 @@
package com.cisd.tms.security.openapi;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Component;
@Component
public class OpenApiReplayGuard {
private final Map<String, Long> nonceExpireSeconds = new ConcurrentHashMap<>();
private final AtomicLong cleanupCounter = new AtomicLong(0);
public boolean tryAcquire(String appId, String nonce, long nowSeconds, long ttlSeconds) {
if (ttlSeconds <= 0) {
ttlSeconds = 300;
}
String key = appId + ":" + nonce;
long expiry = nowSeconds + ttlSeconds;
AtomicBoolean accepted = new AtomicBoolean(false);
nonceExpireSeconds.compute(key, (k, existingExpiry) -> {
if (existingExpiry == null || existingExpiry < nowSeconds) {
accepted.set(true);
return expiry;
}
accepted.set(false);
return existingExpiry;
});
// Opportunistic cleanup to keep memory growth under control.
if (cleanupCounter.incrementAndGet() % 1000 == 0) {
nonceExpireSeconds.entrySet().removeIf(e -> e.getValue() < nowSeconds);
}
return accepted.get();
}
}
@@ -5,19 +5,28 @@ import com.cisd.tms.common.config.properties.TmsSecurityProperties;
import com.cisd.tms.common.enums.ErrorCode;
import com.cisd.tms.common.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),
+6 -1
View File
@@ -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 编码);主密钥初始化接口会读取该值。
@@ -110,7 +110,7 @@ VALUES
(1002, 'TLQ', '消息队列', 'V8.1.11', 'MANUAL', NOW(), '出厂预置'),
(1003, 'TIDB', '数据库', 'V8.1.0', 'MANUAL', NOW(), '出厂预置'),
(1004, 'NGINX', 'Nginx', 'V1.20', 'MANUAL', NOW(), '出厂预置'),
(1005, 'APP', '标准收发器企业版', 'V1.3.2', 'MANUAL', NOW(), '出厂预置'),
(1005, 'RECEIVER', '标准收发器', 'V1.3.2', 'MANUAL', NOW(), '出厂预置'),
(1006, 'TMS', 'CISD终端管理服务', 'V1.0.0', 'MANUAL', NOW(), '出厂预置')
ON DUPLICATE KEY UPDATE
component_name = VALUES(component_name),
@@ -234,6 +234,45 @@ CREATE TABLE IF NOT EXISTS tms_auth_audit_log (
KEY idx_tms_auth_audit_log_role_code (role_code)
);
-- -----------------------------------------------------------------------------
-- 防重放与安全事件
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS tms_replay_nonce (
id BIGINT PRIMARY KEY,
scope VARCHAR(32) NOT NULL COMMENT '防重放作用域:OPENAPI / INTERNAL_API',
principal_id VARCHAR(128) NOT NULL COMMENT '主体标识:appId / sessionToken',
nonce VARCHAR(128) NOT NULL,
request_timestamp BIGINT NOT NULL,
request_method VARCHAR(16) NULL,
request_path VARCHAR(256) NULL,
body_hash VARCHAR(128) NULL,
remote_ip VARCHAR(64) NULL,
user_agent VARCHAR(512) NULL,
expires_at DATETIME(3) NOT NULL,
create_time DATETIME(3) NOT NULL,
update_time DATETIME(3) NOT NULL,
UNIQUE KEY uk_tms_replay_nonce_scope_principal_nonce (scope, principal_id, nonce),
KEY idx_tms_replay_nonce_expires_at (expires_at),
KEY idx_tms_replay_nonce_principal_time (principal_id, create_time)
);
CREATE TABLE IF NOT EXISTS tms_security_event (
id BIGINT PRIMARY KEY,
event_type VARCHAR(64) NOT NULL,
scope VARCHAR(32) NOT NULL,
principal_id VARCHAR(128) NULL,
request_method VARCHAR(16) NULL,
request_path VARCHAR(256) NULL,
nonce VARCHAR(128) NULL,
remote_ip VARCHAR(64) NULL,
detail_json JSON NULL,
create_time DATETIME(3) NOT NULL,
update_time DATETIME(3) NOT NULL,
KEY idx_tms_security_event_event_type (event_type),
KEY idx_tms_security_event_scope_principal (scope, principal_id)
);
INSERT IGNORE INTO tms_role_account (
id,
role_code,
@@ -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';
@@ -0,0 +1,38 @@
-- -----------------------------------------------------------------------------
-- Replay protection tables
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS tms_replay_nonce (
id BIGINT PRIMARY KEY,
scope VARCHAR(32) NOT NULL COMMENT '防重放作用域:OPENAPI / INTERNAL_API',
principal_id VARCHAR(128) NOT NULL COMMENT '主体标识:appId / sessionToken',
nonce VARCHAR(128) NOT NULL,
request_timestamp BIGINT NOT NULL,
request_method VARCHAR(16) NULL,
request_path VARCHAR(256) NULL,
body_hash VARCHAR(128) NULL,
remote_ip VARCHAR(64) NULL,
user_agent VARCHAR(512) NULL,
expires_at DATETIME(3) NOT NULL,
create_time DATETIME(3) NOT NULL,
update_time DATETIME(3) NOT NULL,
UNIQUE KEY uk_tms_replay_nonce_scope_principal_nonce (scope, principal_id, nonce),
KEY idx_tms_replay_nonce_expires_at (expires_at),
KEY idx_tms_replay_nonce_principal_time (principal_id, create_time)
);
CREATE TABLE IF NOT EXISTS tms_security_event (
id BIGINT PRIMARY KEY,
event_type VARCHAR(64) NOT NULL,
scope VARCHAR(32) NOT NULL,
principal_id VARCHAR(128) NULL,
request_method VARCHAR(16) NULL,
request_path VARCHAR(256) NULL,
nonce VARCHAR(128) NULL,
remote_ip VARCHAR(64) NULL,
detail_json JSON NULL,
create_time DATETIME(3) NOT NULL,
update_time DATETIME(3) NOT NULL,
KEY idx_tms_security_event_event_type (event_type),
KEY idx_tms_security_event_scope_principal (scope, principal_id)
);
@@ -54,12 +54,17 @@ class TmsApplicationTests {
@Test
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)"));
}
}
@@ -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<Void> 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());
}
}
@@ -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<PropertySource<?>> 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<PropertySource<?>> propertySources, String name) {
return propertySources.stream()
.filter(source -> source.containsProperty(name))
.findFirst()
.map(source -> source.getProperty(name))
.orElse(null);
}
}
@@ -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()
@@ -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");
}
@@ -0,0 +1,134 @@
package com.cisd.tms.modules.security.replay;
import com.cisd.tms.modules.security.replay.entity.ReplayNonceEntity;
import com.cisd.tms.modules.security.replay.entity.SecurityEventEntity;
import com.cisd.tms.modules.security.replay.mapper.ReplayNonceMapper;
import com.cisd.tms.modules.security.replay.mapper.SecurityEventMapper;
import com.cisd.tms.modules.security.replay.repository.ReplayNonceRepository;
import com.cisd.tms.modules.security.replay.repository.SecurityEventRepository;
import com.cisd.tms.modules.security.replay.repository.impl.ReplayNonceRepositoryImpl;
import com.cisd.tms.modules.security.replay.repository.impl.SecurityEventRepositoryImpl;
import java.time.LocalDateTime;
import java.lang.reflect.Method;
import java.util.Optional;
import org.apache.ibatis.annotations.Delete;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import org.apache.ibatis.annotations.Select;
class ReplayNonceRepositoryTest {
@Test
void shouldSaveNonceAndLookupExistingNonce() {
ReplayNonceMapper mapper = Mockito.mock(ReplayNonceMapper.class);
ReplayNonceRepository repository = new ReplayNonceRepositoryImpl(mapper);
ReplayNonceEntity entity = replayNonce("OPENAPI", "demo-app", "nonce-001");
Mockito.when(mapper.selectByScopeAndPrincipalIdAndNonce("OPENAPI", "demo-app", "nonce-001"))
.thenReturn(entity);
repository.save(entity);
ArgumentCaptor<ReplayNonceEntity> captor = ArgumentCaptor.forClass(ReplayNonceEntity.class);
Mockito.verify(mapper).insert(captor.capture());
Assertions.assertEquals("OPENAPI", captor.getValue().getScope());
Assertions.assertEquals("demo-app", captor.getValue().getPrincipalId());
Assertions.assertEquals("nonce-001", captor.getValue().getNonce());
Optional<ReplayNonceEntity> found = repository.findByScopeAndPrincipalIdAndNonce("OPENAPI", "demo-app", "nonce-001");
Mockito.verify(mapper).selectByScopeAndPrincipalIdAndNonce("OPENAPI", "demo-app", "nonce-001");
Assertions.assertTrue(found.isPresent());
Assertions.assertEquals("nonce-001", found.orElseThrow().getNonce());
}
@Test
void shouldSaveSecurityEvent() {
SecurityEventMapper mapper = Mockito.mock(SecurityEventMapper.class);
SecurityEventRepository repository = new SecurityEventRepositoryImpl(mapper);
SecurityEventEntity entity = securityEvent("REPLAY_DETECTED", "INTERNAL_API", "token-001");
repository.save(entity);
ArgumentCaptor<SecurityEventEntity> captor = ArgumentCaptor.forClass(SecurityEventEntity.class);
Mockito.verify(mapper).insert(captor.capture());
Assertions.assertEquals("REPLAY_DETECTED", captor.getValue().getEventType());
Assertions.assertEquals("INTERNAL_API", captor.getValue().getScope());
Assertions.assertEquals("token-001", captor.getValue().getPrincipalId());
}
@Test
void shouldRepresentDuplicateNonceByLookingUpExistingRecord() {
ReplayNonceMapper mapper = Mockito.mock(ReplayNonceMapper.class);
ReplayNonceRepository repository = new ReplayNonceRepositoryImpl(mapper);
ReplayNonceEntity existing = replayNonce("INTERNAL_API", "token-001", "nonce-dup");
Mockito.when(mapper.selectByScopeAndPrincipalIdAndNonce("INTERNAL_API", "token-001", "nonce-dup"))
.thenReturn(existing);
Optional<ReplayNonceEntity> duplicate = repository.findByScopeAndPrincipalIdAndNonce(
"INTERNAL_API", "token-001", "nonce-dup");
Assertions.assertTrue(duplicate.isPresent());
Assertions.assertEquals("nonce-dup", duplicate.orElseThrow().getNonce());
Assertions.assertEquals("token-001", duplicate.orElseThrow().getPrincipalId());
}
@Test
void shouldDeclareSqlMappingForNonceLookup() throws NoSuchMethodException {
Method method = ReplayNonceMapper.class.getMethod(
"selectByScopeAndPrincipalIdAndNonce", String.class, String.class, String.class);
Assertions.assertTrue(method.isAnnotationPresent(Select.class));
}
@Test
void shouldDeleteExpiredNoncesBeforeCutoff() {
ReplayNonceMapper mapper = Mockito.mock(ReplayNonceMapper.class);
ReplayNonceRepository repository = new ReplayNonceRepositoryImpl(mapper);
LocalDateTime cutoff = LocalDateTime.of(2026, 4, 9, 10, 30, 0);
Mockito.when(mapper.deleteExpiredBefore(cutoff, 1000)).thenReturn(7);
int deletedCount = repository.deleteExpiredBefore(cutoff, 1000);
Mockito.verify(mapper).deleteExpiredBefore(cutoff, 1000);
Assertions.assertEquals(7, deletedCount);
}
@Test
void shouldDeclareSqlMappingForExpiredNonceDeletion() throws NoSuchMethodException {
Method method = ReplayNonceMapper.class.getMethod(
"deleteExpiredBefore", LocalDateTime.class, int.class);
Assertions.assertTrue(method.isAnnotationPresent(Delete.class));
}
private static ReplayNonceEntity replayNonce(String scope, String principalId, String nonce) {
ReplayNonceEntity entity = new ReplayNonceEntity();
entity.setScope(scope);
entity.setPrincipalId(principalId);
entity.setNonce(nonce);
entity.setRequestTimestamp(1740000000L);
entity.setRequestMethod("POST");
entity.setRequestPath("/api/v1/demo");
entity.setBodyHash("abc123");
entity.setRemoteIp("127.0.0.1");
entity.setUserAgent("JUnit");
entity.setExpiresAt(LocalDateTime.of(2026, 4, 9, 10, 0, 0));
return entity;
}
private static SecurityEventEntity securityEvent(String eventType, String scope, String principalId) {
SecurityEventEntity entity = new SecurityEventEntity();
entity.setEventType(eventType);
entity.setScope(scope);
entity.setPrincipalId(principalId);
entity.setRequestMethod("POST");
entity.setRequestPath("/api/v1/demo");
entity.setNonce("nonce-001");
entity.setRemoteIp("127.0.0.1");
entity.setDetailJson("{\"reason\":\"replay\"}");
entity.setCreateTime(LocalDateTime.of(2026, 4, 9, 10, 0, 0));
entity.setUpdateTime(LocalDateTime.of(2026, 4, 9, 10, 0, 0));
return entity;
}
}
@@ -0,0 +1,27 @@
package com.cisd.tms.modules.security.replay.job;
import com.cisd.tms.modules.security.replay.repository.ReplayNonceRepository;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
class ReplayNonceCleanupJobTest {
private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-04-09T10:30:00Z"), ZoneOffset.UTC);
@Test
void shouldDeleteExpiredNoncesWithCurrentUtcTimeAndBatchLimit() {
ReplayNonceRepository replayNonceRepository = Mockito.mock(ReplayNonceRepository.class);
ReplayNonceCleanupJob cleanupJob = new ReplayNonceCleanupJob(replayNonceRepository, FIXED_CLOCK);
cleanupJob.cleanupExpiredNonces();
Mockito.verify(replayNonceRepository).deleteExpiredBefore(
LocalDateTime.of(2026, 4, 9, 10, 30, 0),
1000
);
}
}
@@ -0,0 +1,283 @@
package com.cisd.tms.modules.security.replay.service;
import com.cisd.tms.common.config.properties.TmsSecurityProperties;
import com.cisd.tms.modules.security.replay.dto.ReplayCheckRequest;
import com.cisd.tms.modules.security.replay.dto.ReplayCheckResult;
import com.cisd.tms.modules.security.replay.entity.ReplayNonceEntity;
import com.cisd.tms.modules.security.replay.entity.SecurityEventEntity;
import com.cisd.tms.modules.security.replay.enums.ReplayCheckStatus;
import com.cisd.tms.modules.security.replay.enums.ReplayScope;
import com.cisd.tms.modules.security.replay.enums.SecurityEventType;
import com.cisd.tms.modules.security.replay.exception.ReplayProtectionException;
import com.cisd.tms.modules.security.replay.repository.ReplayNonceRepository;
import com.cisd.tms.modules.security.replay.repository.SecurityEventRepository;
import com.cisd.tms.modules.security.replay.service.impl.ReplayProtectionServiceImpl;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Optional;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
class ReplayProtectionServiceTest {
private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-04-09T10:00:00Z"), ZoneOffset.UTC);
private static final long FIXED_REQUEST_TIMESTAMP = 1775728800L;
private static final long EXPIRED_REQUEST_TIMESTAMP = 1775728440L;
@Test
void shouldClaimFirstNonceAndPersistRecord() {
ReplayNonceRepository replayNonceRepository = Mockito.mock(ReplayNonceRepository.class);
SecurityEventRepository securityEventRepository = Mockito.mock(SecurityEventRepository.class);
Mockito.when(replayNonceRepository.findByScopeAndPrincipalIdAndNonce("OPENAPI", "demo-app", "nonce-001"))
.thenReturn(Optional.empty());
Mockito.when(replayNonceRepository.trySave(Mockito.any(ReplayNonceEntity.class))).thenReturn(true);
ReplayProtectionService service = new ReplayProtectionServiceImpl(
replayNonceRepository,
securityEventRepository,
FIXED_CLOCK
);
ReplayCheckRequest request = request(ReplayScope.OPENAPI, "demo-app", "nonce-001", FIXED_REQUEST_TIMESTAMP,
"POST", "/openapi/v1/demo", "abc123");
ReplayCheckResult result = service.check(request);
Assertions.assertEquals(ReplayCheckStatus.CLAIMED, result.getStatus());
Assertions.assertFalse(result.isReplayed());
Assertions.assertFalse(result.isMismatch());
Assertions.assertFalse(result.isSecurityEventRecorded());
Assertions.assertEquals(LocalDateTime.of(2026, 4, 9, 10, 10), result.getExpiresAt());
ArgumentCaptor<ReplayNonceEntity> captor = ArgumentCaptor.forClass(ReplayNonceEntity.class);
Mockito.verify(replayNonceRepository).trySave(captor.capture());
ReplayNonceEntity saved = captor.getValue();
Assertions.assertEquals("OPENAPI", saved.getScope());
Assertions.assertEquals("demo-app", saved.getPrincipalId());
Assertions.assertEquals("nonce-001", saved.getNonce());
Assertions.assertEquals(FIXED_REQUEST_TIMESTAMP, saved.getRequestTimestamp());
Assertions.assertEquals("POST", saved.getRequestMethod());
Assertions.assertEquals("/openapi/v1/demo", saved.getRequestPath());
Assertions.assertEquals("abc123", saved.getBodyHash());
Assertions.assertEquals("127.0.0.1", saved.getRemoteIp());
Assertions.assertEquals("JUnit", saved.getUserAgent());
Assertions.assertEquals(LocalDateTime.of(2026, 4, 9, 10, 10), saved.getExpiresAt());
Mockito.verifyNoInteractions(securityEventRepository);
}
@Test
void shouldReturnReplayResultForDuplicateNonce() {
ReplayNonceRepository replayNonceRepository = Mockito.mock(ReplayNonceRepository.class);
SecurityEventRepository securityEventRepository = Mockito.mock(SecurityEventRepository.class);
ReplayNonceEntity existing = persistedNonce("INTERNAL_API", "session-001", "nonce-dup",
"PUT", "/api/v1/device", "body-1");
Mockito.when(replayNonceRepository.findByScopeAndPrincipalIdAndNonce("INTERNAL_API", "session-001", "nonce-dup"))
.thenReturn(Optional.of(existing));
ReplayProtectionService service = new ReplayProtectionServiceImpl(
replayNonceRepository,
securityEventRepository,
FIXED_CLOCK
);
ReplayCheckRequest request = request(ReplayScope.INTERNAL_API, "session-001", "nonce-dup", FIXED_REQUEST_TIMESTAMP,
"PUT", "/api/v1/device", "body-1");
ReplayCheckResult result = service.check(request);
Assertions.assertEquals(ReplayCheckStatus.REPLAYED, result.getStatus());
Assertions.assertTrue(result.isReplayed());
Assertions.assertFalse(result.isMismatch());
Assertions.assertFalse(result.isSecurityEventRecorded());
Mockito.verify(replayNonceRepository, Mockito.never()).trySave(Mockito.any());
Mockito.verifyNoInteractions(securityEventRepository);
}
@Test
void shouldReclaimExpiredExistingNonceRecord() {
ReplayNonceRepository replayNonceRepository = Mockito.mock(ReplayNonceRepository.class);
SecurityEventRepository securityEventRepository = Mockito.mock(SecurityEventRepository.class);
ReplayNonceEntity expired = persistedNonce("OPENAPI", "demo-app", "nonce-expired",
"POST", "/openapi/v1/demo", "abc123");
expired.setId(1001L);
expired.setExpiresAt(LocalDateTime.of(2026, 4, 9, 9, 59));
Mockito.when(replayNonceRepository.findByScopeAndPrincipalIdAndNonce("OPENAPI", "demo-app", "nonce-expired"))
.thenReturn(Optional.of(expired), Optional.empty());
Mockito.when(replayNonceRepository.trySave(Mockito.any(ReplayNonceEntity.class))).thenReturn(true);
ReplayProtectionService service = new ReplayProtectionServiceImpl(
replayNonceRepository,
securityEventRepository,
FIXED_CLOCK
);
ReplayCheckRequest request = request(ReplayScope.OPENAPI, "demo-app", "nonce-expired", FIXED_REQUEST_TIMESTAMP,
"POST", "/openapi/v1/demo", "abc123");
ReplayCheckResult result = service.check(request);
Assertions.assertEquals(ReplayCheckStatus.CLAIMED, result.getStatus());
Assertions.assertFalse(result.isReplayed());
Assertions.assertFalse(result.isMismatch());
Assertions.assertFalse(result.isSecurityEventRecorded());
Mockito.verify(replayNonceRepository).deleteById(1001L);
Mockito.verify(replayNonceRepository).trySave(Mockito.any(ReplayNonceEntity.class));
Mockito.verifyNoInteractions(securityEventRepository);
}
@Test
void shouldMarkMismatchAndRecordSecurityEventForDifferentFingerprint() {
ReplayNonceRepository replayNonceRepository = Mockito.mock(ReplayNonceRepository.class);
SecurityEventRepository securityEventRepository = Mockito.mock(SecurityEventRepository.class);
ReplayNonceEntity existing = persistedNonce("INTERNAL_API", "session-001", "nonce-dup",
"PUT", "/api/v1/device", "body-1");
Mockito.when(replayNonceRepository.findByScopeAndPrincipalIdAndNonce("INTERNAL_API", "session-001", "nonce-dup"))
.thenReturn(Optional.of(existing));
Mockito.doNothing().when(securityEventRepository).save(Mockito.any(SecurityEventEntity.class));
ReplayProtectionService service = new ReplayProtectionServiceImpl(
replayNonceRepository,
securityEventRepository,
FIXED_CLOCK
);
ReplayCheckRequest request = request(ReplayScope.INTERNAL_API, "session-001", "nonce-dup", FIXED_REQUEST_TIMESTAMP,
"PUT", "/api/v1/device/config", "body-2");
ReplayCheckResult result = service.check(request);
Assertions.assertEquals(ReplayCheckStatus.MISMATCH, result.getStatus());
Assertions.assertTrue(result.isReplayed());
Assertions.assertTrue(result.isMismatch());
Assertions.assertTrue(result.isSecurityEventRecorded());
ArgumentCaptor<SecurityEventEntity> captor = ArgumentCaptor.forClass(SecurityEventEntity.class);
Mockito.verify(securityEventRepository).save(captor.capture());
SecurityEventEntity event = captor.getValue();
Assertions.assertEquals(SecurityEventType.REPLAY_MISMATCH.name(), event.getEventType());
Assertions.assertEquals("INTERNAL_API", event.getScope());
Assertions.assertEquals("session-001", event.getPrincipalId());
Assertions.assertEquals("PUT", event.getRequestMethod());
Assertions.assertEquals("/api/v1/device/config", event.getRequestPath());
Assertions.assertEquals("nonce-dup", event.getNonce());
Assertions.assertEquals("127.0.0.1", event.getRemoteIp());
}
@Test
void shouldRejectExpiredTimestampBeforeClaim() {
ReplayNonceRepository replayNonceRepository = Mockito.mock(ReplayNonceRepository.class);
SecurityEventRepository securityEventRepository = Mockito.mock(SecurityEventRepository.class);
ReplayProtectionService service = new ReplayProtectionServiceImpl(
replayNonceRepository,
securityEventRepository,
FIXED_CLOCK
);
ReplayCheckRequest request = request(ReplayScope.OPENAPI, "demo-app", "nonce-old", EXPIRED_REQUEST_TIMESTAMP,
"POST", "/openapi/v1/demo", "abc123");
Assertions.assertThrows(ReplayProtectionException.class, () -> service.check(request));
Mockito.verifyNoInteractions(replayNonceRepository);
Mockito.verifyNoInteractions(securityEventRepository);
}
@Test
void shouldFailClosedWhenClaimPersistenceFails() {
ReplayNonceRepository replayNonceRepository = Mockito.mock(ReplayNonceRepository.class);
SecurityEventRepository securityEventRepository = Mockito.mock(SecurityEventRepository.class);
Mockito.when(replayNonceRepository.findByScopeAndPrincipalIdAndNonce("OPENAPI", "demo-app", "nonce-001"))
.thenReturn(Optional.empty());
Mockito.when(replayNonceRepository.trySave(Mockito.any(ReplayNonceEntity.class)))
.thenThrow(new IllegalStateException("db down"));
ReplayProtectionService service = new ReplayProtectionServiceImpl(
replayNonceRepository,
securityEventRepository,
FIXED_CLOCK
);
ReplayCheckRequest request = request(ReplayScope.OPENAPI, "demo-app", "nonce-001", FIXED_REQUEST_TIMESTAMP,
"POST", "/openapi/v1/demo", "abc123");
Assertions.assertThrows(ReplayProtectionException.class, () -> service.check(request));
}
@Test
void shouldHonorConfiguredOpenApiSkewWindow() {
ReplayNonceRepository replayNonceRepository = Mockito.mock(ReplayNonceRepository.class);
SecurityEventRepository securityEventRepository = Mockito.mock(SecurityEventRepository.class);
Mockito.when(replayNonceRepository.findByScopeAndPrincipalIdAndNonce("OPENAPI", "demo-app", "nonce-600"))
.thenReturn(Optional.empty());
Mockito.when(replayNonceRepository.trySave(Mockito.any(ReplayNonceEntity.class))).thenReturn(true);
TmsSecurityProperties securityProperties = securityProperties(600L);
ReplayProtectionService service = new ReplayProtectionServiceImpl(
replayNonceRepository,
securityEventRepository,
securityProperties,
FIXED_CLOCK
);
ReplayCheckRequest request = request(ReplayScope.OPENAPI, "demo-app", "nonce-600", FIXED_REQUEST_TIMESTAMP - 500L,
"POST", "/openapi/v1/demo", "abc123");
ReplayCheckResult result = service.check(request);
Assertions.assertEquals(ReplayCheckStatus.CLAIMED, result.getStatus());
Assertions.assertEquals(LocalDateTime.of(2026, 4, 9, 10, 20), result.getExpiresAt());
}
private static ReplayCheckRequest request(
ReplayScope scope,
String principalId,
String nonce,
long requestTimestampSeconds,
String requestMethod,
String requestPath,
String bodyHash
) {
ReplayCheckRequest request = new ReplayCheckRequest();
request.setScope(scope);
request.setPrincipalId(principalId);
request.setNonce(nonce);
request.setRequestTimestamp(requestTimestampSeconds);
request.setRequestMethod(requestMethod);
request.setRequestPath(requestPath);
request.setBodyHash(bodyHash);
request.setRemoteIp("127.0.0.1");
request.setUserAgent("JUnit");
return request;
}
private static ReplayNonceEntity persistedNonce(
String scope,
String principalId,
String nonce,
String requestMethod,
String requestPath,
String bodyHash
) {
ReplayNonceEntity entity = new ReplayNonceEntity();
entity.setScope(scope);
entity.setPrincipalId(principalId);
entity.setNonce(nonce);
entity.setRequestTimestamp(FIXED_REQUEST_TIMESTAMP);
entity.setRequestMethod(requestMethod);
entity.setRequestPath(requestPath);
entity.setBodyHash(bodyHash);
entity.setRemoteIp("127.0.0.1");
entity.setUserAgent("JUnit");
entity.setExpiresAt(LocalDateTime.of(2026, 4, 9, 10, 10));
return entity;
}
private static TmsSecurityProperties securityProperties(long openApiSkewSeconds) {
TmsSecurityProperties securityProperties = new TmsSecurityProperties();
securityProperties.getOpenapi().setTimestampSkewSeconds(openApiSkewSeconds);
return securityProperties;
}
}
@@ -56,6 +56,32 @@ class UpgradeTaskRunnerTest {
Assertions.assertTrue(log.indexOf("execute") < log.indexOf("verify"));
}
@Test
void shouldUpdateReceiverSoftwareVersionWhenReceiverUpgradeSucceeds() throws Exception {
Path tempDir = Files.createTempDirectory("upgrade-runner-test");
FileRecordEntity fileRecord = buildPackage(tempDir, false, "#!/bin/sh\necho execute\nexit 0\n", false);
InMemoryFileRecordRepository fileRepository = new InMemoryFileRecordRepository();
fileRepository.save(fileRecord);
InMemoryUpgradeTaskRepository taskRepository = new InMemoryUpgradeTaskRepository();
InMemoryDeviceSoftwareVersionRepository versionRepository = new InMemoryDeviceSoftwareVersionRepository();
UpgradeTaskRunner runner = new UpgradeTaskRunner(
taskRepository,
fileRepository,
versionRepository,
stagingService(tempDir.resolve("staging"), tempDir.resolve("logs")),
properties(tempDir.resolve("staging"), tempDir.resolve("logs")),
objectMapper
);
runner.run(task("UPG-001-R", fileRecord.getFileId(), "RECEIVER", "V1.4.0"));
Assertions.assertEquals("SUCCESS", taskRepository.status);
Assertions.assertNull(versionRepository.store.get("APP"));
Assertions.assertEquals("V1.4.0", versionRepository.store.get("RECEIVER").getCurrentVersion());
Assertions.assertEquals("标准收发器", versionRepository.store.get("RECEIVER").getComponentName());
}
@Test
void shouldMarkTaskFailedWhenExecuteScriptFails() throws Exception {
Path tempDir = Files.createTempDirectory("upgrade-runner-test");
@@ -1,153 +0,0 @@
package com.cisd.tms.security.internal;
import com.cisd.tms.common.config.properties.TmsSecurityProperties;
import com.cisd.tms.modules.auth.entity.AuthSessionEntity;
import com.cisd.tms.modules.auth.repository.AuthSessionRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
class InternalApiAuthInterceptorTest {
private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-03-23T02:00:00Z"), ZoneOffset.UTC);
@Test
void shouldRejectRequestWhenSessionTokenIsMissing() throws Exception {
InternalApiAuthInterceptor interceptor = new InternalApiAuthInterceptor(
new InMemoryAuthSessionRepository(),
new ObjectMapper(),
new TmsSecurityProperties(),
FIXED_CLOCK
);
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/device/info");
MockHttpServletResponse response = new MockHttpServletResponse();
boolean allowed = interceptor.preHandle(request, response, new Object());
Assertions.assertFalse(allowed);
Assertions.assertEquals(401, response.getStatus());
Assertions.assertTrue(response.getContentAsString().contains("session token missing"));
}
@Test
void shouldRejectRequestWhenSessionHasExpiredByIdleTimeout() throws Exception {
InMemoryAuthSessionRepository sessions = new InMemoryAuthSessionRepository();
sessions.save(session("token-expired", "KEY_ADMIN", "FULL",
LocalDateTime.of(2026, 3, 23, 1, 45),
LocalDateTime.of(2026, 3, 23, 1, 49),
LocalDateTime.of(2026, 3, 23, 1, 59)));
InternalApiAuthInterceptor interceptor = new InternalApiAuthInterceptor(
sessions,
new ObjectMapper(),
new TmsSecurityProperties(),
FIXED_CLOCK
);
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/device/info");
request.addHeader("Authorization", "Bearer token-expired");
MockHttpServletResponse response = new MockHttpServletResponse();
boolean allowed = interceptor.preHandle(request, response, new Object());
Assertions.assertFalse(allowed);
Assertions.assertEquals(401, response.getStatus());
Assertions.assertTrue(response.getContentAsString().contains("session expired"));
}
@Test
void shouldAllowRequestAndRefreshLastActiveAtForValidSession() throws Exception {
InMemoryAuthSessionRepository sessions = new InMemoryAuthSessionRepository();
sessions.save(session("token-valid", "AUDIT_ADMIN", "LIMITED",
LocalDateTime.of(2026, 3, 23, 1, 55),
LocalDateTime.of(2026, 3, 23, 1, 58),
LocalDateTime.of(2026, 3, 23, 2, 8)));
InternalApiAuthInterceptor interceptor = new InternalApiAuthInterceptor(
sessions,
new ObjectMapper(),
new TmsSecurityProperties(),
FIXED_CLOCK
);
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/device/info");
request.addHeader("Authorization", "Bearer token-valid");
MockHttpServletResponse response = new MockHttpServletResponse();
boolean allowed = interceptor.preHandle(request, response, new Object());
Assertions.assertTrue(allowed);
Assertions.assertEquals("AUDIT_ADMIN", request.getAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE));
Assertions.assertEquals("LIMITED", request.getAttribute(InternalApiAuthInterceptor.ATTR_AUTH_LEVEL));
AuthSessionEntity updated = sessions.findBySessionToken("token-valid").orElseThrow();
Assertions.assertEquals(LocalDateTime.of(2026, 3, 23, 2, 0), updated.getLastActiveAt());
Assertions.assertEquals(LocalDateTime.of(2026, 3, 23, 2, 10), updated.getExpiresAt());
}
@Test
void shouldBypassSessionValidationWhenInternalAuthIsDisabled() throws Exception {
TmsSecurityProperties securityProperties = new TmsSecurityProperties();
securityProperties.getInternalAuth().setEnabled(false);
InternalApiAuthInterceptor interceptor = new InternalApiAuthInterceptor(
new InMemoryAuthSessionRepository(),
new ObjectMapper(),
securityProperties,
FIXED_CLOCK
);
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/device/info");
MockHttpServletResponse response = new MockHttpServletResponse();
boolean allowed = interceptor.preHandle(request, response, new Object());
Assertions.assertTrue(allowed);
Assertions.assertEquals("KEY_ADMIN", request.getAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE));
Assertions.assertEquals("FULL", request.getAttribute(InternalApiAuthInterceptor.ATTR_AUTH_LEVEL));
Assertions.assertEquals("DEBUG-BYPASS", request.getAttribute(InternalApiAuthInterceptor.ATTR_SESSION_TOKEN));
}
private static AuthSessionEntity session(
String token,
String roleCode,
String authLevel,
LocalDateTime issuedAt,
LocalDateTime lastActiveAt,
LocalDateTime expiresAt
) {
AuthSessionEntity entity = new AuthSessionEntity();
entity.setId((long) (token.hashCode() & Integer.MAX_VALUE));
entity.setSessionToken(token);
entity.setRoleCode(roleCode);
entity.setAuthLevel(authLevel);
entity.setIssuedAt(issuedAt);
entity.setLastActiveAt(lastActiveAt);
entity.setExpiresAt(expiresAt);
return entity;
}
private static class InMemoryAuthSessionRepository implements AuthSessionRepository {
private final Map<String, AuthSessionEntity> store = new ConcurrentHashMap<>();
@Override
public Optional<AuthSessionEntity> findBySessionToken(String sessionToken) {
return Optional.ofNullable(store.get(sessionToken));
}
@Override
public void save(AuthSessionEntity entity) {
store.put(entity.getSessionToken(), entity);
}
@Override
public void update(AuthSessionEntity entity) {
store.put(entity.getSessionToken(), entity);
}
}
}
@@ -0,0 +1,180 @@
package com.cisd.tms.security.internal;
import com.cisd.tms.modules.security.replay.dto.ReplayCheckRequest;
import com.cisd.tms.modules.security.replay.dto.ReplayCheckResult;
import com.cisd.tms.modules.security.replay.enums.ReplayCheckStatus;
import com.cisd.tms.modules.security.replay.enums.ReplayScope;
import com.cisd.tms.modules.security.replay.exception.ReplayProtectionException;
import com.cisd.tms.modules.security.replay.service.ReplayProtectionService;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.web.method.HandlerMethod;
class InternalApiReplayInterceptorTest {
private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-04-09T10:00:00Z"), ZoneOffset.UTC);
private static final long FIXED_REQUEST_TIMESTAMP = 1775728800L;
@Test
void shouldAllowReplayProtectedRequestAndHashBody() throws Exception {
ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class);
Mockito.when(replayProtectionService.check(Mockito.any(ReplayCheckRequest.class))).thenReturn(claimedResult());
InternalApiReplayInterceptor interceptor = new InternalApiReplayInterceptor(
replayProtectionService,
new ObjectMapper(),
FIXED_CLOCK
);
MockHttpServletRequest sourceRequest = new MockHttpServletRequest("POST", "/api/v1/init/tasks/task-001/execute");
sourceRequest.setContentType("application/json");
sourceRequest.setContent("{\"taskId\":\"task-001\"}".getBytes(StandardCharsets.UTF_8));
sourceRequest.addHeader("X-Request-Timestamp", Long.toString(FIXED_REQUEST_TIMESTAMP));
sourceRequest.addHeader("X-Request-Nonce", "nonce-001");
sourceRequest.addHeader("User-Agent", "JUnit");
sourceRequest.setRemoteAddr("127.0.0.1");
CachedBodyHttpServletRequest request = new CachedBodyHttpServletRequest(sourceRequest);
request.setAttribute(InternalApiAuthInterceptor.ATTR_SESSION_TOKEN, "session-001");
MockHttpServletResponse response = new MockHttpServletResponse();
boolean allowed = interceptor.preHandle(request, response, protectedHandler("execute"));
Assertions.assertTrue(allowed);
ArgumentCaptor<ReplayCheckRequest> captor = ArgumentCaptor.forClass(ReplayCheckRequest.class);
Mockito.verify(replayProtectionService).check(captor.capture());
ReplayCheckRequest replayRequest = captor.getValue();
Assertions.assertEquals(ReplayScope.INTERNAL_API, replayRequest.getScope());
Assertions.assertEquals("session-001", replayRequest.getPrincipalId());
Assertions.assertEquals("nonce-001", replayRequest.getNonce());
Assertions.assertEquals(FIXED_REQUEST_TIMESTAMP, replayRequest.getRequestTimestamp());
Assertions.assertEquals("POST", replayRequest.getRequestMethod());
Assertions.assertEquals("/api/v1/init/tasks/task-001/execute", replayRequest.getRequestPath());
Assertions.assertEquals(sha256Hex("{\"taskId\":\"task-001\"}"), replayRequest.getBodyHash());
}
@Test
void shouldSkipHandlersWithoutReplayProtection() throws Exception {
ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class);
InternalApiReplayInterceptor interceptor = new InternalApiReplayInterceptor(
replayProtectionService,
new ObjectMapper(),
FIXED_CLOCK
);
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/device/info");
MockHttpServletResponse response = new MockHttpServletResponse();
boolean allowed = interceptor.preHandle(request, response, protectedHandler("unprotected"));
Assertions.assertTrue(allowed);
Mockito.verifyNoInteractions(replayProtectionService);
}
@Test
void shouldRejectProtectedRequestWhenReplayHeadersAreMissing() throws Exception {
ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class);
InternalApiReplayInterceptor interceptor = new InternalApiReplayInterceptor(
replayProtectionService,
new ObjectMapper(),
FIXED_CLOCK
);
MockHttpServletRequest sourceRequest = new MockHttpServletRequest("POST", "/api/v1/init/tasks/task-001/execute");
CachedBodyHttpServletRequest request = new CachedBodyHttpServletRequest(sourceRequest);
request.setAttribute(InternalApiAuthInterceptor.ATTR_SESSION_TOKEN, "session-001");
MockHttpServletResponse response = new MockHttpServletResponse();
boolean allowed = interceptor.preHandle(request, response, protectedHandler("execute"));
Assertions.assertFalse(allowed);
Assertions.assertEquals(400, response.getStatus());
Assertions.assertTrue(response.getContentAsString().contains("missing replay protection headers"));
Mockito.verifyNoInteractions(replayProtectionService);
}
@Test
void shouldRejectProtectedRequestWhenReplayIsDetected() throws Exception {
ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class);
ReplayCheckResult replayed = new ReplayCheckResult();
replayed.setStatus(ReplayCheckStatus.REPLAYED);
replayed.setReplayed(true);
Mockito.when(replayProtectionService.check(Mockito.any(ReplayCheckRequest.class))).thenReturn(replayed);
InternalApiReplayInterceptor interceptor = new InternalApiReplayInterceptor(
replayProtectionService,
new ObjectMapper(),
FIXED_CLOCK
);
MockHttpServletRequest sourceRequest = new MockHttpServletRequest("POST", "/api/v1/init/tasks/task-001/execute");
sourceRequest.addHeader("X-Request-Timestamp", Long.toString(FIXED_REQUEST_TIMESTAMP));
sourceRequest.addHeader("X-Request-Nonce", "nonce-001");
CachedBodyHttpServletRequest request = new CachedBodyHttpServletRequest(sourceRequest);
request.setAttribute(InternalApiAuthInterceptor.ATTR_SESSION_TOKEN, "session-001");
MockHttpServletResponse response = new MockHttpServletResponse();
boolean allowed = interceptor.preHandle(request, response, protectedHandler("execute"));
Assertions.assertFalse(allowed);
Assertions.assertEquals(409, response.getStatus());
Assertions.assertTrue(response.getContentAsString().contains("replayed nonce"));
}
@Test
void shouldFailClosedWhenReplayProtectionErrors() throws Exception {
ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class);
Mockito.when(replayProtectionService.check(Mockito.any(ReplayCheckRequest.class)))
.thenThrow(new ReplayProtectionException("db down"));
InternalApiReplayInterceptor interceptor = new InternalApiReplayInterceptor(
replayProtectionService,
new ObjectMapper(),
FIXED_CLOCK
);
MockHttpServletRequest sourceRequest = new MockHttpServletRequest("POST", "/api/v1/init/tasks/task-001/execute");
sourceRequest.addHeader("X-Request-Timestamp", Long.toString(FIXED_REQUEST_TIMESTAMP));
sourceRequest.addHeader("X-Request-Nonce", "nonce-001");
CachedBodyHttpServletRequest request = new CachedBodyHttpServletRequest(sourceRequest);
request.setAttribute(InternalApiAuthInterceptor.ATTR_SESSION_TOKEN, "session-001");
MockHttpServletResponse response = new MockHttpServletResponse();
boolean allowed = interceptor.preHandle(request, response, protectedHandler("execute"));
Assertions.assertFalse(allowed);
Assertions.assertEquals(503, response.getStatus());
Assertions.assertTrue(response.getContentAsString().contains("replay protection unavailable"));
}
private static HandlerMethod protectedHandler(String methodName) throws NoSuchMethodException {
return new HandlerMethod(new ReplayProtectedController(), ReplayProtectedController.class.getMethod(methodName));
}
private static ReplayCheckResult claimedResult() {
ReplayCheckResult result = new ReplayCheckResult();
result.setStatus(ReplayCheckStatus.CLAIMED);
result.setReplayed(false);
return result;
}
private static String sha256Hex(String value) throws Exception {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
StringBuilder builder = new StringBuilder(digest.length * 2);
for (byte b : digest) {
builder.append(String.format("%02x", b));
}
return builder.toString();
}
static class ReplayProtectedController {
@ReplayProtected
public void execute() {
}
public void unprotected() {
}
}
}
@@ -0,0 +1,112 @@
package com.cisd.tms.security.internal;
import com.cisd.tms.modules.auth.controller.AuthAdminController;
import com.cisd.tms.modules.auth.controller.AuthController;
import com.cisd.tms.modules.device.controller.CryptoCardController;
import com.cisd.tms.modules.device.controller.DeviceController;
import com.cisd.tms.modules.device.controller.IpWhitelistController;
import com.cisd.tms.modules.device.controller.NetworkConfigController;
import com.cisd.tms.modules.device.controller.TimeConfigController;
import com.cisd.tms.modules.init.controller.InitController;
import com.cisd.tms.modules.mk.controller.LmkController;
import com.cisd.tms.modules.upgrade.controller.UpgradeController;
import java.lang.reflect.Method;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.core.annotation.AnnotatedElementUtils;
class ReplayProtectedEndpointsTest {
@Test
void shouldProtectSelectedInitActions() throws Exception {
Assertions.assertNotNull(annotation(InitController.class, "createTask", com.cisd.tms.modules.init.dto.InitPreviewRequest.class));
Assertions.assertNotNull(annotation(InitController.class, "executeTask", String.class));
Assertions.assertNotNull(annotation(InitController.class, "createResetTask"));
Assertions.assertNotNull(annotation(InitController.class, "executeResetTask", String.class));
}
@Test
void shouldProtectSelectedUpgradeActions() throws Exception {
Assertions.assertNotNull(annotation(UpgradeController.class, "uploadPackage", org.springframework.web.multipart.MultipartFile.class));
Assertions.assertNotNull(annotation(UpgradeController.class, "create", com.cisd.tms.modules.upgrade.dto.UpgradeCreateTaskRequest.class));
Assertions.assertNotNull(annotation(UpgradeController.class, "execute", String.class));
Assertions.assertNotNull(annotation(UpgradeController.class, "rollback", String.class));
}
@Test
void shouldProtectSensitiveControllersAndMethods() throws Exception {
Assertions.assertNotNull(annotation(AuthController.class, "changePassword",
com.cisd.tms.modules.auth.dto.ChangePasswordRequest.class, jakarta.servlet.http.HttpServletRequest.class));
Assertions.assertNotNull(annotation(DeviceController.class, "restart", jakarta.servlet.http.HttpServletRequest.class));
Assertions.assertNotNull(AnnotatedElementUtils.findMergedAnnotation(AuthAdminController.class, ReplayProtected.class));
Assertions.assertNotNull(AnnotatedElementUtils.findMergedAnnotation(TimeConfigController.class, ReplayProtected.class));
Assertions.assertNotNull(AnnotatedElementUtils.findMergedAnnotation(CryptoCardController.class, ReplayProtected.class));
}
@Test
void shouldOnlyProtectWriteActionsOnMixedReadWriteControllers() throws Exception {
Assertions.assertNull(AnnotatedElementUtils.findMergedAnnotation(NetworkConfigController.class, ReplayProtected.class));
Assertions.assertNull(AnnotatedElementUtils.findMergedAnnotation(IpWhitelistController.class, ReplayProtected.class));
Assertions.assertNull(AnnotatedElementUtils.findMergedAnnotation(LmkController.class, ReplayProtected.class));
Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "setIpv4Config",
com.cisd.tms.modules.device.dto.network.Ipv4ConfigRequest.class));
Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "setIpv6Config",
com.cisd.tms.modules.device.dto.network.Ipv6ConfigRequest.class));
Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "createBond",
com.cisd.tms.modules.device.dto.network.BondCreateRequest.class));
Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "deleteBond", String.class));
Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "addSlavesTOBond",
com.cisd.tms.modules.device.dto.network.BondAddSlavesRequest.class));
Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "removeSlaveFromBond",
com.cisd.tms.modules.device.dto.network.BondRemoveSlaveRequest.class));
Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "setBondMode",
com.cisd.tms.modules.device.dto.network.BondModifyModeRequest.class));
Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "setDefaultRoute",
com.cisd.tms.modules.device.dto.network.SetDefaultRouteRequest.class));
Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "addStaticRoute",
com.cisd.tms.modules.device.dto.network.AddStaticRouteRequest.class));
Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "deleteDefaultRoute",
com.cisd.tms.modules.device.dto.network.DeleteDefaultRouteRequest.class));
Assertions.assertNotNull(effectiveAnnotation(NetworkConfigController.class, "deleteStaticRoute",
com.cisd.tms.modules.device.dto.network.DeleteStaticRouteRequest.class));
Assertions.assertNull(effectiveAnnotation(NetworkConfigController.class, "getNetworkInfo"));
Assertions.assertNull(effectiveAnnotation(NetworkConfigController.class, "getIpv4Info", String.class));
Assertions.assertNull(effectiveAnnotation(NetworkConfigController.class, "getIpv6Info", String.class));
Assertions.assertNull(effectiveAnnotation(NetworkConfigController.class, "getAllBondName"));
Assertions.assertNull(effectiveAnnotation(NetworkConfigController.class, "getBondSlaves", String.class));
Assertions.assertNull(effectiveAnnotation(NetworkConfigController.class, "getBondMode", String.class));
Assertions.assertNull(effectiveAnnotation(NetworkConfigController.class, "getRoutingTable"));
Assertions.assertNotNull(effectiveAnnotation(IpWhitelistController.class, "addWhitelist",
com.cisd.tms.modules.device.dto.network.IpWhitelistRequest.class));
Assertions.assertNotNull(effectiveAnnotation(IpWhitelistController.class, "updateWhitelist",
com.cisd.tms.modules.device.dto.network.IpWhitelistRequest.class));
Assertions.assertNotNull(effectiveAnnotation(IpWhitelistController.class, "deleteWhitelist", Long.class));
Assertions.assertNull(effectiveAnnotation(IpWhitelistController.class, "getWhitelistPage",
com.cisd.tms.modules.device.dto.network.IpWhitelistRequest.class));
Assertions.assertNotNull(effectiveAnnotation(LmkController.class, "initMasterKey"));
Assertions.assertNotNull(effectiveAnnotation(LmkController.class, "destroyMasterKey"));
Assertions.assertNotNull(effectiveAnnotation(LmkController.class, "recoverLmkEx",
com.cisd.tms.modules.mk.dto.MasterKeyRecoverPacketsRequest.class));
Assertions.assertNull(effectiveAnnotation(LmkController.class, "queryMasterKeyStatus"));
Assertions.assertNull(effectiveAnnotation(LmkController.class, "signIk", String.class));
}
private static ReplayProtected annotation(Class<?> type, String methodName, Class<?>... parameterTypes) throws Exception {
Method method = type.getMethod(methodName, parameterTypes);
return AnnotatedElementUtils.findMergedAnnotation(method, ReplayProtected.class);
}
private static ReplayProtected effectiveAnnotation(Class<?> type, String methodName, Class<?>... parameterTypes) throws Exception {
Method method = type.getMethod(methodName, parameterTypes);
ReplayProtected methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, ReplayProtected.class);
if (methodAnnotation != null) {
return methodAnnotation;
}
return AnnotatedElementUtils.findMergedAnnotation(type, ReplayProtected.class);
}
}
@@ -0,0 +1,183 @@
package com.cisd.tms.security.openapi;
import com.cisd.tms.common.config.properties.TmsSecurityProperties;
import com.cisd.tms.common.util.HmacUtil;
import com.cisd.tms.modules.security.replay.dto.ReplayCheckRequest;
import com.cisd.tms.modules.security.replay.dto.ReplayCheckResult;
import com.cisd.tms.modules.security.replay.enums.ReplayCheckStatus;
import com.cisd.tms.modules.security.replay.enums.ReplayScope;
import com.cisd.tms.modules.security.replay.exception.ReplayProtectionException;
import com.cisd.tms.modules.security.replay.service.ReplayProtectionService;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
class OpenApiSignAuthInterceptorTest {
private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-04-09T10:00:00Z"), ZoneOffset.UTC);
private static final long FIXED_NOW_SECONDS = 1775728800L;
@Test
void shouldPassConfiguredSkewToReplayProtectionServiceAfterSignatureValidation() throws Exception {
TmsSecurityProperties securityProperties = securityProperties("demo-app", "demo-secret", 600L);
ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class);
Mockito.when(replayProtectionService.check(Mockito.any(ReplayCheckRequest.class)))
.thenReturn(claimedResult());
OpenApiSignAuthInterceptor interceptor = new OpenApiSignAuthInterceptor(
securityProperties,
replayProtectionService,
new ObjectMapper(),
FIXED_CLOCK
);
MockHttpServletRequest request = signedRequest(
"demo-app",
"demo-secret",
FIXED_NOW_SECONDS - 500L,
"nonce-001",
"POST",
"/openapi/v1/demo"
);
MockHttpServletResponse response = new MockHttpServletResponse();
boolean allowed = interceptor.preHandle(request, response, new Object());
Assertions.assertTrue(allowed);
ArgumentCaptor<ReplayCheckRequest> captor = ArgumentCaptor.forClass(ReplayCheckRequest.class);
Mockito.verify(replayProtectionService).check(captor.capture());
ReplayCheckRequest replayRequest = captor.getValue();
Assertions.assertEquals(ReplayScope.OPENAPI, replayRequest.getScope());
Assertions.assertEquals("demo-app", replayRequest.getPrincipalId());
Assertions.assertEquals("nonce-001", replayRequest.getNonce());
Assertions.assertEquals(FIXED_NOW_SECONDS - 500L, replayRequest.getRequestTimestamp());
Assertions.assertEquals("POST", replayRequest.getRequestMethod());
Assertions.assertEquals("/openapi/v1/demo", replayRequest.getRequestPath());
}
@Test
void shouldRejectInvalidSignatureWithoutCallingReplayProtectionService() throws Exception {
TmsSecurityProperties securityProperties = securityProperties("demo-app", "demo-secret", 300L);
ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class);
OpenApiSignAuthInterceptor interceptor = new OpenApiSignAuthInterceptor(
securityProperties,
replayProtectionService,
new ObjectMapper(),
FIXED_CLOCK
);
MockHttpServletRequest request = signedRequest(
"demo-app",
"wrong-secret",
FIXED_NOW_SECONDS,
"nonce-001",
"POST",
"/openapi/v1/demo"
);
MockHttpServletResponse response = new MockHttpServletResponse();
boolean allowed = interceptor.preHandle(request, response, new Object());
Assertions.assertFalse(allowed);
Assertions.assertEquals(401, response.getStatus());
Assertions.assertTrue(response.getContentAsString().contains("signature invalid"));
Mockito.verifyNoInteractions(replayProtectionService);
}
@Test
void shouldRejectWhenReplayProtectionReportsReplay() throws Exception {
TmsSecurityProperties securityProperties = securityProperties("demo-app", "demo-secret", 300L);
ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class);
ReplayCheckResult replayed = new ReplayCheckResult();
replayed.setStatus(ReplayCheckStatus.REPLAYED);
replayed.setReplayed(true);
Mockito.when(replayProtectionService.check(Mockito.any(ReplayCheckRequest.class))).thenReturn(replayed);
OpenApiSignAuthInterceptor interceptor = new OpenApiSignAuthInterceptor(
securityProperties,
replayProtectionService,
new ObjectMapper(),
FIXED_CLOCK
);
MockHttpServletRequest request = signedRequest(
"demo-app",
"demo-secret",
FIXED_NOW_SECONDS,
"nonce-001",
"POST",
"/openapi/v1/demo"
);
MockHttpServletResponse response = new MockHttpServletResponse();
boolean allowed = interceptor.preHandle(request, response, new Object());
Assertions.assertFalse(allowed);
Assertions.assertEquals(401, response.getStatus());
Assertions.assertTrue(response.getContentAsString().contains("replayed nonce"));
}
@Test
void shouldFailClosedWhenReplayProtectionServiceErrors() throws Exception {
TmsSecurityProperties securityProperties = securityProperties("demo-app", "demo-secret", 300L);
ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class);
Mockito.when(replayProtectionService.check(Mockito.any(ReplayCheckRequest.class)))
.thenThrow(new ReplayProtectionException("db down"));
OpenApiSignAuthInterceptor interceptor = new OpenApiSignAuthInterceptor(
securityProperties,
replayProtectionService,
new ObjectMapper(),
FIXED_CLOCK
);
MockHttpServletRequest request = signedRequest(
"demo-app",
"demo-secret",
FIXED_NOW_SECONDS,
"nonce-001",
"POST",
"/openapi/v1/demo"
);
MockHttpServletResponse response = new MockHttpServletResponse();
boolean allowed = interceptor.preHandle(request, response, new Object());
Assertions.assertFalse(allowed);
Assertions.assertEquals(401, response.getStatus());
Assertions.assertTrue(response.getContentAsString().contains("replay protection unavailable"));
}
private static TmsSecurityProperties securityProperties(String appId, String secret, long skewSeconds) {
TmsSecurityProperties securityProperties = new TmsSecurityProperties();
securityProperties.getOpenapi().setTimestampSkewSeconds(skewSeconds);
securityProperties.getOpenapi().getClients().put(appId, secret);
return securityProperties;
}
private static MockHttpServletRequest signedRequest(
String appId,
String secret,
long timestamp,
String nonce,
String method,
String path
) {
MockHttpServletRequest request = new MockHttpServletRequest(method, path);
request.addHeader("X-App-Id", appId);
request.addHeader("X-Timestamp", Long.toString(timestamp));
request.addHeader("X-Nonce", nonce);
request.addHeader("X-Signature", HmacUtil.hmacSha256Hex(secret, appId + "\n" + timestamp + "\n" + nonce));
request.addHeader("User-Agent", "JUnit");
request.setRemoteAddr("127.0.0.1");
return request;
}
private static ReplayCheckResult claimedResult() {
ReplayCheckResult result = new ReplayCheckResult();
result.setStatus(ReplayCheckStatus.CLAIMED);
result.setReplayed(false);
result.setMismatch(false);
return result;
}
}