一个 403 不能说明身份验证失败

同一个管理员账户,GET 请求成功,POST 请求返回 403。用户名和密码完全相同,差异可能来自 CSRF 校验。另一个已经通过认证的读者账户访问管理员地址,也会得到 403,但这次失败来自授权规则。只有状态码,不能定位安全链中的具体拒绝位置。

安全处理至少包含凭证识别、身份认证、资源授权以及与浏览器请求相关的保护。它们使用不同输入,在不同位置失败。把所有拒绝都交给 ControllerAdvice,或者为了让测试变绿而同时关闭 CSRF 和 CORS,会掩盖这些边界。

实验固定 Boot 3.5.6、Framework 6.2.11、Security 6.5.5、Tomcat 10.1.46 和 JDK 21,由 Boot BOM 管理兼容版本。服务只在实验期间监听随机端口,使用 Java HttpClient 发送实际 HTTP。账户 reader/admin 和 {noop}pw 仅用于可复现断言;这里验证的是处理机制,真实服务还需要合适的密码编码、TLS 和账户管理。

HTTP安全链与服务方法授权边界

容器 Filter 链怎样进入 SecurityFilterChain

Servlet 容器调用注册的 Filter,Spring 的 DelegatingFilterProxy 把调用转交给容器中的目标 Bean。Spring Security 的 FilterChainProxy 再根据请求选择 SecurityFilterChain,顺序执行这条链中的安全过滤器。

多个 SecurityFilterChain 不会自动合并为一条大链。FilterChainProxy 使用首个匹配请求的链;因此链级 matcher 的覆盖范围和链之间的顺序决定哪些保护会执行。单条链内部的 requestMatchers 则用于配置该链的授权规则。这两个匹配层次不能互换。FilterChainProxy 6.5.5 源码

实验只有一条 SecurityFilterChain,启动后打印实际 Filter 类名。断言要求 CsrfFilter 和 AuthorizationFilter 仍存在,并要求 CorsFilter 位于 BasicAuthenticationFilter 之前。这样,后面的成功请求建立在保护组件真实安装的前提上。

框架的适配入口仍然遵循 Servlet 生命周期。DelegatingFilterProxy 的职责是查找和委托目标 Filter,不会把安全拒绝转换成 MVC 控制器调用。因此安全链提前返回时,DispatcherServlet 和业务 Controller 都可能尚未执行。Framework 固定源码

认证失败与授权失败使用不同证据

/public 允许匿名 GET,实际响应 200。/private 要求已认证,匿名请求返回 401;不存在的用户名携带 Basic 凭证也返回 401。有效 reader 凭证访问同一地址返回 200。这组对照把“没有身份”与“凭证不被接受”都保留为实际请求。

/admin 要求 ADMIN 角色。reader 凭证已经足以通过 /private,但访问 /admin 得到 403;admin 凭证得到 200。这里的拒绝不能修复为“重新认证一次”,因为失败的是授权条件。

hasRole("ADMIN") 使用角色前缀约定,实验用户通过 .roles("ADMIN") 创建匹配的权限。若业务直接使用不同格式的 authority,应显式选择匹配方式并统一命名。用户拥有一个称为 admin 的登录名,不等于拥有 ADMIN 权限。

实验最后再次发送没有 Authorization 头的 /private 请求,仍得到 401。该观测说明这套 Basic 配置没有让之前一次带凭证的请求自动认证后续匿名请求。它不能推广成“所有 Spring Security 配置都无会话”,因为 CSRF token 访问在同一实验里实际创建了会话 cookie。

方法安全在 URL 授权之后检查服务边界

/method 的 URL 只要求 authenticated,Controller 再调用 Guard.restricted。Guard 是独立 Bean,方法标有 @PreAuthorize("hasRole('ADMIN')"),配置启用了 @EnableMethodSecurity。reader 可以进入已认证请求范围,但方法调用被拒绝,HTTP 返回 403;admin 调用成功并返回 method-admin。

这个结构把方法授权放在服务调用入口。若同一服务还被定时任务、消息消费者或其他控制器调用,不能依靠某一个 URL matcher 自动覆盖这些路径;调用方需要提供相应认证上下文,方法安全也必须通过受增强的 Bean 引用执行。

方法安全仍受到代理边界影响。同类内部调用带注解方法,不能假定会重新经过方法安全拦截器。实验特意把 Guard 与 Controller 分成两个 Bean,从真实 HTTP 进入后跨 Bean 调用;它没有使用直接 new Guard 的伪集成测试。方法安全文档

请求授权与方法授权可以同时存在,但应分别描述职责。URL 规则适合按入口粗粒度保护,方法规则适合描述服务动作的权限要求。实际业务还可能需要对象所有权检查,单个 ADMIN 角色实验没有证明多租户数据隔离。

保留 CSRF 后,POST 成功需要哪些输入

