开发小记 解决将Controller设置为suspend之后无条件返回HTTP 403的问题
仍然先展示复现环境
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上找到一个帖子



我不是专家我搞不懂具体原理,而且就在我写这篇文章的时候我才注意到另外一个问题
众所周知,被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。
所以,最终的解决方案是
