This commit is contained in:
litianxiang
2026-04-21 14:11:01 +08:00
commit 72c3b8c2db
11 changed files with 623 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
package com.aida.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
@SpringBootApplication
@EnableDiscoveryClient
public class AidaGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(AidaGatewayApplication.class, args);
System.out.println("AidaGatewayApplication 启动完成!");
}
}

View File

@@ -0,0 +1,36 @@
package com.aida.gateway.common;
/**
* Auth-related constants shared between Gateway and downstream services.
*/
public final class AuthConstants {
private AuthConstants() {}
/** 请求头:用户 IDGateway 验证后写入) */
public static final String USER_ID_HEADER = "X-User-Id";
/** 请求头:用户信息的 JSON 字符串Gateway 验证后写入) */
public static final String USER_INFO_HEADER = "X-User-Info";
/** 客户端传来的原始 JWT token 头 */
public static final String TOKEN_HEADER = "Authorization";
/** JWT token 前缀(带连字符) */
public static final String TOKEN_PREFIX = "Bearer-";
/** Redis 黑名单 key 前缀 */
public static final String BLACKLIST_PREFIX = "jwt:blacklist:";
/** 响应码:未认证 */
public static final int STATUS_UNAUTHORIZED = 401;
/** 响应码:禁止访问 */
public static final int STATUS_FORBIDDEN = 403;
/** 响应消息 */
public static final String MSG_MISSING_TOKEN = "请传入token";
public static final String MSG_INVALID_TOKEN = "Token无效";
public static final String MSG_TOKEN_EXPIRED = "Token已过期请重新登录";
public static final String MSG_TOKEN_BLACKLISTED = "Token已失效请重新登录";
}

View File

@@ -0,0 +1,25 @@
package com.aida.gateway.common;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* 用户主体信息(从 JWT subject 解析)。
* 与 aida_back_001 的 AuthPrincipalVo 结构保持一致。
*/
@Data
public class AuthPrincipalVo implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private String username;
private String avatarUrl;
private Boolean isAdmin;
private String source;
private Integer status;
private String language;
private String country;
private List<String> authorities;
}

View File

@@ -0,0 +1,23 @@
package com.aida.gateway.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.List;
@Data
@Component
@ConfigurationProperties(prefix = "gateway.auth")
public class GatewayAuthProperties {
private String jwtSecret = "JWTSECRET";
private String jwtTokenHeader = "Authorization";
private String jwtTokenPrefix = "Bearer-";
private List<String> ignorePaths;
private boolean blacklistEnabled = true;
}

View File

@@ -0,0 +1,28 @@
package com.aida.gateway.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory;
import org.springframework.data.redis.core.ReactiveRedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
@Primary
public ReactiveRedisTemplate<String, String> reactiveRedisTemplate(
ReactiveRedisConnectionFactory factory) {
StringRedisSerializer serializer = new StringRedisSerializer();
RedisSerializationContext<String, String> context = RedisSerializationContext
.<String, String>newSerializationContext()
.key(serializer)
.value(serializer)
.hashKey(serializer)
.hashValue(serializer)
.build();
return new ReactiveRedisTemplate<>(factory, context);
}
}

View File

