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




