feat: 新增ai设备推送自动过鱼设施鉴权接口

This commit is contained in:
tangwei 2026-07-30 14:48:57 +08:00
parent 46902f2a7c
commit a3589c7ceb
5 changed files with 514 additions and 1 deletions

View File

@ -0,0 +1,161 @@
package com.yfd.platform.config;
import jakarta.annotation.Resource;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* 通用 Bearer Token 鉴权过滤器
*
* 通过 application.yml 配置需要保护的接口路径支持 Ant 风格匹配
*
* <p>配置示例
* <pre>
* bearer-token:
* paths:
* - /fb/fishhatchrecr/**
* - /fprd/wva/**
* - /some/api/**
* </pre>
*
* <p>外部系统调用流程
* <ol>
* <li>先调用 OAuth2 token 端点获取 access_token</li>
* <li>在业务请求中携带 Authorization: Bearer &lt;access_token&gt;</li>
* </ol>
*/
@Component
public class BearerTokenFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(BearerTokenFilter.class);
private final AntPathMatcher pathMatcher = new AntPathMatcher();
/**
* 需要 Bearer Token 鉴权保护的路径模式列表
* 支持 Ant 风格/api/**, /fb/* /GetKendoListCust
*/
private final List<String> protectedPathPatterns = new ArrayList<>();
@Resource
private BearerTokenService bearerTokenService;
@Resource
private BearerTokenProperties bearerTokenProperties;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String requestPath = resolveRequestPath(request);
// 检查当前请求路径是否需要 Bearer Token 鉴权
if (!isProtectedPath(requestPath)) {
filterChain.doFilter(request, response);
return;
}
log.debug("请求路径 {} 需要 Bearer Token 鉴权", requestPath);
// 解析 Authorization: Bearer <token>
String token = bearerTokenService.resolveToken(request);
if (token == null) {
log.warn("缺少 Bearer Token, path={}", requestPath);
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "缺少 Authorization: Bearer <token> 请求头");
return;
}
// 验证 token
if (!bearerTokenService.validateToken(token, request)) {
log.warn("Bearer Token 无效或已过期, path={}", requestPath);
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Bearer Token 无效或已过期");
return;
}
// token 有效可选刷新 token 有效期
// bearerTokenService.refreshToken(token);
// 设置 Spring Security 认证上下文放行后续过滤器
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(
"bearer-access",
null,
AuthorityUtils.NO_AUTHORITIES
);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
log.debug("Bearer Token 鉴权通过, path={}", requestPath);
filterChain.doFilter(request, response);
}
/**
* 判断路径是否需要 Bearer Token 鉴权保护
*/
private boolean isProtectedPath(String uri) {
if (uri == null) return false;
List<String> patterns = getProtectedPaths();
if (patterns.isEmpty()) return false;
for (String pattern : patterns) {
if (pathMatcher.match(pattern, uri)) {
return true;
}
}
return false;
}
/**
* 获取受保护的路径模式列表
* 优先从 BearerTokenProperties 获取也可以通过子类覆盖或动态添加
*/
protected List<String> getProtectedPaths() {
List<String> paths = new ArrayList<>();
// 从配置文件加载
if (bearerTokenProperties != null && bearerTokenProperties.getPaths() != null) {
paths.addAll(bearerTokenProperties.getPaths());
}
// 合并代码中动态添加的路径
paths.addAll(protectedPathPatterns);
return paths;
}
/**
* 程序化添加受保护路径Ant 风格匹配
*
* @param pattern Ant 路径模式 /fb/fishhatchrecr/**
*/
public void addProtectedPath(String pattern) {
if (pattern != null && !protectedPathPatterns.contains(pattern)) {
protectedPathPatterns.add(pattern);
log.info("已添加 Bearer Token 保护路径: {}", pattern);
}
}
private String resolveRequestPath(HttpServletRequest request) {
String servletPath = request.getServletPath();
if (servletPath != null && !servletPath.isEmpty()) {
return servletPath;
}
String uri = request.getRequestURI();
String contextPath = request.getContextPath();
if (contextPath != null && !contextPath.isEmpty() && uri.startsWith(contextPath)) {
return uri.substring(contextPath.length());
}
return uri;
}
}

View File

@ -0,0 +1,32 @@
package com.yfd.platform.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* Bearer Token 鉴权配置属性
*
* application.yml 中配置需要保护的接口路径
* <pre>
* bearer-token:
* paths:
* - /fb/fishhatchrecr/**
* - /some/other/api/**
* </pre>
*/
@Data
@Component
@ConfigurationProperties(prefix = "bearer-token")
public class BearerTokenProperties {
/**
* 需要使用 Bearer Token 鉴权的接口路径列表
* 支持 Ant 风格路径匹配 /api/**, /fb/**
*/
private List<String> paths = new ArrayList<>(List.of("/base/fpssrlR/ai/report"));
}

View File

