fix(admin): 补齐 /api/admin 方法级鉴权并加兜底过滤器

ShopKey/SkipPriceAsin/QueryAsin/ShopManage/ProductCategory 五个 controller
此前无任何 require* 鉴权,SecurityConfig 全局 permitAll 且 nginx 无 ACL,
公网 18080 上可匿名读写店铺密钥等敏感数据。统一补 requireAdminOrInternal;
其中 ShopManage/SkipPrice/QueryAsin 原先信任调用方传入 operatorId/superAdmin,
改为从已认证 principal 推导,消除传 superAdmin=true 越权扩大数据范围的问题。

新增 AdminApiGuardFilter 对 /api/admin/** 兜底要求有效身份(JWT 或内部令牌),
防止后续新增端点遗漏鉴权再裸奔;支持 admin-guard-enabled/exempt-prefixes 配置。
This commit is contained in:
2026-09-04 10:21:12 +08:00
parent 1474f69c73
commit e5909c9977
7 changed files with 436 additions and 116 deletions
@@ -0,0 +1,101 @@
package com.nanri.aiimage.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import java.util.Set;
import java.util.stream.Collectors;
/**
* /api/admin/** 兜底鉴权过滤器。
*
* <p>SecurityConfig 全局 permitAll 且无路径级拦截,历史上有多个 /api/admin 控制器遗漏方法级
* require*(公网 18080 直连可达即匿名裸奔)。本过滤器作为第二道网:凡进入 /api/admin 的请求
* 必须具备"有效身份"Java JWT,或可信内部令牌 X-Internal-Token + operatorId,复用
* AdminAuthSupport),否则直接返回与全局异常一致的 401 响应体(HTTP 200 + {success:false,code})。
*
* <p>只保证"已认证",管理员/菜单级校验仍由各控制器 require* 负责,避免拦截普通登录用户本可
* 访问的 /api/admin 接口(如权限菜单列表仅 requireUser)。
*
* <p>豁免项:OPTIONS 预检;以及 aiimage.security.admin-guard-exempt-prefixes 配置的前缀
* (逗号分隔,用于内部自动化/设备回调等确需匿名可达的端点)。紧急回退:
* aiimage.security.admin-guard-enabled=false 关闭本过滤器。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AdminApiGuardFilter extends OncePerRequestFilter {
private static final String ADMIN_API_PREFIX = "/api/admin";
private final AdminAuthSupport adminAuthSupport;
private final ObjectMapper objectMapper;
@Value("${aiimage.security.admin-guard-enabled:true}")
private boolean enabled;
@Value("${aiimage.security.admin-guard-exempt-prefixes:}")
private String exemptPrefixes;
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
if (!enabled) {
return true;
}
String uri = request.getRequestURI();
if (!(uri.equals(ADMIN_API_PREFIX) || uri.startsWith(ADMIN_API_PREFIX + "/"))) {
return true;
}
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
for (String prefix : exemptSet()) {
if (uri.startsWith(prefix)) {
return true;
}
}
return false;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
try {
adminAuthSupport.requireUserOrInternal(request);
} catch (BusinessException ex) {
ApiResponse<Void> body = ex.getCode() == null
? ApiResponse.fail(ex.getMessage())
: ApiResponse.fail(ex.getCode(), ex.getMessage());
log.warn("[admin-guard] {} {} rejected: {}", request.getMethod(), request.getRequestURI(), ex.getMessage());
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(body));
return;
}
chain.doFilter(request, response);
}
private Set<String> exemptSet() {
if (exemptPrefixes == null || exemptPrefixes.isBlank()) {
return Collections.emptySet();
}
return Arrays.stream(exemptPrefixes.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toSet());
}
}