oapi验签链治理③:删除 /oapi/internal/sign-key 兜底端点,oapi 可整段对外
build / build-push (push) Successful in 53s

- 删 SignKeyInternalController(/oapi/internal/sign-key/{appId},返回 appSecret)、
  SignKeyInternalIpFilter、SignKeyInternalProperties 三文件
- application.yml 删 yh.sign-key-internal 块(其中 192.168.10.0/200 本就是非法前缀,永不匹配)
- OpenApiAuthEnforceConfiguration:删 IP 白名单过滤器与 SignKeyInternalProperties 注册;
  **保留 /api/open 与 /oapi/* 的强制签名**(② oapi 整段对外代理依赖它)
- OapiLogAspect:删对 SignKeyInternalController 的排除死代码
- SignKeyCacheService 契约固化:验签密钥以共享 Redis sign:app:* 为单一真源、UserBase 唯一写入方、
  读写走密钥链专用库(缺省 db0)、读侧无兜底(Redis 不可用即 fail-closed)
- 实测线上该端点在容器内匿名访问为 403(IP 白名单生效)、公网无 Caddy 路由;
  删除后既无密钥外泄面,也使 /userbase/oapi/* 整段代理安全

验证:mvn compile 通过;本模块单测 124 项中 5 项 MySQL 连接失败为环境性(已用 git stash 在
原始树复跑证明同样失败)。
This commit is contained in:
chenxing
2026-09-16 18:08:25 +08:00
parent 9ec65698b6
commit 30b4b0504a
7 changed files with 14 additions and 287 deletions
@@ -40,7 +40,6 @@ import java.util.Map;
* 拦截 {@code controller.oapi} 包下所有方法,规则:<br> * 拦截 {@code controller.oapi} 包下所有方法,规则:<br>
* - POST 写操作:全量自动记录(无需注解),级别/类型由 OperationLogResolver 关键词判定;<br> * - POST 写操作:全量自动记录(无需注解),级别/类型由 OperationLogResolver 关键词判定;<br>
* - GET 查询:默认不记录(高频调用,防刷爆表),满足任一条件则记录:方法标注 {@code @OapiLog}、或配置 oapi.log-get=true;<br> * - GET 查询:默认不记录(高频调用,防刷爆表),满足任一条件则记录:方法标注 {@code @OapiLog}、或配置 oapi.log-get=true;<br>
* - 排除 {@code SignKeyInternalController}(/oapi/internal,响应含 appSecret 明文,绝不落库);<br>
* - 请求参数与响应均经 {@link SensitiveDataMasker} 脱敏后入库;<br> * - 请求参数与响应均经 {@link SensitiveDataMasker} 脱敏后入库;<br>
* - finally 中异步落库(有界池 oapiLogExecutor),失败不影响主业务。<br> * - finally 中异步落库(有界池 oapiLogExecutor),失败不影响主业务。<br>
* *
@@ -52,9 +51,6 @@ import java.util.Map;
@Component @Component
public class OapiLogAspect { public class OapiLogAspect {
/** 内部密钥端点控制器,排除(响应含 appSecret 明文)。 */
private static final String INTERNAL_CONTROLLER = "com.yunhai.userbase.controller.oapi.SignKeyInternalController";
/** GET 默认不记录,开启本配置后全量记录。 */ /** GET 默认不记录,开启本配置后全量记录。 */
@Value("${oapi.log-get:false}") @Value("${oapi.log-get:false}")
private boolean logGet; private boolean logGet;
@@ -67,10 +63,6 @@ public class OapiLogAspect {
@Around("execution(* com.yunhai.userbase.controller.oapi..*.*(..))") @Around("execution(* com.yunhai.userbase.controller.oapi..*.*(..))")
public Object around(ProceedingJoinPoint point) throws Throwable { public Object around(ProceedingJoinPoint point) throws Throwable {
if (INTERNAL_CONTROLLER.equals(point.getSignature().getDeclaringTypeName())) {
return point.proceed();
}
HttpServletRequest request = getRequest(); HttpServletRequest request = getRequest();
String httpMethod = request != null ? request.getMethod() : ""; String httpMethod = request != null ? request.getMethod() : "";
boolean isGet = "GET".equalsIgnoreCase(httpMethod); boolean isGet = "GET".equalsIgnoreCase(httpMethod);
@@ -25,8 +25,8 @@ import java.io.IOException;
* 强制开放接口必须带应用签名 + UserBase 自身 CAS 认证兜底的 UserDetailsProvider。<br> * 强制开放接口必须带应用签名 + UserBase 自身 CAS 认证兜底的 UserDetailsProvider。<br>
* <br> * <br>
* yh-app-auth 的 {@link AppAuthFilter} 设计为「无签名头则放行」(以兼容浏览器 CAS 流), * yh-app-auth 的 {@link AppAuthFilter} 设计为「无签名头则放行」(以兼容浏览器 CAS 流),
* 但 UserBase 作为用户中心的开放接口({/open/api/**})与内部密钥兜底端点({/oapi/internal/**}) * 但 UserBase 作为用户中心的开放接口({/open/api/**} 与 {/oapi/**})绝不允许匿名访问。
* 绝不允许匿名访问。本过滤器在 {@code AppAuthFilter} 之后运行,校验其写入的请求属性: * 本过滤器在 {@code AppAuthFilter} 之后运行,校验其写入的请求属性:
* 已验签(属性存在)则放行,否则返回 401。<br> * 已验签(属性存在)则放行,否则返回 401。<br>
* 顺序须晚于 {@code AppAuthFilter}(HIGHEST_PRECEDENCE+10),故置为 +11。<br> * 顺序须晚于 {@code AppAuthFilter}(HIGHEST_PRECEDENCE+10),故置为 +11。<br>
* <br> * <br>
@@ -39,7 +39,7 @@ import java.io.IOException;
* 导致其 @ConditionalOnMissingBean 抢先注册 NoOp,PathBasedAuthInterceptor 注入后首次请求即抛错。 * 导致其 @ConditionalOnMissingBean 抢先注册 NoOp,PathBasedAuthInterceptor 注入后首次请求即抛错。
*/ */
@Configuration @Configuration
@EnableConfigurationProperties({ SignKeyInternalProperties.class, AppAuthProperties.class }) @EnableConfigurationProperties({ AppAuthProperties.class })
public class OpenApiAuthEnforceConfiguration { public class OpenApiAuthEnforceConfiguration {
@Bean @Bean
@@ -51,21 +51,6 @@ public class OpenApiAuthEnforceConfiguration {
return bean; return bean;
} }
/**
* 内部开放接口(/oapi/internal/*)来源 IP 白名单:
* 在验签过滤器之前先执行(HIGHEST_PRECEDENCE + 9),先卡掉公网来源再进行后续处理。
* 配置项 yh.sign-key-internal.ips / enabled 可由 Nacos 下发。
*/
@Bean
public FilterRegistrationBean<SignKeyInternalIpFilter> signKeyInternalIpFilter(
SignKeyInternalProperties properties) {
FilterRegistrationBean<SignKeyInternalIpFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new SignKeyInternalIpFilter(properties));
bean.addUrlPatterns("/oapi/internal/*");
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 9);
return bean;
}
/** /**
* UserBase 本地 CAS 登录用户提供者:按登录名/手机号查 UserBase 自身库返回 BoaUser。<br> * UserBase 本地 CAS 登录用户提供者:按登录名/手机号查 UserBase 自身库返回 BoaUser。<br>
* 加 {@code @Primary} 稳定覆盖 yh-auth 默认 NoOp 实现;bean 名固定=userDetailsProvider, * 加 {@code @Primary} 稳定覆盖 yh-auth 默认 NoOp 实现;bean 名固定=userDetailsProvider,
@@ -1,124 +0,0 @@
package com.yunhai.userbase.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.CollectionUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.List;
/**
* 内部开放接口(/oapi/internal/*)来源 IP 白名单过滤器。<p>
* 防止密钥兜底端点(/oapi/internal/sign-key/{appId})被公网直接枚举调用;
* 只允许内网 / 显式配置的白名单来源通过,未命中返回 403。<p>
* 支持 X-Forwarded-For / X-Real-IP 等常见代理头解析,取最左侧真实来源 IP 做校验。
*/
public class SignKeyInternalIpFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(SignKeyInternalIpFilter.class);
private final SignKeyInternalProperties properties;
public SignKeyInternalIpFilter(SignKeyInternalProperties properties) {
this.properties = properties;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
if (!properties.isEnabled()) {
filterChain.doFilter(request, response);
return;
}
String sourceIp = resolveSourceIp(request);
List<String> effectiveIps = properties.getEffectiveIps();
if (CollectionUtils.isEmpty(effectiveIps) || matchAny(sourceIp, effectiveIps)) {
filterChain.doFilter(request, response);
return;
}
log.warn("[SignKeyInternal] 拒绝非白名单来源访问内部接口: uri={}, sourceIp={}, whitelist={}",
request.getRequestURI(), sourceIp, effectiveIps);
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":403,\"msg\":\"内部接口不允许从该来源访问\",\"data\":null}");
response.getWriter().flush();
}
private static String resolveSourceIp(HttpServletRequest request) {
String[] headers = new String[] {
"X-Forwarded-For",
"X-Real-IP",
"Proxy-Client-IP",
"WL-Proxy-Client-IP",
"HTTP_CLIENT_IP",
"HTTP_X_FORWARDED_FOR"
};
for (String header : headers) {
String raw = request.getHeader(header);
if (raw == null || raw.isEmpty() || "unknown".equalsIgnoreCase(raw)) {
continue;
}
// X-Forwarded-For 可能是 多个IP逗号分隔,取最左侧
String first = raw.split(",")[0].trim();
if (!first.isEmpty()) {
return first;
}
}
return request.getRemoteAddr();
}
private static boolean matchAny(String ip, List<String> cidrs) {
for (String cidr : cidrs) {
if (matchCidr(ip, cidr)) {
return true;
}
}
return false;
}
private static boolean matchCidr(String ip, String cidr) {
if (ip == null || cidr == null) {
return false;
}
try {
int prefixLen;
String network;
if (cidr.contains("/")) {
String[] parts = cidr.split("/");
network = parts[0].trim();
prefixLen = Integer.parseInt(parts[1].trim());
} else {
network = cidr.trim();
// 判断地址族决定默认前缀长度
InetAddress na = InetAddress.getByName(network);
prefixLen = (na instanceof Inet6Address) ? 128 : 32;
}
InetAddress ipAddr = InetAddress.getByName(ip);
InetAddress netAddr = InetAddress.getByName(network);
if (ipAddr.getClass() != netAddr.getClass()) {
return false;
}
byte[] ipBytes = ipAddr.getAddress();
byte[] netBytes = netAddr.getAddress();
int bits = prefixLen;
for (int i = 0; i < ipBytes.length && bits > 0; i++) {
int mask = bits >= 8 ? 0xFF : (0xFF << (8 - bits));
if ((ipBytes[i] & mask) != (netBytes[i] & mask)) {
return false;
}
bits -= 8;
}
return true;
} catch (UnknownHostException | NumberFormatException ignored) {
return false;
}
}
}
@@ -1,55 +0,0 @@
package com.yunhai.userbase.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* 内部开放接口(/oapi/internal/*)访问白名单 IP 配置。<br>
* 支持单 IP、CIDR(如 10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、127.0.0.1/32)。<br>
* <p>
* 可在 Nacos 上配置,例如:<pre>
* yh:
* sign-key-internal:
* ips:
* - 10.0.0.0/8
* - 172.16.0.0/12
* - 192.168.0.0/16
* - 127.0.0.1/32
* - 172.20.10.5/32 # 某台固定机器
* </pre>
* 未配置时,默认允许 10/172.16-31/192.168 私有段 + 127.0.0.1(方便本地开发/内网部署)。
*/
@ConfigurationProperties(prefix = "yh.sign-key-internal")
public class SignKeyInternalProperties {
/** 显式允许的来源 IP(CIDR 或 单 IP)。为空时使用默认私有段规则。 */
private List<String> ips = new ArrayList<>();
/** 是否启用白名单校验;设置 false 可临时关闭(生产不建议)。 */
private boolean enabled = true;
public List<String> getIps() { return ips; }
public void setIps(List<String> ips) { this.ips = ips; }
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
/** 未配置 ips 时返回默认私有段 + localhost 规则。 */
public List<String> getEffectiveIps() {
if (ips != null && !ips.isEmpty()) {
return ips;
}
return Arrays.asList(
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"127.0.0.1/32",
"0:0:0:0:0:0:0:1/128"
);
}
}
@@ -1,70 +0,0 @@
package com.yunhai.userbase.controller.oapi;
import com.yunhai.platform.appauth.AppAuthProperties;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.repository.ApplicationInfoRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 验签兜底端点:供其他业务服务在「共享 Redis 宕机且本地缓存也未命中」时直连获取某应用的密钥。<br><br>
* 直接从 MySQL 读(绕过可能同宕的共享 Redis),是密钥的权威源;写入侧仍由
* {@code SignKeyCacheService} 同步到 Redis。返回形状与 BasePlatform 读侧解析契约一致:<br>
* {@code {"appKey":"100616","secret":"...","enabled":true}}。<br><br>
* 解析优先级:<br>
* 1) 本库 application_info(覆盖全部「已颁发」应用的实时密钥);<br>
* 2) 配置式受信应用 {@code yh.app-auth.trusted-apps}(平台自身默认密钥,如 应用中心 100616,
* 即便尚未落库也可解析,避免「默认 appKey 未建行」时此处返回 404 导致验签链路失败);<br>
* 两者皆无 → 404。<br><br>
* 注意:此端点返回的是应用密钥,必须仅内网可达(由部署网络隔离保障),切勿暴露到公网。
*/
@RestController
@RequestMapping(Config.OPEN_API_PATH + "internal")
public class SignKeyInternalController {
private final ApplicationInfoRepository applicationInfoRepository;
private final AppAuthProperties appAuthProperties;
public SignKeyInternalController(ApplicationInfoRepository applicationInfoRepository,
AppAuthProperties appAuthProperties) {
this.applicationInfoRepository = applicationInfoRepository;
this.appAuthProperties = appAuthProperties;
}
@GetMapping("sign-key/{appId}")
public ResponseEntity<Map<String, Object>> signKey(@PathVariable String appId) {
Integer key;
try {
key = Integer.parseInt(appId.trim());
} catch (NumberFormatException e) {
return ResponseEntity.notFound().build();
}
ApplicationInfo info = applicationInfoRepository.findByAppKey(key);
if (info != null && info.getAppSecret() != null && !info.getAppSecret().trim().isEmpty()) {
return ok(info.getAppKey() == null ? appId : String.valueOf(info.getAppKey()),
info.getAppSecret());
}
// 库内无此应用密钥:回退到配置式受信应用(平台自身默认密钥 100616 等),
// 避免「应用中心默认 appKey 未落库」时兜底查询返回 404。
String cfgSecret = appAuthProperties.getTrustedApps().get(appId);
if (cfgSecret != null && !cfgSecret.trim().isEmpty()) {
return ok(appId, cfgSecret);
}
return ResponseEntity.notFound().build();
}
private static ResponseEntity<Map<String, Object>> ok(String appKey, String secret) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("appKey", appKey);
body.put("secret", secret);
body.put("enabled", true);
return ResponseEntity.ok(body);
}
}
@@ -25,16 +25,21 @@ import java.util.Map;
import java.util.Set; import java.util.Set;
/** /**
* 应用签名密钥的 Redis 桥接服务。<br> * 应用签名密钥的 Redis 桥接服务——<b>验签密钥以共享 Redis {@code sign:app:*} 为单一真源</b>。<br>
* <br> * <br>
* 职责:把 {@code application_info} 表的 appKey/appSecret 同步到 Redis,供各业务服务的 * 职责:把 {@code application_info} 表的 appKey/appSecret 同步到 Redis,供各业务服务的
* common-sign-starter(SignKeyResolver)读取做验签——业务服务无需直连 UserBase 数据库。<br> * yh-app-auth starter(SignKeyResolver)读取做验签——业务服务无需直连 UserBase 数据库。<br>
* <br> * <br>
* 约定(读侧 starter 必须严格一致):<br> * 契约(读侧 starter 必须严格一致):<br>
* 1) key 前缀 {@code sign:app:},后接 appKey,例 {@code sign:app:100616};<br> * 1) key 前缀 {@code sign:app:},后接 appKey,例 {@code sign:app:100616};<br>
* 2) value 为 fastjson 序列化的 JSON * 2) value 为 fastjson 序列化的 JSON
* 字符串:{@code {"appKey":"100616","secret":"...","enabled":true}};<br> * 字符串:{@code {"appKey":"100616","secret":"...","enabled":true}};<br>
* 3) 不设过期时间,依赖 Redis 持久化(AOF/RDB)保证重启不丢;删除应用时直接删 key(等价于禁用)。<br> * 3) 不设过期时间,依赖 Redis 持久化(AOF/RDB)保证重启不丢;删除应用时直接删 key(等价于禁用)。<br>
* 4) <b>UserBase 是唯一写入方</b>(启动 {@link #refreshAll()} + CRUD 事务提交后同步 + 每日对账),
* 各提供方只读;读写都走密钥链专用库({@code yh.app-auth.sign-redis.database},缺省 db0),
* 与应用主库解耦——否则写入方与读取方分落不同库,链路静默 401。<br>
* 5) 读侧 fail-closed:Redis 不可用即拒绝(已无本地缓存与远端兜底端点)。
* 恢复梯级:AOF 回放 → 必要时重启 UserBase 触发 {@link #refreshAll()} 全量重刷 → 每日对账兜底。<br>
* <br> * <br>
* <b>变更同步说明(IDEA 灰色=正常):</b>本类的 {@code onCacheChanged(...)} 由 Spring 容器在运行时通过 * <b>变更同步说明(IDEA 灰色=正常):</b>本类的 {@code onCacheChanged(...)} 由 Spring 容器在运行时通过
* 反射注册为事务监听器({@code @TransactionalEventListener}),不会在 IDE 的静态调用链中直接显示 * 反射注册为事务监听器({@code @TransactionalEventListener}),不会在 IDE 的静态调用链中直接显示
@@ -58,9 +63,9 @@ public class SignKeyCacheService implements ApplicationRunner, ApplicationContex
private StringRedisTemplate stringRedisTemplate; private StringRedisTemplate stringRedisTemplate;
/** /**
* 密钥链专用模板(yh.app-auth.sign-redis.database 配置时由 yh-app-auth starter 注册)。 * 密钥链专用模板(yh-app-auth starter 缺省即注册,落 db0)。密钥写入走专用库——
* 存在则密钥写入走专用库——sign:app:* 是全平台共享数据,读取方(各提供方 SignKeyResolver) * {@code sign:app:*} 是全平台共享数据,读取方(各提供方 SignKeyResolver)同库才能看到;
* 配置同一库号才能看到;未配置(bean 不存在)= 现状,跟应用主库。 * 若某消费方显式配了其它库号,此处须配同一值(默认无需任何配置)。
*/ */
@Autowired(required = false) @Autowired(required = false)
@org.springframework.beans.factory.annotation.Qualifier("signKeyStringRedisTemplate") @org.springframework.beans.factory.annotation.Qualifier("signKeyStringRedisTemplate")
-6
View File
@@ -71,12 +71,6 @@ yh:
identity: identity:
# 允许调用 oapi/identity/switch 的应用 appKey 白名单(逗号分隔;100001=UserHome,后期会新增) # 允许调用 oapi/identity/switch 的应用 appKey 白名单(逗号分隔;100001=UserHome,后期会新增)
switch-allowed-app-keys: 100001,100002 switch-allowed-app-keys: 100001,100002
sign-key-internal:
enabled: true
ips:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.10.0/200
# 接口文档(springdoc):分组见 SwaggerConfig(oapi=开放接口契约权威源 / api=业务接口) # 接口文档(springdoc):分组见 SwaggerConfig(oapi=开放接口契约权威源 / api=业务接口)
# 契约快照导出:GET /v3/api-docs/oapi # 契约快照导出:GET /v3/api-docs/oapi