Spring Boot 3.x + Security + OpenFeign: 如何避免内部服务调用被重复拦截?

环境:Spring Boot 3.x + Spring Security + OpenFeign
需求:

用户请求必须带 user_token
服务之间用 OpenFeign 调用时必须带 client_token
现发现:

用户携带 access_token 访问服务 A 的接口,请求首先经过 Security 过滤器。过滤器需要通过 OpenFeign 远程调用服务 B 来验证 access_token 的有效性。然而,在 OpenFeign 发起远程调用时,该请求又被 Security 过滤器拦截,导致认证失败。这种情况该如何处理?

如何既让外部用户请求走 user_token 校验,又让服务 A 到服务 B 的内部 OpenFeign 请求只校验 client_token 而不再触发 user_token 拦截?

阅读 1.4k
avatarAI BotBETA

如何避免 OpenFeign 内部调用被 Spring Security 重复拦截

在 Spring Boot 3.x + Spring Security + OpenFeign 的环境中,当服务 A 的安全过滤器使用 OpenFeign 调用服务 B 进行 token 验证时,该内部调用又被服务 A 的安全过滤器拦截,导致认证失败。这是因为 Spring Security 的过滤器链默认处理所有传入请求(包括 OpenFeign 发起的内部调用)。关键点在于区分用户请求(带 user_token)和内部服务调用(带 client_token),并为内部调用设置“豁免”规则或单独的安全路径。

解决方案步骤

以下是完整解决方案,确保外部请求(用户)通过 user_token 认证,而内部 OpenFeign 调用使用 client_token 且不被重复拦截。方案基于 服务 A 和 服务 B 协同配置


1. 问题原因分析

  • 当用户请求到达服务 A,被 Spring Security 过滤器拦截。过滤器通过 OpenFeign 调用服务 B 时,OpenFeign 的请求 也被视为传入请求,因为过滤器链是全局的。
  • 导致认证失败:OpenFeign 请求没有 user_token(可能已使用安全上下文传播),但过滤器期望它(尽管应该用 client_token)。
  • 根本原因:服务 A 的安全配置没有区分“外部请求”和“内部服务调用”。需要将 OpenFeign 调用从安全过滤中排除。

2. 核心思路:使用 @PermitAll 或 RequestMatcher 排除内部路径