CSRF 关注浏览器会自动附带认证材料时,跨站请求是否能伪造用户操作。HTTP Basic 不能仅因为“没有登录表单”就被判定没有 CSRF 风险。是否关闭保护,需要根据客户端和凭证传播方式做决定,不能从 REST 或 JSON 这两个名称推导。

实验未调用 csrf.disable。admin 对 /write 直接发送 POST,响应 403。随后客户端 GET /csrf,Controller 读取 CsrfToken 的 token 和 headerName;这会触发延迟 token 的取得,并在 CookieManager 中留下会话 cookie。客户端在下一次 POST 同时携带 cookie 和返回的 token,得到 200。

返回的 token 值不能硬编码。默认请求处理器可能对呈现给客户端的值做掩码处理;服务端根据 token repository 与请求处理器恢复、比较相应值。客户端应按服务约定携带响应中提供的 token,而不是假设某个固定字段一定等于会话内部原始值。CSRF 文档

这组实验把认证与 CSRF 拆开:两次 POST 都使用有效 admin 凭证,只有具备正确 token/session 关联的请求成功。CsrfFilter 检查是否需要保护、取得 token 并调用对应的拒绝处理器;它发生在 Controller 业务逻辑之前。CsrfFilter 6.5.5

CORS 预检为什么要早于认证

浏览器的跨源预检通常没有业务认证 cookie。若服务先要求预检请求通过登录认证,再考虑跨源许可,合法客户端可能在真正请求发送前就失败。实验显式配置允许源 https://client.example、GET/POST 方法以及必要请求头,并让 Spring Security 使用该 CorsConfigurationSource。

匿名 OPTIONS 请求携带允许的 Origin 和 Access-Control-Request-Method: POST,实际返回 200,并带准确的 Access-Control-Allow-Origin。把 Origin 改为 https://evil.example,实际返回 403。白名单不是通配符,保护没有为测试方便而关闭。

这里用 HttpClient 检查的是服务端预检处理和响应头。Java HttpClient 不执行浏览器的同源策略,因此这些结果不是浏览器跨源脚本执行成功的证据。真实前端验收还需要浏览器检查凭证模式、预检缓存、重定向及响应暴露规则。CORS 集成

CORS 也不替代 CSRF。前者决定浏览器是否允许跨源脚本访问响应,后者防止利用自动附带认证材料提交受保护操作。一个允许源通过预检,不意味着它可以省略 POST 所需的 CSRF token。

ERROR 分派不能覆盖原始故障

/explode 已通过 reader 认证后抛出应用异常。Tomcat 发起真实 ERROR 分派,目标是 /error。如果错误分派再次被不合适的授权规则拒绝,最终响应可能呈现安全错误,掩盖最初的应用异常。

实验显式让 DispatcherType.ERROR 通过请求授权,再由 Boot 错误处理保留 500。独立记录 Filter 配置 REQUEST 和 ERROR 两种分派,日志必须同时出现 REQUEST /explode 和 ERROR /error;HTTP 断言要求最终为 500。仅在测试里手工设置 dispatcherType,不能替代这条真实容器路径。

放行 ERROR 分派不等于公开所有错误细节。错误响应仍应控制堆栈、内部地址和业务数据的暴露。当前测试只检查状态和分派事实,没有把异常消息全部返回给客户端。请求授权与分派类型

安全异常与应用异常也不必共享处理位置。ExceptionTranslationFilter 面向相应的认证/授权异常转换;CSRF 拒绝有自己的处理路径;MVC HandlerExceptionResolver 只处理进入相应 MVC 流程的异常。统一 JSON 错误格式需要覆盖实际使用的边界,不能只增加一个 ControllerAdvice。

运行与一个可证伪的修改

从系列入口取得完整实验工程,使用 JDK 21,在 examples/spring-framework-lab/ 执行:

1
2
./mvnw -f ecosystem-lab/pom.xml -q compile dependency:build-classpath -Dmdep.outputFile=target/classpath.txt
python3 ecosystem-lab/verify.py E02

本地基线实际通过 19 条断言。evidence/E02/local-20261002/run.txt 包含每个请求的方法、路径、测试账户、状态和响应体,以及 Filter 顺序和分派记录。最后关闭 ApplicationContext,并验证端口已经不能重新建立连接。

练习可以只把 /admin 的 hasRole 改成 authenticated。reader 的 /admin 请求应从 403 变为 200,而 /method 仍为 403,因为 Guard 上的方法规则没有改变。修改时需要同步调整这一条断言,其他检查保持不变。该变体属于预测练习,不在冻结基线的 19 条结果内。

另一个故障定位练习是保留会话 cookie,但把 POST 的 token 替换为任意字符串;应仍然得到 403。修复动作应恢复正确 token,不能删除 CsrfFilter。系列的请求分派和代理实验见 深入 Spring(00):从手动组装到可验证的容器实验。

参考资料