TASK:买家端 站内信
This commit is contained in:
6
pom.xml
6
pom.xml
@@ -118,6 +118,12 @@
|
|||||||
<groupId>org.springframework.cloud</groupId>
|
<groupId>org.springframework.cloud</groupId>
|
||||||
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
|
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- WebFlux (provides WebSocket support for Spring Cloud Gateway) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<dependencyManagement>
|
<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/**
|
- Path=/buyer/**
|
||||||
filters:
|
filters:
|
||||||
- StripPrefix=1
|
- 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 网关聚合配置 ----------
|
||||||
knife4j:
|
knife4j:
|
||||||
gateway:
|
gateway:
|
||||||
@@ -78,6 +92,11 @@ knife4j:
|
|||||||
service-name: aida-buyer
|
service-name: aida-buyer
|
||||||
context-path: /buyer
|
context-path: /buyer
|
||||||
order: 3
|
order: 3
|
||||||
|
- name: 支付服务 (Payment)
|
||||||
|
url: /payment/v3/api-docs
|
||||||
|
service-name: payment-service
|
||||||
|
context-path: /payment
|
||||||
|
order: 4
|
||||||
|
|
||||||
# ---------- Gateway JWT 认证(gateway 独有) ----------
|
# ---------- Gateway JWT 认证(gateway 独有) ----------
|
||||||
gateway:
|
gateway:
|
||||||
@@ -155,7 +174,8 @@ gateway:
|
|||||||
- /aida/api/stripe/trade/notify
|
- /aida/api/stripe/trade/notify
|
||||||
# Notification
|
# Notification
|
||||||
- /notification/**
|
- /notification/**
|
||||||
# buyer
|
# WebSocket (由 WebSocketAuthFilter 负责 JWT 验证和 Header 注入)
|
||||||
|
- /ws/**
|
||||||
- /buyer/account/**
|
- /buyer/account/**
|
||||||
- /buyer/designer/shop/**
|
- /buyer/designer/shop/**
|
||||||
- /buyer/designer/search
|
- /buyer/designer/search
|
||||||
|
|||||||
Reference in New Issue
Block a user