You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Kubernetes中Spring Boot WebFlux的OAuth认证、WebFilter及Bean加载问题

Spring Boot WebFlux + Keycloak 集群环境认证失败(本地正常)

我有一个Spring Boot WebFlux项目,需用Keycloak保护REST API。Keycloak与服务部署在Kubernetes同一命名空间,添加Spring Security前其他服务交互正常。

基于开源示例实现并简化后,本地运行正常,但在Kubernetes集群中无法完成认证。调用服务时出现无SecurityContext,授权被拒绝的日志,但无认证的/health端点可以正常访问。

已尝试的排查步骤

  • 初始配置直接沿用示例结构,集群内认证失败
  • 尝试自定义WebFilter手动解析JWT,未生效
  • 添加日志WebFilter观察请求流转,无输出
  • 修改安全配置:禁用CSRF、CORS,关闭HTTP Basic、表单登录、登出等非必要认证方式
  • 移除项目中prod和default两个Profile,排除Profile配置异常
  • 确认Pod已拉取正确镜像,通过构造函数打印验证相关Bean已创建
  • 集群内Keycloak可正常访问:从Pod内用curl获取Token并验证有效,Token Issuer配置正确
  • 本地通过端口转发Keycloak到8081,用Insomnia请求服务可正常完成认证

相关代码配置

Dockerfile

FROM gradle:7.6.0-jdk19
EXPOSE 8080
WORKDIR /app
COPY . /app

RUN apt update -y; apt install curl -y
ENTRYPOINT ["gradle", "bootRun"]

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.1.1'
    id 'io.spring.dependency-management' version '1.1.0'
}

// ....
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
    implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.amqp:spring-amqp'
    implementation 'com.rabbitmq:amqp-client:5.14.2'
    implementation 'io.projectreactor.rabbitmq:reactor-rabbitmq:1.5.6'

    compileOnly 'org.projectlombok:lombok'
    implementation 'io.kubernetes:client-java:17.0.1'
    implementation 'io.kubernetes:client-java-api-fluent:17.0.1'
    annotationProcessor 'org.projectlombok:lombok'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.assertj:assertj-core:3.24.1'
    testImplementation 'com.github.tomakehurst:wiremock-jre8:2.35.0'
}

application.yaml

app:
  security:
    clientId: "<client>"

logging.level:
  org.springframework.security: DEBUG
#  ....

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: "http://keycloak:8080/auth/realms/<realm>"
          #jwk-set-uri: "http://keycloak:8080/auth/realms/<realm>/.well-known/openid-configuration/"

SecurityConfig.java

@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class SecurityConfig {
    @Bean
    SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http,
                                                     @Qualifier("jwtFilter")WebFilter jwtFilter,
                                                     @Qualifier("keycloakJwtAuthenticationConverter") Converter<Jwt, Mono<AbstractAuthenticationToken>> jwtAuthenticationConverter
    ) {

        http.csrf(csrfSpec -> csrfSpec.disable())
                .authorizeExchange(authorizeExchangeSpec -> authorizeExchangeSpec.anyExchange().authenticated())
                .httpBasic(httpBasicSpec -> httpBasicSpec.disable())
                .logout(logoutSpec -> logoutSpec.disable())
                .formLogin(formLoginSpec -> formLoginSpec.disable())
                .oauth2ResourceServer(oauth -> oauth
                        .jwt(jwtSpec -> jwtSpec.jwtAuthenticationConverter(jwtAuthenticationConverter)))
                // fourth try:
                //.addFilterBefore(jwtFilter, SecurityWebFiltersOrder.AUTHENTICATION);
        ;
        return http.build();
    }
}

KeycloakConfiguration.java

@Configuration
public class KeycloakConfiguration {

    @Bean(name = "keycloakGrantedAuthoritiesConverter")
    Converter<Jwt, Collection<GrantedAuthority>> keycloakGrantedAuthoritiesConverter(@Value("${app.security.clientId}") String clientId) {
        return new KeycloakGrantedAuthoritiesConverter(clientId);
    }

