Spring Boot OAuth 2.0资源服务器 排除路径(Cloudflare Turnstile)
我有一个用Kotlin编写的Spring Boot REST API,使用Keycloak与 Spring Security进行身份认证和授权。我想公开一个端点,但用Cloudflare Turnstile来保护,防止机器人访问。
我实现了一个用于Turnstile的自定义OncePerRequestFilter,通常能工作。然而,当我用有效的请求体调用POST端点 /company-applications,且没有任何头部(包括没有Turnstile头部)时,返回的是401 Unauthorized,而不是预期的403 Forbidden。
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.annotation.Order
import org.springframework.http.HttpMethod
import org.springframework.security.config.Customizer
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true)
class SecurityConfig(private val turnstileCaptchaFilter: TurnstileCaptchaFilter) {
@Bean
@Order(1)
fun publicFilterChain(http: HttpSecurity): SecurityFilterChain {
return http.csrf { configurer -> configurer.disable() }
.securityMatchers { it.requestMatchers(HttpMethod.POST, "/company-applications") }
.authorizeHttpRequests { it.anyRequest().permitAll() }
.addFilterBefore(turnstileCaptchaFilter, UsernamePasswordAuthenticationFilter::class.java)
.build()
}
@Bean
@Order(2)
@Throws(Exception::class)
fun filterChain(http: HttpSecurity): SecurityFilterChain {
return http.csrf { configurer -> configurer.disable() }
.cors { configurer -> configurer.configure(http) }
.sessionManagement { configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { requests ->
requests
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/statistics").permitAll()
.anyRequest().authenticated()
}
.oauth2ResourceServer { configurer -> configurer.jwt(Customizer.withDefaults()) }
.build()
}
}
如果我从第二个SecurityFilterChain中移除 oauth2ResourceServer,我会正确得到403。 一旦启用它,响应就始终是401。
Turnstile过滤器、安全配置及相关设置如下所示。
spring:
security:
oauth2:
client:
provider:
<PROVIDER>:
authorization-uri: http://keycloak:8080/realms/<REALM>/protocol/openid-connect/auth
token-uri: http://keycloak:8080/realms/<REALM>/protocol/openid-connect/token
user-info-uri: http://keycloak:8080/realms/<REALM>/protocol/openid-connect/userinfo
jwk-set-uri: http://keycloak:8080/realms/<REALM>/protocol/openid-connect/certs
issuer-uri: http://keycloak:8080/realms/<REALM>
registration:
<REGISTRATION>:
provider: <PROVIDER>
client-id: <CLIENT_ID>
client-name: <CLIENT_NAME>
client-secret: <CLIENT_SECRET>
authorization-grant-type: authorization_code
scope:
- openid
resourceserver:
jwt:
jwk-set-uri: http://keycloak:8080/realms/<REALM>/protocol/openid-connect/certs
issuer-uri: http://keycloak:8080/realms/<REALM>
import com.digitalsanctuary.cf.turnstile.service.TurnstileValidationService
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.HttpMethod
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher
import org.springframework.security.web.util.matcher.OrRequestMatcher
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter
@Component
class TurnstileCaptchaFilter(private val turnstileValidationService: TurnstileValidationService) : OncePerRequestFilter() {
private val matcher = OrRequestMatcher(
PathPatternRequestMatcher.pathPattern(HttpMethod.POST, "/company-applications"),
// TODO: More Paths later
)
override fun shouldNotFilter(request: HttpServletRequest): Boolean = !matcher.matches(request)
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val token = request.getHeader("X-Turnstile-Token")
?.takeIf { it.isNotBlank() }
?: request.getParameter("cf-turnstile-response")?.takeIf { it.isNotBlank() }
if (token.isNullOrBlank()) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Cloudflare Turnstile token missing")
return
}
val remoteIp = request.getHeader("X-Forwarded-For")
?.split(",")
?.firstOrNull()
?.trim()
?.takeIf { it.isNotBlank() }
?: request.remoteAddr
val result = turnstileValidationService.validateTurnstileResponseDetailed(token, remoteIp)
if (!result.isSuccess) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Invalid Cloudflare Turnstile token")
return
}
filterChain.doFilter(request, response)
}
}
在这个设置中,是什么原因导致Spring Security返回401而不是403?如何对这个公开端点强制返回期望的403?
解决方案
经过一些调试(以及来自Google Gemini的有用提示),我意识到问题是由Spring内部的 /error 派发所致。
当我的 TurnstileCaptchaFilter 触发异常时,Spring Boot尝试将请求转发到默认的 /error 路径来呈现响应。然而,因为我的主 SecurityFilterChain 被配置为一个 oauth2ResourceServer,它拦截了这次内部重定向并要求一个有效的JWT,从而导致返回 401 Unauthorized 而非预期的 403 Forbidden。
解决办法: 我在安全配置中显式允许对 /error 路径的访问,这样异常解析器就可以在不被JWT过滤器拦截的情况下完成错误响应的渲染。
.authorizeHttpRequests { auth ->
auth.requestMatchers("/error").permitAll() // Allow the error-handling dispatch
auth.anyRequest().authenticated()
}
安全提示: 如果将错误路径设为公开,请确保不会泄露敏感元数据。请检查你的 application.yml:
- 将
server.error.include-stacktrace: never设置为隐藏内部代码结构。 - 仅在你有信心你的自定义异常不会泄露秘密时,开启
server.error.include-message: always(或on-param)。 - 小心处理
include-binding-errors,以避免暴露内部DTO结构。