
1. 从一次“能跑但不会改”的尴尬聊起我之前带过不少新人也帮朋友团队review过代码发现一个特别普遍的现象很多人项目里的Spring Security是照着网上的demo“抄”出来的登录能通、接口能拦但只要需求一变——比如从Session改成JWT、从单应用改成网关转发、从内存用户改成数据库用户——立刻就抓瞎了。为什么因为Spring Security这套东西配置长得像魔法原理其实特别朴素。它本质上就是一长串过滤器Filter按顺序执行每个过滤器干一件明确的事。你如果只记住了“要继承WebSecurityConfigurerAdapter”或者“要写SecurityFilterChain”但不知道这些配置最终变成了哪些过滤器、每个过滤器在什么时机做了什么那你就永远只能停留在“能跑”的阶段一遇到奇怪的问题就无从下手。这篇东西不是官方文档的翻译也不是配置大全。我打算从一个“真正把它用明白”的角度出发把Spring Security的骨架、认证流程、授权模型、以及Spring Boot 3时代的新变化一次讲透。适合已经写过几个接口、想搞懂安全框架底层逻辑的Java开发也适合被各种诡异报错折磨过、想建立完整认知地图的人。2. 过滤器链Spring Security的骨架与执行逻辑2.1 一次请求进来到底经过了什么先别急着看配置先搞清楚一件事Spring Security在Web层是怎么“插手”你的请求的。所有Java Web应用请求进来之后都会经过Servlet容器Tomcat、Jetty这些然后由容器按照注册顺序调用一个个Filter最终才到你的Controller。Spring Security做的就是在Servlet容器里注册了一个顶级过滤器FilterChainProxy然后把你自己配置的所有安全逻辑都塞到这个顶级过滤器内部形成一条Spring Security自己的过滤器链。你可以把这条链路想象成机场安检每个过滤器是一个安检岗位有的岗位检查你有没有带登机牌认证信息有的岗位检查你有没有某个区域的通行权限授权有的岗位是给行李贴标签的给请求附加一些上下文如果某个岗位发现你没有证件就会直接把你拦下来根本不会让你走到登机口Controller这个类比能解释90%的Spring Security行为。比如你在网上看到的各种配置——formLogin()、httpBasic()、csrf()——本质上不是在“开启某个功能”而是在往这条安检链路上添加或调整具体岗位。2.2 FilterChainProxy与SecurityFilterChain的职责划分Spring Security里有两个容易混淆的概念FilterChainProxy和SecurityFilterChain。FilterChainProxy是Spring Security暴露给Servlet容器的唯一入口它本身也是一个Filter但它不干具体的安检工作。它只做一件事根据请求的URL决定把请求交给哪一条SecurityFilterChain去处理。为什么要这么设计因为在复杂应用里你可能希望对不同的路径应用不同的安全规则。比如/api/**走无状态的Token认证/login、/register这些公开接口不设防/admin/**需要管理员权限/ws/**是WebSocket走单独的认证方式FilterChainProxy就像一个调度中心它手里有几条安检通道SecurityFilterChain每条通道对应的安检项目都不一样它根据请求的路径把请求分到对应的通道里。每条通道内部才是真正的过滤器序列。Spring Security的核心过滤器大概有十多个但这几个你必须认识过滤器职责你通常在什么时候感知到它SecurityContextPersistenceFilter请求进来时从Session或SecurityContextRepository中加载认证信息请求结束时保存回去登录状态莫名其妙丢失时UsernamePasswordAuthenticationFilter处理表单登录解析用户名密码执行认证登录接口的行为不符合预期时BasicAuthenticationFilter处理HTTP Basic认证看到浏览器弹出账号密码框时AuthorizationFilter最后做授权判断看你有没有权限访问这个URL返回403时ExceptionTranslationFilter捕获安全异常转换成401或403响应未登录跳登录页、无权限返回403CsrfFilter处理CSRF令牌校验POST请求莫名其妙403时理解这条链的另一个好处是调试问题的时候你就能根据报错出现的位置判断是哪一环出了问题。比如你在过滤器里自己写了个SecurityContextHolder.getContext().getAuthentication()拿到的是null那很可能是你的过滤器执行得比SecurityContextPersistenceFilter还早——这个场景我后面会重点讲。2.3 为什么Spring Boot 3里配置写法全变了很多人在网上搜Spring Security教程时会发现有些代码用的是WebSecurityConfigurerAdapter有些用的是SecurityFilterChainBean这两种写法让人很困惑。真相是WebSecurityConfigurerAdapter是Spring Security 5.4之前的主流写法。到了5.4官方引入了基于SecurityFilterChainBean的组件式配置而WebSecurityConfigurerAdapter在Spring Security 5.7开始标记为过期在Spring Security 6对应Spring Boot 3里已经被彻底移除了。所以如果你在Spring Boot 3的项目里看到有人还在继承WebSecurityConfigurerAdapter那代码大概率是从旧项目抄过来的编译都过不了。Spring Boot 3时代的标准姿势是这样的Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth - auth .requestMatchers(/api/public/**).permitAll() .requestMatchers(/api/admin/**).hasRole(ADMIN) .anyRequest().authenticated() ) .formLogin(Customizer.withDefaults()) .httpBasic(Customizer.withDefaults()); return http.build(); }对比一下旧写法核心变化有几点不再继承任何类直接声明一个返回SecurityFilterChain的Bean通过HttpSecurity来配置方法名从authorizeRequests变成了authorizeHttpRequests语义更准确授权匹配器从antMatchers变成了requestMatchers底层支持了Spring MVC的路径匹配规则这种写法的好处是你可以声明多个SecurityFilterChainBean每个Bean服务不同的URL模式实现了前面说的“多条安检通道”的效果。3. 认证到底是怎么发生的从登录请求到SecurityContext的完整链条3.1 一次表单登录的内部旅程说真的Spring Security的认证流程是整个框架里最值得花时间搞明白的部分。我见过太多人配置完登录功能但问起“认证成功之后用户信息存哪了”“下次请求是怎么认出你的”就答不上来了。完整的表单登录认证流程大概是这样第一步用户提交POST /login携带username和password参数。第二步请求穿过过滤器链到达UsernamePasswordAuthenticationFilter。这个过滤器只认两件事请求路径是不是/login请求方法是不是POST。这是它内部的默认判断逻辑你也可以自己改。第三步过滤器把用户名和密码封装成一个UsernamePasswordAuthenticationToken对象。注意这个对象虽然在类名叫Authentication但此刻它还不代表认证成功它只是一个“尚未认证的凭证”里面只装了用户名和密码没有权限信息。第四步过滤器把这个未认证的Token交给AuthenticationManager。AuthenticationManager是认证的核心入口它底下通常有多个AuthenticationProvider每个Provider负责一种认证方式。比如表单登录会走到DaoAuthenticationProviderOAuth2登录走的是OAuth2LoginAuthenticationProvider。第五步DaoAuthenticationProvider拿到用户名后调用UserDetailsService.loadUserByUsername(username)从你的数据源中加载用户信息。你八成听说过UserDetailsService这个接口——它就是Spring Security和你的用户表之间的桥梁。从数据库查到用户后封装成UserDetails对象。第六步DaoAuthenticationProvider比对密码。密码通常是用PasswordEncoder加密存储的所以它做的事情是用同一个加密算法把用户输入的密码重新计算一遍然后和数据库里存的哈希值比对。严格来说BCrypt的比对过程略有不同但你可以这么理解。第七步比对通过AuthenticationManager返回一个已认证的Authentication对象里面包含了完整的用户信息、权限列表和认证状态。第八步UsernamePasswordAuthenticationFilter把这个认证结果存入SecurityContextHolder。SecurityContextHolder默认使用ThreadLocal策略意味着认证信息会被保存在当前线程的局部变量里。处理完请求后SecurityContextPersistenceFilter会把SecurityContext中的认证信息保存到Session中。第九步下次请求进来时SecurityContextPersistenceFilter先从Session里读取SecurityContext放回SecurityContextHolder这样后面的过滤器就知道“这个用户已经登录过了”。请求结束再把它存回去。你这下应该明白了Spring Security的“记住你”本质上就是往Session里塞了一个认证对象。如果你用的是JWT无状态方案要做的事情就是把这套Session机制换掉换成“从请求头里取Token、解析出用户信息、手动放入SecurityContext”。3.2 DaoAuthenticationProvider与UserDetailsService的解耦设计很多初学者不理解为什么要搞出一个UserDetailsService接口而不是直接提供数据库查询的默认实现。原因很简单每个项目的用户表长什么样、密码怎么存、用户状态字段叫什么是完全不一样的Spring Security没法替你决定。UserDetailsService是Spring Security定义的一个“获取用户信息的标准接口”但具体的实现逻辑全凭你自己。它和认证框架之间是解耦的框架只关心你返回的UserDetails对象长什么样不关心你是从MySQL、Redis还是第三方接口里查出来的。这个设计的意义在于你换用户存储方式时根本不用动认证框架的代码。我见过用MongoDB存用户、用LDAP认证、对接公司统一登录平台的项目它们唯一的共同点就是都实现了UserDetailsService。为了帮助你理解一个典型的基于数据库的实现长这样Service public class DatabaseUserDetailsService implements UserDetailsService { private final UserRepository userRepository; public DatabaseUserDetailsService(UserRepository userRepository) { this.userRepository userRepository; } Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { UserAccount user userRepository.findByUsername(username) .orElseThrow(() - new UsernameNotFoundException(用户不存在: username)); return org.springframework.security.core.userdetails.User.builder() .username(user.getUsername()) .password(user.getPassword()) .roles(user.getRoles().toArray(new String[0])) .disabled(user.isDisabled()) .build(); } }这里有一个很关键也很容易踩坑的点如果loadUserByUsername抛出了UsernameNotFoundExceptionSpring Security会把异常转换成BadCredentialsException抛给上层而且日志里不会告诉你到底是用户不存在还是密码错误。这是刻意的设计防止攻击者通过不同的异常信息判断用户名是否存在。我早年调试时明明数据库里有这个用户却一直报“用户名或密码错误”排查了半天才发现是我的UserDetailsService里查完用户后没处理空值直接往下走了。3.3 无状态认证改造JWT方案的核心思路前面说了默认的认证状态维护依赖Session。但在前后端分离、微服务的场景下Session方案有几个天生的问题Session不便于在多个服务实例之间共享、移动端不方便维护Cookie、跨域场景下Cookie处理麻烦。所以现在主流的方案是JWT。我在这里说清楚它的核心思路因为很多人把它理解成“一个前后端约定的加密字符串”这在概念上没错但不够完整。JWT方案不是Spring Security内置的能力它需要你自己改造过滤器链。典型的做法是这样的第一步登录接口改成自己写的一个AuthController不再走UsernamePasswordAuthenticationFilter。你在Controller里手动调用AuthenticationManager完成认证认证成功之后自己生成一个JWT返回给前端。第二步禁用Session相关的过滤器。因为无状态方案压根不需要SecurityContextPersistenceFilter去Session里存取认证信息一般会通过SessionCreationPolicy.STATELESS告诉Spring Security“别用Session了”。第三步写一个自定义过滤器JwtAuthenticationFilter把它插入到UsernamePasswordAuthenticationFilter的位置之前。这个过滤器做的唯一一件事从请求头里取Bearer Token解析Token得到用户名调用UserDetailsService加载用户然后把认证信息手动塞进SecurityContextHolder。原理上讲这就是把“从Session里恢复登录状态”这件事换成了“从Token里恢复登录状态”。我贴一段非常精简的核心代码展示手动构建认证信息的标准方式Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider tokenProvider; private final UserDetailsService userDetailsService; public JwtAuthenticationFilter(JwtTokenProvider tokenProvider, UserDetailsService userDetailsService) { this.tokenProvider tokenProvider; this.userDetailsService userDetailsService; } Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token resolveToken(request); if (token ! null tokenProvider.validateToken(token) SecurityContextHolder.getContext().getAuthentication() null) { String username tokenProvider.getUsername(token); UserDetails userDetails userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); } filterChain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String bearer request.getHeader(Authorization); if (bearer ! null bearer.startsWith(Bearer )) { return bearer.substring(7); } return null; } }这里有几个要命的细节都是实际开发中容易出问题的第一过滤器继承了OncePerRequestFilter而不是直接实现Filter接口这是为了保证一次请求只执行一次。因为Spring Boot的过滤器转发机制可能导致一个请求被Filter处理多次OncePerRequestFilter通过内部标记解决了这个问题。第二判断条件里有个SecurityContextHolder.getContext().getAuthentication() null。这个判断很关键如果前面已经有过滤器设置了认证信息你就不应该覆盖它。第三也是最常见的坑自定义过滤器一定要通过addFilterBefore注册到安全过滤器链上而不是当成普通Servlet Filter注册。很多新手直接在启动类上用Component让Spring管理这个过滤器结果发现JWT认证完全不生效。原因是它没有进入FilterChainProxy内部的安检链路而是被Servlet容器直接执行了执行时机和顺序都不受Spring Security控制。正确的注册方式是在SecurityFilterChain配置里加上http .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));4. 授权模型与注解方法级权限控制的正确打开方式4.1 基于URL的授权粗粒度控制认证解决的是“你是谁”的问题授权解决的是“你能干什么”的问题。Spring Security在Web层的授权核心就是配置requestMatchers和对应的访问规则。常见的规则包括permitAll()——所有人都能访问不管是否登录authenticated()——只要登录了就能访问hasRole(ADMIN)——必须有ADMIN角色hasAuthority(user:read)——必须有某个具体权限hasAnyRole(ADMIN, MANAGER)——有任意一个角色即可access(hasRole(ADMIN) and ipAddress(192.168.1.0/24))——组合条件我见过很多团队在这里犯同一个错误把业务权限判断全部塞到URL匹配里结果配置文件变得极其臃肿。比如http.authorizeHttpRequests(auth - auth .requestMatchers(/api/order/list).hasRole(USER) .requestMatchers(/api/order/create).hasRole(USER) .requestMatchers(/api/order/delete).hasRole(ADMIN) // 二十多行类似的规则…… );这种写法的实质问题是权限规则和URL耦合在了一起一旦你的接口路径调整或者权限模型变化要改的地方非常多。而且后端接口的权限往往不仅取决于URL还取决于数据本身——比如“用户只能删除自己创建的订单”这种规则在URL层面根本无法表达。所以我的建议是URL层做粗粒度控制比如区分“需要登录”和“完全公开”顶多再区分一下“管理端接口”和“普通用户接口”细粒度的权限判断放到方法级注解里去处理。4.2 方法级安全注解PreAuthorize的正确用法方法级安全注解是Spring Security对AOP能力的封装。你需要在配置类上先开启这个能力Configuration EnableMethodSecurity public class MethodSecurityConfig { }然后就可以在Service层或者Controller层方法上标注注解了Service public class OrderService { PreAuthorize(hasRole(ADMIN)) public void deleteOrder(Long orderId) { // 只有管理员才能删除订单 } PreAuthorize(hasRole(USER) and #order.userId authentication.principal.id) public void updateOrder(Order order) { // 用户只能修改属于自己的订单 } }看到了吗方法级注解真正强大的是它支持SpEL表达式Spring Expression Language可以在表达式里引用方法参数、当前认证对象的详细信息。上面的#order.userId authentication.principal.id就实现了一个典型的“数据级权限校验”。为了能使用#order这种参数引用你还需要在方法参数上加上注解或开启编译参数保留。Spring Security 6里默认使用Spring的DefaultMethodSecurityExpressionHandler做参数解析。在实际项目里我强烈推荐把复杂的SpEL表达式拆成独立的Bean方法而不是全部写在注解里。比如Component public class OrderSecurityEvaluator { public boolean isOwnerOrAdmin(Order order) { Authentication authentication SecurityContextHolder.getContext().getAuthentication(); if (authentication null || !(authentication.getPrincipal() instanceof UserDetails)) { return false; } UserDetails currentUser (UserDetails) authentication.getPrincipal(); boolean isAdmin currentUser.getAuthorities().stream() .anyMatch(a - a.getAuthority().equals(ROLE_ADMIN)); return isAdmin || order.getUserId().equals(currentUser.getUsername()); } }然后注解简化为PreAuthorize(orderSecurityEvaluator.isOwnerOrAdmin(#order)) public void updateOrder(Order order) { ... }这样做的优势很明显复杂的逻辑可以单元测试表达式可读性也高。一个经验法则是注解里超过一行或含两个以上逻辑运算符的表达式就应该挪到专门的Bean方法里。4.3 角色与权限的语义区别还有一个绕不开的概念辨析ROLE_ADMIN和ADMIN有什么关系hasRole和hasAuthority有什么区别先说结论在Spring Security内部角色和权限本质上都是GrantedAuthority对象只是角色约定俗成以ROLE_为前缀。hasRole(ADMIN)在底层会被翻译成检查是否存在ROLE_ADMIN这个权限。而hasAuthority(ROLE_ADMIN)是直接检查权限集合里有没有这个字符串两者效果相同。但hasAuthority更灵活因为你可以定义一些不是角色的“操作权限”比如user:read、order:export。有些团队会把角色和操作权限分两层管理角色是一组权限的集合然后在UserDetails里把角色展开成具体的操作权限。这样PreAuthorize(hasAuthority(order:export))就能精确控制到某个按钮级的操作权限而不用关心用户有几个角色。判断该用哪个我的建议是判断“你是谁”——用hasRole判断“你能不能干这件事”——用hasAuthority如果权限模型很简单、只有几个固定角色——hasRole完全够用如果权限模型复杂、有角色继承或动态权限——建议用hasAuthority把角色展开为权限集合5. Spring Boot 3时代的配置差异与OAuth2整合要点5.1 从Spring Security 5到6哪些配置写法变了Spring Boot 3引入了Jakarta EE 9的命名空间Spring Security 6也随之调整了一批API。除了前面说的WebSecurityConfigurerAdapter被移除还有几个高频变化值得关注第一antMatchers换成了requestMatchers。antMatchers基于Ant路径匹配规则requestMatchers可以使用Spring MVC的PathPattern匹配规则语义更清晰性能也更好。这里有一个细节如果你用的是PathPattern匹配方式某些Ant风格的通配符写法比如/**的优先级和行为会有细微差别建议升级后把路径规则全部过一遍测试用例。第二授权配置的方法名变了。authorizeRequests()变成了authorizeHttpRequests()AccessDecisionManager和AffirmativeBased这些底层授权管理器也被新的AuthorizationManager体系取代。日常开发中你一般感知不到但如果你自定义过AccessDecisionVoter升级时大概率要改代码。第三Lambda DSL成为唯一推荐写法。Spring Security 6里http.formLogin(form - form.loginPage(/login).permitAll())这种Lambda写法成了标准以前那种链式调用的http.formLogin().loginPage(/login).permitAll()虽然还能用但官方已经不推荐了。Lambda写法的好处是作用域清晰每个Lambda块内的配置只影响当前配置器不会出现多个配置器之间互相覆盖的情况。第四默认行为变了。Spring Security 6默认会开启CSRF防护这个在5.x默认就开了但6里对不安全的HTTP方法限制更严格、默认拒绝OPTIONS请求如果你做前后端分离需要在CORS配置里显式允许、默认禁止框架页在iframe中展示。5.2 基于Spring Authorization Server的OAuth2整合关于热搜词里提到的“Spring Boot3整合Spring Security OAuth2”这个话题我必须分清楚两件事因为它们经常被混为一谈作为OAuth2客户端去对接别人的授权服务器比如对接GitHub登录、企业微信登录作为OAuth2授权服务器自己给第三方应用发Token比如你们公司要做开放平台如果你只是想对接第三方登录Spring Boot 3的配置相对简单在pom.xml里引入spring-boot-starter-oauth2-client然后配置spring: security: oauth2: client: registration: github: client-id: your-client-id client-secret: your-client-secret scope: read:user然后在SecurityFilterChain里加一个oauth2Login()配置Spring Security就会自动生成/oauth2/authorization/github这个跳转入口处理OAuth2回调、换取Token、加载用户信息。但如果你的需求是自己实现一个授权服务器那情况完全不同。注意spring-security-oauth2-authorization-server是一个独立的项目不是Spring Security主项目的一部分而且它和早年间那个已经停止维护的Spring Security OAuth项目不是一回事。你在网上搜教程时如果看到文章里的包名是org.springframework.security.oauth2.provider.endpoint那大概率是旧版教程在新版本里已经不能用了。新版的授权服务器配置官方推荐用AuthorizationServerSettings和RegisteredClientRepository来管理客户端应用用OAuth2AuthorizationService来管理授权码和Token的存储。配置起来比Spring Security本身复杂不少涉及授权码模式、PKCE、Client Credentials等多种流程不同流程的适用场景也完全不同。这里推荐直接看官方文档的Samples目录比任何二手教程都靠谱。5.3 一个容易踩的坑SecurityContext在子线程中丢失Spring Security的SecurityContextHolder默认使用ThreadLocal存储认证信息这在绝大多数单线程请求处理场景下没问题但一旦你用了Async或者自己手动创建子线程处理业务逻辑子线程里是拿不到父线程的SecurityContext的。这个问题的背后是ThreadLocal的工作机制每个线程拥有自己独立的变量副本。父线程向SecurityContextHolder里set的值子线程根本看不见。解决方式有三种使用SecurityContextDelegatingExecutor或DelegatingSecurityContextRunnable它们会把父线程的SecurityContext传递给子线程使用SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL)这种方式在创建子线程时会自动继承但在线程池场景下不一定可靠在异步任务中显式从某个地方加载用户信息比如把用户ID作为参数传过去子线程里自己查询我个人的建议是第三种别让SecurityContext跨线程传递成为你系统的隐性依赖。在异步场景里明确地传递用户ID或用户信息比“偷偷继承上下文”要可靠、可追踪得多。如果你非要用前两种一定要在单元测试里覆盖线程池复用的情况否则很可能出现“第一次执行正常第二次执行时串了用户身份”的诡异问题。6. 我见过最多的五个配置坑及排查思路6.1 过滤器顺序导致的“认证信息读不到”症状在自定义Filter里通过SecurityContextHolder.getContext().getAuthentication()拿到的总是null但Controller里又能正常拿到。根源自定义Filter没有正确注册到Spring Security的过滤器链上或者注册位置不对执行时机早于SecurityContextPersistenceFilter。排查链路先在自定义Filter打日志看它是否经过FilterChainProxy内部如果打了日志但顺序不对检查是不是用了Component导致被Servlet容器直接加载了再检查addFilterBefore的第二个参数是不是写对。修复参考前面JWT过滤器注册那部分用http.addFilterBefore(自定义Filter, 某个标准过滤器.class)注册。6.2 跨域配置不生效前端接口预检请求403症状前端访问跨域接口浏览器控制台报CORS错误或者OPTIONS预检请求返回403。根源很多人只配置了Spring MVC的CorsConfigurationSource但Spring Security的过滤器链会拦截在MVC处理之前如果安全配置里没有放行CORS预检请求就会先被拦下。排查链路先看响应头里有没有Access-Control-Allow-Origin如果没有说明请求根本没到MVC层就被安全框架拦了再看Spring Security日志里是否打印了“Invalid CORS request”或Rejected by AuthorizationFilter。修复在SecurityFilterChain里加上http.cors(Customizer.withDefaults())并确保CorsConfigurationSource这个Bean被正确声明。顺带说一句如果你在Spring Security配置里用了permitAll()但跨域还是不生效原因往往在CorsFilter放行的路径和CorsConfiguration的路径匹配不一致上。6.3 图片、静态资源被拦截症状页面的CSS、JS、图片全部加载不出来或者返回404/403。根源把安全规则里的anyRequest().authenticated()放在了requestMatchers(...).permitAll()之前或者根本没有放行静态资源路径。排查链路看浏览器Network面板失败资源的响应状态码是什么。如果是302跳转到登录页说明被AuthorizationFilter拦截了如果是404或5xx需要考虑是否是资源本身路径就不对。修复调整规则顺序把精确的、放行的规则写在前面.authorizeHttpRequests(auth - auth .requestMatchers(/css/**, /js/**, /images/**, /favicon.ico).permitAll() .anyRequest().authenticated() )记住一个原则Spring Security的授权规则是按声明顺序逐个匹配的第一条匹配上的规则生效后面的规则就短路了。所以一定要把具体放行的放前面、兜底的anyRequest()放最后。6.4 密码加密方式升级导致老用户无法登录症状项目从明文密码或MD5升级到BCrypt后老用户全部登录失败只有新建的用户能正常登录。根源PasswordEncoder的校验逻辑不兼容旧格式。BCrypt校验时要求数据库里的哈希值以$2a$、$2b$或$2y$开头老格式的MD5哈希不符合这个特征。解决思路一种方式是建一个DelegatingPasswordEncoder它的原理是“前缀标识编码方式”比如{bcrypt}开头用BCrypt{MD5}开头用MD5。但这种方式的安全性取决于你对旧格式的容忍度强烈建议最终过渡到统一的新格式。更推荐的做法用UserDetailsPasswordService。Spring Security在发现用户用旧的密码编码方式通过认证后会自动调用这个服务把密码更新成新的编码格式。核心理念是“认证时不强求格式一致但认证通过后主动升级存储格式”。6.5 自定义登录页反复跳转回登录页症状配置了自定义登录页输入正确的账号密码提交后页面又跳回登录页没有报错也没有进入系统。根源这是一个非常经典的问题原因通常是登录成功后无法确定用户从哪个页面来或者认证成功后的跳转被不断打断。最常见的原因是登录请求处理成功后Spring Security尝试重定向到defaultSuccessUrl但该URL又被安全规则拦截了于是陷入“登录成功-重定向-被拦截-再登录”的循环。排查链路看登录成功后302重定向的目标是什么地址检查那个地址的访问权限如果目标地址需要特殊角色确认当前用户确实有这个角色。修复给登录后的默认跳转页面配好权限或者在formLogin的配置里显式指定defaultSuccessUrl(/index, true)——第二个参数true的意思是无论用户从哪个页面发起登录成功后统一跳到指定页面避免“回跳原页面被拦截”的问题。7. 调试Spring Security的实用手段7.1 开启DEBUG日志看清过滤器执行顺序Spring Security的过滤器链执行过程对排错很有帮助但默认日志级别下你什么都看不到。建议在开发环境开启DEBUG日志logging: level: org.springframework.security: DEBUG开启后你会看到类似这样的输出Security filter chain: [ DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, SecurityContextHolderFilter, HeaderWriterFilter, CorsFilter, CsrfFilter, UsernamePasswordAuthenticationFilter, BasicAuthenticationFilter, AuthorizationFilter, ExceptionTranslationFilter, ... ]这串列表简直是诊断问题的金钥匙。你可以直观地确认你自定义的过滤器有没有出现在链上出现在哪个位置有没有过滤器因为你配置了disable()而被移除我排查上面说的“JWT认证不生效”问题时就是通过这串日志确认JwtAuthenticationFilter根本没有被加入到链里才定位到注册方式有误。7.2 用Spring Security Test写集成测试Spring Boot项目里我强烈建议引入spring-boot-starter-security自带的测试支持dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency dependency groupIdorg.springframework.security/groupId artifactIdspring-security-test/artifactId scopetest/scope /dependency然后你可以用WithMockUser快速模拟登录用户SpringBootTest AutoConfigureMockMvc class OrderControllerTest { Autowired private MockMvc mockMvc; Test WithMockUser(username alice, roles USER) void shouldReturnOrdersForUser() throws Exception { mockMvc.perform(get(/api/orders)) .andExpect(status().isOk()); } Test WithMockUser(username bob, roles ADMIN) void shouldAllowAdminToDeleteOrder() throws Exception { mockMvc.perform(delete(/api/orders/1)) .andExpect(status().isOk()); } Test void shouldRedirectToLoginWhenNotAuthenticated() throws Exception { mockMvc.perform(get(/api/orders)) .andExpect(status().is3xxRedirection()); } }WithMockUser只是模拟了一个认证对象它不触发真实的认证流程。如果你的测试目标就是服务逻辑里的权限判断这个注解效率很高要是想测真实的登录流程那就得用WithUserDetails配合测试数据库或者直接用MockMvc模拟表单提交。7.3 几个值得养成的习惯排查Spring Security问题这么多年我总结出几个实用的习惯一是写一个全局的AuthenticationEntryPoint和AccessDeniedHandler分别处理未登录和已登录但无权限的情况。很多诡异问题之所以难排查是因为默认的返回逻辑太隐蔽你可能被重定向到登录页而不自知。自定义Handler里统一返回JSON错误信息前后端联调时一眼就能看到问题在哪。二是把测试用的安全配置和生产环境分开。测试环境可以临时放开CSRF生产环境严格按照规范来。别小看这一点因为CSRF导致的403问题在联调阶段会消耗掉大量不必要的时间。三是做安全配置改动后一定要跑一遍全量的接口测试。Spring Security的规则是顺序敏感的加一条规则可能影响一片接口。很多人在调整配置时只测了改动的接口结果把别的接口搞挂了上线之后才会暴露。8. 写在最后的建议Spring Security这套框架的内容量确实大光是官方文档就有好几千页。但如果你能抓住几个核心锚点——过滤器链、认证流程、授权模型、配置方式的演进逻辑——再多的新功能都是在这几个骨架上做扩展。我个人认为学习它的最佳路径不是从头到尾翻文档而是带着一个真实的需求去拆解。比如你想在项目里加上登录功能就先想清楚“认证信息从哪来、存哪里、怎么恢复”这三个问题再去看formLogin、UserDetailsService、Session这些概念就会觉得每一块都长在它该长的位置上。最后再分享一个小技巧遇到Spring Security的问题时优先在官方GitHub的Issue和Stack Overflow里搜英文关键词搜的时候带上版本号。网上大量中文教程的截图和代码是几年前的版本差异带来的API变化会让你陷入“照着改也对不上”的困境。版本号是排查一切问题的第一把钥匙。