TASK:买家端 站内信
This commit is contained in:
6
pom.xml
6
pom.xml
@@ -118,6 +118,12 @@
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- WebFlux (provides WebSocket support for Spring Cloud Gateway) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<dependencyManagement>
|
||||
|
||||
187
src/main/java/com/aida/gateway/filter/WebSocketAuthFilter.java
Normal file
187
src/main/java/com/aida/gateway/filter/WebSocketAuthFilter.java
Normal file
@@ -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 认证过滤器(原生模式)
|
||||
* <p>
|
||||
* 拦截所有 WebSocket 升级请求(/ws/**),复用 JWT 验证逻辑,
|
||||
* 验证通过后将 X-User-Id 和 X-User-Info 以 HTTP Header 形式注入到请求中。
|
||||
* <p>
|
||||
* 由于浏览器原生 WebSocket API 不会自动携带 Authorization Header,
|
||||
* 客户端在连接时需通过 query 参数传递 token:ws://host/ws?token=<JWT>
|
||||
* (此方式同时适用于原生 WebSocket 和 SockJS 握手)。
|
||||
* <p>
|
||||
* 执行顺序: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<String, String> redisTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return ORDER;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> 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 服务。
|
||||
* <p>
|
||||
* Spring Cloud Gateway 的 WebSocketRoutingFilter 会将这些 Header 原样转发,
|
||||
* 下游 aida-buyer 通过 MessageWebSocketHandler 读取。
|
||||
*/
|
||||
private Mono<Void> 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<Void> 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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user