解决网络配置接口冲突
This commit is contained in:
@@ -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 {
|
||||
|
||||
|
||||
+2
-2
@@ -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;
|
||||
}
|
||||
|
||||
+5
-5
@@ -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
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.cisd.tms.modules.security.replay.exception;
|
||||
|
||||
public class ReplayProtectionException extends RuntimeException {
|
||||
|
||||
public ReplayProtectionException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ReplayProtectionException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.cisd.tms.modules.security.replay.job;
|
||||
|
||||
import com.cisd.tms.modules.security.replay.repository.ReplayNonceRepository;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class ReplayNonceCleanupJob {
|
||||
|
||||
private static final int DELETE_BATCH_LIMIT = 1000;
|
||||
|
||||
private final ReplayNonceRepository replayNonceRepository;
|
||||
private final Clock clock;
|
||||
|
||||
public ReplayNonceCleanupJob(ReplayNonceRepository replayNonceRepository, Clock clock) {
|
||||
this.replayNonceRepository = replayNonceRepository;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 60000L)
|
||||
public void cleanupExpiredNonces() {
|
||||
// 分批清理过期 nonce,避免单次删除过多记录导致数据库抖动。
|
||||
replayNonceRepository.deleteExpiredBefore(nowUtc(), DELETE_BATCH_LIMIT);
|
||||
}
|
||||
|
||||
private LocalDateTime nowUtc() {
|
||||
return LocalDateTime.ofInstant(clock.instant(), ZoneOffset.UTC);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.cisd.tms.modules.security.replay.mapper;
|
||||
|
||||
import com.cisd.tms.infrastructure.persistence.mapper.BaseMapperX;
|
||||
import com.cisd.tms.modules.security.replay.entity.ReplayNonceEntity;
|
||||
import java.time.LocalDateTime;
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Result;
|
||||
import org.apache.ibatis.annotations.Results;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface ReplayNonceMapper extends BaseMapperX<ReplayNonceEntity> {
|
||||
|
||||
@Select("""
|
||||
SELECT id,
|
||||
scope,
|
||||
principal_id,
|
||||
nonce,
|
||||
request_timestamp,
|
||||
request_method,
|
||||
request_path,
|
||||
body_hash,
|
||||
remote_ip,
|
||||
user_agent,
|
||||
expires_at,
|
||||
create_time,
|
||||
update_time
|
||||
FROM tms_replay_nonce
|
||||
WHERE scope = #{scope}
|
||||
AND principal_id = #{principalId}
|
||||
AND nonce = #{nonce}
|
||||
LIMIT 1
|
||||
""")
|
||||
@Results(id = "ReplayNonceResultMap", value = {
|
||||
@Result(column = "id", property = "id", id = true),
|
||||
@Result(column = "scope", property = "scope"),
|
||||
@Result(column = "principal_id", property = "principalId"),
|
||||
@Result(column = "nonce", property = "nonce"),
|
||||
@Result(column = "request_timestamp", property = "requestTimestamp"),
|
||||
@Result(column = "request_method", property = "requestMethod"),
|
||||
@Result(column = "request_path", property = "requestPath"),
|
||||
@Result(column = "body_hash", property = "bodyHash"),
|
||||
@Result(column = "remote_ip", property = "remoteIp"),
|
||||
@Result(column = "user_agent", property = "userAgent"),
|
||||
@Result(column = "expires_at", property = "expiresAt"),
|
||||
@Result(column = "create_time", property = "createTime"),
|
||||
@Result(column = "update_time", property = "updateTime")
|
||||
})
|
||||
ReplayNonceEntity selectByScopeAndPrincipalIdAndNonce(
|
||||
@Param("scope") String scope,
|
||||
@Param("principalId") String principalId,
|
||||
@Param("nonce") String nonce
|
||||
);
|
||||
|
||||
@Delete("""
|
||||
DELETE FROM tms_replay_nonce
|
||||
WHERE scope = #{scope}
|
||||
AND principal_id = #{principalId}
|
||||
AND nonce = #{nonce}
|
||||
""")
|
||||
int deleteByScopeAndPrincipalIdAndNonce(
|
||||
@Param("scope") String scope,
|
||||
@Param("principalId") String principalId,
|
||||
@Param("nonce") String nonce
|
||||
);
|
||||
|
||||
@Delete("""
|
||||
DELETE FROM tms_replay_nonce
|
||||
WHERE expires_at < #{cutoff}
|
||||
LIMIT #{limit}
|
||||
""")
|
||||
int deleteExpiredBefore(@Param("cutoff") LocalDateTime cutoff, @Param("limit") int limit);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.cisd.tms.modules.security.replay.mapper;
|
||||
|
||||
import com.cisd.tms.infrastructure.persistence.mapper.BaseMapperX;
|
||||
import com.cisd.tms.modules.security.replay.entity.SecurityEventEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface SecurityEventMapper extends BaseMapperX<SecurityEventEntity> {
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.cisd.tms.modules.security.replay.repository;
|
||||
|
||||
import com.cisd.tms.modules.security.replay.entity.ReplayNonceEntity;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ReplayNonceRepository {
|
||||
|
||||
void save(ReplayNonceEntity entity);
|
||||
|
||||
boolean trySave(ReplayNonceEntity entity);
|
||||
|
||||
Optional<ReplayNonceEntity> findByScopeAndPrincipalIdAndNonce(String scope, String principalId, String nonce);
|
||||
|
||||
int deleteById(Long id);
|
||||
|
||||
int deleteByScopeAndPrincipalIdAndNonce(String scope, String principalId, String nonce);
|
||||
|
||||
int deleteExpiredBefore(LocalDateTime cutoff, int limit);
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.cisd.tms.modules.security.replay.repository;
|
||||
|
||||
import com.cisd.tms.modules.security.replay.entity.SecurityEventEntity;
|
||||
|
||||
public interface SecurityEventRepository {
|
||||
|
||||
void save(SecurityEventEntity entity);
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.cisd.tms.modules.security.replay.repository.impl;
|
||||
|
||||
import com.cisd.tms.modules.security.replay.entity.ReplayNonceEntity;
|
||||
import com.cisd.tms.modules.security.replay.mapper.ReplayNonceMapper;
|
||||
import com.cisd.tms.modules.security.replay.repository.ReplayNonceRepository;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public class ReplayNonceRepositoryImpl implements ReplayNonceRepository {
|
||||
|
||||
private final ReplayNonceMapper replayNonceMapper;
|
||||
|
||||
public ReplayNonceRepositoryImpl(ReplayNonceMapper replayNonceMapper) {
|
||||
this.replayNonceMapper = replayNonceMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(ReplayNonceEntity entity) {
|
||||
replayNonceMapper.insert(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean trySave(ReplayNonceEntity entity) {
|
||||
try {
|
||||
replayNonceMapper.insert(entity);
|
||||
return true;
|
||||
} catch (DataIntegrityViolationException ex) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ReplayNonceEntity> findByScopeAndPrincipalIdAndNonce(String scope, String principalId, String nonce) {
|
||||
return Optional.ofNullable(replayNonceMapper.selectByScopeAndPrincipalIdAndNonce(scope, principalId, nonce));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int deleteById(Long id) {
|
||||
return replayNonceMapper.deleteById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int deleteByScopeAndPrincipalIdAndNonce(String scope, String principalId, String nonce) {
|
||||
return replayNonceMapper.deleteByScopeAndPrincipalIdAndNonce(scope, principalId, nonce);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int deleteExpiredBefore(LocalDateTime cutoff, int limit) {
|
||||
return replayNonceMapper.deleteExpiredBefore(cutoff, limit);
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.cisd.tms.modules.security.replay.repository.impl;
|
||||
|
||||
import com.cisd.tms.modules.security.replay.entity.SecurityEventEntity;
|
||||
import com.cisd.tms.modules.security.replay.mapper.SecurityEventMapper;
|
||||
import com.cisd.tms.modules.security.replay.repository.SecurityEventRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public class SecurityEventRepositoryImpl implements SecurityEventRepository {
|
||||
|
||||
private final SecurityEventMapper securityEventMapper;
|
||||
|
||||
public SecurityEventRepositoryImpl(SecurityEventMapper securityEventMapper) {
|
||||
this.securityEventMapper = securityEventMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(SecurityEventEntity entity) {
|
||||
securityEventMapper.insert(entity);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.cisd.tms.modules.security.replay.service;
|
||||
|
||||
import com.cisd.tms.modules.security.replay.dto.ReplayCheckRequest;
|
||||
import com.cisd.tms.modules.security.replay.dto.ReplayCheckResult;
|
||||
|
||||
public interface ReplayProtectionService {
|
||||
|
||||
ReplayCheckResult check(ReplayCheckRequest request);
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
package com.cisd.tms.modules.security.replay.service.impl;
|
||||
|
||||
import com.cisd.tms.common.config.properties.TmsSecurityProperties;
|
||||
import com.cisd.tms.modules.security.replay.dto.ReplayCheckRequest;
|
||||
import com.cisd.tms.modules.security.replay.dto.ReplayCheckResult;
|
||||
import com.cisd.tms.modules.security.replay.entity.ReplayNonceEntity;
|
||||
import com.cisd.tms.modules.security.replay.entity.SecurityEventEntity;
|
||||
import com.cisd.tms.modules.security.replay.enums.ReplayCheckStatus;
|
||||
import com.cisd.tms.modules.security.replay.enums.ReplayScope;
|
||||
import com.cisd.tms.modules.security.replay.enums.SecurityEventType;
|
||||
import com.cisd.tms.modules.security.replay.exception.ReplayProtectionException;
|
||||
import com.cisd.tms.modules.security.replay.repository.ReplayNonceRepository;
|
||||
import com.cisd.tms.modules.security.replay.repository.SecurityEventRepository;
|
||||
import com.cisd.tms.modules.security.replay.service.ReplayProtectionService;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Objects;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class ReplayProtectionServiceImpl implements ReplayProtectionService {
|
||||
|
||||
private static final long DEFAULT_ALLOWED_SKEW_SECONDS = 300L;
|
||||
|
||||
private final ReplayNonceRepository replayNonceRepository;
|
||||
private final SecurityEventRepository securityEventRepository;
|
||||
private final TmsSecurityProperties securityProperties;
|
||||
private final Clock clock;
|
||||
|
||||
@Autowired
|
||||
public ReplayProtectionServiceImpl(
|
||||
ReplayNonceRepository replayNonceRepository,
|
||||
SecurityEventRepository securityEventRepository,
|
||||
TmsSecurityProperties securityProperties,
|
||||
Clock clock
|
||||
) {
|
||||
this.replayNonceRepository = replayNonceRepository;
|
||||
this.securityEventRepository = securityEventRepository;
|
||||
this.securityProperties = securityProperties;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public ReplayProtectionServiceImpl(
|
||||
ReplayNonceRepository replayNonceRepository,
|
||||
SecurityEventRepository securityEventRepository,
|
||||
Clock clock
|
||||
) {
|
||||
this(replayNonceRepository, securityEventRepository, new TmsSecurityProperties(), clock);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReplayCheckResult check(ReplayCheckRequest request) {
|
||||
validateRequest(request);
|
||||
long nowSeconds = clock.instant().getEpochSecond();
|
||||
long allowedSkewSeconds = allowedSkewSeconds(request.getScope());
|
||||
ensureTimestampWithinWindow(request.getRequestTimestamp(), nowSeconds, allowedSkewSeconds);
|
||||
|
||||
String scope = request.getScope().name();
|
||||
String principalId = normalize(request.getPrincipalId());
|
||||
String nonce = normalize(request.getNonce());
|
||||
|
||||
var existing = replayNonceRepository.findByScopeAndPrincipalIdAndNonce(scope, principalId, nonce);
|
||||
if (existing.isPresent()) {
|
||||
if (isExpired(existing.get(), nowSeconds)) {
|
||||
// 过期记录即使清理任务尚未来得及删除,也不应永久阻塞新的合法请求。
|
||||
reclaimExpiredNonce(existing.get());
|
||||
existing = replayNonceRepository.findByScopeAndPrincipalIdAndNonce(scope, principalId, nonce);
|
||||
}
|
||||
}
|
||||
if (existing.isPresent()) {
|
||||
return buildReplayResult(request, existing.get());
|
||||
}
|
||||
|
||||
ReplayNonceEntity entity = buildReplayEntity(request, nowSeconds, allowedSkewSeconds);
|
||||
try {
|
||||
if (replayNonceRepository.trySave(entity)) {
|
||||
return buildClaimedResult(entity);
|
||||
}
|
||||
} catch (RuntimeException ex) {
|
||||
throw new ReplayProtectionException("failed to persist replay nonce", ex);
|
||||
}
|
||||
|
||||
var reloaded = replayNonceRepository.findByScopeAndPrincipalIdAndNonce(scope, principalId, nonce);
|
||||
if (reloaded.isEmpty()) {
|
||||
throw new ReplayProtectionException("replay nonce claim failed without existing record");
|
||||
}
|
||||
return buildReplayResult(request, reloaded.get());
|
||||
}
|
||||
|
||||
private void reclaimExpiredNonce(ReplayNonceEntity expiredEntity) {
|
||||
if (expiredEntity.getId() == null) {
|
||||
throw new ReplayProtectionException("expired replay nonce id is missing");
|
||||
}
|
||||
try {
|
||||
replayNonceRepository.deleteById(expiredEntity.getId());
|
||||
} catch (RuntimeException ex) {
|
||||
throw new ReplayProtectionException("failed to remove expired replay nonce", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateRequest(ReplayCheckRequest request) {
|
||||
if (request == null) {
|
||||
throw new ReplayProtectionException("replay request must not be null");
|
||||
}
|
||||
if (request.getScope() == null) {
|
||||
throw new ReplayProtectionException("replay scope is required");
|
||||
}
|
||||
if (isBlank(request.getPrincipalId())) {
|
||||
throw new ReplayProtectionException("principal id is required");
|
||||
}
|
||||
if (isBlank(request.getNonce())) {
|
||||
throw new ReplayProtectionException("nonce is required");
|
||||
}
|
||||
if (request.getRequestTimestamp() <= 0L) {
|
||||
throw new ReplayProtectionException("request timestamp is required");
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureTimestampWithinWindow(long requestTimestamp, long nowSeconds, long allowedSkewSeconds) {
|
||||
if (Math.abs(nowSeconds - requestTimestamp) > allowedSkewSeconds) {
|
||||
throw new ReplayProtectionException("request timestamp is outside allowed window");
|
||||
}
|
||||
}
|
||||
|
||||
private ReplayCheckResult buildClaimedResult(ReplayNonceEntity entity) {
|
||||
ReplayCheckResult result = new ReplayCheckResult();
|
||||
result.setStatus(ReplayCheckStatus.CLAIMED);
|
||||
result.setReplayed(false);
|
||||
result.setMismatch(false);
|
||||
result.setSecurityEventRecorded(false);
|
||||
result.setExpiresAt(entity.getExpiresAt());
|
||||
return result;
|
||||
}
|
||||
|
||||
private ReplayCheckResult buildReplayResult(ReplayCheckRequest request, ReplayNonceEntity existing) {
|
||||
boolean mismatch = !sameFingerprint(request, existing);
|
||||
if (mismatch) {
|
||||
// 同一 nonce 命中但请求指纹不同,按更高风险事件记录,便于后续审计或告警。
|
||||
recordSecurityEvent(request);
|
||||
}
|
||||
|
||||
ReplayCheckResult result = new ReplayCheckResult();
|
||||
result.setStatus(mismatch ? ReplayCheckStatus.MISMATCH : ReplayCheckStatus.REPLAYED);
|
||||
result.setReplayed(true);
|
||||
result.setMismatch(mismatch);
|
||||
result.setSecurityEventRecorded(mismatch);
|
||||
result.setExpiresAt(existing.getExpiresAt());
|
||||
return result;
|
||||
}
|
||||
|
||||
private void recordSecurityEvent(ReplayCheckRequest request) {
|
||||
SecurityEventEntity event = new SecurityEventEntity();
|
||||
event.setEventType(SecurityEventType.REPLAY_MISMATCH.name());
|
||||
event.setScope(request.getScope().name());
|
||||
event.setPrincipalId(normalize(request.getPrincipalId()));
|
||||
event.setRequestMethod(normalize(request.getRequestMethod()));
|
||||
event.setRequestPath(normalize(request.getRequestPath()));
|
||||
event.setNonce(normalize(request.getNonce()));
|
||||
event.setRemoteIp(normalize(request.getRemoteIp()));
|
||||
event.setDetailJson(buildMismatchDetail(request));
|
||||
|
||||
try {
|
||||
securityEventRepository.save(event);
|
||||
} catch (RuntimeException ex) {
|
||||
throw new ReplayProtectionException("failed to persist security event", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private String buildMismatchDetail(ReplayCheckRequest request) {
|
||||
return "request fingerprint mismatch: scope=" + request.getScope().name()
|
||||
+ ", method=" + normalize(request.getRequestMethod())
|
||||
+ ", path=" + normalize(request.getRequestPath())
|
||||
+ ", bodyHash=" + normalize(request.getBodyHash());
|
||||
}
|
||||
|
||||
private ReplayNonceEntity buildReplayEntity(ReplayCheckRequest request, long nowSeconds, long allowedSkewSeconds) {
|
||||
ReplayNonceEntity entity = new ReplayNonceEntity();
|
||||
entity.setScope(request.getScope().name());
|
||||
entity.setPrincipalId(normalize(request.getPrincipalId()));
|
||||
entity.setNonce(normalize(request.getNonce()));
|
||||
entity.setRequestTimestamp(request.getRequestTimestamp());
|
||||
entity.setRequestMethod(normalize(request.getRequestMethod()));
|
||||
entity.setRequestPath(normalize(request.getRequestPath()));
|
||||
entity.setBodyHash(normalize(request.getBodyHash()));
|
||||
entity.setRemoteIp(normalize(request.getRemoteIp()));
|
||||
entity.setUserAgent(normalize(request.getUserAgent()));
|
||||
entity.setExpiresAt(LocalDateTime.ofInstant(Instant.ofEpochSecond(nowSeconds + allowedSkewSeconds * 2L), ZoneOffset.UTC));
|
||||
return entity;
|
||||
}
|
||||
|
||||
private long allowedSkewSeconds(ReplayScope scope) {
|
||||
// OpenAPI 允许使用配置时间窗,内部管理端走固定默认值,避免由调用方自行放宽窗口。
|
||||
if (scope == ReplayScope.OPENAPI) {
|
||||
long configuredSkewSeconds = securityProperties.getOpenapi().getTimestampSkewSeconds();
|
||||
if (configuredSkewSeconds > 0L) {
|
||||
return configuredSkewSeconds;
|
||||
}
|
||||
}
|
||||
return DEFAULT_ALLOWED_SKEW_SECONDS;
|
||||
}
|
||||
|
||||
private boolean sameFingerprint(ReplayCheckRequest request, ReplayNonceEntity existing) {
|
||||
return same(normalize(request.getRequestMethod()), normalize(existing.getRequestMethod()))
|
||||
&& same(normalize(request.getRequestPath()), normalize(existing.getRequestPath()))
|
||||
&& same(normalize(request.getBodyHash()), normalize(existing.getBodyHash()));
|
||||
}
|
||||
|
||||
private boolean same(String left, String right) {
|
||||
return Objects.equals(left, right);
|
||||
}
|
||||
|
||||
private boolean isExpired(ReplayNonceEntity entity, long nowSeconds) {
|
||||
if (entity.getExpiresAt() == null) {
|
||||
return false;
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.ofInstant(Instant.ofEpochSecond(nowSeconds), ZoneOffset.UTC);
|
||||
return !entity.getExpiresAt().isAfter(now);
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
}
|
||||
@@ -11,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");
|
||||
}
|
||||
|
||||
+4
-1
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
+19
@@ -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)
|
||||
);
|
||||
Reference in New Issue
Block a user