This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
package com.yunhai.svchall.config;
|
||||
|
||||
import com.yunhai.svchall.utils.FileUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.event.EventListener;
|
||||
|
||||
/**
|
||||
* 文件存储配置(Notice FileStorageConfig 同款三件套之一):
|
||||
* FILE_PATH 磁盘落盘根 + svchall.file.public-base-url 对外可达基址 → FileUtil.init()。
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class FileStorageConfig {
|
||||
|
||||
@Value("${FILE_PATH}")
|
||||
private String filePath;
|
||||
|
||||
@Value("${svchall.file.public-base-url}")
|
||||
private String publicBaseUrl;
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void init() {
|
||||
FileUtil.init(filePath, publicBaseUrl);
|
||||
log.info("[svc-hall] 文件存储就绪:FILE_PATH={},publicBaseUrl={}", filePath, publicBaseUrl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.yunhai.svchall.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import java.nio.file.Paths;
|
||||
|
||||
/**
|
||||
* 本地上传目录静态映射(Notice MyWebAppConfigurer 同款;仅本地开发生效——
|
||||
* 生产走 Projman 生成的 Caddy 静态段 /svc-hall/upload/* 直出,不进应用)。
|
||||
* 有 context-path 时该映射自动变成 /svc-hall/upload/**。
|
||||
*/
|
||||
@Configuration
|
||||
public class MyWebAppConfigurer implements WebMvcConfigurer {
|
||||
|
||||
@org.springframework.beans.factory.annotation.Value("${FILE_PATH}")
|
||||
private String basePath;
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
String root = Paths.get(basePath.replace("\\", "/")).toUri().toString();
|
||||
registry.addResourceHandler("/upload/image/**").addResourceLocations(root + "image/");
|
||||
registry.addResourceHandler("/upload/file/**").addResourceLocations(root + "file/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.yunhai.svchall.config;
|
||||
|
||||
import com.yunhai.platform.appauth.AppAuthFilter;
|
||||
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 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;
|
||||
|
||||
/**
|
||||
* 开放接口签名强制(照 Cms OpenApiAuthEnforceConfiguration 范式):yh-app-auth 的
|
||||
* {@link AppAuthFilter}(order +10,全路由)设计为「无签名头则放行」以兼容浏览器 CAS 流;
|
||||
* 本过滤器(order +11)只挂 /oapi/**,校验其写入的请求属性——已验签放行,否则 401。
|
||||
* /oapi/svc/**(CMS 供数)无用户会话语义,一律应用签名三件套。
|
||||
* 注意:流程中心回调在 /callback/**(不在本过滤器范围),由 FlowCallbackVerifyFilter 用
|
||||
* X-Flw-* 四头验签,两种签名体系互不干扰。
|
||||
*/
|
||||
@Configuration
|
||||
public class OpenApiAuthEnforceConfiguration {
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<RequireAppSignFilter> requireAppSignFilter() {
|
||||
FilterRegistrationBean<RequireAppSignFilter> bean = new FilterRegistrationBean<>();
|
||||
bean.setFilter(new RequireAppSignFilter());
|
||||
bean.addUrlPatterns("/oapi/*");
|
||||
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 11);
|
||||
return bean;
|
||||
}
|
||||
|
||||
public static class RequireAppSignFilter implements Filter {
|
||||
@Override
|
||||
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||
throws IOException, ServletException {
|
||||
HttpServletRequest req = (HttpServletRequest) request;
|
||||
HttpServletResponse res = (HttpServletResponse) response;
|
||||
if (req.getAttribute(AppAuthFilter.APP_AUTH_ATTR) == null) {
|
||||
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "missing app signature");
|
||||
return;
|
||||
}
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user