2026年7月31日

开发小记 解决将Controller设置为suspend之后无条件返回HTTP 403的问题

作者 TheWhiteDog9487

仍然先展示复现环境

	implementation("org.springframework.boot:spring-boot-starter-security")
	implementation("org.springframework.boot:spring-boot-starter-webmvc")
	implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
	implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core")

只需要这几个依赖

package com.example.demo

import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.filter.OncePerRequestFilter

@SpringBootApplication
class DemoApplication

class f: OncePerRequestFilter() {
	override fun doFilterInternal(
		request: HttpServletRequest,
		response: HttpServletResponse,
		filterChain: FilterChain) {
		if (request.getHeader("X-API-Key") != null) {
			SecurityContextHolder.getContext().authentication = UsernamePasswordAuthenticationToken("", "", listOf(
				SimpleGrantedAuthority("api") ) ) }
		filterChain.doFilter(request, response) } }

@Configuration
class s{

	@Bean
	fun a(s: HttpSecurity): SecurityFilterChain {
		return s.addFilterBefore(f(), UsernamePasswordAuthenticationFilter::class.java )
			.securityMatcher("/hello")
			.authorizeHttpRequests {
				it.anyRequest().authenticated() }
			.build() } }

@RestController
class c{
	@GetMapping("/hello")
	suspend fun hello(): String {
		return "123" } }

fun main(args: Array<String>) {
	runApplication<DemoApplication>(*args) }

因为是一次性代码,我就没太管理格式
而且为了避免各位在好几个代码块之间上翻下翻很麻烦,我就写在一起了

总而言之言而总之,这个Security配置的作用是对唯一的这个Controller鉴权,只要请求携带了X-API-Key这个请求头就放行,否则拦下来。

所以,我们开调试器来实际测试一下。

嗯,看起来不太对。
我的请求携带了X-API-Key,仍然出现了403

而很神奇的是,当我把suspend删掉之后,它正常了

并且,如果你的Controller里出现了一个异常,你会发现你的ExceptionHandler竟然从始至终没运行过


这个问题极为折磨人,我花了一整个下午来排到底是哪里出了问题,然后在StackOverflow上找到一个帖子

kotlin – 403 Forbidden for suspend controller methods with basic authentication after migration to Spring Boot 3 – Stack Overflow

我不是专家我搞不懂具体原理,而且就在我写这篇文章的时候我才注意到另外一个问题
众所周知,被Spring Security拦下来的请求确实会收到HTTP 403,但是响应体应该是空的,但是我们上面的测试发现Body里实际上是个Json,这是什么情况?
所以呢,我为各位请来了DeepSeek老师


一、suspend 控制器必然经历两次 Servlet 分派

Spring MVC 对所有 suspend 控制器方法一视同仁地按异步请求处理(与是否挂起、是否抛异常无关):

第 1 次分派  DispatcherType.REQUEST(Tomcat 线程 T1)
┌───────────────────────────────────────────────┐
│ Security 过滤器链执行 │
│ DispatcherServlet 调用 hello() │
│ 拿到 Deferred → startAsync() → T1 被释放 │
└───────────────────────────────────────────────┘
↓ Deferred 完成("123")
第 2 次分派 DispatcherType.ASYNC(Tomcat 线程 T2)
┌───────────────────────────────────────────────┐
│ Security 过滤器链再次执行 ← 问题就出在这里 │
│ DispatcherServlet 写回 "123" │
└───────────────────────────────────────────────┘

Security 的 FilterChainProxy 注册时覆盖所有分派类型(REQUEST/ASYNC/ERROR),所以每次分派都会从头跑一遍过滤器链 —— 这就是”请求触发了两次 Security 校验”。

二、两次分派之间,认证信息为什么会丢失

四个机制叠加导致:

1. SecurityContextHolderFilter 只负责加载,从不主动保存

它的 javadoc 明确写着:saveContext 必须由认证机制显式调用

finally {
    this.securityContextHolderStrategy.clearContext();   // 只清空,不保存
    request.removeAttribute(FILTER_APPLIED);
}

2. 你的过滤器绕过了标准认证流程

if (request.getHeader("X-API-Key") != null) {
    SecurityContextHolder.getContext().authentication = UsernamePasswordAuthenticationToken(...)
}

直接写 SecurityContextHolder,全程没有调用过 securityContextRepository.saveContext()。认证信息没有落到 session、也没有落到 request attribute,只存在于当前线程的 ThreadLocal 里。

对比:formLogin 等标准认证过滤器认证成功后都会调用 saveContext(),所以普通应用不会遇到这个问题 —— 这也是为什么这个问题”教科书上没见过”。

3. 初始分派结束时,认证被清空

第 1 次分派的 finally 执行 clearContext(),T1 线程上的认证随线程释放永远消失。

4. 异步分派上,没人重建认证

f 继承自 OncePerRequestFilter

protected boolean shouldNotFilterAsyncDispatch() {
    return true;   // 默认:异步分派时不再执行该过滤器
}

