Compare commits
18 Commits
a5c0695488
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d68bca9e7 | |||
| 574ce00657 | |||
|
|
5a1244ddd6 | ||
|
|
74efef7c24 | ||
|
|
0eb6426a99 | ||
|
|
743b2bbda9 | ||
|
|
9fc17d2940 | ||
|
|
41bb344e97 | ||
|
|
cddd74db2a | ||
|
|
69dba26b73 | ||
|
|
206431ccb0 | ||
| 62f8219d30 | |||
|
|
2daec6b123 | ||
|
|
7e19ba4d06 | ||
|
|
58951ff9b6 | ||
|
|
9124256f01 | ||
|
|
7f69eebedf | ||
|
|
e00e7f3e5e |
@@ -50,7 +50,7 @@ jobs:
|
|||||||
VOLUME /tmp
|
VOLUME /tmp
|
||||||
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
|
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
|
||||||
RUN echo 'Asia/Shanghai' > /etc/timezone
|
RUN echo 'Asia/Shanghai' > /etc/timezone
|
||||||
ADD ./*.jar /app.jar
|
ADD ./target/*.jar /app.jar
|
||||||
ENTRYPOINT ["java","-jar","/app.jar"]
|
ENTRYPOINT ["java","-jar","/app.jar"]
|
||||||
EOF
|
EOF
|
||||||
echo "Dockerfile内容:"
|
echo "Dockerfile内容:"
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
/.idea/
|
/.idea/
|
||||||
/target/
|
/target/
|
||||||
|
/log/
|
||||||
|
|||||||
24
pom.xml
24
pom.xml
@@ -62,17 +62,11 @@
|
|||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Jackson -->
|
<!-- Hutool -->
|
||||||
<dependency>
|
|
||||||
<groupId>com.fasterxml.jackson.core</groupId>
|
|
||||||
<artifactId>jackson-databind</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- Hutool (aligned with aida_seller 5.8.26) -->
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>cn.hutool</groupId>
|
<groupId>cn.hutool</groupId>
|
||||||
<artifactId>hutool-all</artifactId>
|
<artifactId>hutool-all</artifactId>
|
||||||
<version>5.8.26</version>
|
<version>5.8.23</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Redis (for token blacklist) -->
|
<!-- Redis (for token blacklist) -->
|
||||||
@@ -100,6 +94,12 @@
|
|||||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot Logging(显式引入,确保 logback 正确初始化) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-logging</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Knife4j Gateway Aggregation -->
|
<!-- Knife4j Gateway Aggregation -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.github.xiaoymin</groupId>
|
<groupId>com.github.xiaoymin</groupId>
|
||||||
@@ -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>
|
||||||
@@ -145,6 +151,8 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
<configuration>
|
<configuration>
|
||||||
|
<!-- 强制工作目录为模块根目录,确保 ./log 指向项目目录而非 Maven 安装目录 -->
|
||||||
|
<workingDirectory>${project.basedir}</workingDirectory>
|
||||||
<excludes>
|
<excludes>
|
||||||
<exclude>
|
<exclude>
|
||||||
<groupId>org.projectlombok</groupId>
|
<groupId>org.projectlombok</groupId>
|
||||||
|
|||||||
@@ -19,5 +19,13 @@ public class GatewayAuthProperties {
|
|||||||
|
|
||||||
private List<String> ignorePaths;
|
private List<String> ignorePaths;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可选认证路径:token 有则解析并写入下游请求头,无则放行。
|
||||||
|
* 与 ignorePaths 的区别:ignorePaths 完全跳过认证逻辑;
|
||||||
|
* optionalAuthPaths 仍然尝试解析 token,有 token 时正常写入 X-User-Id / X-User-Info,
|
||||||
|
* 无 token 时才放行,确保已登录用户的信息能正确传递。
|
||||||
|
*/
|
||||||
|
private List<String> optionalAuthPaths;
|
||||||
|
|
||||||
private boolean blacklistEnabled = true;
|
private boolean blacklistEnabled = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,24 +65,43 @@ public class GlobalAuthWebFilter implements WebFilter, Ordered {
|
|||||||
return chain.filter(exchange);
|
return chain.filter(exchange);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 白名单直接放行
|
// 2. 白名单直接放行(完全跳过认证)
|
||||||
if (isIgnoredPath(path)) {
|
if (isIgnoredPath(path)) {
|
||||||
return chain.filter(exchange);
|
return chain.filter(exchange);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 提取 token
|
// 3. 可选认证路径:token 有则解析,无则放行
|
||||||
|
if (isOptionalAuthPath(path)) {
|
||||||
|
String rawHeader = exchange.getRequest().getHeaders()
|
||||||
|
.getFirst(authProperties.getJwtTokenHeader());
|
||||||
|
if (StrUtil.isBlank(rawHeader)) {
|
||||||
|
// 无 token,直接放行,不写任何 header
|
||||||
|
return chain.filter(exchange);
|
||||||
|
}
|
||||||
|
// 有 token,正常走解析流程(复用下面的验证逻辑)
|
||||||
|
return processTokenWithAuthCheck(exchange, chain, rawHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 其他路径:必须有 token
|
||||||
String rawHeader = exchange.getRequest().getHeaders()
|
String rawHeader = exchange.getRequest().getHeaders()
|
||||||
.getFirst(authProperties.getJwtTokenHeader());
|
.getFirst(authProperties.getJwtTokenHeader());
|
||||||
if (StrUtil.isBlank(rawHeader)) {
|
if (StrUtil.isBlank(rawHeader)) {
|
||||||
return writeUnauthorized(exchange, AuthConstants.MSG_MISSING_TOKEN);
|
return writeUnauthorized(exchange, AuthConstants.MSG_MISSING_TOKEN);
|
||||||
}
|
}
|
||||||
|
return processTokenWithAuthCheck(exchange, chain, rawHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一的 token 解析与认证流程:解析 JWT → 黑名单检查 → 写入下游 header。
|
||||||
|
* 专供可选认证路径中有 token 的情况,以及普通路径的鉴权。
|
||||||
|
*/
|
||||||
|
private Mono<Void> processTokenWithAuthCheck(ServerWebExchange exchange, WebFilterChain chain, String rawHeader) {
|
||||||
String token = rawHeader;
|
String token = rawHeader;
|
||||||
if (rawHeader.startsWith(authProperties.getJwtTokenPrefix())) {
|
if (rawHeader.startsWith(authProperties.getJwtTokenPrefix())) {
|
||||||
token = rawHeader.substring(authProperties.getJwtTokenPrefix().length());
|
token = rawHeader.substring(authProperties.getJwtTokenPrefix().length());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. JWT 签名验证
|
// JWT 签名验证
|
||||||
Claims claims;
|
Claims claims;
|
||||||
try {
|
try {
|
||||||
claims = parseToken(token);
|
claims = parseToken(token);
|
||||||
@@ -91,7 +110,7 @@ public class GlobalAuthWebFilter implements WebFilter, Ordered {
|
|||||||
return writeUnauthorized(exchange, AuthConstants.MSG_TOKEN_EXPIRED);
|
return writeUnauthorized(exchange, AuthConstants.MSG_TOKEN_EXPIRED);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 解析用户信息
|
// 解析用户信息
|
||||||
AuthPrincipalVo principal;
|
AuthPrincipalVo principal;
|
||||||
try {
|
try {
|
||||||
principal = objectMapper.readValue(claims.getSubject(), AuthPrincipalVo.class);
|
principal = objectMapper.readValue(claims.getSubject(), AuthPrincipalVo.class);
|
||||||
@@ -104,7 +123,7 @@ public class GlobalAuthWebFilter implements WebFilter, Ordered {
|
|||||||
return writeUnauthorized(exchange, AuthConstants.MSG_INVALID_TOKEN);
|
return writeUnauthorized(exchange, AuthConstants.MSG_INVALID_TOKEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 黑名单检查(仅当启用时)
|
// 黑名单检查
|
||||||
if (authProperties.isBlacklistEnabled()) {
|
if (authProperties.isBlacklistEnabled()) {
|
||||||
String blacklistKey = AuthConstants.BLACKLIST_PREFIX + principal.getId();
|
String blacklistKey = AuthConstants.BLACKLIST_PREFIX + principal.getId();
|
||||||
return redisTemplate.hasKey(blacklistKey).flatMap(isBlacklisted -> {
|
return redisTemplate.hasKey(blacklistKey).flatMap(isBlacklisted -> {
|
||||||
@@ -151,6 +170,18 @@ public class GlobalAuthWebFilter implements WebFilter, Ordered {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isOptionalAuthPath(String requestUri) {
|
||||||
|
if (authProperties.getOptionalAuthPaths() == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (String pattern : authProperties.getOptionalAuthPaths()) {
|
||||||
|
if (pathMatcher.match(pattern, requestUri)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private Claims parseToken(String token) {
|
private Claims parseToken(String token) {
|
||||||
SecretKey key = buildSigningKey();
|
SecretKey key = buildSigningKey();
|
||||||
return Jwts.parser()
|
return Jwts.parser()
|
||||||
|
|||||||
@@ -32,30 +32,65 @@ public class LogoutBlacklistWebFilter implements WebFilter {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
|
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
|
||||||
// 仅处理 /internal/logout 路径
|
String path = exchange.getRequest().getURI().getPath();
|
||||||
if (!exchange.getRequest().getURI().getPath().equals("/internal/logout")) {
|
|
||||||
|
// /internal/logout → 拉黑用户
|
||||||
|
// /internal/clear-blacklist → 清除黑名单
|
||||||
|
if ("/internal/logout".equals(path)) {
|
||||||
|
return handleLogout(exchange, chain);
|
||||||
|
}
|
||||||
|
if ("/internal/clear-blacklist".equals(path)) {
|
||||||
|
return handleClearBlacklist(exchange, chain);
|
||||||
|
}
|
||||||
|
|
||||||
return chain.filter(exchange);
|
return chain.filter(exchange);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Mono<Void> handleLogout(ServerWebExchange exchange, WebFilterChain chain) {
|
||||||
if (!"POST".equalsIgnoreCase(exchange.getRequest().getMethod().name())) {
|
if (!"POST".equalsIgnoreCase(exchange.getRequest().getMethod().name())) {
|
||||||
return chain.filter(exchange);
|
return chain.filter(exchange);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从请求头读取 X-User-Id(内部调用,不需要鉴权)
|
|
||||||
String userId = exchange.getRequest().getHeaders().getFirst(AuthConstants.USER_ID_HEADER);
|
String userId = exchange.getRequest().getHeaders().getFirst(AuthConstants.USER_ID_HEADER);
|
||||||
|
if (userId == null || userId.isBlank()) {
|
||||||
|
userId = exchange.getRequest().getQueryParams().getFirst("userId");
|
||||||
|
}
|
||||||
if (userId == null || userId.isBlank()) {
|
if (userId == null || userId.isBlank()) {
|
||||||
return writeResponse(exchange, HttpStatus.BAD_REQUEST, "{\"code\":400,\"message\":\"userId required\"}");
|
return writeResponse(exchange, HttpStatus.BAD_REQUEST, "{\"code\":400,\"message\":\"userId required\"}");
|
||||||
}
|
}
|
||||||
|
|
||||||
String blacklistKey = AuthConstants.BLACKLIST_PREFIX + userId;
|
String blacklistKey = AuthConstants.BLACKLIST_PREFIX + userId;
|
||||||
|
String finalUserId = userId;
|
||||||
// 黑名单 TTL 设为 7 天(与 JWT 有效期保持一致)
|
|
||||||
return redisTemplate.opsForValue()
|
return redisTemplate.opsForValue()
|
||||||
.set(blacklistKey, "1")
|
.set(blacklistKey, "1")
|
||||||
.then(redisTemplate.expire(blacklistKey, Duration.ofDays(7)))
|
.then(redisTemplate.expire(blacklistKey, Duration.ofDays(7)))
|
||||||
.then(writeResponse(exchange, HttpStatus.OK, "{\"code\":200,\"message\":\"ok\"}"))
|
.then(writeResponse(exchange, HttpStatus.OK, "{\"code\":200,\"message\":\"ok\"}"))
|
||||||
.onErrorResume(e -> {
|
.onErrorResume(e -> {
|
||||||
log.error("Failed to add token to blacklist, userId={}", userId, e);
|
log.error("Failed to add token to blacklist, userId={}", finalUserId, e);
|
||||||
|
return writeResponse(exchange, HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
"{\"code\":500,\"message\":\"internal error\"}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<Void> handleClearBlacklist(ServerWebExchange exchange, WebFilterChain chain) {
|
||||||
|
if (!"POST".equalsIgnoreCase(exchange.getRequest().getMethod().name())) {
|
||||||
|
return chain.filter(exchange);
|
||||||
|
}
|
||||||
|
|
||||||
|
String userId = exchange.getRequest().getHeaders().getFirst(AuthConstants.USER_ID_HEADER);
|
||||||
|
if (userId == null || userId.isBlank()) {
|
||||||
|
userId = exchange.getRequest().getQueryParams().getFirst("userId");
|
||||||
|
}
|
||||||
|
if (userId == null || userId.isBlank()) {
|
||||||
|
return writeResponse(exchange, HttpStatus.BAD_REQUEST, "{\"code\":400,\"message\":\"userId required\"}");
|
||||||
|
}
|
||||||
|
|
||||||
|
String blacklistKey = AuthConstants.BLACKLIST_PREFIX + userId;
|
||||||
|
String finalUserId = userId;
|
||||||
|
return redisTemplate.delete(blacklistKey)
|
||||||
|
.then(writeResponse(exchange, HttpStatus.OK, "{\"code\":200,\"message\":\"ok\"}"))
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
log.error("Failed to clear blacklist, userId={}", finalUserId, e);
|
||||||
return writeResponse(exchange, HttpStatus.INTERNAL_SERVER_ERROR,
|
return writeResponse(exchange, HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
"{\"code\":500,\"message\":\"internal error\"}");
|
"{\"code\":500,\"message\":\"internal error\"}");
|
||||||
});
|
});
|
||||||
|
|||||||
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,26 @@ spring:
|
|||||||
uri: http://18.167.251.121:9994
|
uri: http://18.167.251.121:9994
|
||||||
predicates:
|
predicates:
|
||||||
- Path=/python/**
|
- Path=/python/**
|
||||||
|
- id: aida-buyer
|
||||||
|
uri: lb://aida-buyer
|
||||||
|
predicates:
|
||||||
|
- 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 网关聚合配置 ----------
|
||||||
knife4j:
|
knife4j:
|
||||||
gateway:
|
gateway:
|
||||||
@@ -67,6 +87,16 @@ knife4j:
|
|||||||
service-name: aida-seller
|
service-name: aida-seller
|
||||||
context-path: /seller
|
context-path: /seller
|
||||||
order: 2
|
order: 2
|
||||||
|
- name: 买家端服务 (Buyer)
|
||||||
|
url: /buyer/v3/api-docs
|
||||||
|
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 JWT 认证(gateway 独有) ----------
|
||||||
gateway:
|
gateway:
|
||||||
@@ -144,5 +174,8 @@ gateway:
|
|||||||
- /aida/api/stripe/trade/notify
|
- /aida/api/stripe/trade/notify
|
||||||
# Notification
|
# Notification
|
||||||
- /notification/**
|
- /notification/**
|
||||||
|
# WebSocket (由 WebSocketAuthFilter 负责 JWT 验证和 Header 注入)
|
||||||
logging:
|
- /ws/**
|
||||||
|
- /buyer/account/**
|
||||||
|
- /buyer/designer/shop/**
|
||||||
|
- /buyer/designer/search
|
||||||
|
|||||||
@@ -2,11 +2,9 @@
|
|||||||
<configuration>
|
<configuration>
|
||||||
<include resource="org/springframework/boot/logging/logback/defaults.xml" />
|
<include resource="org/springframework/boot/logging/logback/defaults.xml" />
|
||||||
|
|
||||||
<!-- 日志存放路径(可通过环境变量覆盖) -->
|
<!-- 日志存放路径 -->
|
||||||
<property name="log.path" value="${LOG_PATH:-./log}" />
|
<property name="log.path" value="./log" />
|
||||||
<!-- 各服务独立文件名 -->
|
<!-- 日志输出格式 -->
|
||||||
<property name="log.file.name" value="aida-gateway" />
|
|
||||||
|
|
||||||
<property name="log.pattern.console" value="${CONSOLE_LOG_PATTERN:-%clr(%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd HH:mm:ss.SSS}}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}" />
|
<property name="log.pattern.console" value="${CONSOLE_LOG_PATTERN:-%clr(%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd HH:mm:ss.SSS}}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}" />
|
||||||
<property name="log.pattern.file" value="${FILE_LOG_PATTERN:-%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd HH:mm:ss.SSS}} ${LOG_LEVEL_PATTERN:-%5p} ${PID:- } --- [%15t] %-40.40logger{39} : %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}" />
|
<property name="log.pattern.file" value="${FILE_LOG_PATTERN:-%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd HH:mm:ss.SSS}} ${LOG_LEVEL_PATTERN:-%5p} ${PID:- } --- [%15t] %-40.40logger{39} : %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}" />
|
||||||
|
|
||||||
@@ -19,26 +17,25 @@
|
|||||||
|
|
||||||
<!-- Info 日志文件 -->
|
<!-- Info 日志文件 -->
|
||||||
<appender name="file_info" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
<appender name="file_info" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||||
<file>${log.path}/${log.file.name}-info.log</file>
|
<file>${log.path}/aida-gateway-info.log</file>
|
||||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||||
<fileNamePattern>${log.path}/${log.file.name}-info.%d{yyyy-MM-dd}.log</fileNamePattern>
|
<fileNamePattern>${log.path}/aida-gateway-info.%d{yyyy-MM-dd}.log</fileNamePattern>
|
||||||
<maxHistory>60</maxHistory>
|
<maxHistory>60</maxHistory>
|
||||||
</rollingPolicy>
|
</rollingPolicy>
|
||||||
<encoder>
|
<encoder>
|
||||||
<pattern>${log.pattern.file}</pattern>
|
<pattern>${log.pattern.file}</pattern>
|
||||||
</encoder>
|
</encoder>
|
||||||
<filter class="ch.qos.logback.classic.filter.LevelFilter">
|
<filter class="ch.qos.logback.classic.filter.LevelFilter">
|
||||||
<level>ERROR</level>
|
<onMatch>ACCEPT</onMatch>
|
||||||
<onMatch>DENY</onMatch>
|
<onMismatch>DENY</onMismatch>
|
||||||
<onMismatch>ACCEPT</onMismatch>
|
|
||||||
</filter>
|
</filter>
|
||||||
</appender>
|
</appender>
|
||||||
|
|
||||||
<!-- Error 日志文件 -->
|
<!-- Error 日志文件 -->
|
||||||
<appender name="file_error" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
<appender name="file_error" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||||
<file>${log.path}/${log.file.name}-error.log</file>
|
<file>${log.path}/aida-gateway-error.log</file>
|
||||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||||
<fileNamePattern>${log.path}/${log.file.name}-error.%d{yyyy-MM-dd}.log</fileNamePattern>
|
<fileNamePattern>${log.path}/aida-gateway-error.%d{yyyy-MM-dd}.log</fileNamePattern>
|
||||||
<maxHistory>60</maxHistory>
|
<maxHistory>60</maxHistory>
|
||||||
</rollingPolicy>
|
</rollingPolicy>
|
||||||
<encoder>
|
<encoder>
|
||||||
@@ -51,14 +48,16 @@
|
|||||||
</filter>
|
</filter>
|
||||||
</appender>
|
</appender>
|
||||||
|
|
||||||
<!-- 服务日志级别控制(由 Nacos 的 logging.level 统一管理,此处仅作兜底) -->
|
<!-- 服务模块日志级别控制 -->
|
||||||
<logger name="com.aida.gateway" level="${LOG_LEVEL_GATEWAY:-info}" />
|
<logger name="com.aida.gateway" level="debug" />
|
||||||
<!-- Spring 框架日志 -->
|
<!-- Spring 日志级别控制 -->
|
||||||
<logger name="org.springframework" level="warn" />
|
<logger name="org.springframework" level="warn" />
|
||||||
<logger name="org.springframework.cloud.gateway" level="warn" />
|
|
||||||
|
|
||||||
<root level="${LOG_LEVEL_ROOT:-info}">
|
<root level="info">
|
||||||
<appender-ref ref="console" />
|
<appender-ref ref="console" />
|
||||||
|
</root>
|
||||||
|
|
||||||
|
<root level="info">
|
||||||
<appender-ref ref="file_info" />
|
<appender-ref ref="file_info" />
|
||||||
<appender-ref ref="file_error" />
|
<appender-ref ref="file_error" />
|
||||||
</root>
|
</root>
|
||||||
|
|||||||
Reference in New Issue
Block a user