- 回调迁 starter(2026-09-20):删本地实现 FlowCallbackService/Controller/ FilterConfiguration 及旧测试,X-Flw 验签/幂等/乱序门由 starter 承担, 业务映射入 SvcFlowCallbackHandler(biz-type svc_item);端点仍 /callback/flow, 存量注册 callback_url 不变、secret 零轮换,镜像表 flow_callback_reg 由 sql/20260920_flow_callback_starter_migration.sql 迁移 - 表单级流程绑定:ServiceForm 增 flowGuid,事项未绑流程时回退取绑定表单 最新版本(effectiveFlowGuid),绑定校验/在线办理口径同步 - FlowReconcileJob/FlowCenterClient/OpenApiAuthEnforceConfiguration 等 SDK 适配微调;补 .gitignore(target/、日志) - 全量 mvn test 本地绿(含新增 SvcFlowCallbackHandlerTest/ ServiceItemEffectiveFlowTest)
This commit is contained in:
@@ -1,151 +0,0 @@
|
||||
package com.yunhai.svchall.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.svchall.service.impl.FlowCallbackService;
|
||||
import com.yunhai.svchall.utils.ReReadableRequestWrapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import javax.servlet.Filter;
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.ServletRequest;
|
||||
import javax.servlet.ServletResponse;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* 流程中心回调验签(验收报告 §四.6,Notice FlowCallbackVerifyFilter 同构):
|
||||
* 只拦 /callback/*(CAS url-pattern /api/* 之外,独立于 oapi 的 appKey 签名体系)。
|
||||
* 协议对齐流程中心概览 §8.4:X-Flw-EventId / X-Flw-Timestamp(±5 分钟防重放)/
|
||||
* X-Flw-Nonce / X-Flw-Signature = Base64(HmacSHA256(secret, timestamp + "." + nonce + "." + body)),
|
||||
* secret 为 reg 接口按 flowGuid 下发的回调密钥(FlowCallbackService.secretOf 查找,须 status=0 且非空)。
|
||||
* <b>注册探活 ping 直接 200</b>——flow-center 探活的签名是字面量 "ping" 不可能验签通过,且实测把
|
||||
* 5xx 判为不可达(§四.6),以 ping 200 保证注册顺畅;探活只判连接级可达,无状态语义。
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class FlowCallbackFilterConfiguration {
|
||||
|
||||
/** ±5 分钟防重放窗口(对齐概览 §8.4) */
|
||||
private static final long TIMESTAMP_WINDOW_MS = 5 * 60 * 1000L;
|
||||
|
||||
private final FlowCallbackService callbackService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<FlowCallbackVerifyFilter> flowCallbackVerifyFilter() {
|
||||
FilterRegistrationBean<FlowCallbackVerifyFilter> bean = new FilterRegistrationBean<>();
|
||||
bean.setFilter(new FlowCallbackVerifyFilter());
|
||||
bean.addUrlPatterns("/callback/*");
|
||||
// 先于业务过滤器链(AppAuthFilter +10 与签名强制 +11 均在 HIGHEST_PRECEDENCE 之上)
|
||||
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 20);
|
||||
return bean;
|
||||
}
|
||||
|
||||
class FlowCallbackVerifyFilter implements Filter {
|
||||
|
||||
@Override
|
||||
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||
throws IOException, ServletException {
|
||||
HttpServletRequest req = (HttpServletRequest) request;
|
||||
HttpServletResponse res = (HttpServletResponse) response;
|
||||
if (!"POST".equalsIgnoreCase(req.getMethod())) {
|
||||
res.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
|
||||
return;
|
||||
}
|
||||
ReReadableRequestWrapper wrapper = new ReReadableRequestWrapper(req);
|
||||
String body = wrapper.bodyString();
|
||||
// 注册探活:只判连接级可达,直接 200(ping 无状态语义,验签不可能通过)
|
||||
if (isPing(body)) {
|
||||
byte[] ok = "{\"ok\":true}".getBytes(StandardCharsets.UTF_8);
|
||||
res.setContentType("application/json");
|
||||
res.setContentLength(ok.length);
|
||||
res.setStatus(HttpServletResponse.SC_OK);
|
||||
res.getOutputStream().write(ok);
|
||||
return;
|
||||
}
|
||||
String eventId = req.getHeader("X-Flw-EventId");
|
||||
String timestamp = req.getHeader("X-Flw-Timestamp");
|
||||
String nonce = req.getHeader("X-Flw-Nonce");
|
||||
String signature = req.getHeader("X-Flw-Signature");
|
||||
if (!StringUtils.hasText(eventId) || !StringUtils.hasText(timestamp)
|
||||
|| !StringUtils.hasText(nonce) || !StringUtils.hasText(signature)) {
|
||||
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "missing callback headers");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
long ts = Long.parseLong(timestamp.trim());
|
||||
if (Math.abs(System.currentTimeMillis() - ts) > TIMESTAMP_WINDOW_MS) {
|
||||
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "timestamp out of window");
|
||||
return;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "bad timestamp");
|
||||
return;
|
||||
}
|
||||
String flowGuid = extractFlowGuid(body);
|
||||
String secret = flowGuid == null ? null : callbackService.secretOf(flowGuid);
|
||||
if (secret == null) {
|
||||
log.warn("[svc-hall] 回调验签失败:无注册或无 secret,flowGuid={}", flowGuid);
|
||||
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "unknown callback registration");
|
||||
return;
|
||||
}
|
||||
String expected = hmacSha256Base64(secret, timestamp.trim() + "." + nonce.trim() + "." + body);
|
||||
if (!constantTimeEquals(expected, signature.trim())) {
|
||||
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "bad signature");
|
||||
return;
|
||||
}
|
||||
chain.doFilter(wrapper, response);
|
||||
}
|
||||
|
||||
/** 注册探活判定:event=ping(flow-center 注册时发送,签名头是字面量 "ping") */
|
||||
private boolean isPing(String body) {
|
||||
try {
|
||||
JsonNode payload = objectMapper.readTree(body);
|
||||
return payload != null && "ping".equals(payload.path("event").asText(null));
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 从推送体提取 flowGuid(验签必需的最小解析) */
|
||||
private String extractFlowGuid(String body) {
|
||||
try {
|
||||
JsonNode payload = objectMapper.readTree(body);
|
||||
return payload.has("flowGuid") && !payload.get("flowGuid").isNull()
|
||||
? payload.get("flowGuid").asText() : null;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String hmacSha256Base64(String secret, String content) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
byte[] raw = mac.doFinal(content.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(raw);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("HmacSHA256 计算失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean constantTimeEquals(String a, String b) {
|
||||
return java.security.MessageDigest.isEqual(
|
||||
a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import java.io.IOException;
|
||||
* {@link AppAuthFilter}(order +10,全路由)设计为「无签名头则放行」以兼容浏览器 CAS 流;
|
||||
* 本过滤器(order +11)只挂 /oapi/**,校验其写入的请求属性——已验签放行,否则 401。
|
||||
* /oapi/svc/**(CMS 供数)无用户会话语义,一律应用签名三件套。
|
||||
* 注意:流程中心回调在 /callback/**(不在本过滤器范围),由 FlowCallbackVerifyFilter 用
|
||||
* 注意:流程中心回调在 /callback/**(不在本过滤器范围),由 yh-flow-callback starter 的 X-Flw 验签过滤器用
|
||||
* X-Flw-* 四头验签,两种签名体系互不干扰。
|
||||
*/
|
||||
@Configuration
|
||||
|
||||
Reference in New Issue
Block a user