尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spring Security 6.0核心原理:过滤器链、认证授权与安全上下文

Spring Security 6.0核心原理:过滤器链、认证授权与安全上下文 很多同学第一次啃 Spring Security 的源码普遍的感受是类太多了过滤器太多了概念绕来绕去。你问它为什么启动时候自动帮我们拦了请求好像能说两句你问它认证通过之后那个对象到底存哪儿了异步线程里为什么又拿不到了就支支吾吾。本文不打算逐行念源码而是把 Spring Security 6.0 的实现原理拆成几个核心问题过滤器是怎么注册进来的认证链路是怎么跑通的密码是怎么校验的SecurityContext 是怎么传递的授权是怎么裁决的以及从 5.x 迁到 6.0 到底要改什么。理清这几条线你会发现这套框架没那么神秘它本质上就是一条有序的过滤器链路加一堆策略接口。在正式开讲之前先给一个总纲。Spring Security 的一切运行时行为几乎都发生在 Servlet 过滤器链路里。它的所有能力——认证、授权、防攻击、会话管理——都被拆成一个个过滤器编排在一条链上。6.0 相比老版本最大的变化就是把很多过时的配置方式删掉强制你走组件化的思路去理解它。你只要能回答清楚“哪个过滤器在处理什么”就基本搞懂了这个框架。1. 先从入口说起DelegatingFilterProxy 是谁FilterChainProxy 又是谁很多新手看 Spring Security 源码时第一个懵的点就在这里。明明在 Spring MVC 里写的 Controller、Interceptor 都没经过什么过滤器为什么 Spring Security 就能在所有请求进来时拦一道项目里明明没有手动注册任何 Servlet Filter那这条过滤器链是哪来的1.1 为什么 Spring Boot 能自动把 Security 的过滤器拉进来Spring Boot 的自动配置在 spring-boot-autoconfigure 里有一个SecurityFilterAutoConfiguration它干了一件关键的事注册了一个名叫springSecurityFilterChain的 Servlet Filter。这个 Filter 的实际类型不是 Spring Security 里的某个过滤器而是DelegatingFilterProxy。DelegatingFilterProxy这个名字已经说明了它的职责——代理。它本身不处理任何安全逻辑它只是 Spring 容器和 Servlet 容器之间的一个桥。Servlet 容器只认标准的jakarta.servlet.Filter但它不知道 Spring 容器里有什么。而DelegatingFilterProxy是一个“标准 Filter”Servlet 容器能直接往链路里塞它真正干活的过滤器它管不着它只负责把这个 request 转发给 Spring 容器里名为springSecurityFilterChain的那个 Bean。为什么要绕这么一层核心原因是生命周期统一管理。如果你直接用Component把 Security 的过滤器声明成一个 Bean它会同时被 Spring Boot 自动注册到 Servlet 容器的过滤器链里这样就会出现“容器链路里有一个Security 链路里还有一堆”的问题。用DelegatingFilterProxy做中间层Servlet 容器里永远只有一个入口真正的安全逻辑全在 Spring 容器内部编排。这里有个细节值得记住DelegatingFilterProxy是延迟查找的。它启动时不会立刻去拿springSecurityFilterChain而是等第一个请求进来才去 ApplicationContext 里按名字找对应的 Bean。这个设计是为了避免过滤器初始化和容器刷新顺序冲突。如果你在运行日志里看到“No bean named springSecurityFilterChain available”多半是 WebApplicationContext 初始化有问题而不是过滤器本身写错了。1.2 FilterChainProxy 的职责它到底是过滤器还是一条链现在问题来了DelegatingFilterProxy找到了名为springSecurityFilterChain的 Bean这个 Bean 是谁它就是FilterChainProxy。FilterChainProxy实现了Filter接口但它自己不干安全校验。它的内部维护了一个ListSecurityFilterChain每个SecurityFilterChain又持有自己的ListFilter。当请求进来FilterChainProxy会从上到下遍历这些SecurityFilterChain通过SecurityFilterChain.matches(HttpServletRequest)来判断当前请求应该走哪一条链。换句话说FilterChainProxy本身是一个 Servlet Filter但它管理的是一堆 Security 内部的 Filter并且这些 Filter 不直接注册到 Servlet 容器里。这个分层很关键Servlet 容器只看到DelegatingFilterProxy和FilterChainProxy而真正的安全过滤器全部隐藏在FilterChainProxy内部。这也是为什么你可以在一套应用里配置多条安全过滤链/api/**走无状态的 JWT 链/login走表单登录链/static/**直接放行。每条链由不同的SecurityFilterChain描述FilterChainProxy从第一个开始匹配命中哪条就走哪条后面的就不再看了。1.3 关键过滤器逐个盘点从 SecurityContextPersistenceFilter 到 ExceptionTranslationFilter默认配置下Spring Security 6.0 的过滤链大概有十几条我按执行顺序挑几条核心的讲后面分析认证和授权时还会反复提到它们DisableEncodeUrlFilter处理 session 的 URL 重写问题防止把 sessionId 泄露到 URL是一个很小的安全增强。WebAsyncManagerIntegrationFilter把 SecurityContext 和 Spring MVC 的异步处理机制WebAsyncManager绑定起来让异步请求里也能拿到上下文。SecurityContextHolderFilter在请求开始时从SecurityContextRepository读取上下文放入SecurityContextHolder请求结束时清理。6.0 里它取代了老版本的SecurityContextPersistenceFilter默认只在请求期间持有上下文。HeaderWriterFilter往响应头里写入安全相关 header比如X-Content-Type-Options、X-Frame-Options、Strict-Transport-Security。CsrfFilter处理 CSRF Token 的校验和生成默认拦截所有非 GET、HEAD、TRACE、OPTIONS 的请求。LogoutFilter处理登出逻辑默认匹配/logout。UsernamePasswordAuthenticationFilter处理表单登录把请求里的 username/password 封装成认证对象。DefaultLoginPageGeneratingFilter没配置登录页时自动生成一个默认登录页。BasicAuthenticationFilter处理 HTTP Basic 认证。RequestCacheAwareFilter用户访问受保护页面被拦截并跳登录后登录成功跳回原页面靠的就是它。SecurityContextHolderAwareRequestFilter把HttpServletRequest包装成SecurityContextHolderAwareRequestWrapper方便在 MVC 层使用isUserInRole这类 API。AnonymousAuthenticationFilter如果上面所有认证过滤器都没给 SecurityContext 塞东西这个过滤器会给一个匿名的 Authentication 对象避免后面拿到 null 处理起来麻烦。ExceptionTranslationFilter捕获后续过滤器抛出的AccessDeniedException和AuthenticationException决定是跳登录页还是返回 403。AuthorizationFilter最终执行授权判断的地方。6.0 里它取代了 5.x 的FilterSecurityInterceptor负责把请求交给AuthorizationManager裁决。上面这些过滤器的顺序不是随便排的它们之间有严格的依赖关系。比如ExceptionTranslationFilter必须放在授权过滤器前面因为它要捕获授权异常SecurityContextHolderFilter必须放在认证过滤器前面因为认证过滤器需要往 SecurityContextHolder 里写数据。如果你用SecurityFilterChain的addFilterBefore或addFilterAfter定制链路最好不要动这些核心顺序。2. 认证链路的完整运转从 UsernamePasswordAuthenticationToken 到 AuthenticationManager理清过滤器链路之后我们把视角拉近到认证这个环节。认证是 Spring Security 里最容易被问源码的模块尤其是AuthenticationManager、AuthenticationProvider、UserDetailsService这三者之间的关系很多人调了半天接口也没搞明白谁先谁后。2.1 一个登录请求在过滤器链里经历了什么以一个标准的表单登录请求为例。用户 POST 提交/login携带usernamezhangsanpassword123456走到UsernamePasswordAuthenticationFilter时它做了这样几件事从HttpServletRequest里取出 username 和 password 参数参数名默认是username和password可通过UsernamePasswordAuthenticationFilter配置修改。判断请求方法只处理 POST如果当前请求不是requiresAuthentication匹配的地址直接放行到下一个过滤器。把用户名密码封装成UsernamePasswordAuthenticationToken其中 principal 是用户名credentials 是密码此时authenticated还是 false。把 token 交给AuthenticationManager.authenticate()。注意第 3 步UsernamePasswordAuthenticationToken构造出来的对象并不代表认证成功它只是一个待认证的凭证载体。真正的认证逻辑全在AuthenticationManager里。如果认证成功过滤器会把返回的 Authentication 对象放入SecurityContextHolder然后调用成功处理器的onAuthenticationSuccess默认跳回之前被拦截的页面如果认证失败会走失败处理器默认重定向到/login?error。2.2 ProviderManager 如何选择 AuthenticationProviderAuthenticationManager是顶层接口默认实现是ProviderManager。ProviderManager内部维护了一个ListAuthenticationProviderauthenticate()方法会遍历这些 Provider逐个尝试。每个AuthenticationProvider有一个supports(Class? authentication)方法用来判断自己能不能处理当前传入的 Authentication 类型。比如DaoAuthenticationProvider支持的是UsernamePasswordAuthenticationTokenJwtAuthenticationProvider支持的是JwtAuthenticationToken。ProviderManager会先调用supports筛选能处理的才去执行authenticate逻辑。这里有个容易踩坑的点ProviderManager有一个parent的概念。如果当前 ProviderManager 里所有 Provider 都不能处理这个 token它会把认证请求继续抛给 parent。默认情况下parent 是全局共享的一个ProviderManager里面包含AnonymousAuthenticationProvider等。如果你自定义了多个 ProviderManager注意 parent 的传递关系不然会出现“我这个 Provider 明明写了 supports但就是没生效”的诡异情况。ProviderManager还负责清理凭证。认证成功后它会调用eraseCredentials把 token 里的 credentials也就是密码擦掉避免密码在内存里长期驻留。这个细节在审计代码时值得留意——如果你想在认证成功后再拿到原始密码默认是不行的。2.3 DaoAuthenticationProvider 的用户校验流程DaoAuthenticationProvider是AuthenticationProvider最常用的实现名字里的 Dao 指的就是UserDetailsService。它的校验流程大致如下根据UsernamePasswordAuthenticationToken里的 username调用UserDetailsService.loadUserByUsername(username)加载用户。如果用户不存在抛出UsernameNotFoundException如果用户存在但被锁定或禁用抛出对应的AccountStatusException。用PasswordEncoder.matches(rawPassword, encodedPassword)校验密码是否匹配。校验通过后返回一个UsernamePasswordAuthenticationToken此时 token 里携带的 principal 是完整的UserDetails对象且authenticatedtrue。这里要特别强调UserDetailsService只负责“查用户”不负责“校验密码”。密码校验在DaoAuthenticationProvider内部完成它用的编码器是PasswordEncoder。这两个接口职责分离是理解 Spring Security 的关键。很多人误以为在loadUserByUsername里做密码比对这其实绕过了框架的密码策略而且容易写出和PasswordEncoder逻辑不一致的坑。DaoAuthenticationProvider还有一个隐藏逻辑preAuthenticationChecks和postAuthenticationChecks。前者在密码比对前检查用户状态是否锁定、是否禁用、是否过期后者在密码正确后检查凭证是否过期。如果你想实现“密码过期强制修改”之类的功能自定义UserDetailsChecker会比在UserDetailsService里写死逻辑优雅得多。3. PasswordEncoder 与密码校验为什么存储的密文能带前缀密码加密是认证链路里不能跳过的一环。Spring Security 5.0 之后默认使用的PasswordEncoder是DelegatingPasswordEncoder它的特点很鲜明存储在数据库里的密码通常会带一个{bcrypt}、{noop}之类的前缀用来标识编码算法。6.0 延续了这个设计并且把默认算法定死为 BCrypt。3.1 DelegatingPasswordEncoder 的多算法兼容设计DelegatingPasswordEncoder本身不做加密它只是一个路由分发器。构造它的时候传入一个MapString, PasswordEncoderkey 是算法名value 是对应的编码器实例。比如PasswordEncoder passwordEncoder PasswordEncoderFactories.createDelegatingPasswordEncoder();它内部会注册 BCrypt、SCrypt、Argon2、PBKDF2、MD5、SHA-1、noop 等一堆编码器。当你要校验一个密文时它会读取密文的{id}前缀找到对应的编码器去执行matches方法。这个设计的核心价值在于密码迁移。假设你的系统很老最早用的是 MD5后来迁移到 BCrypt。老用户数据库里存的是 MD5 散列新用户存的是 BCrypt 散列。如果没有多算法路由你只能写一段if (password.startsWith({bcrypt}))之类的分支判断非常丑。DelegatingPasswordEncoder把这种判断标准化了你只需要在数据库里保留前缀它自己会找到对应的算法。3.2 明文密码的坑{noop} 前缀为什么会出现在配置里开发阶段图省事有人会写PasswordEncoder passwordEncoder PasswordEncoderFactories.createDelegatingPasswordEncoder();然后数据库里直接存{noop}123456。{noop}表示明文它对应的是NoOpPasswordEncoder。这个类在 Spring Security 5.x 里还能通过PasswordEncoderFactories拿到但它是Deprecated的而且在 Spring Security 6.0 里执行时会直接抛异常因为明文存储被认为是不安全的。如果你只是想本地测试不推荐用{noop}。正确做法是写一个合理的 BCrypt 密文。你可以在测试环境里临时用new BCryptPasswordEncoder().encode(123456)生成密文再写进数据库。这样既能测试认证流程又不会在代码里残留不安全的明文策略。3.3 自定义 PasswordEncoder什么时候需要怎么接进去绝大多数项目不需要自定义PasswordEncoderBCrypt 足够应对常规场景。但如果你有特殊算法比如公司安全规范要求用国密 SM3或者你想兼容一套老系统的加密结果就需要实现PasswordEncoder接口public class Sm3PasswordEncoder implements PasswordEncoder { Override public String encode(CharSequence rawPassword) { return Sm3Util.hash(rawPassword.toString()); } Override public boolean matches(CharSequence rawPassword, String encodedPassword) { return Sm3Util.hash(rawPassword.toString()).equals(encodedPassword); } }实现只有两个方法要注意encode负责生成密文matches负责校验。如果你要在DelegatingPasswordEncoder里挂自己的算法可以通过PasswordEncoderFactories创建时传入自定义 Map 来处理。这里切记matches方法要处理encodedPassword为空的情况否则空指针可能在登录时把整个过滤链打断日志又看不出来是哪里崩的。4. SecurityContext 的传递机制ThreadLocal、异步与并发认证通过后用户信息放在SecurityContextHolder里。这个类名字带 Holder实际上它内部是一个ThreadLocalSecurityContext。这带来一个好消息在同一个线程的任意位置都能拿到当前用户。但这同时也带来一个经典问题切到别的线程用户信息就没了。这一节我们专门聊这个事。4.1 SecurityContextHolder 的三种存储模式SecurityContextHolder默认使用ThreadLocalSecurityContextHolderStrategy也就是把 SecurityContext 存在ThreadLocal里。每个请求进来时SecurityContextHolderFilter会从SecurityContextRepository读上下文塞进ThreadLocal请求结束时再把上下文清理掉。SecurityContextHolder还支持另外两种模式MODE_GLOBAL整个 JVM 共享一个 SecurityContext所有线程拿到的都是同一个对象。一般不推荐多用户环境会互相覆盖。MODE_INHERITABLETHREADLOCAL子线程自动继承父线程的 SecurityContext。用new Thread()创建的子线程能拿到父线程的上下文但线程池复用线程时容易串用户非常危险。切换模式的姿势是设置SecurityContextHolder.setStrategyName(...)或者通过 JVM 系统属性spring.security.strategy指定。默认策略不用改改了反而容易出事。4.2 异步线程里拿不到用户问题出在哪里这是网上提问频率非常高的一个问题。代码大致是CompletableFuture.runAsync(() - { Authentication auth SecurityContextHolder.getContext().getAuthentication(); // 这里拿到的往往是 null });原因很简单CompletableFuture.runAsync用的是 ForkJoinPool 里的公共线程池这些线程和请求线程不是同一个ThreadLocal当然不会跨线程传递。Spring Security 提供了解决办法DelegatingSecurityContextRunnable和DelegatingSecurityContextCallable。这两个类的作用是在创建线程任务的那一刻把当前线程的 SecurityContext 抓出来塞到新线程任务的包装对象里等新线程真正执行时再把它放进ThreadLocal。用法如下Runnable task () - { Authentication auth SecurityContextHolder.getContext().getAuthentication(); System.out.println(auth.getName()); }; Runnable wrapped new DelegatingSecurityContextRunnable(task); new Thread(wrapped).start();Async注解场景也可以通过在AsyncConfigurer里自定义TaskDecorator来统一包装避免每个业务方法都手动包一层。4.3 自定义 TaskDecorator 的正确姿势如果你的项目大量使用Async在EnableAsync的配置类里定义Executor时顺手加一个TaskDecorator就能一劳永逸Bean public Executor asyncExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(10); executor.setQueueCapacity(100); executor.setTaskDecorator(runnable - { SecurityContext context SecurityContextHolder.getContext(); return () - { try { SecurityContextHolder.setContext(context); runnable.run(); } finally { SecurityContextHolder.clearContext(); } }; }); return executor; }这样Async方法内部再拿SecurityContextHolder.getContext()就有值了。注意finally里务必clearContext()否则线程池复用线程时上一个用户的上下文会泄漏到下一个任务里轻则拿到错误用户重则引发越权漏洞。这种问题在测试阶段很难发现往往要等线上出现“A 用户看到了 B 用户数据”的工单才会暴露。5. 授权裁决从 FilterSecurityInterceptor 到 AuthorizationManager认证做完之后下一步是授权。Spring Security 6.0 在授权这块做了一个比较彻底的换血5.x 时代的FilterSecurityInterceptor和access()表达式被移除取而代之的是AuthorizationFilter加AuthorizationManager。很多老项目升级后遇到的错误大多和这个变化有关。5.1 Spring Security 6.0 的请求授权配置长什么样6.0 的推荐写法是基于requestMatchers加permitAll、hasRole等语义化方法http .authorizeHttpRequests(auth - auth .requestMatchers(/public/**).permitAll() .requestMatchers(/admin/**).hasRole(ADMIN) .requestMatchers(/user/**).hasAnyRole(USER, ADMIN) .anyRequest().authenticated() );注意几个变化方法名是authorizeHttpRequests不再是authorizeRequests。requestMatchers接收的是String模式或RequestMatcher实例不再用antMatchers。原来的access(hasRole(ADMIN))字符串表达式在 6.0 里不能直接用了需要改用AccessControlList或AuthorizationManager的自定义实现。AuthorizationFilter是所有请求授权判断的最后一环。它拿到当前请求后调用AuthorizationManager.check()返回AuthorizationDecision判断是允许还是拒绝。若拒绝会抛出AccessDeniedException交给前边的ExceptionTranslationFilter处理。5.2 方法级安全与注解式授权除了 URL 级别的授权Spring Security 还支持方法级授权。在配置类上加EnableMethodSecurity然后在方法上用注解PreAuthorize(hasRole(ADMIN)) public void deleteUser(Long userId) { ... }PreAuthorize会在方法执行前做权限检查基于 SpEL 表达式。它背后关联的方法拦截器是AuthorizationManagerBeforeMethodInterceptor内部会创建PreAuthorizeAuthorizationManager。6.0 里方法安全还支持Secured和 JSR-250 的RolesAllowed。区别在于Secured默认只支持角色判断RolesAllowed是标准注解PreAuthorize的能力最强支持任意 SpEL 表达式。为了既能用注解又能在方法里拿到当前用户常见的姿势是PreAuthorize(hasRole(ADMIN)) public void adjustUserStatus(AuthenticationPrincipal UserDetails userDetails) { ... }AuthenticationPrincipal是 Spring Security 提供的参数解析器它会把当前 SecurityContext 里 Authentication 的 principal 注入到方法参数。如果你把 principal 封装成了自定义的UserInfo对象直接注入即可非常方便。5.3 自定义 AuthorizationManager 来处理复杂权限当内置的hasRole、hasAuthority不够用时比如权限数据需要动态查询就可以自己实现AuthorizationManagerpublic class ResourceOwnerAuthorizationManager implements AuthorizationManagerRequestAuthorizationContext { Override public AuthorizationDecision check(SupplierAuthentication authentication, RequestAuthorizationContext object) { Authentication auth authentication.get(); if (auth null || !auth.isAuthenticated()) { return new AuthorizationDecision(false); } HttpServletRequest request object.getRequest(); String resourceId request.getParameter(resourceId); if (resourceId null) { return new AuthorizationDecision(false); } // 伪代码根据 resourceId 查询所属用户判断当前登录用户是否拥有权限 boolean owner resourceService.isOwner(resourceId, auth.getName()); return new AuthorizationDecision(owner); } }在配置里通过access(AuthorizationManager)接上http.authorizeHttpRequests(auth - auth .requestMatchers(/resource/**).access(resourceOwnerAuthorizationManager) .anyRequest().authenticated() );自定义AuthorizationManager最坑的一点是SupplierAuthentication的返回值。在匿名请求时Authentication 可能是一个AnonymousAuthenticationTokenisAuthenticated()返回 true但 principal 是anonymousUser。所以判断“匿名用户”不能只看isAuthenticated字段还要判断 token 类型否则授权判断会被绕过。6. 从 5.x 升级到 6.0最容易踩的坑和迁移清单Spring Security 6.0 不是一个兼容小版本迁移会涉及一批 API 的改动。你在网上搜到的 5.x 配置片段直接贴到 6.0 项目里大概率编译不过。我把自己实际迁移过程中遇到的坑按类别整理了一下。6.1 WebSecurityConfigurerAdapter 被移除强制组件化配置5.x 时代最常见的写法Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers(/login).permitAll() .anyRequest().authenticated(); } }6.0 里WebSecurityConfigurerAdapter已经被删了。正确姿势是注册SecurityFilterChainBeanBean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth - auth .requestMatchers(/login).permitAll() .anyRequest().authenticated() ) .formLogin(Customizer.withDefaults()); return http.build(); }记住http.build()是必须的。很多新手只写完配置忘了在方法末尾build()Spring 容器启动时报NoSuchBeanDefinitionException还以为是依赖没加。同时AuthenticationManager也不再通过继承父类来拿到了。如果你要在自定义接口里注入AuthenticationManager可以这样声明Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); }AuthenticationConfiguration会使用全局的UserDetailsService和PasswordEncoder自动构建AuthenticationManager不用你再手动组装DaoAuthenticationProvider。6.2 默认行为变化CSRF 开启、请求匹配规则、静态资源6.0 默认开启了 CSRF 防护。这是好事但对前后端分离项目来说如果你用 JWT 且无 Cookie通常不需要 CSRF。很多人把 5.x 项目的http.csrf().disable()抄过来结果 6.0 会提示要改成http.csrf(csrf - csrf.disable());虽然能编译但我建议先想清楚到底要不要全局关闭 CSRF。如果走的是 Session Cookie 登录CSRF 建议保留只有纯 token 无状态接口才适合关掉。静态资源在 6.0 的默认放行逻辑不太一样了。以前WebSecurityConfigurerAdapter里会用.ignoring()跳过静态资源现在推荐在SecurityFilterChain里对静态资源路径直接permitAll()http.authorizeHttpRequests(auth - auth .requestMatchers(/css/**, /js/**, /images/**, /favicon.ico).permitAll() .anyRequest().authenticated() );这俩的区别是.ignoring()直接在FilterChainProxy之前放行过滤器链根本不进去permitAll()是链里的授权过滤器直接允许但前面的SecurityContextHolderFilter、CsrfFilter这些还是会走一遍。从安全姿势上来讲能用permitAll就别用 ignoring因为前者还能记录安全日志。6.3 升级后的报错清单遇到这些错误先翻这里我不可能把迁移中所有异常都列出来但你可以把下面这个表当成排查的第一站报错或现象可能原因常见解法MethodSecurityInterceptor相关错误没开启方法安全配置类加EnableMethodSecurityNo bean named springSecurityFilterChainSecurityFilterChain Bean 缺失或配置类没被扫描检查配置类注解确认filterChainBean 已注册Unable to resolve AuthenticationManager忘了注册 AuthenticationManager Bean通过AuthenticationConfiguration暴露 Bean前端请求返回 403CSRF 默认开启未处理提交请求携带 CSRF Token或明确关闭 CSRF登录成功后跳转 404没有配置成功处理器自定义AuthenticationSuccessHandler返回 JSONPasswordEncoder报错 “There is no PasswordEncoder mapped for the id”密文没有{id}前缀或 id 不被支持确认密文前缀或升级createDelegatingPasswordEncoder支持静态资源全被拦截未配置静态资源放行加.requestMatchers(/static/**).permitAll()PreAuthorize不生效没加EnableMethodSecurity加注解或改用 XML 配置方式6.4 一个比较隐蔽的坑多 SecurityFilterChain 的匹配顺序上边提到FilterChainProxy会按SecurityFilterChain的注册顺序做匹配。这是升级后很容易忽略的一个点。假设你有两条链Bean public SecurityFilterChain apiChain(HttpSecurity http) throws Exception { http.securityMatcher(/api/**) .authorizeHttpRequests(auth - auth.anyRequest().permitAll()); return http.build(); } Bean public SecurityFilterChain mainChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(auth - auth.anyRequest().authenticated()); return http.build(); }如果mainChain在前apiChain在后那么/api/xxx会先匹配mainChain所有/api/**的请求都得登录。这个问题在 5.x 里也一样但 6.0 的方法链式写法更容易让人忽略匹配顺序。我在项目里遇到的一次线上事故就是两条链顺序写反导致对外的开放 API 一夜之间全部返回 302 跳登录页。还有一个细节securityMatcher和requestMatchers是两回事。前者决定“这条 SecurityFilterChain 处理哪些请求”后者决定“这条链内部的授权规则如何处理请求”。很多新手把requestMatchers当成了分流器结果配了一圈请求还是走错链。结语调试 Spring Security 的正确姿势最后分享一个我用起来最顺手的调试方法。遇到 Spring Security 行为不符合预期时不要急着查资料先打断点看过滤链执行。最简单的做法在FilterChainProxy.doFilter方法上加一个断点然后看当前请求匹配到了哪条SecurityFilterChain再逐个过滤器往下走。你很快就会看到是哪个过滤器把请求拦下来的——是 CSRF、是匿名过滤器还是授权过滤器。比对着日志猜半天快得多。另外强烈建议把 Spring Security 的日志级别开到 DEBUGlogging: level: org.springframework.security: DEBUGDEBUG 日志会打印很多内部决策信息比如“AuthorizationFilter 拒绝了请求原因是...”“CsrfFilter 发现 token 不匹配”之类的。但注意正式环境不要开 DEBUG日志量太大而且 GET 请求的授权决策日志可能会记录一些敏感路径审计时要小心。Spring Security 6.0 这套框架说到底就是一条过滤器链加一堆策略接口。你可以把它当成流水线请求进来先过安全上下文加载再走认证再做 CSRF 检查最后过授权。每一站都有对应的接口和默认实现。你把这条线走通了再去读具体某个过滤器的源码会发现无非是“取配置、做校验、写上下文”这三步。真正复杂的不是框架本身而是业务场景怎么和框架的设计对上。什么时候写自定义AuthenticationProvider什么时候用AuthorizationManager什么时候该考虑SecurityContext跨线程传递这些判断才是一个项目里安全架构的关键。希望这篇文章能帮你把这条线理顺少走我当初踩过的那些弯路。
返回列表