@ -0,0 +1,189 @@
package com.yfd.platform.config;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.UUID;
/**
* Bearer Token 鉴权服务
*
* 负责 Bearer Token 的生成缓存和验证
* Token 通过 OAuth2 client_credentials 模式下发后由本服务统一验证
*/
@Service
public class BearerTokenService {
private static final Logger log = LoggerFactory.getLogger(BearerTokenService.class);
/** 默认 token 有效期2 小时 */
public static final long DEFAULT_EXPIRE_MILLIS = 2 * 60 * 60 * 1000L;
private static final String CACHE_PREFIX = "bearer:token:";
@Resource
private WebConfig webConfig;
/**
* 生成 Bearer Token 并存入缓存
* OAuth2 token 端点调用
*
* @param request 当前请求
* @param clientId 客户端标识
* @return token 信息accessToken, expiresIn
*/
public BearerTokenResult createToken(HttpServletRequest request, String clientId) {
String token = UUID.randomUUID().toString().replace("-", "");
long expireAt = System.currentTimeMillis() + DEFAULT_EXPIRE_MILLIS;
JSONObject payload = new JSONObject();
payload.put("clientId", clientId);
payload.put("ip", getClientIp(request));
payload.put("expireAt", expireAt);
payload.put("createTime", System.currentTimeMillis());
webConfig.loginuserCache().put(CACHE_PREFIX + token, payload.toJSONString(), DEFAULT_EXPIRE_MILLIS);
log.info("Bearer Token 已创建, clientId={}, expireAt={}", clientId, expireAt);
BearerTokenResult result = new BearerTokenResult();
result.setAccessToken(token);
result.setTokenType("bearer");
result.setExpiresIn(DEFAULT_EXPIRE_MILLIS / 1000);
result.setExpireAt(expireAt);
return result;
}
/**
* 验证 Bearer Token 是否有效
*
* @param token Bearer Token
* @param request 当前请求
* @return true 有效false 无效或过期
*/
public boolean validateToken(String token, HttpServletRequest request) {
if (StrUtil.isBlank(token)) {
log.debug("Bearer Token 验证失败: token 为空");
return false;
}
String cacheValue = webConfig.loginuserCache().get(CACHE_PREFIX + token);
if (StrUtil.isBlank(cacheValue)) {
log.debug("Bearer Token 验证失败: token 不存在或已过期, token={}", maskToken(token));
return false;
}
JSONObject payload;
try {
payload = JSON.parseObject(cacheValue);
} catch (Exception e) {
log.warn("Bearer Token 解析失败: {}", e.getMessage());
return false;
}
// 检查过期时间
Long expireAt = payload.getLong("expireAt");
if (expireAt == null || expireAt < System.currentTimeMillis()) {
log.debug("Bearer Token 已过期, token={}", maskToken(token));
webConfig.loginuserCache().remove(CACHE_PREFIX + token);
return false;
}
// 可选校验 IP 一致性可在子类中覆盖
return true;
}
/**
* 从请求中解析 Bearer Token
* Authorization: Bearer <token> 头中提取
*
* @param request HTTP 请求
* @return token 字符串解析失败返回 null
*/
public String resolveToken(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
if (StrUtil.isBlank(authHeader)) {
return null;
}
// 支持 "Bearer xxx" "bearer xxx" 格式
String trimmed = authHeader.trim();
if (trimmed.length() > 7
&& "bearer".equalsIgnoreCase(trimmed.substring(0, 6))
&& trimmed.charAt(6) == ' ') {
return trimmed.substring(7).trim();
}
return null;
}
/**
* 刷新 token 有效期延长使用时间
*
* @param token Bearer Token
*/
public void refreshToken(String token) {
if (StrUtil.isBlank(token)) return;
String cacheValue = webConfig.loginuserCache().get(CACHE_PREFIX + token);
if (StrUtil.isBlank(cacheValue)) return;
JSONObject payload = JSON.parseObject(cacheValue);
long newExpireAt = System.currentTimeMillis() + DEFAULT_EXPIRE_MILLIS;
payload.put("expireAt", newExpireAt);
webConfig.loginuserCache().put(CACHE_PREFIX + token, payload.toJSONString(), DEFAULT_EXPIRE_MILLIS);
}
/**
* 撤销 token
*
* @param token Bearer Token
*/
public void revokeToken(String token) {
if (StrUtil.isNotBlank(token)) {
webConfig.loginuserCache().remove(CACHE_PREFIX + token);
log.info("Bearer Token 已撤销");
}
}
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (StrUtil.isNotBlank(ip)) {
return ip.split(",")[0].trim();
}
return request.getRemoteAddr();
}
private String maskToken(String token) {
if (token == null || token.length() <= 8) return "***";
return token.substring(0, 4) + "***" + token.substring(token.length() - 4);
}
/**
* Bearer Token 创建结果
*/
public static class BearerTokenResult {
/** 访问令牌 */
private String accessToken;
/** 令牌类型 */
private String tokenType;
/** 有效期(秒) */
private long expiresIn;
/** 过期时间戳 */
private long expireAt;
public String getAccessToken() { return accessToken; }
public void setAccessToken(String accessToken) { this.accessToken = accessToken; }
public String getTokenType() { return tokenType; }
public void setTokenType(String tokenType) { this.tokenType = tokenType; }
public long getExpiresIn() { return expiresIn; }
public void setExpiresIn(long expiresIn) { this.expiresIn = expiresIn; }
public long getExpireAt() { return expireAt; }
public void setExpireAt(long expireAt) { this.expireAt = expireAt; }
}
}