在服务 A 中,为所有内部 API(包括 OpenFeign 客户端调用的接口)配置一个特定路径前缀(例如 /internal/**),并在 Spring Security 中设置此路径不要求认证。

优点

  • OpenFeign 调用服务 B 的请求被定向到 /internal/** 路径,在服务 A 中被标记为“内部”,从而跳过 user_token 拦截。
  • 服务 B 使用相同路径前缀处理内部调用,但要求 client_token 认证(配置在服务 B 的安全规则中)。

3. 具体实现步骤(配置服务 A 和 服务 B)

假设服务 A 的 OpenFeign 客户端调用服务 B 的验证端点(例如 /verify-token)。

步骤 1: 在服务 A 中创建内部 API 路径并配置 OpenFeign

  • 定义内部路径前缀:所有内部 API 使用如 /internal/**
  • 创建 OpenFeign 客户端:调用服务 B 的验证端点时,使用 /internal/verify-token 路径,并在请求头中添加 client_token。
// 服务 A: 创建 FeignClient 接口,指向服务 B 的内部路径
@FeignClient(name = "serviceBClient", url = "http://service-b")
public interface ServiceBClient {
    @PostMapping("/internal/verify-token")
    boolean verifyToken(@RequestHeader("Authorization") String clientToken, @RequestBody String userToken);
}
  • 添加 RequestInterceptor:自动为所有 OpenFeign 调用添加 client_token 头。
// 服务 A: 配置全局 Feign Interceptor
@Configuration
public class FeignConfig {
    @Value("${security.client-token}") // 从配置读取 client_token
    private String clientToken;

    @Bean
    public RequestInterceptor requestInterceptor() {
        return requestTemplate -> {
            requestTemplate.header("Authorization", "Bearer " + clientToken); // 添加 Bearer client_token
        };
    }
}

步骤 2: 在服务 A 中配置 Spring Security 忽略内部路径

  • Spring Security 配置:创建一个自定义 SecurityFilterChain,让 /internal/** 路径的请求跳过认证。
  • 使用 permitAll() 允许访问,或使用 @PermitAll 在 Controller 层标记(但配置中更可靠)。
// 服务 A: 安全配置类
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/internal/**").permitAll() // 关键:跳过所有 /internal 路径的认证
                .anyRequest().authenticated() // 其他请求要求 user_token 认证
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter()) // 用户 user_token 验证逻辑
            )
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .csrf(csrf -> csrf.disable()); // 无状态服务通常需禁用 CSRF

        return http.build();
    }

    private Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthConverter() {
        // 自定义 Jwt 转换器,处理 user_token 逻辑
        return jwt -> {
            String tokenValue = jwt.getTokenValue();
            // 这里是验证 user_token 的逻辑(但在此方案中,内部调用已跳过)
            return new JwtAuthenticationToken(jwt, Collections.emptyList());
        };
    }
}

步骤 3: 在服务 B 中配置双重安全规则

  • 服务 B 需处理两种请求:

    • 用户直接访问的路径(如 /api/**):要求 user_token 认证。
    • 内部服务调用的路径(如 /internal/**):要求 client_token 认证,而不是 user_token。
  • 使用多个 SecurityFilterChain:一个用于内部路径(要求 client_token),一个用于外部路径(要求 user_token)。
// 服务 B: 安全配置类
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    @Order(1) // 高优先级匹配内部路径
    public SecurityFilterChain internalFilterChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/internal/**") // 只匹配内部路径
            .authorizeHttpRequests(authz -> authz.anyRequest().authenticated())
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt.jwtAuthenticationConverter(clientTokenAuthConverter()) // 处理 client_token
            )
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .csrf(csrf -> csrf.disable());

        return http.build();
    }

    @Bean
    @Order(2) // 低优先级,匹配其他路径
    public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/api/**") // 用户访问路径
            .authorizeHttpRequests(authz -> authz.anyRequest().authenticated())
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt.jwtAuthenticationConverter(userTokenAuthConverter()) // 处理 user_token
            )
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        return http.build();
    }

    // 自定义转换器处理 client_token
    private Converter<Jwt, ? extends AbstractAuthenticationToken> clientTokenAuthConverter() {
        return jwt -> {
            String tokenValue = jwt.getTokenValue();
            // 验证 client_token 逻辑,例如检查 issuer 是否为服务账户
            if (!"expected-issuer-for-client".equals(jwt.getIssuer())) {
                throw new JwtValidationException("Invalid client token");
            }
            return new JwtAuthenticationToken(jwt, Collections.emptyList());
        };
    }

    // 自定义转换器处理 user_token
    private Converter<Jwt, ? extends AbstractAuthenticationToken> userTokenAuthConverter() {
        return jwt -> {
            // 正常 user_token 验证逻辑
            return new JwtAuthenticationToken(jwt, Collections.singletonList(new SimpleGrantedAuthority("USER")));
        };
    }
}

步骤 4: 在服务 B 中实现验证端点

  • 在内部路径(/internal/verify-token)提供 token 验证逻辑。这个端点被服务 A 的 OpenFeign 调用。
// 服务 B: 控制器提供验证端点
@RestController
public class TokenController {
    @PostMapping("/internal/verify-token")
    public boolean verifyToken(@RequestHeader("Authorization") String authHeader, @RequestBody String userToken) {
        // 验证 client_token(自动由服务 B 的 internalFilterChain 处理)
        // 提取和验证 user_token 的业务逻辑
        return "valid".equals(userToken); // 简化示例
    }
}

4. 如何避免安全上下文传播问题

  • 当服务 A 的过滤器调用 OpenFeign 时,如果 SecurityContext 被传播,可能导致内部调用被误认证为“用户请求”。
  • 解决方法:在调用 OpenFeign 前手动清除 SecurityContext(可选,通常不需)。
// 在服务 A 的过滤器中调用 OpenFeign 前清除上下文
public class CustomFilter extends OncePerRequestFilter {
    private final ServiceBClient serviceBClient;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) {
        // ... 处理外部请求
        
        // 调用 OpenFeign 前清除上下文
        SecurityContextHolder.clearContext();
        boolean isValid = serviceBClient.verifyToken(clientToken, userToken); // 请求不会被服务 A 拦截
        SecurityContext context = ...; // 恢复原始上下文(如果需要)

        // 继续过滤器链
        filterChain.doFilter(request, response);
    }
}

5. 验证与测试

  • 测试用户请求:发送带 user_token 的请求到服务 A 的非 /internal 路径,安全过滤器正常工作,并调用 OpenFeign 验证。
  • 测试内部调用:服务 A 的 OpenFeign 调用服务 B 的 /internal/verify-token 时:

    • 在服务 A 中:请求被 requestMatchers("/internal/**").permitAll() 放行,不触发认证。
    • 在服务 B 中:请求被 internalFilterChain 处理,只要求 client_token 认证。
  • 确保 client_token 安全:从服务 A 的配置文件(如 application.yml)读取 client_token,并用 Vault 或 Secrets Manager 加密。

总结

  • 关键点:通过路径隔离(/internal/**)和安全配置中的 permitAll() 避免 OpenFeign 内部调用在服务 A 中被重复拦截。
  • 优势:用户请求和内部调用分离;外部路径使用 user_token,内部路径使用 client_token(服务 B 处理)。
  • 依赖:Spring Security 的多个 SecurityFilterChain 和 OpenFeign 的 RequestInterceptor。
  • 注意:在微服务架构中,这种模式扩展性好,但需确保所有服务都采用一致的内部路径约定。最终实现了外部请求通过 user_token 认证,内部调用只通过 client_token 认证而不再触发 user_token 拦截。
1 个回答

解决方案结构

一、Feign 请求添加标识头 + client_token

@Configuration
public class FeignClientInterceptorConfig {

    @Bean
    public RequestInterceptor internalRequestInterceptor() {
        return requestTemplate -> {
            requestTemplate.header("X-Internal-Call", "true");
            requestTemplate.header("Authorization", "Bearer " + getClientToken());
        };
    }

    private String getClientToken() {
        // 从配置、缓存或远程获取 client_token
        return "your-client-token";
    }
}

二、自定义过滤器:跳过内部调用的用户认证

@Component
public class InternalCallBypassFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        String internalCall = request.getHeader("X-Internal-Call");

        if ("true".equalsIgnoreCase(internalCall)) {
            // 设置一个标志位,供后续 Security 判断
            request.setAttribute("INTERNAL_CALL", true);
        }

        filterChain.doFilter(request, response);
    }
}

三、Spring Security 配置中区分处理逻辑

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .addFilterBefore(new InternalCallAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

四、自定义认证过滤器:区分 user_token 和 client_token

public class InternalCallAuthenticationFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        Boolean isInternal = (Boolean) request.getAttribute("INTERNAL_CALL");

        if (Boolean.TRUE.equals(isInternal)) {
            // 校验 client_token
            String token = resolveToken(request);
            if (isValidClientToken(token)) {
                // 设置一个简单的认证对象,跳过 user_token 验证
                SecurityContextHolder.getContext().setAuthentication(
                    new UsernamePasswordAuthenticationToken("internal-client", null, List.of())
                );
                filterChain.doFilter(request, response);
                return;
            } else {
                response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid client token");
                return;
            }
        }

        // 否则继续走 user_token 验证逻辑(可集成 OAuth2 或 JWT)
        filterChain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearer = request.getHeader("Authorization");
        return (bearer != null && bearer.startsWith("Bearer ")) ? bearer.substring(7) : null;
    }

    private boolean isValidClientToken(String token) {
        // 实现 client_token 校验逻辑
        return "your-client-token".equals(token);
    }
}
撰写回答
你尚未登录,登录后可以
  • 和开发者交流问题的细节
  • 关注并接收问题和回答的更新提醒
  • 参与内容的编辑和改进,让解决方法与时俱进