You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多
文档控制台
免费开始使用

Spring WebFlux集成JWT授权服务器时暴露公开端点问题

解决BFF微服务公开认证端点的安全配置问题

问题背景

我正在开发一个BFF微服务,核心职责包括:

  • 将用户提交的用户名/密码凭证转发至Keycloak,换取JWT令牌
  • 对其他受保护端点进行JWT令牌验证

但当前无法正常暴露无需JWT验证的公开端点api/v1/backoffice/auth(用户通过该端点提交凭证换取令牌),需要调整安全配置实现需求。

当前安全配置

@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {

    @Bean
    public SecurityWebFilterChain globalSecurityWebFilterChain(ServerHttpSecurity http) {
        return http
                .formLogin(ServerHttpSecurity.FormLoginSpec::disable)
                .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
                .csrf(ServerHttpSecurity.CsrfSpec::disable)
                .logout(ServerHttpSecurity.LogoutSpec::disable)
                .securityContextRepository(NoOpServerSecurityContextRepository.getInstance())
                .authorizeExchange(exchanges -> exchanges.anyExchange().authenticated())
                .oauth2ResourceServer(oAuth -> oAuth.jwt(Customizer.withDefaults()))
                .build();
    }
}

当前依赖配置

dependencies {
    // Intra-project dependencies
    implementation(project(":common"))

    // Security dependencies
    implementation("org.springframework.boot:spring-boot-starter-oauth2-client:3.4.1")
    implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server:3.4.1")
    implementation("org.springframework.boot:spring-boot-starter-security:3.4.1")
    implementation("org.springframework.boot:spring-boot-starter-webflux:3.4.1")

    // Dev dependencies
    developmentOnly("org.springframework.boot:spring-boot-devtools:3.4.1")

    // Communication
    implementation("org.springframework.boot:spring-boot-starter-amqp:3.4.1")
    implementation("org.springframework.amqp:spring-rabbit-stream:3.2.1")

    // Cloud capacity
    implementation("org.springframework.cloud:spring-cloud-starter-config:4.2.0")
    implementation("org.springframework.cloud:spring-cloud-starter-netflix-eureka-client:4.2.0") {
        exclude(group = "com.fasterxml.woodstox", module = "woodstox-core")
        exclude(group = "org.apache.httpcomponents", module = "httpclient")
        exclude(group = "com.thoughtworks.xstream", module = "xstream")
    }

    // resolve transitive dependency security issues
    implementation("com.fasterxml.woodstox:woodstox-core:7.1.0")
    implementation("org.apache.httpcomponents:httpclient:4.5.14")
    implementation("com.thoughtworks.xstream:xstream:1.4.21")
}

认证端点控制器

@RestController
@RequestMapping("/api/v1/backoffice")
public class BoAuthController {

    @Value("${keycloak.client.secret}")
    private String clientSecret;

    private final WebClient webClient = WebClient.create("http://localhost:8080");

    @PostMapping("/auth")
    public Mono<ResponseEntity<Map>> login(@RequestBody Map<String, String> credentials) {
        return webClient.post()
                .uri("/realms/backoffice-realm/protocol/openid-connect/token")
                .header("Content-Type", "application/x-www-form-urlencoded")
                .bodyValue("grant_type=password&client_id=backoffice-client&client_secret=" + clientSecret
                        + "&username=" + credentials.get("username")
                        + "&password=" + credentials.get("password"))
                .retrieve()
                .bodyToMono(Map.class)
                .map(ResponseEntity::ok)
                .onErrorResume(error -> Mono.just(
                        ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                                .body(Map.of("error", "Invalid credentials"))
                ));
    }
}

核心解决方案:调整安全配置放行公开端点

当前安全配置中anyExchange().authenticated()要求所有请求必须认证,导致公开的认证端点也被拦截。需要在授权规则中优先放行目标端点,再对其余请求要求认证。

修改后的安全配置

@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {

    @Bean
    public SecurityWebFilterChain globalSecurityWebFilterChain(ServerHttpSecurity http) {
        return http
                .formLogin(ServerHttpSecurity.FormLoginSpec::disable)
                .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
                .csrf(ServerHttpSecurity.CsrfSpec::disable)
                .logout(ServerHttpSecurity.LogoutSpec::disable)
                .securityContextRepository(NoOpServerSecurityContextRepository.getInstance())
                .authorizeExchange(exchanges -> exchanges
                        // 放行公开的认证POST端点,允许匿名访问
                        .pathMatchers(HttpMethod.POST, "/api/v1/backoffice/auth").permitAll()
                        // 其余所有端点强制要求JWT认证
                        .anyExchange().authenticated()
                )
                .oauth2ResourceServer(oAuth -> oAuth.jwt(Customizer.withDefaults()))
                .build();
    }
}

修改说明

  • 添加pathMatchers(HttpMethod.POST, "/api/v1/backoffice/auth").permitAll():明确指定该POST请求无需认证,允许匿名访问
  • 保留anyExchange().authenticated():确保其他所有端点仍需JWT令牌验证,保障服务安全性

额外优化建议

1. WebClient配置优化

避免硬编码Keycloak地址,通过配置注入提升灵活性:

@Bean
public WebClient keycloakWebClient(@Value("${keycloak.base-url}") String keycloakBaseUrl) {
    return WebClient.create(keycloakBaseUrl);
}

在控制器中注入使用:

private final WebClient keycloakWebClient;

public BoAuthController(WebClient keycloakWebClient) {
    this.keycloakWebClient = keycloakWebClient;
}

2. 表单参数安全处理

避免直接拼接表单字符串,使用MultiValueMap传递参数,防止注入风险:

MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("grant_type", "password");
formData.add("client_id", "backoffice-client");
formData.add("client_secret", clientSecret);
formData.add("username", credentials.get("username"));
formData.add("password", credentials.get("password"));

return keycloakWebClient.post()
        .uri("/realms/backoffice-realm/protocol/openid-connect/token")
        .contentType(MediaType.APPLICATION_FORM_URLENCODED)
        .bodyValue(formData)
        .retrieve()
        .bodyToMono(Map.class)
        .map(ResponseEntity::ok)
        // 后续异常处理逻辑不变

3. 异常处理细化

区分不同异常类型,返回更精准的错误信息:

.onErrorResume(WebClientResponseException.class, ex -> {
    if (ex.getStatusCode() == HttpStatus.UNAUTHORIZED) {
        return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body(Map.of("error", "Invalid credentials")));
    }
    return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(Map.of("error", "Failed to connect to authentication service")));
})
.onErrorResume(Exception.class, ex -> Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
        .body(Map.of("error", "Unexpected error occurred"))));

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

火山引擎 最新活动