This commit is contained in:
2026-08-08 10:17:43 +08:00
parent dd8147c535
commit 73bfc5a65c
11 changed files with 3109 additions and 27 deletions
@@ -18,6 +18,8 @@ import java.util.Map;
@ConfigurationProperties(prefix = "firstleg.measurement-openapi")
public class MeasurementOpenApiProperties {
private boolean enabled = false;
/** 联调期间可关闭 HMAC、时间戳和 Nonce;固定 AppKey 与业务幂等仍强制校验。 */
private boolean signatureRequired = true;
private Duration maxClockSkew = Duration.ofMinutes(5);
private Duration nonceTtl = Duration.ofMinutes(10);
private Map<String, AppCredential> apps = new LinkedHashMap<>();
@@ -47,9 +47,9 @@ public class OpenApiInboundMeasurementController {
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<EventResponse> receive(
@RequestHeader("X-App-Key") String appKey,
@RequestHeader("X-Timestamp") String timestamp,
@RequestHeader("X-Nonce") String nonce,
@RequestHeader("X-Signature") String signature,
@RequestHeader(value = "X-Timestamp", required = false) String timestamp,
@RequestHeader(value = "X-Nonce", required = false) String nonce,
@RequestHeader(value = "X-Signature", required = false) String signature,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestPart("data") String rawData,
@RequestPart("image") MultipartFile image) {
@@ -67,9 +67,9 @@ public class OpenApiInboundMeasurementController {
@Parameter(description = "第三方业务事件号", required = true)
@PathVariable @Pattern(regexp = "[A-Za-z0-9._:-]{1,128}") String eventNo,
@RequestHeader("X-App-Key") String appKey,
@RequestHeader("X-Timestamp") String timestamp,
@RequestHeader("X-Nonce") String nonce,
@RequestHeader("X-Signature") String signature) {
@RequestHeader(value = "X-Timestamp", required = false) String timestamp,
@RequestHeader(value = "X-Nonce", required = false) String nonce,
@RequestHeader(value = "X-Signature", required = false) String signature) {
return ApiResponse.success(service.query(eventNo,
new MeasurementRequestHeaders(appKey, timestamp, nonce, signature, null)));
}
@@ -36,35 +36,43 @@ public class MeasurementRequestAuthenticator {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "第三方测量开放接口未启用");
}
requireHeader(headers.appKey(), "X-App-Key");
requireHeader(headers.timestamp(), "X-Timestamp");
requireHeader(headers.nonce(), "X-Nonce");
requireHeader(headers.signature(), "X-Signature");
validateTokenLength(headers.appKey(), 128, "X-App-Key");
if (requireIdempotencyKey) {
requireHeader(headers.idempotencyKey(), "Idempotency-Key");
}
validateTokenLength(headers.nonce(), 128, "X-Nonce");
if (requireIdempotencyKey) {
validateTokenLength(headers.idempotencyKey(), 128, "Idempotency-Key");
}
MeasurementOpenApiProperties.AppCredential credential = properties.getApps().get(headers.appKey());
if (credential == null || !credential.isEnabled() || !StringUtils.hasText(credential.getSecret())
if (credential == null || !credential.isEnabled()
|| properties.isSignatureRequired() && !StringUtils.hasText(credential.getSecret())
|| credential.getTenantId() == null || credential.getTenantId() <= 0
|| credential.getSubjectId() == null || credential.getSubjectId() <= 0) {
throw unauthorized("应用凭据无效");
}
long timestamp = parseTimestamp(headers.timestamp());
Duration drift = Duration.between(Instant.ofEpochMilli(timestamp), clock.instant()).abs();
if (drift.compareTo(properties.getMaxClockSkew()) > 0) {
throw unauthorized("请求时间戳已过期");
}
String canonical = canonicalString(method, path, headers.timestamp(), headers.nonce(),
requireIdempotencyKey ? headers.idempotencyKey() : "", sha256Hex(rawBody));
String expected = hmacHex(credential.getSecret(), canonical);
if (!constantTimeEquals(expected, headers.signature())) {
throw unauthorized("请求签名无效");
if (properties.isSignatureRequired()) {
requireHeader(headers.timestamp(), "X-Timestamp");
requireHeader(headers.nonce(), "X-Nonce");
requireHeader(headers.signature(), "X-Signature");
validateTokenLength(headers.nonce(), 128, "X-Nonce");
long timestamp = parseTimestamp(headers.timestamp());
Duration drift = Duration.between(Instant.ofEpochMilli(timestamp), clock.instant()).abs();
if (drift.compareTo(properties.getMaxClockSkew()) > 0) {
throw unauthorized("请求时间戳已过期");
}
String canonical = canonicalString(method, path, headers.timestamp(), headers.nonce(),
requireIdempotencyKey ? headers.idempotencyKey() : "", sha256Hex(rawBody));
String expected = hmacHex(credential.getSecret(), canonical);
if (!constantTimeEquals(expected, headers.signature())) {
throw unauthorized("请求签名无效");
}
claimNonce(headers);
}
return new AuthenticatedMeasurementApp(headers.appKey(), credential.getTenantId(),
credential.getSubjectId(), Map.copyOf(credential.getWarehouseBindings()));
}
private void claimNonce(MeasurementRequestHeaders headers) {
try {
if (!nonceReplayGuard.claim(headers.appKey(), headers.nonce(), properties.getNonceTtl())) {
throw new BizException(ResultCode.CONFLICT, "Nonce 已使用,拒绝重放请求");
@@ -74,8 +82,6 @@ public class MeasurementRequestAuthenticator {
} catch (RuntimeException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Nonce 防重放服务暂不可用");
}
return new AuthenticatedMeasurementApp(headers.appKey(), credential.getTenantId(),
credential.getSubjectId(), Map.copyOf(credential.getWarehouseBindings()));
}
public static String canonicalString(String method, String path, String timestamp, String nonce,