Spring Security 过滤链:认证、授权与请求上下文
同一个接口,未带凭证时返回 401,普通用户访问管理功能时返回 403。前一个结果要求调用方建立身份,后一个结果来自已经建立身份之后的权限判断。Spring Security 用不同的过滤器和处理器完成这两件事,Controller 可能一次也没有执行。
安全配置最终形成一组有顺序的 Servlet Filter。请求命中了哪条链、身份存放在哪里、哪一层处理拒绝,共同决定了实际响应。
请求怎样选中过滤链
容器入口、链选择器与链内过滤器
Servlet 容器的 FilterChain
├── 应用外围 Filter
├── DelegatingFilterProxy
│ └── springSecurityFilterChain:FilterChainProxy Bean
│ ├── HttpFirewall:检查路径等请求特征
│ ├── 选中第一条匹配的 SecurityFilterChain
│ │ ├── SecurityContextHolderFilter
│ │ ├── 请求防护、认证与授权过滤器
│ │ └── 返回容器原来的 FilterChain
│ └── 请求退出时清理 SecurityContextHolder
└── DispatcherServlet → ControllerDelegatingFilterProxy 把容器中的 Filter 调用委托给 Spring Bean。FilterChainProxy 根据请求选链,SecurityFilterChain 保存一条链的匹配条件和 Filter 列表。三个名字对应三个层次,SecurityFilterChain Bean 的数量并不等于容器中注册了多少个安全代理。
选链采用第一条匹配即停止的规则。某请求同时匹配 /api/** 和兜底链时,只运行前面那条链,不会把两条链的配置合并。公开资源如果需要安全响应头,通常仍应经过安全链,再用 permitAll 放行。Servlet 安全架构说明了这些组件的关系。
两种 matcher 控制不同层次
@Bean
@Order(1)
SecurityFilterChain api(HttpSecurity http) throws Exception {
http.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/admin").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/api/**").authenticated()
.anyRequest().denyAll())
.httpBasic(Customizer.withDefaults());
return http.build();
}securityMatcher 决定这条链是否参与本次请求;内部的 requestMatchers 决定选中之后采用哪条授权规则。内部规则也有顺序,所以 /api/admin 必须在较宽的 /api/** 前面。
链末尾的 anyRequest().denyAll() 只作用于已经进入这条链的请求。它不会保护 /forgotten 这样的链外地址。需要再注册一个没有 securityMatcher 限制的兜底链。没有任何链匹配时,请求可能继续进入应用而不接受这些安全规则。多链示例见 Java Configuration。
路径还受到 context path、Servlet 映射和请求重写影响。Spring Security 7 使用 PathPattern 相关匹配机制时,应与 MVC 的解析器配置保持一致;控制器看到的路径和安全规则匹配的路径必须对应。不要通过反复放宽 /** 解决代理改写问题。MVC 集成说明给出了 PathPatternRequestMatcher 与 MVC 的配合方式。
运行一个具有不同访问规则的应用
构建和启动
下载 security-http-lab.zip,解压后进入 security-http-lab。工程中的 SecurityConfiguration.java 定义安全规则,Endpoints.java 提供接口,ProtectedService.java 放置方法授权,SecurityHttpTest.java 从 HTTP 入口执行测试。
版本为 Spring Boot 4.1.1、Spring Security 7.1.1、Maven 3.9.12,编译目标 Java 17;可使用 Java 17 或 25 运行。依赖由 Boot 管理版本表统一约束,不单独把某个 Security JAR 改成另一条发行线。
下面在 Linux 宿主执行,需要 Docker Engine、curl、jq、unzip 和 OpenSSL。宿主账号需要访问 Docker daemon;一次性 Maven 容器和应用容器都显式使用当前宿主 UID/GID。Docker daemon 自身的权限仍由宿主安装方式决定。
unzip security-http-lab.zip
cd security-http-lab
mkdir -p .m2 secrets
umask 077
openssl rand -base64 24 > secrets/password
LAB_UID=$(id -u)
LAB_GID=$(id -g)
docker run --rm --user "$LAB_UID:$LAB_GID" \
-e MAVEN_CONFIG=/tmp/.m2 \
-v "$PWD:/work" -v "$PWD/.m2:/m2" -w /work \
maven:3.9.12-eclipse-temurin-17 \
mvn -B -ntp -Dmaven.repo.local=/m2 -Duser.home=/tmp clean verify.m2 和工程输出目录属于宿主账号,构建容器可以写入;MAVEN_CONFIG 与 user.home 避免尝试使用不可写的 /root。构建结束后出现 target/security-http-lab-1.0.0.jar,测试报告位于 target/surefire-reports。缺工具时通过系统的 apt 或 dnf 安装上述客户端工具;镜像或依赖下载失败先检查宿主网络、Docker 代理和 Maven 仓库配置,不关闭 TLS 校验。企业内网可同步这些固定版本到内部仓库,或用 docker save/load 搬运镜像并保存 Maven 依赖。
docker run -d --name sec15-http --user "$LAB_UID:$LAB_GID" \
--read-only --tmpfs /tmp:rw,nosuid,nodev,size=128m \
-p 127.0.0.1:18511:8080 \
-v "$PWD/target:/app:ro" -v "$PWD/secrets:/run/secrets:ro" \
eclipse-temurin:17.0.20_8-jdk \
java -jar /app/security-http-lab-1.0.0.jar \
--lab.password-file=/run/secrets/password
docker logs --tail 30 sec15-http
curl -q --noproxy '*' --fail-with-body http://127.0.0.1:18511/health健康接口返回 ready。启动失败先看是否缺少密码文件、文件是否能被运行 UID 读取,以及 18511 端口是否被占用。docker ps 中的 Up 表示容器进程仍在运行,首次请求仍需检查 HTTP 结果;刚启动时连接被拒绝,可先看日志,待 Tomcat 完成监听后重试健康请求。
应用预置 alice、bob、admin 三个内存用户,使用刚生成的实验密码。用户和会话会随进程退出而丢失;回环 HTTP 配置只供本机实验,真实登录入口必须使用 HTTPS。密码不写入源码、镜像或日志。
观察 200、401、403 和链选择
BASE=http://127.0.0.1:18511
LAB_PASSWORD=$(cat secrets/password)
curl -q --noproxy '*' --fail-with-body \
-u "alice:$LAB_PASSWORD" "$BASE/api/me"响应是 {"user":"alice"}。-q 必须放在 curl 的第一个选项,用于忽略默认 curlrc;--noproxy '*' 防止代理环境改变回环请求;--fail-with-body 让预期成功路径遇到 HTTP 错误时非零退出。命令选项完整定义见 curl 手册。实验密码可出现在本机短暂进程参数中,不用这条演示命令传入真实业务账号密码。
对预期拒绝的请求,分别检查传输是否完整和 HTTP 状态,不使用成功路径的 fail 选项:
code=$(curl -q --noproxy '*' -sS -D anonymous.headers \
-o anonymous.body -w '%{http_code}' "$BASE/api/me")
transport=$?
test "$transport" -eq 0 && test "$code" = 401 && \
grep -i '^WWW-Authenticate: Basic' anonymous.headers || \
{ printf '匿名请求未得到预期的 401 Basic challenge\n' >&2; exit 1; }
code=$(curl -q --noproxy '*' -sS -u "alice:$LAB_PASSWORD" \
-o denied.body -w '%{http_code}' "$BASE/api/admin")
transport=$?
test "$transport" -eq 0 && test "$code" = 403 && \
test "$(cat denied.body)" = denied || \
{ printf '普通用户请求未得到预期的 403 denied\n' >&2; exit 1; }
curl -q --noproxy '*' --fail-with-body \
-u "admin:$LAB_PASSWORD" "$BASE/api/admin"第一条请求没有认证身份,返回 Basic challenge;第二条已经认证为 alice,但缺少 ROLE_ADMIN,返回 denied;最后一条返回 admin。X-Lab-Chain 响应头标记选中的实验链,工程测试还直接读取 FilterChainProxy 的 Filter 列表核对匹配结果。
这组 /api/** 只允许 GET,并保留 CSRF 配置。HTTP Basic 凭证可能被浏览器自动重复发送,不能因为 Session 设置成 STATELESS 就一并关闭 CSRF。提供写接口时,应根据凭证承载方式选择 CSRF 方案;仅接受调用方显式设置的 Bearer Header,与依赖 Cookie 或浏览器缓存 Basic 凭证的请求不同。
身份怎样建立、保存和清理
认证过滤器与认证提供者
Authorization: Basic ...
└── BasicAuthenticationFilter
├── 解析用户名和密码 → 未认证 Authentication
└── AuthenticationManager / ProviderManager
└── DaoAuthenticationProvider
├── UserDetailsService 取得用户记录
├── PasswordEncoder 验证密码
└── 返回已认证 principal + authoritiesAuthentication 接口既可承载待验证的凭证,也可承载已经验证的主体。两者的可信程度由认证流程决定;业务代码不能根据客户端字段自行构造一个“已认证”对象后直接放入上下文。
ProviderManager 根据提供者支持的 token 类型调用认证逻辑。用户名密码和 Bearer JWT 使用的输入对象、Provider 与失败原因不同。成功对象通常包含 principal 和 authorities;敏感凭证应在不再需要时擦除。对象职责见 认证架构。
实验使用 DelegatingPasswordEncoder 保存带 {bcrypt} 标识的哈希。标识让验证器知道应选择哪个编码器,也允许成功登录后按应用策略升级旧参数。密码编码和账号生命周期的配置见认证、Session 与 JWT;过滤链负责把请求交给这些组件。
当前线程与跨请求存储
SecurityContextHolder 默认通过 ThreadLocal 向当前线程暴露 SecurityContext。Controller 参数中的 Authentication、@AuthenticationPrincipal 和方法授权都可以使用这个认证结果。
跨请求保存由 SecurityContextRepository 承担。使用 Session 的应用可以把上下文保存在 HttpSession;无状态 API 则可以每次重新验证凭证。SecurityContextHolderFilter 负责读取上下文,采用显式保存配置时,不会在请求结束时自动把任何修改写回 Session。自己实现登录时,只调用 SecurityContextHolder.setContext 会造成“本次请求已登录,下次又匿名”;需要在响应提交前调用适用 repository 的 saveContext。标准登录过滤器已经集成相应保存流程。认证持久化与会话管理解释了保存与会话创建策略。
| 策略 | Spring Security 怎样使用 Session |
|---|---|
| ALWAYS | 创建 Session |
| IF_REQUIRED | 需要时创建,适合常见表单登录 |
| NEVER | 不主动创建,但可能使用已有 Session;其他组件仍可能创建 |
| STATELESS | 不用 Session 获取和保存 SecurityContext,通常每次独立认证 |
请求退出时,安全过滤链清理线程中的上下文,使容器线程能够处理其他用户请求。工程中的外层 CleanupObserver 在安全代理返回后读取 Holder,正常访问、拒绝和异常都检查不到残留认证。这个观察器用于实验,不需要在每个生产项目复制一套清理监控。
表单登录、Session 与 CSRF 连续操作
浏览器链在 /browser/** 上处理登录和写操作。先取得 CSRF token 和匿名 Session,再提交登录表单:
curl -q --noproxy '*' --fail-with-body -c browser.cookies \
"$BASE/browser/csrf" > csrf.json
CSRF=$(jq -r .token csrf.json)
curl -q --noproxy '*' --fail-with-body \
-b browser.cookies -c browser.cookies \
--data-urlencode username=alice \
--data-urlencode "password=$LAB_PASSWORD" \
--data-urlencode "_csrf=$CSRF" "$BASE/browser/login"
curl -q --noproxy '*' --fail-with-body \
-b browser.cookies "$BASE/browser/me"登录返回 authenticated,随后返回 alice 的身份。Session ID 在登录后轮换,登录前后比较同一轮 Cookie 文件中的值即可,不把某个具体 ID 当成固定输出。成功认证还会清理旧 CSRF token,写请求前重新获取:
code=$(curl -q --noproxy '*' -sS -b browser.cookies \
-X POST -o denied.body -w '%{http_code}' "$BASE/browser/action")
transport=$?
test "$transport" -eq 0 && test "$code" = 403 && \
test "$(cat denied.body)" = denied || \
{ printf '缺少 CSRF token 的写请求未被正确拒绝\n' >&2; exit 1; }
curl -q --noproxy '*' --fail-with-body -b browser.cookies \
"$BASE/browser/csrf" > csrf.json
CSRF=$(jq -r .token csrf.json)
curl -q --noproxy '*' --fail-with-body -b browser.cookies \
-H "X-CSRF-TOKEN: $CSRF" -X POST "$BASE/browser/action"缺 token 的请求止于 CsrfFilter,业务计数不变;携带当前 token 的请求进入 Controller,返回 changed:N,N 是该进程实际完成的写次数。CSRF 文档说明了延迟 token、登录/注销清理以及单页应用的处理差异。
注销也使用 POST 和当前 CSRF token:
curl -q --noproxy '*' --fail-with-body -b browser.cookies \
-H "X-CSRF-TOKEN: $CSRF" -X POST "$BASE/browser/logout"
code=$(curl -q --noproxy '*' -sS -b browser.cookies \
-o logout.body -w '%{http_code}' "$BASE/browser/me")
transport=$?
test "$transport" -eq 0 && test "$code" = 401 || \
{ printf '注销后旧 Cookie 的响应不符合预期\n' >&2; exit 1; }注销返回 204。即使再次发送旧 Cookie,服务端也不再恢复登录身份。生产 Cookie 应设置 Secure、HttpOnly 和与业务跨站方式相符的 SameSite;使用 Secure 后,普通 HTTP 测试地址可能不再满足发送条件,应换成正确的 HTTPS 入口验证,而不是删除生产属性。
授权、异常和异步请求的处理位置
URL 规则与方法授权保护不同入口
URL 规则可以很早阻止请求。方法授权位于 Spring 代理调用处,能够保护多个入口共用的业务操作。实验的 URL /api/method 只要求已认证,真正的管理要求在服务方法上:
@Service
public class ProtectedService {
@PreAuthorize("hasRole('ADMIN')")
public String adminAction() {
return "method-admin";
}
}需要显式启用 @EnableMethodSecurity。hasRole("ADMIN") 默认检查 ROLE_ADMIN,hasAuthority 则按给定权限字符串匹配。alice 通过 URL 规则后仍被方法授权拒绝;admin 调用返回 method-admin。
方法调用必须经过相应 Spring 代理。类内自调用、手工 new 的服务对象,以及代理方式不能拦截的方法,可能不进入同一授权逻辑。对象权限还要包含 tenant、owner、目标操作与业务状态;一个 ADMIN 角色不应自动扩大到所有租户的数据。方法安全解释了拦截器、表达式及代理条件;查询中的对象权限见租户、签名与重放。
异常只能沿调用栈向外传播
认证 Filter
├── 认证失败:执行自己的失败处理器或认证入口
└── 成功继续
└── ExceptionTranslationFilter
└── AuthorizationFilter → 后续 Filter → MVC / 方法代理
├── AuthenticationException:启动认证
└── AccessDeniedException
├── 匿名或需要重新认证:启动认证
└── 当前身份已满足认证条件:AccessDeniedHandlerExceptionTranslationFilter 只能接住它调用的下游抛出的相应异常。放在它前面的自定义 Filter 抛出异常,不会倒退进入它。表单登录失败通常由 AuthenticationFailureHandler 处理;Basic、Bearer 认证有自己的认证入口处理。
响应形式由处理器决定。浏览器页面可以重定向登录页;API 常使用 401 与 WWW-Authenticate,权限不足使用 403。实验刻意把浏览器链也配置为 401,便于观察状态;并非所有 formLogin 应用默认如此。Controller 的普通业务异常则由 MVC/容器错误处理,不应被一律包装成认证失败。具体规则见 Handling Security Exceptions。
添加 Filter 时要明确它依赖哪个已有结果。需要 Authentication 的过滤器应放在认证之后;自定义拒绝若要由 ExceptionTranslationFilter 处理,应处于它的下游,或自己明确调用拒绝处理器。Filter 声明成 Spring Bean 后,Boot 还可能把它注册到容器链;只想在安全链执行时,使用 FilterRegistrationBean#setEnabled(false) 禁止第二次容器注册。
对于固定的 Security 7.1.1,addFilterAt 把 Filter 放在某个已有 Filter 的排序位置,并不删除那个 Filter。同位置有多个 Filter 时不要依赖它们的相对次序。需要替换组件时,应先禁用原 DSL 添加的组件,再显式安装新组件。对应行为可查 7.1.1 HttpSecurity 源码。
分派类型和异步线程分别配置
一个 Servlet 请求可以经历 REQUEST、ASYNC、ERROR 或 FORWARD 等分派。容器是否再次调用安全代理,先由 Filter 注册的 dispatcher types 决定;进入代理后,再由各 Filter 的分派规则决定执行内容。
工程显式注册:
spring.security.filter.dispatcher-types=request,async,error,forward/api/forward 转发到 /api/admin 后重新检查目标授权,普通用户得到 403。/api/error 产生业务异常后,在 ERROR 分派中进入兜底链的错误页面许可规则,保留 500,而不是再次要求登录。允许 ERROR 分派服务于错误渲染,错误页面自身不应做新的敏感业务操作;普通 REQUEST 请求 /error 也不会因为这条 dispatcher 规则而自动放行。
异步生产线程和异步分派不是同一个问题:
| 执行方式 | 身份怎样进入执行线程 |
|---|---|
| MVC Controller 返回 Callable | 与 WebAsyncManager 自动集成,传播处理开始时的上下文 |
| Servlet AsyncContext.start(Runnable) | 在启用的 Servlet API 集成下由安全 request 包装器提供传播 |
| 自建 Executor、CompletableFuture 或 DeferredResult 的生产任务 | 应用选择是否传播,并使用相应包装器 |
| 独立消息消费或定时任务 | 建立任务自己的服务身份及授权信息,不借用已结束请求的 ThreadLocal |
MVC 异步集成说明 Callable 与 DeferredResult 的区别。独立线程池可使用 DelegatingSecurityContextCallable / Executor:
Callable<String> readUser = () ->
SecurityContextHolder.getContext().getAuthentication().getName();
Future<String> result = executor.submit(
new DelegatingSecurityContextCallable<>(readUser));包装器捕获提交时的上下文,在执行期间设置它,并在正常或异常退出时恢复/清理 worker 上下文。它传播的是认证对象,不会自动重新查询用户是否已被撤销权限;长时间排队的敏感任务应在执行时重新授权。直接把 Holder 策略改成 InheritableThreadLocal,无法正确处理早已创建、供多个用户复用的线程池。
根据响应和执行位置排查
预期 401,却得到登录页面或重定向
先关闭客户端自动跟随重定向,保留响应头:
curl -q --noproxy '*' -sS -D response.headers \
-o response.body "$BASE/api/me"
grep -Ei '^(HTTP/|Location:|WWW-Authenticate:|X-Lab-Chain:)' response.headers302 和 Location 指向登录页时,检查是否命中了浏览器链、是否启用了默认 formLogin 入口,以及 request cache 是否保存了原请求。修复 matcher 或链上的 AuthenticationEntryPoint 后,用同一匿名请求验证 401 和所需 challenge,不用 Controller 返回一个“未登录 JSON”绕过过滤链。
已登录的 POST 返回 403
先看写请求是否携带当前 Session 与 CSRF token,再判断业务授权。登录、注销或 Session 失效后,先前取得的 token 可能已经不再适用。用前面的 /browser/csrf 重新获取并重发请求;修复后应看到业务计数只增加一次。
若 CSRF 正确仍被拒绝,检查实际 authorities、ROLE_ 前缀、方法代理规则和对象查询条件。不要先关闭 CSRF 再尝试定位所有 403。CORS 预检失败则检查 Origin、允许方法和 Header;一个预检 200 允许浏览器继续发实际请求,实际请求仍要认证和授权。
Filter 执行两次或上下文在异步任务中丢失
Filter 双重执行先区分“容器注册加安全链注册”和“REQUEST 后又发生另一种分派”。记录 Filter 类型、URI、dispatcher type 和调用次数即可,不记录 Authorization、Cookie 或完整请求体。需要检查链列表时,可在受控实验环境启用:
java -jar target/security-http-lab-1.0.0.jar \
--lab.password-file=secrets/password \
--logging.level.org.springframework.security.web.DefaultSecurityFilterChain=DEBUG该命令在已安装 Java 17+ 的 Linux 宿主启动另一实例,默认端口为 8080;先确认该端口空闲。观察启动时每条链的 matcher 与 Filter 名称。排障后关闭额外日志等级,避免长期收集不必要的安全上下文。
上下文丢失时定位真正执行代码的线程:MVC Callable、用户线程池和消息 worker 分别按前面的传播方式处理。线程包装的测试还应包含任务抛错和后续匿名任务,分别检查执行时身份和结束后的 worker 上下文。
升级后规则失效
docker run --rm --user "$LAB_UID:$LAB_GID" \
-e MAVEN_CONFIG=/tmp/.m2 \
-v "$PWD:/work" -v "$PWD/.m2:/m2" -w /work \
maven:3.9.12-eclipse-temurin-17 \
mvn -B -ntp -Dmaven.repo.local=/m2 -Duser.home=/tmp \
dependency:tree -Dincludes=org.springframework.security确认实际加载的 Security 模块版本一致,再按真实 URL、方法、角色、CSRF、分派和方法调用方式回归。DSL 编译成功仍可能选中另一条链,旧的适配器或 voter 迁移也需要保留原来的拒绝规则。发布回退时保持账号、会话和令牌格式的兼容;已撤销的凭证不应因程序回退重新变成有效。
停止实验只回收本轮容器:
docker stop sec15-http
docker rm sec15-http
unset LAB_PASSWORD CSRF密码文件、Cookie 文件与响应材料留在实验目录,确认不再使用后按本机文件管理方式删除。Maven 缓存可以保留,用于同版本重建。
权威资料与规范地址
框架组成与请求授权
Servlet 安全架构:https://docs.spring.io/spring-security/reference/servlet/architecture.html
多链 Java 配置:https://docs.spring.io/spring-security/reference/servlet/configuration/java.html
Spring MVC 集成:https://docs.spring.io/spring-security/reference/servlet/integrations/mvc.html
Spring Boot 管理依赖版本:https://docs.spring.io/spring-boot/appendix/dependency-versions/coordinates.html
Servlet 认证架构:https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html
认证持久化与 Session:https://docs.spring.io/spring-security/reference/servlet/authentication/session-management.html
方法安全:https://docs.spring.io/spring-security/reference/servlet/authorization/method-security.html
Spring Security 7.1.1 HttpSecurity:https://github.com/spring-projects/spring-security/blob/7.1.1/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java
浏览器、异步与诊断
CSRF 防护:https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html
MVC 异步安全集成:https://docs.spring.io/spring-security/reference/servlet/integrations/mvc.html#mvc-async
并发上下文传播:https://docs.spring.io/spring-security/reference/servlet/integrations/concurrency.html
