接口防重放

This commit is contained in:
waner
2026-04-09 15:14:49 +08:00
parent 3d28f84115
commit 8f4f5f4774
48 changed files with 2643 additions and 232 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;
@@ -58,6 +59,13 @@ public class GlobalExceptionHandler {
.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<String> 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<String> 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<String> deleteWhitelist(@PathVariable Long id) {
ipWhitelistService.deleteWhitelist(id);
return ApiResponse.success("删除成功");
@@ -48,4 +52,4 @@ public class IpWhitelistController {
IPage<IpWhitelistResponse> page = ipWhitelistService.getWhitelistPage(req);
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<String> setIpv4Config(@RequestBody Ipv4ConfigRequest req) {
String resultMessage = networkConfigService.setIpv4Config(req);
return ApiResponse.success(resultMessage);
@@ -50,6 +52,7 @@ public class NetworkConfigController {
@Operation(summary = "设置IPv6配置", description = "为指定网络接口配置IPv6地址、前缀长度、网关等信息")
@PostMapping("/ipv6-config/set")
@ReplayProtected
public ApiResponse<String> setIpv6Config(@RequestBody Ipv6ConfigRequest req) {
String resultMessage = networkConfigService.setIpv6Config(req);
return ApiResponse.success(resultMessage);
@@ -63,6 +66,7 @@ public class NetworkConfigController {
@Operation(summary = "创建Bond", description = "创建一个新的Bond聚合接口,需指定名称、模式和从属接口")
@PostMapping("/bond/create")
@ReplayProtected
public ApiResponse<String> createBond(@RequestBody BondCreateRequest req) {
String resultMessage = networkConfigService.createBond(req);
return ApiResponse.success(resultMessage);
@@ -71,6 +75,7 @@ public class NetworkConfigController {
@Operation(summary = "删除Bond", description = "根据Bond名称删除指定的聚合接口")
@PostMapping("/bond/delete/{bondName}")
@ReplayProtected
public ApiResponse<String> deleteBond(@PathVariable("bondName") String bondName){
String resultMessage = networkConfigService.deleteBond(bondName);
return ApiResponse.success(resultMessage);
@@ -78,6 +83,7 @@ public class NetworkConfigController {
@Operation(summary = "添加从属接口到Bond", description = "向指定Bond中添加一个或多个从属网络接口")
@PostMapping("/bond-slave/add")
@ReplayProtected
public ApiResponse<String> addSlavesTOBond(@RequestBody BondAddSlavesRequest req) {
String resultMessage = networkConfigService.addSlavesTOBond(req);
return ApiResponse.success(resultMessage);
@@ -85,6 +91,7 @@ public class NetworkConfigController {
@Operation(summary = "从Bond中移除从属接口", description = "从指定Bond中移除一个或多个从属网络接口")
@PostMapping("/bond-slave/remove")
@ReplayProtected
public ApiResponse<String> removeSlaveFromBond(@RequestBody BondRemoveSlaveRequest req) {
String resultMessage = networkConfigService.removeSlaveFromBond(req);
return ApiResponse.success(resultMessage);
@@ -112,6 +119,7 @@ public class NetworkConfigController {
@Operation(summary = "设置Bond模式", description = "修改指定Bond的绑定模式")
@PostMapping("/bond/mode/set")
@ReplayProtected
public ApiResponse<String> setBondMode(@RequestBody BondModifyModeRequest req) {
String resultMessage = networkConfigService.setBondMode(req);
@@ -127,6 +135,7 @@ public class NetworkConfigController {
@Operation(summary = "设置默认路由", description = "配置或修改系统的默认网关路由")
@PostMapping("/routes/default/set")
@ReplayProtected
public ApiResponse<String> setDefaultRoute(@RequestBody SetDefaultRouteRequest req) {
String resultMessage = networkConfigService.setDefaultRoute(req);
return ApiResponse.success(resultMessage);
@@ -134,6 +143,7 @@ public class NetworkConfigController {
@Operation(summary = "添加静态路由", description = "新增一条静态路由规则")
@PostMapping("/routes/static/add")
@ReplayProtected
public ApiResponse<String> addStaticRoute(@RequestBody AddStaticRouteRequest req) {
String resultMessage = networkConfigService.addStaticRoute(req);
return ApiResponse.success(resultMessage);
@@ -141,6 +151,7 @@ public class NetworkConfigController {
@Operation(summary = "删除默认路由", description = "删除指定的默认路由")
@PostMapping("/routes/default/delete")
@ReplayProtected
public ApiResponse<String> deleteDefaultRoute(@RequestBody DeleteDefaultRouteRequest req) {
String resultMessage = networkConfigService.deleteDefaultRoute(req);
return ApiResponse.success(resultMessage);
@@ -148,6 +159,7 @@ public class NetworkConfigController {
@Operation(summary = "删除静态路由", description = "删除指定的静态路由")
@PostMapping("/routes/static/delete")
@ReplayProtected
public ApiResponse<String> deleteStaticRoute(@RequestBody DeleteStaticRouteRequest req) {
String resultMessage = networkConfigService.deleteStaticRoute(req);
return ApiResponse.success(resultMessage);
@@ -5,6 +5,7 @@ import com.cisd.tms.common.api.ApiResponse;
import com.cisd.tms.common.enums.ErrorCode;
import com.cisd.tms.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 {
@@ -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,6 +11,7 @@ import com.cisd.tms.modules.upgrade.dto.UpgradePreviewResponse;
import com.cisd.tms.modules.upgrade.dto.UpgradeTaskPageResponse;
import com.cisd.tms.modules.upgrade.dto.UpgradeTaskResponse;
import com.cisd.tms.modules.upgrade.service.UpgradeService;
import com.cisd.tms.security.internal.ReplayProtected;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -36,6 +37,7 @@ public class UpgradeController {
@PostMapping("/upgrade-packages")
@Operation(summary = "上传离线升级包", description = "上传离线升级包,返回 fileId 供升级预检和任务创建使用。")
@ReplayProtected
public ApiResponse<FileUploadResponse> uploadPackage(
@Parameter(description = "待上传的离线升级包")
@RequestParam("file") MultipartFile file
@@ -51,12 +53,14 @@ public class UpgradeController {
@PostMapping("/upgrades")
@Operation(summary = "创建离线升级任务", description = "根据 fileId 创建离线升级任务,状态初始为 PENDING_CONFIRM。")
@ReplayProtected
public ApiResponse<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
@@ -66,6 +70,7 @@ public class UpgradeController {
@PostMapping("/upgrades/{taskId}/rollback")
@Operation(summary = "回滚离线升级任务", description = "异步受理升级回滚请求,立即返回任务当前快照。")
@ReplayProtected
public ApiResponse<UpgradeTaskResponse> rollback(
@Parameter(description = "升级任务号")
@PathVariable String taskId
@@ -0,0 +1,69 @@
package com.cisd.tms.security.internal;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import org.springframework.util.StreamUtils;
public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper {
private final byte[] cachedBody;
public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException {
super(request);
this.cachedBody = StreamUtils.copyToByteArray(request.getInputStream());
}
public byte[] getCachedBody() {
return cachedBody.clone();
}
@Override
public ServletInputStream getInputStream() {
return new CachedBodyServletInputStream(cachedBody);
}
@Override
public BufferedReader getReader() {
Charset charset = getCharacterEncoding() == null
? StandardCharsets.UTF_8
: Charset.forName(getCharacterEncoding());
return new BufferedReader(new InputStreamReader(getInputStream(), charset));
}
private static class CachedBodyServletInputStream extends ServletInputStream {
private final ByteArrayInputStream inputStream;
private CachedBodyServletInputStream(byte[] cachedBody) {
this.inputStream = new ByteArrayInputStream(cachedBody);
}
@Override
public boolean isFinished() {
return inputStream.available() == 0;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setReadListener(ReadListener readListener) {
throw new UnsupportedOperationException("ReadListener is not supported");
}
@Override
public int read() {
return inputStream.read();
}
}
}
@@ -13,12 +13,15 @@ import java.io.IOException;
import java.time.Clock;
import java.time.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),
@@ -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,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)
);