这是设计意图(多数过滤器只需在初始分派执行一次),但副作用是:异步分派上你的过滤器被跳过

5. AuthorizationFilter 却是例外

它继承 GenericFilterBean(不受跳过机制影响),且 filterAsyncDispatch 默认 true —— 异步分派上照常执行鉴权

第 2 次分派(ASYNC):
f → 被跳过,没人重建认证
SecurityContextHolderFilter → 加载到空上下文(从未保存过)
AuthorizationFilter → authenticated() 失败 → 403
→ sendError → /error 分派 → 默认错误 JSON

三、为什么 dispatcherTypeMatchers(ASYNC).permitAll() 能修复

.authorizeHttpRequests {
    it
        .dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll()
        .dispatcherTypeMatchers(DispatcherType.ERROR).permitAll()
        .anyRequest().authenticated() }

1. 匹配原理

dispatcherTypeMatchers 生成只匹配特定 DispatcherType 的匹配器。AuthorizationFilter 内部的授权管理器按规则声明顺序评估:

  • 第 1 次分派(REQUEST):ASYNC/ERROR 匹配器都不命中 → 落到 anyRequest().authenticated() → 此时 f 已设置认证 → 通过
  • 第 2 次分派(ASYNC):命中 dispatcherTypeMatchers(ASYNC) → permitAll() → 无需任何 Authentication 直接放行 → 响应正常写回

2. 为什么这样是安全的

  • DispatcherType 由容器决定,客户端无法伪造。HTTP 请求进来永远是 REQUEST 分派;ASYNC 分派只能由 startAsync() 触发,ERROR 分派只能由容器错误机制触发
  • ASYNC/ERROR 分派必然是先前 REQUEST 分派的延续(同一个 AsyncContext),而安全决策(API Key 校验、authenticated()已经在初始分派上完成
  • 如果 API Key 无效,过滤器在初始分派就直接返回 403,根本不会进入异步流程

所以这个方案的本质是:每个请求只在初始分派做一次完整的安全决策,后续容器内部的分派一律放行 —— 既修复了问题,又不产生安全漏洞。

3. ERROR 分派的作用

ERROR 放行是兜底:异常发生后容器会向错误页发起 ERROR 分派,如果错误分派命中的是同一条受保护链,会被再拦一次 403,导致错误响应也无法渲染。显式放行后,异常处理器/错误页都能正常输出。


Q1:删掉 suspend 为什么就正常了

非 suspend 控制器是同步执行的:

fun hello(): String { return "123" }

第 1 次分派(REQUEST):
f 设置认证 → AuthorizationFilter 通过 → 控制器返回 "123"
→ 响应直接写回 → 结束。全程只有一次分派。

没有 startAsync(),就没有异步分派,Security 链只跑一次,认证信息不需要跨分派存活,问题自然不存在。

这反过来也证明了:suspend 是产生”第二次分派”的唯一原因

Q2:删掉 securityMatcher 后 403 但响应体为空

securityMatcher("/hello") 的作用是限定这条链只匹配 /hello。删掉之后,这条链匹配所有请求 —— 包括容器发起的 /error 错误分派。于是:

异步分派(/hello, ASYNC)→ f 被跳过 → 403 → sendError(403)
↓ 容器发起错误分派,想把错误 JSON 渲染出来
ERROR 分派(/error, ERROR)→ 命中同一条 authenticated 链!
f → 被跳过(shouldNotFilterErrorDispatch 默认 true)
AuthorizationFilter → 照常执行(filterErrorDispatch 默认 true)
→ 空上下文 → 又一个 403!

BasicErrorController 被拦在 DispatcherServlet 之外,根本没执行

客户端收到 403,但没有任何内容可写 → 响应体为空

也就是说:渲染错误 JSON 的那次分派,本身又被鉴权拦了一次。这就解释了为什么你的修复里 dispatcherTypeMatchers(DispatcherType.ERROR).permitAll() 是必要的兜底 —— 保证错误分派永远能到达错误控制器。

Q3:响应体里的 JSON 是谁写的

Security 自己一个字都不写。 403 的两条默认路径:

  • Http403ForbiddenEntryPoint(匿名访问被拒)→ response.sendError(403)
  • AccessDeniedHandlerImpl(已认证但权限不足)→ response.sendError(403)

sendError 只做两件事:设置状态码、触发容器的错误分派机制。然后:

sendError(403)

容器向 /error 发起 ERROR 分派

Spring Boot 的 ErrorMvcAutoConfiguration 注册了 /error → BasicErrorController

BasicErrorController 调用 DefaultErrorAttributes 渲染 JSON

{"timestamp": "...", "status": 403, "error": "Forbidden", "path": "/hello"}

所以你看到的 JSON 是 Spring Boot 的 BasicErrorController + DefaultErrorAttributes 在 /error 错误分派上写的,字段(timestamp/status/error/path/message)也来自 DefaultErrorAttributes


所以,最终的解决方案是

kotlin – 403 Forbidden for suspend controller methods with basic authentication after migration to Spring Boot 3 – Stack Overflow