@@ -0,0 +1,170 @@
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.core.io.buffer.DataBuffer;
import org.springframework.data.redis.core.ReactiveRedisTemplate;
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 org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import org.springframework.beans.factory.annotation.Qualifier;
/**
* Gateway 全局鉴权过滤器。
* <p>
* 流程:
* 1. 白名单路径直接放行
* 2. 从请求头读取 JWT验证签名和有效期
* 3. 检查 Redis 黑名单logout 后 token 被拉黑)
* 4. 将用户 ID 和用户信息 JSON 写入下游请求头
* 5. 失败返回 401 JSON
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class GlobalAuthWebFilter implements WebFilter {
private final GatewayAuthProperties authProperties;
@Qualifier("reactiveRedisTemplate")
private final ReactiveRedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
// 1. 白名单直接放行
if (isIgnoredPath(path)) {
return chain.filter(exchange);
}
// 2. 提取 token
String rawHeader = exchange.getRequest().getHeaders()
.getFirst(authProperties.getJwtTokenHeader());
if (StrUtil.isBlank(rawHeader)) {
return writeUnauthorized(exchange, AuthConstants.MSG_MISSING_TOKEN);
}
String token = rawHeader;
if (rawHeader.startsWith(authProperties.getJwtTokenPrefix())) {
token = rawHeader.substring(authProperties.getJwtTokenPrefix().length());
}
// 3. JWT 签名验证
Claims claims;
try {
claims = parseToken(token);
} catch (Exception e) {
log.warn("JWT signature invalid or expired: {}", e.getMessage());
return writeUnauthorized(exchange, AuthConstants.MSG_TOKEN_EXPIRED);
}
// 4. 解析用户信息
AuthPrincipalVo principal;
try {
principal = objectMapper.readValue(claims.getSubject(), AuthPrincipalVo.class);
} catch (Exception e) {
log.warn("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);
}
// 5. 黑名单检查(仅当启用时)
if (authProperties.isBlacklistEnabled()) {
String blacklistKey = AuthConstants.BLACKLIST_PREFIX + principal.getId();
Boolean isBlacklisted = redisTemplate.hasKey(blacklistKey).block();
if (Boolean.TRUE.equals(isBlacklisted)) {
return writeUnauthorized(exchange, AuthConstants.MSG_TOKEN_BLACKLISTED);
}
}
// 6. 写入下游请求头
String userInfoJson;
try {
userInfoJson = objectMapper.writeValueAsString(principal);
} catch (Exception e) {
log.error("Failed to serialize AuthPrincipalVo", e);
return writeUnauthorized(exchange, AuthConstants.MSG_INVALID_TOKEN);
}
ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
.header(AuthConstants.USER_ID_HEADER, String.valueOf(principal.getId()))
.header(AuthConstants.USER_INFO_HEADER, userInfoJson)
.build();
return chain.filter(exchange.mutate().request(mutatedRequest).build());
}
private boolean isIgnoredPath(String requestUri) {
if (authProperties.getIgnorePaths() == null) {
return false;
}
for (String pattern : authProperties.getIgnorePaths()) {
if (matches(pattern, requestUri)) {
return true;
}
}
return false;
}
private boolean matches(String pattern, String uri) {
if (pattern.endsWith("/**")) {
String prefix = pattern.substring(0, pattern.length() - 3);
return uri.startsWith(prefix);
}
if (pattern.endsWith("/*")) {
String prefix = pattern.substring(0, pattern.length() - 2);
if (!uri.startsWith(prefix)) return false;
String suffix = uri.substring(prefix.length());
return !suffix.contains("/");
}
return uri.contains(pattern);
}
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));
}
}

View File

@@ -0,0 +1,71 @@
package com.aida.gateway.filter;
import com.aida.gateway.common.AuthConstants;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.ReactiveRedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
/**
* Logout 黑名单接口。
* 服务调用此端点将指定用户的 token 加入 Redis 黑名单,
* Gateway 会在下次请求时拒绝已拉黑的 token。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class LogoutBlacklistWebFilter implements WebFilter {
private final
@Qualifier("reactiveRedisTemplate")
ReactiveRedisTemplate<String, String> redisTemplate;
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
// 仅处理 /internal/logout 路径
if (!exchange.getRequest().getURI().getPath().equals("/internal/logout")) {
return chain.filter(exchange);
}
if (!"POST".equalsIgnoreCase(exchange.getRequest().getMethod().name())) {
return chain.filter(exchange);
}
// 从请求头读取 X-User-Id内部调用不需要鉴权
String userId = exchange.getRequest().getHeaders().getFirst(AuthConstants.USER_ID_HEADER);
if (userId == null || userId.isBlank()) {
return writeResponse(exchange, HttpStatus.BAD_REQUEST, "{\"code\":400,\"message\":\"userId required\"}");
}
String blacklistKey = AuthConstants.BLACKLIST_PREFIX + userId;
// 黑名单 TTL 设为 7 天(与 JWT 有效期保持一致)
return redisTemplate.opsForValue()
.set(blacklistKey, "1")
.then(redisTemplate.expire(blacklistKey, Duration.ofDays(7)))
.then(writeResponse(exchange, HttpStatus.OK, "{\"code\":200,\"message\":\"ok\"}"))
.onErrorResume(e -> {
log.error("Failed to add token to blacklist, userId={}", userId, e);
return writeResponse(exchange, HttpStatus.INTERNAL_SERVER_ERROR,
"{\"code\":500,\"message\":\"internal error\"}");
});
}
private Mono<Void> writeResponse(ServerWebExchange exchange, HttpStatus status, String body) {
exchange.getResponse().setStatusCode(status);
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
return exchange.getResponse().writeWith(
Mono.just(exchange.getResponse().bufferFactory()
.wrap(body.getBytes(StandardCharsets.UTF_8))));
}
}

View File

@@ -0,0 +1,37 @@
package com.aida.gateway.route;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 路由配置。
* <p>
* 注意:实际生产环境中建议将路由配置放在 Nacos 配置中心。
* StripPrefix=1 将 /seller 前缀剥离,例如:
* /seller/designer/check -> /designer/check (发到 aida-seller 的 /api/designer/check)
*/
@Configuration
public class RouteConfig {
@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
return builder.routes()
// /internal/** 用于内部服务调用(如 logout 黑名单),不需要 stripPrefix
.route("aida-gateway-internal", r -> r
.path("/internal/**")
.uri("forward:/internal"))
// aida-seller 服务
.route("aida-seller", r -> r
.path("/seller/**")
.filters(f -> f.stripPrefix(1))
.uri("lb://aida-seller"))
// aida-back_001 服务
.route("aida-back", r -> r
.path("/api/**")
.filters(f -> f.stripPrefix(1))
.uri("lb://aida-back"))
.build();
}
}