    @Bean(name = "keycloakJwtAuthenticationConverter")
    Converter<Jwt, Mono<AbstractAuthenticationToken>> keycloakJwtAuthenticationConverter(
          @Qualifier("keycloakGrantedAuthoritiesConverter") Converter<Jwt, Collection<GrantedAuthority>> converter) {
        return new ReactiveKeycloakJwtAuthenticationConverter(converter);
    }
}

KeycloakGrantedAuthoritiesConverter.java

@RequiredArgsConstructor
public class KeycloakGrantedAuthoritiesConverter implements Converter<Jwt, Collection<GrantedAuthority>> {
    private static final String ROLES = "roles";
    private static final String CLAIM_REALM_ACCESS = "realm_access";
    private static final String RESOURCE_ACCESS = "resource_access";

    private final Converter<Jwt, Collection<GrantedAuthority>> defaultAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();

    private final String clientId;

    @Override
    public Collection<GrantedAuthority> convert(Jwt jwt) {
        List<String> realmRoles = realmRoles(jwt);
        List<String> clientRoles = clientRoles(jwt, clientId);

        Collection<GrantedAuthority> authorities = Stream.concat(realmRoles.stream(), clientRoles.stream())
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toSet());
        authorities.addAll(defaultGrantedAuthorities(jwt));

        System.out.println(authorities);

        return authorities;
    }

    private Collection<GrantedAuthority> defaultGrantedAuthorities(Jwt jwt) {
        return Optional.ofNullable(defaultAuthoritiesConverter.convert(jwt))
                .orElse(emptySet());
    }

    @SuppressWarnings("unchecked")
    protected List<String> realmRoles(Jwt jwt) {
        return Optional.ofNullable(jwt.getClaimAsMap(CLAIM_REALM_ACCESS))
                .map(realmAccess -> (List<String>) realmAccess.get(ROLES))
                .orElse(emptyList());
    }

    @SuppressWarnings("unchecked")
    protected List<String> clientRoles(Jwt jwt, String clientId) {
        if (ObjectUtils.isEmpty(clientId)) {
            return emptyList();
        }

        return Optional.ofNullable(jwt.getClaimAsMap(RESOURCE_ACCESS))
                .map(resourceAccess -> (Map<String, List<String>>) resourceAccess.get(clientId))
                .map(clientAccess -> clientAccess.get(ROLES))
                .orElse(emptyList());
    }
}

ReactiveKeycloakJwtAuthenticationConverter.java

public class ReactiveKeycloakJwtAuthenticationConverter implements Converter<Jwt, Mono<AbstractAuthenticationToken>> {

    private static final String USERNAME_CLAIM = "preferred_username";
    private final Converter<Jwt, Flux<GrantedAuthority>> jwtGrantedAuthoritiesConverter;

    public ReactiveKeycloakJwtAuthenticationConverter(Converter<Jwt, Collection<GrantedAuthority>> jwtGrantedAuthoritiesConverter) {
        Assert.notNull(jwtGrantedAuthoritiesConverter, "jwtGrantedAuthoritiesConverter cannot be null");
        this.jwtGrantedAuthoritiesConverter = new ReactiveJwtGrantedAuthoritiesConverterAdapter(
            jwtGrantedAuthoritiesConverter);
    }

    @Override
    public Mono<AbstractAuthenticationToken> convert(Jwt jwt) {
        // @formatter:off
        return this.jwtGrantedAuthoritiesConverter.convert(jwt)
                .collectList()
                .map((authorities) -> new JwtAuthenticationToken(jwt, authorities, extractUsername(jwt)));
        // @formatter:on
    }

    protected String extractUsername(Jwt jwt) {
        return Optional.ofNullable(jwt.getClaimAsString(USERNAME_CLAIM)).orElseGet(jwt::getSubject);
    }
}

内容的提问来源于stack exchange,提问作者kuemmel

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.16 21:31:57