
SpringSecurity这套东西我前后折腾了两年多才算真正摸透。刚开始接触的时候面对那些Filter、FilterChain、AuthenticationManager说实话确实有点劝退——一个最简单的登录页面配置就要写几十行出了问题报错还特别抽象完全不知道从哪排查。但等你真正理解了它的运作机制回头再看会发现SpringSecurity其实设计得相当规整它把Web应用安全里绝大部分通用逻辑都封装好了你在配置文件里做的每一件事本质上都是在调整一条过滤器链上的各种环节。这篇文章我打算用一套完整的项目实践为主线从前置需求梳理讲起再到最小配置跑通、认证授权细节、记住我功能最后把我在实际项目中踩过的坑和排查思路全部摊开来讲。说句实话配置SpringSecurity最难的从来不是代码本身而是你根本不知道哪些配置是必须的、哪些是可选的、哪些配置之间会互相影响。这篇文章会尽量把这些逻辑讲透。无论你是刚接手一个带Security的项目还是要从零搭建一套带认证授权的服务都应该能从里面找到直接能抄的方案。1. 配置之前先搞清楚SpringSecurity到底在解决什么问题1.1 一个验证码教不会的安检系统很多人一上来就打开官方文档抄配置抄完了发现完全不理解自己在干什么出现一个小问题就懵了。我习惯把SpringSecurity比作机场的安检系统你有一堆旅客想上飞机用户请求各种资源安检系统决定了谁可以进去、走哪个通道、进去之后能去哪些区域。在SpringBoot项目里这套安检系统长这样它通过在Servlet容器中植入一条FilterChain过滤器链来工作。你发出的每一个HTTP请求在到达Controller之前都会先经过这一长串过滤器。有的过滤器负责判断你是否已经登录有的负责解析你提交的用户名密码有的负责校验CSRF Token有的负责把你的登录信息往Session里写。整条链上任何一个环节不通过请求就到此为止根本到不了你的业务代码那一步。理解了这一点配置就好办了。我们在SecurityConfig里写的几乎所有内容都是在回答这几个问题安检口开在哪几个位置哪些路径需要拦截、检查哪些证件类型表单登录还是Token认证、有人没带证件怎么办跳转登录页还是返回401、不同乘客分别能去哪些区域角色权限控制。1.2 配置前先花十分钟理清三个需求我见过太多人项目还没想清楚就直接开始写代码最后返回去改配置改到崩溃。配置SpringSecurity之前至少要把下面三件事想明白这样写出来的配置文件才对得上业务逻辑。第一件事是认证方式你的系统是纯后端接口给前端App调用还是包含服务端渲染的页面如果是前后端分离的纯API服务通常选择返回JSON格式的401响应而不是跳转登录页如果是传统Web项目表单登录跳转那一套就够用了。另外你还要想清楚是否使用Token比如JWT还是依赖Session。这两者的配置逻辑完全不同。第二件事是资源划分你的项目里哪些路径是公开的哪些需要登录就能访问哪些只有特定角色能访问。我一般会在配置之前把接口清单按访客、普通用户、管理员三档列个表格后面写requestMatchers的时候照着填就行省得一会儿漏了/public一会儿把/admin放开了。第三件事是密码存法这是最容易被忽略的。SpringSecurity从5.0版本开始强制要求密码必须通过PasswordEncoder加密再存储存数据库的密码格式通常是{bcrypt}$2a$10$......。如果你手头已经有存量用户数据明文密码或者MD5加密过的就得提前想好迁移方案否则配置好了也登录不进去。2. 从零搭建一套可运行的SpringSecurity配置2.1 版本选择别被网上老教程带跑偏了先说版本这事太重要了。网上大量的教程还在用Spring Boot 2.x Spring Security 5.x时代的写法核心是一个类继承WebSecurityConfigurerAdapter重写三个configure方法。这套写法在Spring Security 6.x配合Spring Boot 3.x里已经彻底废除了WebSecurityConfigurerAdapter被标记为过时并移除官方推荐的是组件化配置方式也就是定义一个Bean返回SecurityFilterChain。我自己现在统一用的是Spring Boot 3.x Spring Security 6.x。如果你手里是Spring Boot 2.x的老项目这篇文章的思路依然适用只是类名和Lambda表达式有一些差异。关键是别新旧混杂看着老教程写新版本代码或者反过来都会出现一堆莫名其妙的编译错误。判断版本很简单项目里pom.xml的Spring Boot父依赖版本如果是3.x开头走的就是这套新写法。依赖引入也顺手说一句极其简单只需要一个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency版本号不用你操心Spring Boot已经帮你管理好了。引入了这个依赖之后启动项目你会看到控制台打印一行随机密码访问任何接口都弹出登录框——这说明Spring Security已经在工作了它给了你一套默认的安全配置。我们的工作就是把它替换成和业务匹配的配置。2.2 最小可运行的SecurityFilterChain配置不废话直接上一套最基础、能跑通的配置每行我后面拆开讲是干什么的package com.example.demo.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth - auth .requestMatchers(/, /home, /login, /css/**, /js/**, /images/**, /error).permitAll() .requestMatchers(/admin/**).hasRole(ADMIN) .requestMatchers(/user/**).hasAnyRole(USER, ADMIN) .anyRequest().authenticated() ) .formLogin(form - form .loginPage(/login) .loginProcessingUrl(/doLogin) .defaultSuccessUrl(/home, true) .failureUrl(/login?error) .permitAll() ) .logout(logout - logout .logoutUrl(/logout) .logoutSuccessUrl(/login?logout) .invalidateHttpSession(true) .deleteCookies(JSESSIONID) .permitAll() ) .rememberMe(remember - remember .key(my-unique-key-2024) .tokenValiditySeconds(7 * 24 * 60 * 60) ) .csrf(csrf - csrf.disable()); return http.build(); } Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }这段配置拆开来看其实就是一个安检流程的完整描述authorizeHttpRequests那段规定了哪些路径不需要安检直接放行permitAll哪些路径必须是什么角色才能进hasRole、hasAnyRole剩下的所有请求只要登录了就行authenticated。formLogin这一段设置的是登录入口你自己写了登录页就配置loginPage指向它提交表单的地址是/doLoginSpringSecurity会自动在这个地址接收用户名和密码。这套配置往项目里一放再配合一个简单的登录页面、一个/login的GET请求Controller一个带用户名密码的用户存储整个认证流程就能跑起来了。会跑通之后再往里面加东西就容易多了。2.3 理解Order多个过滤链如何协作先说一个初看不重要、踩坑时才追悔莫及的概念过滤器链的顺序。SecurityFilterChain是支持配置多条的比如有的接口用表单登录有的接口用API Token认证可以分别定义两条过滤器链。每条链通过Order注解规定先后顺序请求到达时按顺序匹配命中哪条链就由哪条链处理。实际项目中我见过一个特别典型的错误API服务既需要给App提供免登录的Token接口又需要后台管理员的表单登录页面。这时候如果你只配一条过滤器链规则之间就会打架。正确做法是拆两条链比如用Order(1)放API Token认证链只拦截/api/**路径用Order(2)放表单登录链处理所有其他请求。匹配顺序很重要因为一旦第一条链匹配上了后面的链不会再被检查。如果你项目里暂时只有一条链那不需要管Order默认就生效。但知道这个概念能帮你避免以后遇到多端认证需求时推倒重来——直接在现有结构上加一条链就行不用重构。3. 用户从哪来三种UserDetailsService的落地方式3.1 内存用户适合演示不适合生产最简单的用户数据来源是内存。适合本地联调、测试环境验证逻辑配置几行就能用Bean public UserDetailsService userDetailsService(PasswordEncoder encoder) { UserDetails admin User.builder() .username(admin) .password(encoder.encode(admin123)) .roles(ADMIN, USER) .build(); UserDetails user User.builder() .username(user) .password(encoder.encode(user123)) .roles(USER) .build(); return new InMemoryUserDetailsManager(admin, user); }注意password那一项password(encoder.encode(...))的意思是存储加密后的密文而不是明文。roles(ADMIN, USER)对应数据库里通常说的角色字段SpringSecurity会把它们自动转成ROLE_ADMIN和ROLE_USER来比对也就是你在hasRole(ADMIN)里写的角色名会自动加上ROLE_前缀。这种方式的缺陷很明显用户写死在代码里运维想加一个账号还得改代码重新发布。所以它只适合做验证和演示生产环境基本都会换成数据库存储。3.2 JDBC用户存储有表结构就能跑如果你不想自己写查询逻辑SpringSecurity提供了一个现成的JdbcUserDetailsManager它默认会去查一张users表和authorities表。我早期图省事直接用过这个方案其实也挺方便的前提是你接受它默认的SQL语句和表结构。核心配置如下Bean public UserDetailsService userDetailsService(DataSource dataSource) { return new JdbcUserDetailsManager(dataSource); }表结构如果是默认的话大致是这样的create table users( username varchar(50) not null primary key, password varchar(100) not null, enabled boolean not null ); create table authorities ( username varchar(50) not null, authority varchar(50) not null, constraint fk_authorities_users foreign key(username) references users(username) );密码字段存的是加密后的密文这个之前已经反复强调过。这个方案的优点是省事SpringSecurity帮你把数据库查询、角色加载、增删用户的逻辑都写好了缺点是你得将就它的表结构设计。比如你现成的用户表叫sys_user、字段叫nickname那就得改默认SQL或者干脆不用这个方案。3.3 自定义UserDetailsService最灵活的方案大部分业务系统的用户表都是自己设计的所以我个人最常用的是自定义方式。核心就是实现一个接口告诉SpringSecurity给你一个用户名你去数据库查出这个用户的信息拼装成一个UserDetails对象返回Service public class CustomUserDetailsService implements UserDetailsService { private final UserMapper userMapper; public CustomUserDetailsService(UserMapper userMapper) { this.userMapper userMapper; } Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser sysUser userMapper.findByUsername(username); if (sysUser null) { throw new UsernameNotFoundException(用户不存在); } return org.springframework.security.core.userdetails.User.builder() .username(sysUser.getUsername()) .password(sysUser.getPassword()) .roles(sysUser.getRoles().split(,)) .disabled(!sysUser.isEnabled()) .build(); } }这里有一个我在项目里踩过的坑如果你给用户设置的密码还没有加密比如直接从旧系统迁移过来的明文登录时SpringSecurity拿用户提交的明文密码和数据库里的明文密码一比对密文比对的逻辑不匹配要么直接抛异常要么永远认证失败。解决办法就是无论如何存储前都要用PasswordEncoder加密一遍。另外如果你的用户表里有一个账号是否启用的字段记得映射到disabled上否则你禁用了一个账号他照样能正常登录系统。这个细节很容易被忽略但安全和业务上都挺重要。4. 登录流程细调成功失败处理、登录页、JSON响应4.1 自定义登录页和后端Controller怎么写formLogin配置里面loginPage(/login)的意思是用户未登录访问受保护资源时重定向到/login这个地址。这个地址本身需要由你的Controller提供最常见的就是返回一个HTML模板Controller public class LoginController { GetMapping(/login) public String loginPage() { return login; } }而登录表单提交的目标地址就是配置文件里loginProcessingUrl(/doLogin)指定的那个。这里很多人容易写错以为要写一个PostMapping(/doLogin)的Controller来接用户名密码。不需要SpringSecurity的过滤器会在请求到达Controller之前拦截并处理这个地址你只需要让表单的action指向它就行。登录页表单的关键写法如下form action/doLogin methodpost input typetext nameusername placeholder用户名/ input typepassword namepassword placeholder密码/ !-- 如果你开了rememberMe配置表单里加这个勾选框 -- labelinput typecheckbox nameremember-me/记住我/label button typesubmit登录/button /form需要注意用户名和密码字段的name属性默认必须是username和password除非你在配置里通过usernameParameter(account)这样的方式改掉。另外如果你没有关闭CSRF模板页面的表单里还需要有一个隐藏的_csrfToken字段。纯静态页面手动写表单时最容易被这个卡住用Thymeleaf模板引擎的话它会自动帮你注入。4.2 前后端分离时把登录响应改成JSON现在很多项目是前后端分离的后端只管返回JSON前端拿着Token自己控制页面跳转。这种情况下登录成功、失败、会话过期都应该返回JSON而不是页面重定向。SpringSecurity也完全支持核心是配置里换成自定义的AuthenticationSuccessHandler和AuthenticationFailureHandler.formLogin(form - form .loginPage(/login) .loginProcessingUrl(/api/auth/login) .successHandler((request, response, authentication) - { response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:0,\message\:\登录成功\}); }) .failureHandler((request, response, exception) - { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\ exception.getMessage() \}); }) )这里/api/auth/login就是前端实际提交用户名密码的地址。登录成功后你可以在Authentication对象里拿到用户信息在处理器里拼一个Token返回给前端常见的做法是配合JWT使用。未登录状态的处理也要单独改一下。默认情况下未登录用户访问受保护接口会被重定向到登录页这显然不是API需要的。可以在配置里加.exceptionHandling(exception - exception .authenticationEntryPoint((request, response, authException) - { response.setContentType(application/json;charsetUTF-8); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write({\code\:401,\message\:\未登录或登录已过期\}); }) )这样前后端协作的时候就非常清晰前端收到HTTP 401状态码就知道需要跳转登录页收到其他状态码正常处理业务数据。5. 授权与接口放行细节不要全用anyRequest打天下5.1 requestMatchers的匹配逻辑和坑配置授权规则时requestMatchers是绝对的主角但用法细节我见过不少同事写错。它有几个重要的特性值得展开说说。第一匹配规则可以按路径也可以用Ant风格的通配符。/admin/**匹配/admin下所有层级但注意/admin/*只匹配/admin下一层比如/admin/user不匹配/admin/user/detail。如果你是做微服务的网关层建议多用**这样做权限的粗粒度控制比较省事。第二匹配顺序是从上到下的先声明先匹配。permitAll、hasRole这些规则一旦命中后面的规则就不会再看了。所以写规则时的顺序建议是先放行静态资源和公开接口再配置细粒度权限控制最后放anyRequest().authenticated()兜底。反过来放的话一个anyRequest().authenticated()就把后面的规则全部挡在门外了这是一个非常隐蔽的逻辑错误。第三如果你的项目里用了Spring MVC的路径匹配方式比如自定义了PathMatchConfigurer设置了setUseTrailingSlashMatch(false)那么SpringSecurity这边也要保持一致的配置否则可能会出现你访问/admin不拦截结果SpringSecurity拦截了/admin/的尴尬情况。5.2 方法级权限Controller上的PreAuthorizeURL层面的权限控制在SecurityConfig里配置就够了但如果你需要细粒度到方法级别的控制——比如某个接口要求必须是本人或者管理员才能调用——那就得开启方法级安全配置在启动类或者某个配置类上加EnableMethodSecurity注解然后在Controller方法上使用PreAuthorizeRestController RequestMapping(/api/order) public class OrderController { PreAuthorize(hasAnyRole(ADMIN,USER)) GetMapping(/list) public String list() { return 订单列表; } PreAuthorize(hasRole(ADMIN)) DeleteMapping(/{id}) public String delete(PathVariable Long id) { return 删除订单; } }这个方法级注解的参数是Spring Expression Language功能非常强。比如你可以写PreAuthorize(hasAuthority(order:delete))精确到操作权限也可以写PreAuthorize(authentication.principal.username #username)判断当前登录用户是否就是操作对象本人。实际项目中我用的比较多的组合是URL上做粗粒度角色控制方法上做细粒度操作权限校验两层叠加。要注意的是开启方法级安全后如果方法上没加注解默认是允许通过的因为它只对你明确声明的方法生效。不要指望它像全局URL规则那样自动拦截。6. 记住我功能的配置与原理6.1 记住我的两种实现方式记住我功能看起来平平无奇就是一个复选框实际上后面有讲究而且网上讲得云里雾里的人太多了。先说结论SpringSecurity里rememberMe有两种实现一种是简单令牌模式一种是持久化令牌模式。简单令牌模式就是把用户名和一个过期时间加签名算成一个Token然后种到Cookie里浏览器下次访问时带上这个Cookie系统验证签名和过期时间通过就算自动登录。这个模式配置非常简单就是文章前面那段代码里的.rememberMe(remember - remember .key(my-unique-key-2024) .tokenValiditySeconds(7 * 24 * 60 * 60) )tokenValiditySeconds设置的是有效期我这里写的是7天。注意那个key它是用来给Token做签名用的私钥一定不要泄露出去否则任何人都可以伪造一个记住我的Cookie直接登录你的系统这危害和数据库密码泄露是一个级别的。生产环境请用足够长的随机字符串最好放到配置中心或环境变量里。持久化令牌模式更安全它会在数据库里维护一张persistent_logins表每个记住我的会话都会对应一条记录用户可以主动记住我的会话失效安全性比简单模式高不少代价就是要多一张表和一点配置。配置方式也简单额外定义一个PersistentTokenRepositoryBeanBean public PersistentTokenRepository persistentTokenRepository(DataSource dataSource) { JdbcTokenRepositoryImpl repo new JdbcTokenRepositoryImpl(); repo.setDataSource(dataSource); // true表示启动时自动建表生产环境建议手动建好 repo.setCreateTableOnStartup(false); return repo; }然后在rememberMe配置里指定它.rememberMe(remember - remember .tokenRepository(persistentTokenRepository(dataSource)) .tokenValiditySeconds(7 * 24 * 60 * 60) )多一张表而已带来的是可以精确控制每个会话、用户主动注销时能强制让记住我令牌全部失效的好处。6.2 记住我的常见误区第一个误区是只配置了rememberMe但登录页没有提交remember-me这个参数。前面表单代码里我特意写了那个复选框name属性必须是remember-me否则SpringSecurity默认检测不到用户勾选了记住我功能形同虚设。如果你要改成别的参数名记得在配置里用rememberMeParameter(remember)显式声明。第二个误区是把记住我的Token有效期设置得过长。我见过有人设置30天甚至90天的如果你的系统涉及资金、个人信息这些敏感业务强烈不建议。记住我的本质是便捷而不是永久在线有效期一周是一个比较平衡的取值。第三个误区和安全相关记住我的Token一旦种到Cookie里就相当于一把长期钥匙。如果有人能偷到你的Cookie在没有其他校验的情况下他就能在有效期内以你的身份登录。所以如果你的系统安全级别要求高开启记住我功能的同时建议配合IP变化检测、异地登录提醒这些辅助手段并且给用户提供注销所有记住我的会话的入口。7. CSRF、CORS与过滤器顺序的那些坑7.1 CSRF到底关不关CSRF跨站请求伪造这个概念网上一搜一大堆这里只讲配置层面怎么决策。SpringSecurity默认开启了CSRF防护。它的工作机制简单说就是在需要修改状态的非GET请求里要求携带一个服务器下发的随机Token攻击者伪造的跨站请求拿不到这个Token就会被拦截。问题来了什么时候能关什么时候必须开如果你的系统是前后端分离的纯API服务前端通过Authorization请求头携带Token比如JWT而不是依赖Cookie传递会话ID这种场景下CSRF的风险本身就比较低因为第三方攻击者既无法窃取你的自定义请求头CSRF攻击通常靠的是Cookie自动携带这个特性既然你不用Cookie认证防御的优先级也就没那么高了。这种情况下配置.csrf(csrf - csrf.disable())是合理的省得每个POST请求都去适配Token开发效率高很多。如果你是传统的服务端渲染项目登录状态靠Session和Cookie维持那CSRF防护一定要保留。Thymeleaf这类模板引擎在渲染表单时会自动添加_csrf隐藏字段默认就支持得很好没必要为了省事关掉。另外还要提醒一个坑当你开启了CSRF防护却用Postman、Apifox这类工具去测试POST接口时经常会收到403错误。这是因为测试工具不会自动携带CSRF Token。排查时先确认是不是CSRF拦截了别一上来就怀疑业务代码。7.2 CORS配置前后端分离必做的一步前后端分离的项目前端域名和后端域名通常不一样浏览器跨域请求就来了。Spring Security在过滤器链层面会拦截请求如果CORS没有正确配置前端调用接口时会发现有的请求能通有的请求报跨域错误或者明明后端已经加了CrossOrigin还是不好使。配置的思路是先单独定义一个CORS配置源再把它绑定到Spring Security上。实际上Spring Security自身提供了一个简单的方法在HttpSecurity上调用.cors(cors - cors.configurationSource(corsConfigurationSource()))配合自定义的CorsConfigurationSourceBean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config new CorsConfiguration(); config.setAllowedOrigins(List.of( http://localhost:5173, https://admin.example.com )); config.setAllowedMethods(List.of(GET, POST, PUT, DELETE, OPTIONS)); config.setAllowedHeaders(List.of(*)); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return source; }setAllowCredentials(true)意味着跨域请求可以携带Cookie。注意一旦设置了allowCredentials(true)setAllowedOrigins里就不能用*通配了必须是明确的来源列表。这个限制是浏览器的安全策略不是Spring的写的时候别冤枉了Spring。还有一个细节OPTIONS预检请求默认会被CORS配置处理所以Spring Security这里不需要特别放行但有的项目在之前的版本中可能需要额外配置permitAll处理OPTIONS请求。如果是Spring Security 6.x正常配置完CORS就行预检请求框架会处理。7.3 过滤器执行顺序的踩坑记录Spring Security自带的过滤器链内部是有顺序的比如CsrfFilter在前、UsernamePasswordAuthenticationFilter在后。但你的自定义过滤器必须通过addFilterBefore、addFilterAfter或addFilterAt显式指定位置不能想加就加。否则你加了一个自定义的认证过滤器以为它会先执行结果它排在后面等Security自带的过滤器已经拦截完请求了都不会轮到你的过滤器。我之前就吃过这个亏。做一个API签名校验过滤器需求是请求先验签名验完通过才走后面的登录认证流程。我当时直接addFilterAt随便指定了一个位置结果请求过来以后SpringSecurity自己的认证过滤器先判定未登录直接返回401了我的签名校验过滤器压根没机会执行。后来查了官方文档才明白UsernamePasswordAuthenticationFilter这个位置才是表单登录认证的核心环节自定义认证过滤器如果要替换默认的认证逻辑应该加在它之前。配置示例.addFilterBefore(new MySignatureFilter(), UsernamePasswordAuthenticationFilter.class)8. 常见问题排查与速查表写配置的时候踩过的坑太多了我整理一张速查表你在现场排查时可以对着看现象可能原因解决思路POST请求一直403CSRF拦截检查页面是否有_csrf字段或确认是否能关闭CSRF未登录访问API返回302/跳转Login页面默认表单登录重定向配置AuthenticationEntryPoint返回401 JSON登录成功但页面一直回到登录页登录成功后跳转失败或Session失效检查defaultSuccessUrl、记住Session的Cookie配置密码正确但一直报用户名或密码错误密码没加密或PasswordEncoder不匹配用BCryptPasswordEncoder统一加密库内存密文静态资源全部被拦截未配置permitAllrequestMatchers(/css/, /js/, /images/**).permitAll()自定义过滤器不生效过滤器顺序不对用addFilterBefore/After指定位置PreAuthorize不生效没开启方法级安全加EnableMethodSecurity注解Swagger接口全部401Swagger路径未放行requestMatchers(/swagger-ui/, /v3/api-docs/).permitAll()注销后还能访问受保护接口注销后Token/Cookie未清理配置logout删除Cookie、清理记住我Token排查任何SpringSecurity问题我的第一个固定动作是把SecurityFilterChain里所有规则从头到尾读一遍确认匹配顺序和放行规则有没有问题这能解决大约一半的怪问题。第二个动作是看控制台的调试日志——在application.properties里加一行logging.level.org.springframework.securityDEBUG打开Debug日志后SpringSecurity会打印过滤器链上每一步处理了什么、为什么放行、为什么拦截基本等于把安检官的工作记录打开给你看定位问题会快很多。第三个建议是善用OncePerRequestFilter。如果你需要自定义过滤器继承这个类可以确保每个请求最多只执行一次避免被不同容器路径重复调用导致奇怪问题。最后分享两个实操体会项目里我始终建议先把SpringSecurity当成一个最低配置能跑通的东西来看不要一上来就追求JWT、OAuth2、SSO那种复杂组合。最简单的表单登录能跑通了再逐步加数据库用户、加记住我、加方法级权限、加Token认证。每加一块你就多了一份对过滤链的掌控感出了问题也容易定位。另外写配置文件时尽量保持一个原则规则前置、兜底收尾。公开接口、静态资源、登录接口这些放最前面需要精确权限控制的放中间最后用anyRequest().authenticated()兜底。这套组织方式在项目膨胀之后会特别省心新同事来接你代码时也能很快看懂哪些接口是开放的、哪些接口受保护。安全配置这种东西写得越直白越好千万别炫技。