View File

@@ -0,0 +1,60 @@
# ============================================================
# aida-gateway - 本地配置(不区分环境)
# 公共配置DB、Redis、RabbitMQ、MinIO、API Keys 等)由 Nacos 统一管理
# ============================================================
server:
port: 5569
spring:
application:
name: aida-gateway
# ---------- Gateway JWT 认证gateway 独有) ----------
gateway:
auth:
jwt-secret: ${BACK_JWT_SECRET:JWTSECRET}
jwt-token-header: Authorization
jwt-token-prefix: Bearer-
blacklist-enabled: true
ignore-paths:
- /favicon.ico
- /doc.html
- /swagger-ui.html
- /swagger-ui/**
- /swagger-resources/**
- /v2/api-docs
- /v3/api-docs/**
- /webjars/**
- /api/account/login
- /api/account/preLogin
- /api/designer/check
- /actuator/**
- /internal/**
- /api/account/sendEmail
- /api/account/noLoginRequired
- /api/account/resetPwd
- /api/account/designWorksRegister
- /api/account/questionnaire
- /api/account/schoolLogin
- /api/account/enterpriseLogin
- /api/account/organizationNameSearch
- /api/account/activateNewEmail
- /api/python/saveGeneratePicture
- /api/python/getLibraryByUserId
- /api/python/flush
- /api/account/healthy
- /api/third/party/**
- /api/element/initDefaultSysFile
- /api/ali-pay/trade/notify
- /api/paypal/ipn/back
- /api/alipay-hk/trade/notify
- /api/stripe/trade/notify
- /api/portfolio/**
- /api/global-award/**
- /api/llm/stream
- /notification/**
logging:
level:
com.aida.gateway: debug

View File

@@ -0,0 +1,20 @@
# ============================================================
# aida-gateway - Bootstrap
# 通过 NACOS_NAMESPACE 环境变量切换命名空间dev / test / prod
# 示例docker run -e NACOS_NAMESPACE=prod ...
# ============================================================
spring:
application:
name: aida-gateway
config:
import: optional:nacos:aida-public-${NACOS_NAMESPACE:dev}.yml
cloud:
nacos:
discovery:
server-addr: ${NACOS_HOST:127.0.0.1:8848}
namespace: ${NACOS_NAMESPACE:dev}
config:
server-addr: ${NACOS_HOST:127.0.0.1:8848}
namespace: ${NACOS_NAMESPACE:dev}
file-extension: yaml