From 574ce006570c9c5b4c0c7a5b903480e7d5f7fc6c Mon Sep 17 00:00:00 2001 From: xupei Date: Fri, 29 May 2026 19:04:05 +0800 Subject: [PATCH] =?UTF-8?q?TASK:=E4=B9=B0=E5=AE=B6=E7=AB=AF=20=E7=AB=99?= =?UTF-8?q?=E5=86=85=E4=BF=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 6 + .../gateway/filter/WebSocketAuthFilter.java | 187 ++++++++++++++++++ src/main/resources/application.yml | 22 ++- 3 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/aida/gateway/filter/WebSocketAuthFilter.java diff --git a/pom.xml b/pom.xml index 93bfe34..8b23086 100644 --- a/pom.xml +++ b/pom.xml @@ -118,6 +118,12 @@ org.springframework.cloud spring-cloud-starter-loadbalancer + + + + org.springframework.boot + spring-boot-starter-webflux + diff --git a/src/main/java/com/aida/gateway/filter/WebSocketAuthFilter.java b/src/main/java/com/aida/gateway/filter/WebSocketAuthFilter.java new file mode 100644 index 0000000..f0f9ddd --- /dev/null +++ b/src/main/java/com/aida/gateway/filter/WebSocketAuthFilter.java @@ -0,0 +1,187 @@ +package com.aida.gateway.filter; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.digest.DigestUtil; +import com.aida.gateway.common.AuthConstants; +import com.aida.gateway.common.AuthPrincipalVo; +import com.aida.gateway.config.GatewayAuthProperties; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; + +/** + * WebSocket 认证过滤器(原生模式) + *

+ * 拦截所有 WebSocket 升级请求(/ws/**),复用 JWT 验证逻辑, + * 验证通过后将 X-User-Id 和 X-User-Info 以 HTTP Header 形式注入到请求中。 + *

+ * 由于浏览器原生 WebSocket API 不会自动携带 Authorization Header, + * 客户端在连接时需通过 query 参数传递 token:ws://host/ws?token=<JWT> + * (此方式同时适用于原生 WebSocket 和 SockJS 握手)。 + *

+ * 执行顺序:HIGHEST_PRECEDENCE + 1(略高于 WebSocketRoutingFilter) + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class WebSocketAuthFilter implements GlobalFilter, Ordered { + + private static final int ORDER = Ordered.HIGHEST_PRECEDENCE + 1; + private static final String WEBSOCKET_PATH_PREFIX = "/ws"; + + private final GatewayAuthProperties authProperties; + private final ReactiveRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + @Override + public int getOrder() { + return ORDER; + } + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + String path = exchange.getRequest().getURI().getPath(); + + // 仅处理 WebSocket 路径 + if (!path.startsWith(WEBSOCKET_PATH_PREFIX)) { + return chain.filter(exchange); + } + + // OPTIONS 预检放行 + if ("OPTIONS".equalsIgnoreCase(exchange.getRequest().getMethod().name())) { + return chain.filter(exchange); + } + + // ------------------- JWT 验证 ------------------- + // 优先从 Authorization Header 获取 + String rawHeader = exchange.getRequest().getHeaders().getFirst(AuthConstants.TOKEN_HEADER); + + // 浏览器 WebSocket API 不会自动携带 Authorization Header, + // 通过 query 参数传递 token(ws://host/ws?token=xxx) + if (StrUtil.isBlank(rawHeader)) { + rawHeader = exchange.getRequest().getQueryParams().getFirst("token"); + } + if (StrUtil.isBlank(rawHeader)) { + return writeUnauthorized(exchange, AuthConstants.MSG_MISSING_TOKEN + " (provide Authorization header or ?token= query param)"); + } + + String token = rawHeader; + // 仅当从 Header 获取时才尝试去掉 Bearer- 前缀(query param 直接使用原始值) + if (rawHeader.startsWith(authProperties.getJwtTokenPrefix())) { + token = rawHeader.substring(authProperties.getJwtTokenPrefix().length()); + } + + Claims claims; + try { + claims = parseToken(token); + } catch (Exception e) { + log.warn("[WS-Filter] JWT signature invalid or expired: {}", e.getMessage()); + return writeUnauthorized(exchange, AuthConstants.MSG_TOKEN_EXPIRED); + } + + AuthPrincipalVo principal; + try { + principal = objectMapper.readValue(claims.getSubject(), AuthPrincipalVo.class); + } catch (Exception e) { + log.warn("[WS-Filter] Failed to parse AuthPrincipalVo from JWT subject: {}", e.getMessage()); + return writeUnauthorized(exchange, AuthConstants.MSG_INVALID_TOKEN); + } + + if (principal == null || principal.getId() == null) { + return writeUnauthorized(exchange, AuthConstants.MSG_INVALID_TOKEN); + } + + // ------------------- 黑名单检查 ------------------- + if (authProperties.isBlacklistEnabled()) { + String blacklistKey = AuthConstants.BLACKLIST_PREFIX + principal.getId(); + return redisTemplate.hasKey(blacklistKey) + .flatMap(isBlacklisted -> { + if (Boolean.TRUE.equals(isBlacklisted)) { + return writeUnauthorized(exchange, AuthConstants.MSG_TOKEN_BLACKLISTED); + } + return injectUserHeadersAndContinue(exchange, chain, principal); + }) + .onErrorResume(e -> { + log.error("[WS-Filter] Redis check failed, allowing request", e); + return injectUserHeadersAndContinue(exchange, chain, principal); + }); + } + + return injectUserHeadersAndContinue(exchange, chain, principal); + } + + /** + * 将用户信息以 HTTP Header 形式注入请求,传递给下游 WebSocket 服务。 + *

+ * Spring Cloud Gateway 的 WebSocketRoutingFilter 会将这些 Header 原样转发, + * 下游 aida-buyer 通过 MessageWebSocketHandler 读取。 + */ + private Mono injectUserHeadersAndContinue( + ServerWebExchange exchange, + GatewayFilterChain chain, + AuthPrincipalVo principal) { + + String userInfoJson; + try { + userInfoJson = objectMapper.writeValueAsString(principal); + } catch (Exception e) { + log.error("[WS-Filter] Failed to serialize principal", e); + return writeUnauthorized(exchange, AuthConstants.MSG_INVALID_TOKEN); + } + + // 将用户信息作为 HTTP Header 注入,后续 WebSocketRoutingFilter 会原样转发到下游服务 + ServerHttpRequest mutatedRequest = exchange.getRequest().mutate() + .header(AuthConstants.USER_ID_HEADER, String.valueOf(principal.getId())) + .header(AuthConstants.USER_INFO_HEADER, userInfoJson) + .build(); + + log.info("[WS-Filter] JWT verified for user {}, injecting auth headers", principal.getId()); + + return chain.filter(exchange.mutate().request(mutatedRequest).build()); + } + + private Claims parseToken(String token) { + SecretKey key = buildSigningKey(); + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + private SecretKey buildSigningKey() { + byte[] raw = authProperties.getJwtSecret().getBytes(StandardCharsets.UTF_8); + if (raw.length < 32) { + raw = DigestUtil.sha256(raw); + } + return Keys.hmacShaKeyFor(raw); + } + + private Mono writeUnauthorized(ServerWebExchange exchange, String message) { + ServerHttpResponse response = exchange.getResponse(); + response.setStatusCode(HttpStatus.UNAUTHORIZED); + response.getHeaders().setContentType(MediaType.APPLICATION_JSON); + String body = String.format("{\"code\":401,\"message\":\"%s\"}", message); + DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8)); + return response.writeWith(Mono.just(buffer)); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1a51019..a808083 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -56,6 +56,20 @@ spring: - Path=/buyer/** filters: - StripPrefix=1 + + # WebSocket 路由(STOMP over SockJS) + - id: aida-buyer-websocket + uri: lb://aida-buyer + predicates: + - Path=/ws/** + filters: + - StripPrefix=0 + - id: payment-service + uri: lb://payment-service + predicates: + - Path=/payment/** + filters: + - StripPrefix=1 # ---------- Knife4j 网关聚合配置 ---------- knife4j: gateway: @@ -78,6 +92,11 @@ knife4j: service-name: aida-buyer context-path: /buyer order: 3 + - name: 支付服务 (Payment) + url: /payment/v3/api-docs + service-name: payment-service + context-path: /payment + order: 4 # ---------- Gateway JWT 认证(gateway 独有) ---------- gateway: @@ -155,7 +174,8 @@ gateway: - /aida/api/stripe/trade/notify # Notification - /notification/** - # buyer + # WebSocket (由 WebSocketAuthFilter 负责 JWT 验证和 Header 注入) + - /ws/** - /buyer/account/** - /buyer/designer/shop/** - /buyer/designer/search