From a3589c7ceb1270bcba62af057425294d3ab60e7c Mon Sep 17 00:00:00 2001 From: tangwei Date: Thu, 30 Jul 2026 14:48:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9Eai=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E6=8E=A8=E9=80=81=E8=87=AA=E5=8A=A8=E8=BF=87=E9=B1=BC=E8=AE=BE?= =?UTF-8?q?=E6=96=BD=E9=89=B4=E6=9D=83=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../platform/config/BearerTokenFilter.java | 161 +++++++++++++++ .../config/BearerTokenProperties.java | 32 +++ .../platform/config/BearerTokenService.java | 189 ++++++++++++++++++ .../config/OAuth2TokenController.java | 127 ++++++++++++ .../yfd/platform/config/SecurityConfig.java | 6 +- 5 files changed, 514 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/com/yfd/platform/config/BearerTokenFilter.java create mode 100644 backend/src/main/java/com/yfd/platform/config/BearerTokenProperties.java create mode 100644 backend/src/main/java/com/yfd/platform/config/BearerTokenService.java create mode 100644 backend/src/main/java/com/yfd/platform/config/OAuth2TokenController.java diff --git a/backend/src/main/java/com/yfd/platform/config/BearerTokenFilter.java b/backend/src/main/java/com/yfd/platform/config/BearerTokenFilter.java new file mode 100644 index 00000000..35e006b5 --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/config/BearerTokenFilter.java @@ -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 风格匹配。 + * + *

配置示例: + *

+ *   bearer-token:
+ *     paths:
+ *       - /fb/fishhatchrecr/**
+ *       - /fprd/wva/**
+ *       - /some/api/**
+ * 
+ * + *

外部系统调用流程: + *

    + *
  1. 先调用 OAuth2 token 端点获取 access_token
  2. + *
  3. 在业务请求中携带 Authorization: Bearer <access_token>
  4. + *
+ */ +@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 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 + String token = bearerTokenService.resolveToken(request); + if (token == null) { + log.warn("缺少 Bearer Token, path={}", requestPath); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "缺少 Authorization: Bearer 请求头"); + 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 patterns = getProtectedPaths(); + if (patterns.isEmpty()) return false; + + for (String pattern : patterns) { + if (pathMatcher.match(pattern, uri)) { + return true; + } + } + return false; + } + + /** + * 获取受保护的路径模式列表 + * 优先从 BearerTokenProperties 获取,也可以通过子类覆盖或动态添加 + */ + protected List getProtectedPaths() { + List 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; + } +} diff --git a/backend/src/main/java/com/yfd/platform/config/BearerTokenProperties.java b/backend/src/main/java/com/yfd/platform/config/BearerTokenProperties.java new file mode 100644 index 00000000..dd38629c --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/config/BearerTokenProperties.java @@ -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 中配置需要保护的接口路径: + *
+ *   bearer-token:
+ *     paths:
+ *       - /fb/fishhatchrecr/**
+ *       - /some/other/api/**
+ * 
+ */ +@Data +@Component +@ConfigurationProperties(prefix = "bearer-token") +public class BearerTokenProperties { + + /** + * 需要使用 Bearer Token 鉴权的接口路径列表 + * 支持 Ant 风格路径匹配,如 /api/**, /fb/** + */ + private List paths = new ArrayList<>(List.of("/base/fpssrlR/ai/report")); +} diff --git a/backend/src/main/java/com/yfd/platform/config/BearerTokenService.java b/backend/src/main/java/com/yfd/platform/config/BearerTokenService.java new file mode 100644 index 00000000..e31fbc33 --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/config/BearerTokenService.java @@ -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 头中提取 + * + * @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; } + } +} diff --git a/backend/src/main/java/com/yfd/platform/config/OAuth2TokenController.java b/backend/src/main/java/com/yfd/platform/config/OAuth2TokenController.java new file mode 100644 index 00000000..9be0d6b9 --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/config/OAuth2TokenController.java @@ -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 端点 + * + *

供外部系统通过 client_credentials 模式获取 Bearer Token。 + * + *

请求格式(参考 鉴权与文件上传接口说明.md 3.2 节): + *

+ *   POST /oauth2/token
+ *   Content-Type: application/x-www-form-urlencoded
+ *   Authorization: Basic 
+ *   
+ *   grant_type=client_credentials
+ * 
+ * + *

返回格式: + *

+ * {
+ *   "success": true,
+ *   "errorCode": 0,
+ *   "message": "获取token成功",
+ *   "data": {
+ *     "access_token": "xxx",
+ *     "token_type": "bearer",
+ *     "expires_in": 7200
+ *   }
+ * }
+ * 
+ */ +@RestController +@RequestMapping("/api/oauth2") +public class OAuth2TokenController { + + private static final Logger log = LoggerFactory.getLogger(OAuth2TokenController.class); + + @Resource + private BearerTokenService bearerTokenService; + + /** + * 获取 Access Token(client_credentials 模式) + */ + @PostMapping("/oauth/token") + public Map getToken(HttpServletRequest request) { + Map result = new HashMap<>(); + + // 解析 Authorization: Basic + 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 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 头中解析 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; + } + } + +} diff --git a/backend/src/main/java/com/yfd/platform/config/SecurityConfig.java b/backend/src/main/java/com/yfd/platform/config/SecurityConfig.java index 8fe0309f..a1178822 100644 --- a/backend/src/main/java/com/yfd/platform/config/SecurityConfig.java +++ b/backend/src/main/java/com/yfd/platform/config/SecurityConfig.java @@ -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