Spring Boot OAuth 2.0资源服务器 排除路径(Cloudflare Turnstile)

移动开发 2026-07-10

我有一个用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结构。
站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章