From 9cb4844be43446d0c2f6759d35002ee57c081714 Mon Sep 17 00:00:00 2001 From: wangys <3401275564@qq.com> Date: Tue, 2 Dec 2025 18:46:03 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BD=91=E5=85=B3=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/网关认证方案.md | 396 ++++++++++++++++++ .../common/auth/config/GatewayAuthConfig.java | 82 ++++ .../common/auth/config/SecurityConfig.java | 32 +- urbanLifelineServ/gateway/pom.xml | 121 ++++++ .../org/xyzh/gateway/GatewayApplication.java | 21 + .../gateway/config/GatewayRouteConfig.java | 78 ++++ .../gateway/config/RateLimiterConfig.java | 47 +++ .../xyzh/gateway/filter/AuthGlobalFilter.java | 177 ++++++++ .../xyzh/gateway/filter/LogGlobalFilter.java | 54 +++ .../handler/GatewayExceptionHandler.java | 70 ++++ .../src/main/resources/application-dev.yml | 19 + .../src/main/resources/application.yml | 133 ++++++ urbanLifelineServ/pom.xml | 1 + 13 files changed, 1226 insertions(+), 5 deletions(-) create mode 100644 docs/网关认证方案.md create mode 100644 urbanLifelineServ/common/common-auth/src/main/java/org/xyzh/common/auth/config/GatewayAuthConfig.java create mode 100644 urbanLifelineServ/gateway/pom.xml create mode 100644 urbanLifelineServ/gateway/src/main/java/org/xyzh/gateway/GatewayApplication.java create mode 100644 urbanLifelineServ/gateway/src/main/java/org/xyzh/gateway/config/GatewayRouteConfig.java create mode 100644 urbanLifelineServ/gateway/src/main/java/org/xyzh/gateway/config/RateLimiterConfig.java create mode 100644 urbanLifelineServ/gateway/src/main/java/org/xyzh/gateway/filter/AuthGlobalFilter.java create mode 100644 urbanLifelineServ/gateway/src/main/java/org/xyzh/gateway/filter/LogGlobalFilter.java create mode 100644 urbanLifelineServ/gateway/src/main/java/org/xyzh/gateway/handler/GatewayExceptionHandler.java create mode 100644 urbanLifelineServ/gateway/src/main/resources/application-dev.yml create mode 100644 urbanLifelineServ/gateway/src/main/resources/application.yml diff --git a/docs/网关认证方案.md b/docs/网关认证方案.md new file mode 100644 index 0000000..903e206 --- /dev/null +++ b/docs/网关认证方案.md @@ -0,0 +1,396 @@ +# Gateway 认证方案说明 + +## 问题背景 + +在微服务架构中,如果同时使用 Gateway 和 common-auth 模块,会出现**重复认证**的问题: + +``` +浏览器 → Gateway (AuthGlobalFilter 验证 JWT) + → 微服务 (JwtAuthenticationFilter 再次验证 JWT) ❌ 重复! +``` + +## 解决方案 + +提供两种认证模式,通过配置选择: + +### **模式一:Gateway 统一认证(推荐)** + +Gateway 负责认证,微服务信任 Gateway 传递的用户信息。 + +#### 架构流程 + +``` +浏览器 + ↓ (带 JWT Token) +Nginx (80端口) + ↓ +Gateway (8080端口) + ├─ AuthGlobalFilter: 验证 JWT ✓ + ├─ 提取用户信息 (userId, username) + ├─ 添加到请求头传递给下游 + └─ 路由到微服务 + ↓ +微服务 + └─ GatewayTrustFilter: 从请求头获取用户信息 ✓ +``` + +#### 配置方式 + +**1. Gateway 服务配置** (`gateway/application.yml`) +```yaml +auth: + enabled: true + token-header: Authorization + token-prefix: "Bearer " + auth-paths: + - /auth/login + - /auth/logout + whitelist: + - /actuator/** + - /v3/api-docs/** +``` + +**2. 微服务配置** (`system/application.yml`, `log/application.yml` 等) +```yaml +auth: + enabled: true + gateway-mode: true # 关键:启用 Gateway 模式 +``` + +#### 优点 +- ✅ 避免重复认证,性能更好 +- ✅ 统一认证逻辑,易维护 +- ✅ 微服务之间调用不需要传递 JWT + +--- + +### **模式二:微服务独立认证** + +每个微服务独立验证 JWT,Gateway 不做认证。 + +#### 架构流程 + +``` +浏览器 + ↓ (带 JWT Token) +Nginx + ↓ +Gateway + └─ 直接路由(不验证) + ↓ +微服务 + └─ JwtAuthenticationFilter: 验证 JWT ✓ +``` + +#### 配置方式 + +**1. Gateway 服务配置** +```yaml +auth: + enabled: false # 关键:关闭 Gateway 认证 +``` + +**2. 微服务配置** +```yaml +auth: + enabled: true + gateway-mode: false # 或不配置(默认 false) + token-header: Authorization + token-prefix: "Bearer " +``` + +#### 适用场景 +- 微服务需要直接对外暴露(不经过 Gateway) +- 对安全性要求极高,需要每层都验证 + +--- + +## 配置文件对比 + +### Gateway 服务 (`gateway/application.yml`) + +```yaml +server: + port: 8080 + +spring: + application: + name: gateway-service + + cloud: + gateway: + routes: + - id: auth-service + uri: lb://auth-service + predicates: + - Path=/auth/** + filters: + - StripPrefix=1 + + - id: system-service + uri: lb://system-service + predicates: + - Path=/system/** + filters: + - StripPrefix=1 + +# 认证配置 +auth: + enabled: true # 是否启用认证 + gateway-mode: false # Gateway 本身不需要此配置 + token-header: Authorization + token-prefix: "Bearer " + auth-paths: + - /auth/login + - /auth/logout + - /auth/captcha + - /auth/refresh + whitelist: + - /actuator/** + - /v3/api-docs/** +``` + +### 微服务配置 (Gateway 模式) + +**auth-service/application.yml** +```yaml +server: + port: 8081 + +spring: + application: + name: auth-service + +# 认证配置 +auth: + enabled: true + gateway-mode: true # 关键:信任 Gateway + token-header: Authorization + token-prefix: "Bearer " + whitelist: + - /v3/api-docs/** + - /actuator/** +``` + +**system-service/application.yml** +```yaml +server: + port: 8082 + +spring: + application: + name: system-service + +auth: + enabled: true + gateway-mode: true # 关键:信任 Gateway +``` + +--- + +## 工作原理 + +### Gateway 认证流程 + +**AuthGlobalFilter (Gateway 层)** +```java +1. 检查请求路径是否在白名单 +2. 提取 Authorization 请求头中的 JWT Token +3. 验证 Token 是否过期 +4. 验证 Token 签名是否有效 +5. 提取用户信息 (userId, username) +6. 将用户信息添加到请求头: + - X-User-Id: {userId} + - X-Username: {username} +7. 路由到下游微服务 +``` + +### 微服务信任流程 + +**GatewayTrustFilter (微服务层)** +```java +1. 从请求头获取 Gateway 传递的用户信息: + - X-User-Id + - X-Username +2. 构造 Spring Security 认证对象 +3. 设置到 SecurityContext +4. 设置到 request attributes(供业务代码使用) +``` + +--- + +## 安全考虑 + +### 内网安全 + +采用 Gateway 模式时,需确保: + +1. **微服务不对外暴露** + - 只能通过 Gateway 访问 + - 使用 Kubernetes Network Policy 或防火墙隔离 + +2. **请求头保护** + - Gateway 在转发前清除任何客户端传递的 `X-User-Id` 等头 + - 防止伪造用户身份 + +3. **Gateway 过滤器增强**(可选) + ```java + // 在 Gateway 中清除客户端可能伪造的请求头 + ServerHttpRequest mutatedRequest = request.mutate() + .headers(headers -> { + headers.remove("X-User-Id"); + headers.remove("X-Username"); + }) + .header(AuthContants.USER_ID_ATTRIBUTE, userId) + .header(AuthContants.TOKEN_ATTRIBUTE, token) + .build(); + ``` + +--- + +## 测试验证 + +### 测试 Gateway 模式 + +**1. 登录获取 Token** +```bash +curl -X POST http://localhost/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"123456"}' + +# 响应 +{ + "code": 200, + "data": { + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "userId": "1001" + } +} +``` + +**2. 使用 Token 访问受保护接口** +```bash +curl -X GET http://localhost/api/system/user/profile \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +``` + +**3. 查看日志验证单次认证** +``` +Gateway 日志: +[Gateway] Token 验证成功: userId=1001, path=/system/user/profile + +System-Service 日志: +[GatewayTrustFilter] 从 Gateway 获取用户信息: userId=1001, username=admin +``` + +--- + +## 迁移指南 + +### 从独立认证迁移到 Gateway 统一认证 + +**步骤 1**: 更新所有微服务配置 +```yaml +auth: + gateway-mode: true # 添加这一行 +``` + +**步骤 2**: 重启服务(先重启微服务,后重启 Gateway) +```bash +# 重启微服务 +docker-compose restart auth-service system-service log-service + +# 重启 Gateway +docker-compose restart gateway +``` + +**步骤 3**: 验证功能 +- 测试登录 +- 测试受保护接口访问 +- 检查日志确认只认证一次 + +--- + +## 常见问题 + +### Q1: Gateway 模式下,微服务之间如何调用? + +**A**: 微服务间调用不需要传递 JWT Token,直接调用即可。Gateway已经验证过身份。 + +```java +// 微服务 A 调用微服务 B +@Autowired +private RestTemplate restTemplate; + +public void callServiceB() { + // 直接调用,不需要添加 Authorization 头 + String result = restTemplate.getForObject( + "http://service-b/api/xxx", + String.class + ); +} +``` + +### Q2: 如何获取当前登录用户信息? + +**A**: 使用 `@HttpLogin` 注解或从 SecurityContext 获取。 + +```java +// 方式一:使用注解(推荐) +@GetMapping("/profile") +public ResultDomain getProfile(@HttpLogin LoginDomain loginDomain) { + String userId = loginDomain.getUser().getUserId(); + // ... +} + +// 方式二:从 SecurityContext 获取 +Authentication auth = SecurityContextHolder.getContext().getAuthentication(); +String userId = (String) auth.getPrincipal(); +``` + +### Q3: Gateway 模式更安全还是独立认证更安全? + +**A**: 取决于网络拓扑: +- **内网隔离良好**: Gateway 模式更优(性能好,维护简单) +- **微服务直接对外**: 独立认证更安全(每层验证) + +--- + +## 推荐配置 + +### 生产环境推荐 + +```yaml +# Gateway +auth: + enabled: true + gateway-mode: false + +# 所有微服务 +auth: + enabled: true + gateway-mode: true +``` + +### 开发环境(快速调试) + +可以临时关闭认证: + +```yaml +auth: + enabled: false +``` + +--- + +## 总结 + +| 对比项 | Gateway 统一认证 | 微服务独立认证 | +|--------|-----------------|---------------| +| 认证次数 | 1次(仅 Gateway) | N次(每个服务) | +| 性能 | ⭐⭐⭐⭐⭐ 最优 | ⭐⭐⭐ 一般 | +| 维护性 | ⭐⭐⭐⭐⭐ 统一管理 | ⭐⭐⭐ 分散管理 | +| 安全性 | ⭐⭐⭐⭐ 需内网隔离 | ⭐⭐⭐⭐⭐ 多层防护 | +| 推荐场景 | 内网微服务架构 | 微服务对外暴露 | + +**推荐使用 Gateway 统一认证模式!** diff --git a/urbanLifelineServ/common/common-auth/src/main/java/org/xyzh/common/auth/config/GatewayAuthConfig.java b/urbanLifelineServ/common/common-auth/src/main/java/org/xyzh/common/auth/config/GatewayAuthConfig.java new file mode 100644 index 0000000..c524072 --- /dev/null +++ b/urbanLifelineServ/common/common-auth/src/main/java/org/xyzh/common/auth/config/GatewayAuthConfig.java @@ -0,0 +1,82 @@ +package org.xyzh.common.auth.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; +import org.xyzh.common.auth.contants.AuthContants; + +import java.io.IOException; +import java.util.Collections; + +/** + * @description Gateway 认证模式配置 - 信任 Gateway 传递的用户信息 + * @author yslg + * @since 2025-12-02 + * + * 当启用 gateway-mode 时,微服务不再独立验证 JWT,而是信任 Gateway 传递的用户信息 + * 配置项:auth.gateway-mode=true + */ +@Configuration +@ConditionalOnProperty(name = "auth.gateway-mode", havingValue = "true") +public class GatewayAuthConfig { + private static final Logger log = LoggerFactory.getLogger(GatewayAuthConfig.class); + + /** + * Gateway 信任过滤器 - 从请求头获取用户信息 + * 替代 JwtAuthenticationFilter + */ + @Bean + public GatewayTrustFilter gatewayTrustFilter() { + log.info("启用 Gateway 认证模式,微服务将信任 Gateway 传递的用户信息"); + return new GatewayTrustFilter(); + } + + /** + * Gateway 信任过滤器实现 + */ + public static class GatewayTrustFilter extends OncePerRequestFilter { + private static final Logger log = LoggerFactory.getLogger(GatewayTrustFilter.class); + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) + throws ServletException, IOException { + + // 从 Gateway 传递的请求头获取用户信息 + String userId = request.getHeader(AuthContants.USER_ID_ATTRIBUTE); + String username = request.getHeader(AuthContants.USERNAME_ATTRIBUTE); + + if (StringUtils.hasText(userId)) { + // 构造简化的 Principal(使用 userId 作为身份标识) + // 注意:完整的 LoginDomain 应该从 Redis 加载,这里只是基本身份验证 + + // 设置到 Spring Security 上下文 + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList()); + SecurityContextHolder.getContext().setAuthentication(authentication); + + // 同时将用户信息设置到 request attributes 中,供业务代码使用 + request.setAttribute(AuthContants.USER_ID_ATTRIBUTE, userId); + if (StringUtils.hasText(username)) { + request.setAttribute(AuthContants.USERNAME_ATTRIBUTE, username); + } + + log.debug("从 Gateway 获取用户信息: userId={}, username={}", userId, username); + } + + filterChain.doFilter(request, response); + } + } +} diff --git a/urbanLifelineServ/common/common-auth/src/main/java/org/xyzh/common/auth/config/SecurityConfig.java b/urbanLifelineServ/common/common-auth/src/main/java/org/xyzh/common/auth/config/SecurityConfig.java index 95891da..2f94d79 100644 --- a/urbanLifelineServ/common/common-auth/src/main/java/org/xyzh/common/auth/config/SecurityConfig.java +++ b/urbanLifelineServ/common/common-auth/src/main/java/org/xyzh/common/auth/config/SecurityConfig.java @@ -1,5 +1,7 @@ package org.xyzh.common.auth.config; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; @@ -15,17 +17,29 @@ import org.xyzh.redis.service.RedisService; @EnableMethodSecurity public class SecurityConfig { + /** + * JWT 认证过滤器 - 仅在非 Gateway 模式下启用 + * 当 auth.gateway-mode=false 或未配置时使用 + */ @Bean + @ConditionalOnProperty(name = "auth.gateway-mode", havingValue = "false", matchIfMissing = true) public JwtAuthenticationFilter jwtAuthenticationFilter(TokenParser tokenParser, AuthProperties authProperties, RedisService redisService) { return new JwtAuthenticationFilter(tokenParser, authProperties, redisService); } + /** + * Security 过滤器链配置 + * 根据是否启用 Gateway 模式决定是否添加 JWT 过滤器 + */ @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http, - AuthProperties authProperties, - JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception { + public SecurityFilterChain securityFilterChain( + HttpSecurity http, + AuthProperties authProperties, + @Autowired(required = false) JwtAuthenticationFilter jwtAuthenticationFilter, + @Autowired(required = false) GatewayAuthConfig.GatewayTrustFilter gatewayTrustFilter) throws Exception { + http .csrf(csrf -> csrf.disable()) .formLogin(form -> form.disable()) @@ -45,8 +59,16 @@ public class SecurityConfig { authz .requestMatchers("/error", "/favicon.ico").permitAll() .anyRequest().authenticated(); - }) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + }); + + // 根据模式添加对应的过滤器 + if (jwtAuthenticationFilter != null) { + // 非 Gateway 模式:使用 JWT 过滤器 + http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + } else if (gatewayTrustFilter != null) { + // Gateway 模式:使用信任过滤器 + http.addFilterBefore(gatewayTrustFilter, UsernamePasswordAuthenticationFilter.class); + } return http.build(); } diff --git a/urbanLifelineServ/gateway/pom.xml b/urbanLifelineServ/gateway/pom.xml new file mode 100644 index 0000000..ea18b38 --- /dev/null +++ b/urbanLifelineServ/gateway/pom.xml @@ -0,0 +1,121 @@ + + + 4.0.0 + + org.xyzh + urban-lifeline + 1.0.0 + + + org.xyzh + gateway + ${urban-lifeline.version} + + + 21 + 21 + + + + + + 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 + + + + + org.springframework.cloud + spring-cloud-starter-loadbalancer + + + + + org.xyzh.common + common-auth + + + + + org.xyzh.common + common-redis + + + + + org.xyzh.common + common-utils + + + + + org.xyzh.common + common-core + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.springframework.boot + spring-boot-starter-log4j2 + + + + + org.springframework.boot + spring-boot-starter-webflux + + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + + com.alibaba.fastjson2 + fastjson2 + + + + + org.projectlombok + lombok + true + + + + + ${project.artifactId} + + + org.springframework.boot + spring-boot-maven-plugin + + org.xyzh.gateway.GatewayApplication + + + + + diff --git a/urbanLifelineServ/gateway/src/main/java/org/xyzh/gateway/GatewayApplication.java b/urbanLifelineServ/gateway/src/main/java/org/xyzh/gateway/GatewayApplication.java new file mode 100644 index 0000000..b21c151 --- /dev/null +++ b/urbanLifelineServ/gateway/src/main/java/org/xyzh/gateway/GatewayApplication.java @@ -0,0 +1,21 @@ +package org.xyzh.gateway; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; + +/** + * @description Gateway 网关启动类 + * @author yslg + * @since 2025-12-02 + */ +@SpringBootApplication +@EnableDiscoveryClient +public class GatewayApplication { + public static void main(String[] args) { + SpringApplication.run(GatewayApplication.class, args); + System.out.println("========================================"); + System.out.println(" Gateway 网关服务启动成功!"); + System.out.println("========================================"); + } +} diff --git a/urbanLifelineServ/gateway/src/main/java/org/xyzh/gateway/config/GatewayRouteConfig.java b/urbanLifelineServ/gateway/src/main/java/org/xyzh/gateway/config/GatewayRouteConfig.java new file mode 100644 index 0000000..6054b36 --- /dev/null +++ b/urbanLifelineServ/gateway/src/main/java/org/xyzh/gateway/config/GatewayRouteConfig.java @@ -0,0 +1,78 @@ +package org.xyzh.gateway.config; + +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; + +/** + * @description Gateway 动态路由配置(Java 代码方式) + * @author yslg + * @since 2025-12-02 + * + * 这是路由配置的第二种方式,与 application.yml 中的配置二选一或配合使用 + * 优点:灵活性更高,可以动态添加/修改路由,支持更复杂的路由逻辑 + */ +@Configuration +public class GatewayRouteConfig { + + /** + * 通过代码方式配置路由(示例,可根据需要启用) + */ + // @Bean + public RouteLocator customRouteLocator(RouteLocatorBuilder builder) { + return builder.routes() + // 认证服务路由 + .route("auth-service", r -> r + .path("/auth/**") + .filters(f -> f + .stripPrefix(1) + .addRequestHeader("X-Gateway", "gateway-service") + ) + .uri("lb://auth-service") + ) + + // 系统服务路由 + .route("system-service", r -> r + .path("/system/**") + .filters(f -> f + .stripPrefix(1) + .addRequestHeader("X-Gateway", "gateway-service") + ) + .uri("lb://system-service") + ) + + // 日志服务路由 + .route("log-service", r -> r + .path("/log/**") + .filters(f -> f + .stripPrefix(1) + .addRequestHeader("X-Gateway", "gateway-service") + ) + .uri("lb://log-service") + ) + + // 文件服务路由 + .route("file-service", r -> r + .path("/file/**") + .filters(f -> f + .stripPrefix(1) + .addRequestHeader("X-Gateway", "gateway-service") + ) + .uri("lb://file-service") + ) + + // WebSocket 路由示例 + .route("websocket-route", r -> r + .path("/ws/**") + .uri("lb:ws://system-service") + ) + + .build(); + } + + /** + * 动态路由刷新端点(如需要) + * 可以配合 Nacos 配置中心实现动态路由更新 + */ +} diff --git a/urbanLifelineServ/gateway/src/main/java/org/xyzh/gateway/config/RateLimiterConfig.java b/urbanLifelineServ/gateway/src/main/java/org/xyzh/gateway/config/RateLimiterConfig.java new file mode 100644 index 0000000..639d15a --- /dev/null +++ b/urbanLifelineServ/gateway/src/main/java/org/xyzh/gateway/config/RateLimiterConfig.java @@ -0,0 +1,47 @@ +package org.xyzh.gateway.config; + +import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import reactor.core.publisher.Mono; + +/** + * @description 限流配置 - 基于 Redis 的令牌桶算法 + * @author yslg + * @since 2025-12-02 + */ +@Configuration +public class RateLimiterConfig { + + /** + * 基于 IP 地址的限流 + */ + @Bean + public KeyResolver ipKeyResolver() { + return exchange -> { + String ip = exchange.getRequest().getRemoteAddress() != null + ? exchange.getRequest().getRemoteAddress().getAddress().getHostAddress() + : "unknown"; + return Mono.just(ip); + }; + } + + /** + * 基于用户 ID 的限流(从请求头获取) + */ + // @Bean + public KeyResolver userKeyResolver() { + return exchange -> { + String userId = exchange.getRequest().getHeaders().getFirst("X-User-Id"); + return Mono.just(userId != null ? userId : "anonymous"); + }; + } + + /** + * 基于 API 路径的限流 + */ + // @Bean + public KeyResolver apiKeyResolver() { + return exchange -> Mono.just(exchange.getRequest().getPath().value()); + } +} diff --git a/urbanLifelineServ/gateway/src/main/java/org/xyzh/gateway/filter/AuthGlobalFilter.java b/urbanLifelineServ/gateway/src/main/java/org/xyzh/gateway/filter/AuthGlobalFilter.java new file mode 100644 index 0000000..b9f44c5 --- /dev/null +++ b/urbanLifelineServ/gateway/src/main/java/org/xyzh/gateway/filter/AuthGlobalFilter.java @@ -0,0 +1,177 @@ +package org.xyzh.gateway.filter; + +import com.alibaba.fastjson2.JSON; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +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.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.util.AntPathMatcher; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ServerWebExchange; +import org.xyzh.common.auth.config.AuthProperties; +import org.xyzh.common.auth.contants.AuthContants; +import org.xyzh.common.auth.token.TokenParser; +import org.xyzh.common.core.domain.ResultDomain; +import reactor.core.publisher.Mono; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * @description Gateway 全局认证过滤器 - 用于验证 JWT Token + * @author yslg + * @since 2025-12-02 + */ +@Component +public class AuthGlobalFilter implements GlobalFilter, Ordered { + private static final Logger log = LoggerFactory.getLogger(AuthGlobalFilter.class); + + @Autowired + private TokenParser tokenParser; + + @Autowired + private AuthProperties authProperties; + + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + ServerHttpRequest request = exchange.getRequest(); + String path = request.getURI().getPath(); + + log.debug("Gateway 请求路径: {}", path); + + // 1. 检查认证功能是否启用 + if (!authProperties.isEnabled()) { + log.debug("认证功能未启用,直接放行"); + return chain.filter(exchange); + } + + // 2. 检查是否在白名单中 + if (isWhitelisted(path)) { + log.debug("路径在白名单中,跳过认证: {}", path); + return chain.filter(exchange); + } + + // 3. 提取 Token + String token = extractToken(request); + if (!StringUtils.hasText(token)) { + log.warn("请求缺少 Token: {}", path); + return unauthorizedResponse(exchange, "未提供认证令牌,请先登录"); + } + + try { + // 4. 验证 Token + if (tokenParser.isTokenExpired(token)) { + log.warn("Token 已过期: {}", path); + return unauthorizedResponse(exchange, "认证令牌已过期,请重新登录"); + } + + // 5. 获取用户信息 + String userId = tokenParser.getUserIdFromToken(token); + if (!StringUtils.hasText(userId)) { + log.warn("Token 中未找到用户ID: {}", path); + return unauthorizedResponse(exchange, "认证令牌无效"); + } + + // 6. 验证 Token 有效性 + if (!tokenParser.validateToken(token, userId)) { + log.warn("Token 验证失败: userId={}, path={}", userId, path); + return unauthorizedResponse(exchange, "认证令牌验证失败"); + } + + // 7. 将用户信息添加到请求头中,传递给下游服务 + ServerHttpRequest mutatedRequest = request.mutate() + .header(AuthContants.USER_ID_ATTRIBUTE, userId) + .header(AuthContants.TOKEN_ATTRIBUTE, token) + .build(); + + log.debug("Token 验证成功: userId={}, path={}", userId, path); + + // 8. 继续执行过滤器链 + return chain.filter(exchange.mutate().request(mutatedRequest).build()); + + } catch (Exception e) { + log.error("Token 解析或验证异常: path={}, error={}", path, e.getMessage(), e); + return unauthorizedResponse(exchange, "认证令牌解析失败: " + e.getMessage()); + } + } + + /** + * 检查路径是否在白名单中 + */ + private boolean isWhitelisted(String path) { + // 1. 检查认证相关接口 + if (authProperties.getAuthPaths() != null) { + for (String pattern : authProperties.getAuthPaths()) { + if (pattern != null && pathMatcher.match(pattern, path)) { + return true; + } + } + } + + // 2. 检查通用白名单 + if (authProperties.getWhitelist() != null) { + for (String pattern : authProperties.getWhitelist()) { + if (pattern != null && pathMatcher.match(pattern, path)) { + return true; + } + } + } + + return false; + } + + /** + * 从请求头中提取 Token + */ + private String extractToken(ServerHttpRequest request) { + List headers = request.getHeaders().get(authProperties.getTokenHeader()); + if (headers == null || headers.isEmpty()) { + return null; + } + + String header = headers.get(0); + if (!StringUtils.hasText(header)) { + return null; + } + + // 支持 Bearer 前缀 + String prefix = authProperties.getTokenPrefix(); + if (StringUtils.hasText(prefix) && header.startsWith(prefix)) { + return header.substring(prefix.length()).trim(); + } + + // 也支持直接传递 Token(不带前缀) + return header.trim(); + } + + /** + * 返回未授权响应 + */ + private Mono unauthorizedResponse(ServerWebExchange exchange, String message) { + ServerHttpResponse response = exchange.getResponse(); + response.setStatusCode(HttpStatus.UNAUTHORIZED); + response.getHeaders().setContentType(MediaType.APPLICATION_JSON); + + ResultDomain result = ResultDomain.failure(HttpStatus.UNAUTHORIZED.value(), message); + String json = JSON.toJSONString(result); + DataBuffer buffer = response.bufferFactory().wrap(json.getBytes(StandardCharsets.UTF_8)); + + return response.writeWith(Mono.just(buffer)); + } + + @Override + public int getOrder() { + // 优先级设置为最高,确保在其他过滤器之前执行 + return Ordered.HIGHEST_PRECEDENCE; + } +} diff --git a/urbanLifelineServ/gateway/src/main/java/org/xyzh/gateway/filter/LogGlobalFilter.java b/urbanLifelineServ/gateway/src/main/java/org/xyzh/gateway/filter/LogGlobalFilter.java new file mode 100644 index 0000000..02e8e48 --- /dev/null +++ b/urbanLifelineServ/gateway/src/main/java/org/xyzh/gateway/filter/LogGlobalFilter.java @@ -0,0 +1,54 @@ +package org.xyzh.gateway.filter; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** + * @description Gateway 全局日志过滤器 - 记录请求信息 + * @author yslg + * @since 2025-12-02 + */ +@Component +public class LogGlobalFilter implements GlobalFilter, Ordered { + private static final Logger log = LoggerFactory.getLogger(LogGlobalFilter.class); + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + ServerHttpRequest request = exchange.getRequest(); + long startTime = System.currentTimeMillis(); + + String path = request.getURI().getPath(); + String method = request.getMethod().name(); + String remoteAddress = request.getRemoteAddress() != null + ? request.getRemoteAddress().getAddress().getHostAddress() + : "unknown"; + + log.info("==> Gateway 请求: {} {} | 来源: {}", method, path, remoteAddress); + + return chain.filter(exchange).then( + Mono.fromRunnable(() -> { + long endTime = System.currentTimeMillis(); + long duration = endTime - startTime; + int statusCode = exchange.getResponse().getStatusCode() != null + ? exchange.getResponse().getStatusCode().value() + : 0; + + log.info("<== Gateway 响应: {} {} | 状态: {} | 耗时: {}ms", + method, path, statusCode, duration); + }) + ); + } + + @Override + public int getOrder() { + // 优先级设置较低,在认证过滤器之后执行 + return Ordered.LOWEST_PRECEDENCE; + } +} diff --git a/urbanLifelineServ/gateway/src/main/java/org/xyzh/gateway/handler/GatewayExceptionHandler.java b/urbanLifelineServ/gateway/src/main/java/org/xyzh/gateway/handler/GatewayExceptionHandler.java new file mode 100644 index 0000000..a4f874e --- /dev/null +++ b/urbanLifelineServ/gateway/src/main/java/org/xyzh/gateway/handler/GatewayExceptionHandler.java @@ -0,0 +1,70 @@ +package org.xyzh.gateway.handler; + +import com.alibaba.fastjson2.JSON; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler; +import org.springframework.cloud.gateway.support.NotFoundException; +import org.springframework.core.annotation.Order; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.server.ServerWebExchange; +import org.xyzh.common.core.domain.ResultDomain; +import reactor.core.publisher.Mono; + +import java.nio.charset.StandardCharsets; + +/** + * @description Gateway 全局异常处理器 + * @author yslg + * @since 2025-12-02 + */ +@Component +@Order(-1) // 优先级设置为最高 +public class GatewayExceptionHandler implements ErrorWebExceptionHandler { + private static final Logger log = LoggerFactory.getLogger(GatewayExceptionHandler.class); + + @Override + public Mono handle(ServerWebExchange exchange, Throwable ex) { + ServerHttpResponse response = exchange.getResponse(); + + if (response.isCommitted()) { + return Mono.error(ex); + } + + response.getHeaders().setContentType(MediaType.APPLICATION_JSON); + + ResultDomain result; + HttpStatus httpStatus; + + // 根据不同异常类型返回不同的错误信息 + if (ex instanceof NotFoundException) { + log.error("服务未找到: {}", ex.getMessage()); + httpStatus = HttpStatus.SERVICE_UNAVAILABLE; + result = ResultDomain.failure(httpStatus.value(), "服务暂时不可用: " + ex.getMessage()); + } else if (ex instanceof ResponseStatusException rse) { + log.error("响应状态异常: {}", rse.getMessage()); + httpStatus = (HttpStatus) rse.getStatusCode(); + result = ResultDomain.failure(httpStatus.value(), rse.getReason()); + } else if (ex instanceof IllegalStateException) { + log.error("非法状态异常: {}", ex.getMessage()); + httpStatus = HttpStatus.BAD_REQUEST; + result = ResultDomain.failure(httpStatus.value(), "请求参数错误: " + ex.getMessage()); + } else { + log.error("Gateway 内部错误", ex); + httpStatus = HttpStatus.INTERNAL_SERVER_ERROR; + result = ResultDomain.failure(httpStatus.value(), "网关内部错误,请稍后重试"); + } + + response.setStatusCode(httpStatus); + + String json = JSON.toJSONString(result); + DataBuffer buffer = response.bufferFactory().wrap(json.getBytes(StandardCharsets.UTF_8)); + + return response.writeWith(Mono.just(buffer)); + } +} diff --git a/urbanLifelineServ/gateway/src/main/resources/application-dev.yml b/urbanLifelineServ/gateway/src/main/resources/application-dev.yml new file mode 100644 index 0000000..c5515b9 --- /dev/null +++ b/urbanLifelineServ/gateway/src/main/resources/application-dev.yml @@ -0,0 +1,19 @@ +spring: + cloud: + gateway: + routes: + # 开发环境可以添加更详细的路由配置或测试路由 + + # Nacos 管理界面路由(开发专用) + - id: nacos-console + uri: http://${NACOS_SERVER_ADDR:localhost:8848} + predicates: + - Path=/nacos/** + +# 开发环境日志 +logging: + level: + org.springframework.cloud.gateway: DEBUG + org.springframework.web.reactive: DEBUG + reactor.netty: DEBUG + org.xyzh: DEBUG diff --git a/urbanLifelineServ/gateway/src/main/resources/application.yml b/urbanLifelineServ/gateway/src/main/resources/application.yml new file mode 100644 index 0000000..dd92629 --- /dev/null +++ b/urbanLifelineServ/gateway/src/main/resources/application.yml @@ -0,0 +1,133 @@ +server: + port: 8080 + +spring: + application: + name: gateway-service + + # 配置中心 + cloud: + nacos: + discovery: + server-addr: ${NACOS_SERVER_ADDR:localhost:8848} + namespace: dev + group: DEFAULT_GROUP + config: + server-addr: ${NACOS_SERVER_ADDR:localhost:8848} + file-extension: yml + namespace: dev + group: DEFAULT_GROUP + + # Gateway 路由配置 + gateway: + # 服务发现路由(自动路由) + discovery: + locator: + enabled: false # 关闭自动路由,使用手动配置 + + # 手动配置路由 + routes: + # ==================== 认证服务路由 ==================== + - id: auth-service + uri: lb://auth-service + predicates: + - Path=/auth/** + filters: + - StripPrefix=1 + - name: RequestRateLimiter + args: + redis-rate-limiter.replenishRate: 100 + redis-rate-limiter.burstCapacity: 200 + + # ==================== 系统服务路由 ==================== + - id: system-service + uri: lb://system-service + predicates: + - Path=/system/** + filters: + - StripPrefix=1 + + # ==================== 日志服务路由 ==================== + - id: log-service + uri: lb://log-service + predicates: + - Path=/log/** + filters: + - StripPrefix=1 + + # ==================== 文件服务路由 ==================== + - id: file-service + uri: lb://file-service + predicates: + - Path=/file/** + filters: + - StripPrefix=1 + + # 全局跨域配置 + globalcors: + cors-configurations: + '[/**]': + allowedOriginPatterns: "*" + allowedMethods: + - GET + - POST + - PUT + - DELETE + - OPTIONS + allowedHeaders: "*" + allowCredentials: true + maxAge: 3600 + + # Redis 配置(用于限流、缓存) + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + database: 0 + timeout: 5000ms + lettuce: + pool: + max-active: 20 + max-wait: -1ms + max-idle: 10 + min-idle: 5 + +# 认证配置 +auth: + enabled: true + token-header: Authorization + token-prefix: "Bearer " + # 认证接口白名单(login/logout/captcha/refresh) + auth-paths: + - /auth/login + - /auth/logout + - /auth/captcha + - /auth/refresh + # 通用白名单(Swagger、健康检查等) + whitelist: + - /actuator/** + - /v3/api-docs/** + - /swagger-ui/** + - /swagger-resources/** + - /webjars/** + - /doc.html + - /favicon.ico + - /error + +# Actuator 监控端点 +management: + endpoints: + web: + exposure: + include: health,info,gateway + endpoint: + health: + show-details: always + +# 日志配置 +logging: + level: + org.springframework.cloud.gateway: DEBUG + org.xyzh.gateway: DEBUG + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n" diff --git a/urbanLifelineServ/pom.xml b/urbanLifelineServ/pom.xml index dad3d45..1e5f399 100644 --- a/urbanLifelineServ/pom.xml +++ b/urbanLifelineServ/pom.xml @@ -10,6 +10,7 @@ common apis + gateway log system auth