From 72c3b8c2dbf15d1d0e456e9317bcd26ff19b93a6 Mon Sep 17 00:00:00 2001 From: litianxiang Date: Tue, 21 Apr 2026 14:11:01 +0800 Subject: [PATCH] 1 --- pom.xml | 139 ++++++++++++++ .../aida/gateway/AidaGatewayApplication.java | 14 ++ .../aida/gateway/common/AuthConstants.java | 36 ++++ .../aida/gateway/common/AuthPrincipalVo.java | 25 +++ .../gateway/config/GatewayAuthProperties.java | 23 +++ .../com/aida/gateway/config/RedisConfig.java | 28 +++ .../gateway/filter/GlobalAuthWebFilter.java | 170 ++++++++++++++++++ .../filter/LogoutBlacklistWebFilter.java | 71 ++++++++ .../com/aida/gateway/route/RouteConfig.java | 37 ++++ src/main/resources/application.yml | 60 +++++++ src/main/resources/bootstrap.yml | 20 +++ 11 files changed, 623 insertions(+) create mode 100644 pom.xml create mode 100644 src/main/java/com/aida/gateway/AidaGatewayApplication.java create mode 100644 src/main/java/com/aida/gateway/common/AuthConstants.java create mode 100644 src/main/java/com/aida/gateway/common/AuthPrincipalVo.java create mode 100644 src/main/java/com/aida/gateway/config/GatewayAuthProperties.java create mode 100644 src/main/java/com/aida/gateway/config/RedisConfig.java create mode 100644 src/main/java/com/aida/gateway/filter/GlobalAuthWebFilter.java create mode 100644 src/main/java/com/aida/gateway/filter/LogoutBlacklistWebFilter.java create mode 100644 src/main/java/com/aida/gateway/route/RouteConfig.java create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/bootstrap.yml diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..363a1b7 --- /dev/null +++ b/pom.xml @@ -0,0 +1,139 @@ + + + 4.0.0 + + com.aida + aida-gateway + 1.0.0 + jar + aida-gateway + Gateway module for unified authentication + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + + 21 + 2023.0.1 + 2023.0.1.0 + + + + + + org.springframework.cloud + spring-cloud-starter-gateway + + + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-discovery + + + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + + + io.jsonwebtoken + jjwt-api + 0.12.3 + + + io.jsonwebtoken + jjwt-impl + 0.12.3 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.3 + runtime + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + cn.hutool + hutool-all + 5.8.26 + + + + + org.springframework.boot + spring-boot-starter-data-redis-reactive + + + + + org.projectlombok + lombok + true + + + + + org.springframework.cloud + spring-cloud-starter-bootstrap + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + com.alibaba.cloud + spring-cloud-alibaba-dependencies + ${spring-cloud-alibaba.version} + pom + import + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/src/main/java/com/aida/gateway/AidaGatewayApplication.java b/src/main/java/com/aida/gateway/AidaGatewayApplication.java new file mode 100644 index 0000000..9316b67 --- /dev/null +++ b/src/main/java/com/aida/gateway/AidaGatewayApplication.java @@ -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 启动完成!"); + } +} diff --git a/src/main/java/com/aida/gateway/common/AuthConstants.java b/src/main/java/com/aida/gateway/common/AuthConstants.java new file mode 100644 index 0000000..4c14204 --- /dev/null +++ b/src/main/java/com/aida/gateway/common/AuthConstants.java @@ -0,0 +1,36 @@ +package com.aida.gateway.common; + +/** + * Auth-related constants shared between Gateway and downstream services. + */ +public final class AuthConstants { + + private AuthConstants() {} + + /** 请求头:用户 ID(Gateway 验证后写入) */ + 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已失效,请重新登录"; +} diff --git a/src/main/java/com/aida/gateway/common/AuthPrincipalVo.java b/src/main/java/com/aida/gateway/common/AuthPrincipalVo.java new file mode 100644 index 0000000..c2eb9d6 --- /dev/null +++ b/src/main/java/com/aida/gateway/common/AuthPrincipalVo.java @@ -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 authorities; +} diff --git a/src/main/java/com/aida/gateway/config/GatewayAuthProperties.java b/src/main/java/com/aida/gateway/config/GatewayAuthProperties.java new file mode 100644 index 0000000..28e2f5e --- /dev/null +++ b/src/main/java/com/aida/gateway/config/GatewayAuthProperties.java @@ -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 ignorePaths; + + private boolean blacklistEnabled = true; +} diff --git a/src/main/java/com/aida/gateway/config/RedisConfig.java b/src/main/java/com/aida/gateway/config/RedisConfig.java new file mode 100644 index 0000000..b655104 --- /dev/null +++ b/src/main/java/com/aida/gateway/config/RedisConfig.java @@ -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 reactiveRedisTemplate( + ReactiveRedisConnectionFactory factory) { + StringRedisSerializer serializer = new StringRedisSerializer(); + RedisSerializationContext context = RedisSerializationContext + .newSerializationContext() + .key(serializer) + .value(serializer) + .hashKey(serializer) + .hashValue(serializer) + .build(); + return new ReactiveRedisTemplate<>(factory, context); + } +} diff --git a/src/main/java/com/aida/gateway/filter/GlobalAuthWebFilter.java b/src/main/java/com/aida/gateway/filter/GlobalAuthWebFilter.java new file mode 100644 index 0000000..7f3264d --- /dev/null +++ b/src/main/java/com/aida/gateway/filter/GlobalAuthWebFilter.java @@ -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 全局鉴权过滤器。 + *

+ * 流程: + * 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 redisTemplate; + private final ObjectMapper objectMapper; + + @Override + public Mono 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 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/java/com/aida/gateway/filter/LogoutBlacklistWebFilter.java b/src/main/java/com/aida/gateway/filter/LogoutBlacklistWebFilter.java new file mode 100644 index 0000000..a1a7f05 --- /dev/null +++ b/src/main/java/com/aida/gateway/filter/LogoutBlacklistWebFilter.java @@ -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 redisTemplate; + + @Override + public Mono 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 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)))); + } +} diff --git a/src/main/java/com/aida/gateway/route/RouteConfig.java b/src/main/java/com/aida/gateway/route/RouteConfig.java new file mode 100644 index 0000000..46725bc --- /dev/null +++ b/src/main/java/com/aida/gateway/route/RouteConfig.java @@ -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; + +/** + * 路由配置。 + *

+ * 注意:实际生产环境中建议将路由配置放在 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(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..948d240 --- /dev/null +++ b/src/main/resources/application.yml @@ -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 diff --git a/src/main/resources/bootstrap.yml b/src/main/resources/bootstrap.yml new file mode 100644 index 0000000..fcafe5d --- /dev/null +++ b/src/main/resources/bootstrap.yml @@ -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