View File

@ -0,0 +1,127 @@
package com.yfd.platform.config;
import cn.hutool.core.util.StrUtil;
import com.yfd.platform.config.BearerTokenService.BearerTokenResult;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
/**
* OAuth2 Token 端点
*
* <p>供外部系统通过 client_credentials 模式获取 Bearer Token
*
* <p>请求格式参考 鉴权与文件上传接口说明.md 3.2
* <pre>
* POST /oauth2/token
* Content-Type: application/x-www-form-urlencoded
* Authorization: Basic <base64(clientId:clientSecret)>
*
* grant_type=client_credentials
* </pre>
*
* <p>返回格式
* <pre>
* {
* "success": true,
* "errorCode": 0,
* "message": "获取token成功",
* "data": {
* "access_token": "xxx",
* "token_type": "bearer",
* "expires_in": 7200
* }
* }
* </pre>
*/
@RestController
@RequestMapping("/api/oauth2")
public class OAuth2TokenController {
private static final Logger log = LoggerFactory.getLogger(OAuth2TokenController.class);
@Resource
private BearerTokenService bearerTokenService;
/**
* 获取 Access Tokenclient_credentials 模式
*/
@PostMapping("/oauth/token")
public Map<String, Object> getToken(HttpServletRequest request) {
Map<String, Object> result = new HashMap<>();
// 解析 Authorization: Basic <base64>
String authHeader = request.getHeader("Authorization");
if (StrUtil.isBlank(authHeader) || !authHeader.trim().toLowerCase().startsWith("basic ")) {
log.warn("获取 Token 失败: 缺少有效的 Basic Authorization 头");
result.put("success", false);
result.put("errorCode", -1);
result.put("message", "缺少有效的 Authorization: Basic 认证信息");
return result;
}
String clientId = resolveClientId(authHeader);
if (StrUtil.isBlank(clientId)|| !"client".equals(clientId)) {
log.warn("获取 Token 失败: 无法解析 client");
result.put("success", false);
result.put("errorCode", -1);
result.put("message", "无法解析客户端标识");
return result;
}
// 验证 grant_type
String grantType = request.getParameter("grant_type");
if (StrUtil.isBlank(grantType) || !"client_credentials".equals(grantType)) {
log.warn("获取 Token 失败: 不支持的 grant_type={}", grantType);
result.put("success", false);
result.put("errorCode", -1);
result.put("message", "仅支持 client_credentials 授权模式");
return result;
}
BearerTokenResult tokenResult = bearerTokenService.createToken(request, clientId);
Map<String, Object> data = new HashMap<>();
data.put("access_token", tokenResult.getAccessToken());
data.put("token_type", tokenResult.getTokenType());
data.put("expires_in", tokenResult.getExpiresIn());
result.put("success", true);
result.put("errorCode", 0);
result.put("message", "获取token成功");
result.put("data", data);
log.info("OAuth2 Token 签发成功, clientId={}", clientId);
return result;
}
/**
* Authorization: Basic <base64> 头中解析 client
*/
private String resolveClientId(String authHeader) {
try {
String base64 = authHeader.substring(6).trim();
byte[] decoded = Base64.getDecoder().decode(base64);
String credentials = new String(decoded, StandardCharsets.UTF_8);
// 格式: clientId:clientSecret
int colonIdx = credentials.indexOf(':');
if (colonIdx > 0) {
return credentials.substring(0, colonIdx);
}
return credentials;
} catch (Exception e) {
log.warn("解析 Basic 认证信息失败: {}", e.getMessage());
return null;
}
}
}

View File

@ -42,6 +42,9 @@ public class SecurityConfig {
@Autowired
private RegisterAccessTokenFilter registerAccessTokenFilter;
@Autowired
private BearerTokenFilter bearerTokenFilter;
@Autowired
private AuthenticationException authenticationException;
@ -60,7 +63,7 @@ public class SecurityConfig {
.requestMatchers("/data/fishDraft/previewFile").permitAll()
.requestMatchers("/tempFile/**").permitAll()
.requestMatchers("/system/user/auditUser").permitAll()
.requestMatchers("/register/accessToken").permitAll()
.requestMatchers("/api/oauth2/oauth/token").permitAll()
.requestMatchers("/sys/psbmodulelbb/**").permitAll()
.requestMatchers("/base/operationLog/**").permitAll()
// .requestMatchers("/eng/**").permitAll()
@ -114,6 +117,7 @@ public class SecurityConfig {
.cors(cors -> {});
http.addFilterBefore(registerAccessTokenFilter, UsernamePasswordAuthenticationFilter.class);
http.addFilterBefore(bearerTokenFilter, UsernamePasswordAuthenticationFilter.class);
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
http.exceptionHandling(ex -> ex