Security过滤链:认证、授权、异常与清理
接口带着“登录成功”的日志却被拒绝,或者接口根本没过预期过滤器,应先找真正选中的SecurityFilterChain,再问谁建立身份、谁裁决权限、谁清理线程。固定 Spring Security 6.3.3 / Framework 6.1.12 / Servlet API 6.0。本篇是框架机制训练,不是漏洞审计或生产安全保证。协议加密与连接只引用HTTP/TLS正文。
U30 衔接:OAuth2/OIDC 与令牌验证补协议、PKCE、JWT/opaque 与轮换撤销;资源授权补方法、对象与租户边界。本页固定 6.3.3 过滤链机制、原图与局部实验继续保留。
FilterChainProxy 只选第一条匹配链
常见部署入口是Servlet容器中的DelegatingFilterProxy委托名为springSecurityFilterChain的FilterChainProxy。后者使用HttpFirewall包装请求,按登记顺序找第一条匹配SecurityFilterChain,再执行该链的过滤器;不是把所有匹配链合并,也不是“更具体的路径自然优先”。本实验直接把真实FilterChainProxy放入MockMvc,不假装部署了DelegatingFilterProxy或真实容器映射。
原码:FilterChainProxy.java,6.3.3,连续241–253行。固定来源。
private List<Filter> getFilters(HttpServletRequest request) {
int count = 0;
for (SecurityFilterChain chain : this.filterChains) {
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.format("Trying to match request against %s (%d/%d)", chain, ++count,
this.filterChains.size()));
}
if (chain.matches(request)) {
return chain.getFilters();
}
}
return null;
}匹配即返回filters,后续链不再参与。全匹配兜底链若在前,可遮住后面的/api/**。选中空链或未选中链可能直接进入原Servlet链,不能说所有URL都默认受相同规则保护。示例公开路径是刻意的演示范围,实际服务必须检查所有入口和兜底策略;认证组件存在不证明路径覆盖正确。
过滤链内部也是嵌套调用:某个Filter不调用chain.doFilter会短路,之后的认证/授权/业务不会发生。代理层、认证过滤器与异常翻译器各有不同catch/finally,不应把ControllerAdvice当作它们共同的异常入口。
上下文加载、显式保存与清理
原码:SecurityContextHolderFilter.java,6.3.3,连续72–87行。固定来源。
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
if (request.getAttribute(FILTER_APPLIED) != null) {
chain.doFilter(request, response);
return;
}
request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
Supplier<SecurityContext> deferredContext = this.securityContextRepository.loadDeferredContext(request);
try {
this.securityContextHolderStrategy.setDeferredContext(deferredContext);
chain.doFilter(request, response);
}
finally {
this.securityContextHolderStrategy.clearContext();
request.removeAttribute(FILTER_APPLIED);
}repository延迟加载上下文,安装deferredContext后才进入下游;finally清SecurityContextHolder并移除本Filter标记。加载发生在try前,不可声称这个finally覆盖repository加载本身失败。SecurityContextHolderFilter负责加载与清理,不自动把任意修改持久保存,持久化需明确saveContext及repository策略。
RequestAttribute、HttpSession与无状态策略的生命周期不同。实验用RequestAttributeSecurityContextRepository,没有会话登录保持;Basic过滤器在成功路径有自己的保存动作。线程局部身份清理与把身份保存在repository也是两件事,不能因为下一请求仍登录就说线程泄漏,或因为clearContext就说session身份已撤销。
原码:FilterChainProxy.java,6.3.3,连续182–207行。固定来源。
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
boolean clearContext = request.getAttribute(FILTER_APPLIED) == null;
if (!clearContext) {
doFilterInternal(request, response, chain);
return;
}
try {
request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
doFilterInternal(request, response, chain);
}
catch (Exception ex) {
Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex);
Throwable requestRejectedException = this.throwableAnalyzer
.getFirstThrowableOfType(RequestRejectedException.class, causeChain);
if (!(requestRejectedException instanceof RequestRejectedException)) {
throw ex;
}
this.requestRejectedHandler.handle((HttpServletRequest) request, (HttpServletResponse) response,
(RequestRejectedException) requestRejectedException);
}
finally {
this.securityContextHolderStrategy.clearContext();
request.removeAttribute(FILTER_APPLIED);
}
}FilterChainProxy最外层还有清理:首次进入设置标记,finally清线程上下文、移除标记;嵌套已应用分支不在这里重复执行该层清理。它处理RequestRejectedException的边界也不是通用业务异常转换器。清理的是当前线程持有者,不回收业务已经共享给其他线程的可变认证对象。
认证建立身份,授权裁决访问
Basic只是此最小实验的凭据入口。BasicAuthenticationFilter解析Authorization,交AuthenticationManager,ProviderManager遍历合适Provider;DaoAuthenticationProvider借UserDetailsService和密码校验器建立认证结果。客户端传来的用户名或tenant字符串本身不是服务端可信身份。
原码:BasicAuthenticationFilter.java,6.3.3,连续174–214行。固定来源。
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
try {
Authentication authRequest = this.authenticationConverter.convert(request);
if (authRequest == null) {
this.logger.trace("Did not process authentication request since failed to find "
+ "username and password in Basic Authorization header");
chain.doFilter(request, response);
return;
}
String username = authRequest.getName();
this.logger.trace(LogMessage.format("Found username '%s' in Basic Authorization header", username));
if (authenticationIsRequired(username)) {
Authentication authResult = this.authenticationManager.authenticate(authRequest);
SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
context.setAuthentication(authResult);
this.securityContextHolderStrategy.setContext(context);
if (this.logger.isDebugEnabled()) {
this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
}
this.rememberMeServices.loginSuccess(request, response, authResult);
this.securityContextRepository.saveContext(context, request, response);
onSuccessfulAuthentication(request, response, authResult);
}
}
catch (AuthenticationException ex) {
this.securityContextHolderStrategy.clearContext();
this.logger.debug("Failed to process authentication request", ex);
this.rememberMeServices.loginFail(request, response);
onUnsuccessfulAuthentication(request, response, ex);
if (this.ignoreFailure) {
chain.doFilter(request, response);
}
else {
this.authenticationEntryPoint.commence(request, response, ex);
}
return;
}
chain.doFilter(request, response);
}无可转换凭据时继续后续链,不代表自动允许访问。需要认证时才authenticate,成功新建SecurityContext、安装并按repository保存;AuthenticationException清上下文,按ignoreFailure配置继续或调用entry point。错误凭据的401来自该过滤器自己的失败路径;它位于本实验ExceptionTranslationFilter之前,不能说后者捕获了所有认证失败。
原码:AuthorizationFilter.java,6.3.3,连续76–105行。固定来源。
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain)
throws ServletException, IOException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
if (this.observeOncePerRequest && isApplied(request)) {
chain.doFilter(request, response);
return;
}
if (skipDispatch(request)) {
chain.doFilter(request, response);
return;
}
String alreadyFilteredAttributeName = getAlreadyFilteredAttributeName();
request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);
try {
AuthorizationDecision decision = this.authorizationManager.check(this::getAuthentication, request);
this.eventPublisher.publishAuthorizationEvent(this::getAuthentication, request, decision);
if (decision != null && !decision.isGranted()) {
throw new AccessDeniedException("Access Denied");
}
chain.doFilter(request, response);
}
finally {
request.removeAttribute(alreadyFilteredAttributeName);
}
}AuthorizationManager得到Authentication supplier与请求,明确deny时抛AccessDeniedException;null决策在这个实现中不走deny分支,不能忽略自定义管理器的弃权语义。finally移除的是本过滤器应用标记,身份清理仍由前述上下文组件负责。方法权限、资源归属、租户条件与数据层约束还需要独立协议;拥有READ权限不证明可读任意租户的订单。
异常翻译只覆盖它包住的下游
原码:ExceptionTranslationFilter.java,6.3.3,连续123–149行。固定来源。
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
try {
chain.doFilter(request, response);
}
catch (IOException ex) {
throw ex;
}
catch (Exception ex) {
// Try to extract a SpringSecurityException from the stacktrace
Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex);
RuntimeException securityException = (AuthenticationException) this.throwableAnalyzer
.getFirstThrowableOfType(AuthenticationException.class, causeChain);
if (securityException == null) {
securityException = (AccessDeniedException) this.throwableAnalyzer
.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
}
if (securityException == null) {
rethrow(ex);
}
if (response.isCommitted()) {
throw new ServletException("Unable to handle the Spring Security Exception "
+ "because the response is already committed.", ex);
}
handleSpringSecurityException(request, response, chain, securityException);
}
}ExceptionTranslationFilter只处理下游异常链中的AuthenticationException/AccessDeniedException;IOException原样抛,其他业务异常重抛,响应已提交时也无法可靠重写。它不是认证器,也不授予权限。后续handleSpringSecurityException对认证异常启动entry point;对AccessDeniedException,匿名/remember-me身份可能重新进入认证入口,充分认证者交AccessDeniedHandler。实验具体选择401入口与默认403拒绝,不把状态码说成所有配置的固定输出。
实际链为context → Basic → exception translation → authorization → MVC。匿名缺身份由授权阶段抛认证异常后被翻译;已认证viewer只有OTHER权限,授权拒绝403;reader可进入MVC。MVC内普通IllegalStateException如果未解析,会穿出过滤链,但各finally仍清身份。已提交响应、容器ERROR调度和安全异常跨越MVC resolver的组合需另测。
分派与线程不会自动扩大安全保证
REQUEST、ASYNC、ERROR是否进入Servlet Filter先受容器dispatcher映射影响,过滤器自身的once/skip配置又影响分支;不同安全过滤器不必共享同一策略。Callable可能有框架集成帮助传播,普通@Async/自建池不能据此假定继承同一Authentication。显式安装的委托上下文也要在finally恢复,见异步上下文正文。
不复制共享可变SecurityContext或活请求给后台。延迟任务需要最小可信主体、允许的委托范围、执行时授权与撤销契约;事务连接仍不跨线程共享。对HTTP入口的授权不替代内部服务调用和数据行归属验证,客户端tenant声明必须与服务端可信身份和资源关系核对。
本最小链没有覆盖CSRF、CORS、session fixation、remember-me或OAuth2/token生命周期,不代表这些保护可在生产省略。新增工程线的真实会话、密码升级、登录注销与remember-me见认证会话,CSRF/CORS/headers/firewall见浏览器防护;两页固定Security7.0.8,不改写本页6.3.3局部实验事实。Basic fixture的{noop}密码仅用于本地确定性校验,不作为生产凭据方案。HTTP凭据传输安全按TLS主题验证,本实验没有真实网络。
实验:真实安全过滤器与 MockMvc 边界
执行 JAVA_HOME=$(/usr/libexec/java_home -v17) bash labs/spring-senior/run.sh security。独立模块固定Security6.3.3,Framework BOM6.1.12;不升级原核心pom。真实Provider、认证/授权过滤器、ExceptionTranslationFilter与FilterChainProxy组合,MockMvc只代替Servlet请求与响应边界。
| 输入 | 实际预期 |
|---|---|
| 宽泛空链在前(RED控制) | /api/read匿名意外200,说明第一匹配遮住保护链 |
| 保护链在前、匿名 | 401,线程无残留Authentication |
| reader错误密码 | 401,失败不进入业务 |
| viewer正确凭据、OTHER权限 | 403 |
| reader正确凭据、READ | 200/reader |
| 紧接着无凭据请求 | 401,不能继承reader |
| /public | 200/anonymous,明确演示兜底范围 |
| /api/fail合法身份 | 精确IllegalStateException(endpoint failure)穿出,finally后无身份残留 |
本批已实跑并通过上述GREEN条件。没有spring-security-test假身份注入,没有用手工setAuthentication代替认证;不冒称覆盖真实容器、TLS、全部安全默认配置或漏洞审计。
package study.security;
import java.util.*;
import jakarta.servlet.Filter;
import org.springframework.security.authentication.*;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.authorization.AuthorityAuthorizationManager;
import org.springframework.security.web.*;
import org.springframework.security.web.access.*;
import org.springframework.security.web.access.intercept.AuthorizationFilter;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.context.*;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.test.web.servlet.*;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.*;
public class SecurityLab {
@RestController public static class Endpoint {
@GetMapping({"/api/read","/public"}) public String read(){
var auth=SecurityContextHolder.getContext().getAuthentication();return auth==null?"anonymous":auth.getName();
}
@GetMapping("/api/fail") public String fail(){throw new IllegalStateException("endpoint failure");}
}
static String basic(String user,String password){return "Basic "+Base64.getEncoder().encodeToString((user+":"+password).getBytes(java.nio.charset.StandardCharsets.UTF_8));}
static void expect(MockMvc mvc,String path,String credentials,int status,String body)throws Exception {
var request=MockMvcRequestBuilders.get(path);if(credentials!=null)request.header("Authorization",credentials);
var result=mvc.perform(request).andReturn();
if(result.getResponse().getStatus()!=status||body!=null&&!body.equals(result.getResponse().getContentAsString()))
throw new AssertionError(path+" status="+result.getResponse().getStatus()+" body="+result.getResponse().getContentAsString());
if(SecurityContextHolder.getContext().getAuthentication()!=null)throw new AssertionError("principal leaked after request");
}
public static void main(String[] args)throws Exception {
// Fixture passwords only; this experiment does not define a production credential policy.
var users=new InMemoryUserDetailsManager(
User.withUsername("reader").password("{noop}secret").authorities("READ").build(),
User.withUsername("viewer").password("{noop}secret").authorities("OTHER").build());
var provider=new DaoAuthenticationProvider();provider.setUserDetailsService(users);
var manager=new ProviderManager(provider);
AuthenticationEntryPoint entry=(request,response,error)->response.sendError(401);
var context=new SecurityContextHolderFilter(new RequestAttributeSecurityContextRepository());
var authentication=new BasicAuthenticationFilter(manager,entry);
var translation=new ExceptionTranslationFilter(entry,new org.springframework.security.web.savedrequest.NullRequestCache());
var authorization=new AuthorizationFilter(AuthorityAuthorizationManager.hasAuthority("READ"));
List<Filter> filters=List.of(context,authentication,translation,authorization);
var protectedChain=new DefaultSecurityFilterChain(new AntPathRequestMatcher("/api/**"),filters);
var fallback=new DefaultSecurityFilterChain(request->true,List.of());
var proxy=new FilterChainProxy(List.of(protectedChain,fallback));
MockMvc mvc=MockMvcBuilders.standaloneSetup(new Endpoint()).addFilters(proxy).build();
try {
expect(mvc,"/api/read",null,401,null);
expect(mvc,"/api/read",basic("reader","wrong"),401,null);
expect(mvc,"/api/read",basic("viewer","secret"),403,null);
expect(mvc,"/api/read",basic("reader","secret"),200,"reader");
// A second request in the same test thread must not inherit reader.
expect(mvc,"/api/read",null,401,null);
expect(mvc,"/public",null,200,"anonymous");
try {mvc.perform(MockMvcRequestBuilders.get("/api/fail").header("Authorization",basic("reader","secret")));throw new AssertionError("expected endpoint error");}
catch(Exception ex){Throwable root=ex;while(root.getCause()!=null)root=root.getCause();if(!(root instanceof IllegalStateException)||!"endpoint failure".equals(root.getMessage()))throw ex;}
if(SecurityContextHolder.getContext().getAuthentication()!=null)throw new AssertionError("exception cleanup failed");
System.out.println("PASS Security chain-order/authentication/authorization/exception/context-cleanup");
} finally {SecurityContextHolder.clearContext();}
}
}架构师进阶:授权时点与恢复成本
给定1000请求/秒,每次远程授权平均20ms,近似需要20个并行授权请求;若下游变成200ms则约200。无限等待放大会话、连接和线程占用,不能只给授权接口加重试。候选一请求时权威检查,撤销更及时但增加延迟与故障耦合;候选二缓存权限/本地验证,减压却引入TTL、版本、撤销广播和过期凭据窗口。必须按风险定义失效/超时策略,敏感动作通常拒绝未能确认的授权,不用默认成功掩盖故障。
任务排队10分钟后执行时,提交者权限可能已撤销、资源可能迁租户。选择提交时授权等于定义有限委托,需要可审计范围和有效期;选择执行时重新授权则要处理权限服务不可用、重试和已完成副作用。可靠审计还需持久意图与恢复,不把单次after通知当可靠交付。跨租户负例、撤销、匹配遮蔽、身份清理、超时预算和重复任务都应进入验证。
排障连续追问为“选了哪条链 → 哪个组件验证凭据 → 谁做资源授权 → 哪层看见异常 → 谁清当前线程 → 延迟后凭据是否仍有效”。顺序图能解释故障,不能代替生产威胁建模、配置审查与漏洞验证。
闭卷验收
本页 8 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。