Spring Security 内核:过滤器链与认证授权
Spring Security 干的事,用一句话说完是:在每个请求到达你的业务代码之前,插一排关卡,先问「你是谁」,再问「你能干什么」,两问都过了才放行。它不是一个注解、不是一个拦截器,而是一整条 Servlet 过滤器链——这决定了后面所有的坑:为什么请求还没到 Controller 就被拦、为什么 401 和 403 会在两个不同地方冒出来、为什么配一条规则能影响全局。
先给六个词一句话解释(全文都会用到):
- 认证(Authentication):证明「你是你说的那个人」,产出的是一个装着身份与权限的
Authentication对象 - 授权(Authorization):证明「这件事你可以做」,依据是上一步产出的权限列表
- 过滤器(Filter):Servlet 规范里的请求拦截器,比 Controller 更早执行,所以能在业务代码之前动手
- SecurityContext:当前请求的「身份Holder」,底层是
ThreadLocal,请求结束就清空 - 401 / 403:401 = 没认证(或凭证无效),403 = 已认证但没权限;两者由不同的处理器写回
- SecurityFilterChain:一条安全链的配置对象,多条链可以并存,按
@Order依次匹配
这一整篇就是小区门禁 + 快递安检 + 钥匙三件事。门禁卡是认证——刷脸、输密码,证明你住这栋楼;快递安检是授权——你就是住户,但包裹里有没有违禁品、你的货架权限覆盖哪一层,是另一套检查;最后那把钥匙就是 SecurityContext 里那份「你是谁、你能到哪一层」的凭证,由保安(SecurityContextHolderFilter)在你进门时发给你、离开时收回。三者缺一不可:没有门禁卡(未认证)你连楼都进不了;进了楼没权限(已认证但无权限)只能停在门口——这就是 401 与 403 的差别,也是它们必须分开的那条线。

这张流程图是全篇的主轴:上半段是「问题一 · 你是谁」,下半段是「问题二 · 你能干什么」。第三节走通上半段,第五节走通下半段,第七节专门讲两道门各自的失败长什么样(401 与 403)。
学完这一篇,你应该能回答三个问题:
- 我只加了一个依赖、什么配置都没写,为什么所有接口都开始要登录?
- 用户明明在数据库里是 ADMIN,访问
/api/admin/**却拿到 403——框架到底在比对什么字符串? - 为什么配了
permitAll()的/actuator/health还是会 401?规则顺序错在哪一步?
很多人第一次给项目加上 spring-boot-starter-security 依赖,会遇到一串灵异事件:什么配置都没写,所有接口突然都要登录了;浏览器弹出登录框,可不知道用户名密码;想放行一个健康检查接口,怎么配都不生效。 先把这三个问题集中回答掉,它们指向的是同一套机制。
为什么加了依赖所有接口都要登录? 因为 Spring Boot 的自动配置里有一个 SecurityAutoConfiguration,它一旦发现 classpath 上存在 Spring Security 的类,就导入 SpringBootWebSecurityConfiguration,为你注册一条默认安全链——任何请求都必须经过认证。这不是你配错了,而是框架怕你忘关门,先把门锁上了。
默认密码在哪? 在启动日志里搜 Using generated security password。它来自 UserDetailsServiceAutoConfiguration:在没有任何自定义用户时,框架创建一个内存用户 user,密码随机生成。它只用于本地体验,绝对不能进生产环境。
怎么放行接口? 在 Spring Boot 3 里,定义一个 SecurityFilterChain Bean,配合 authorizeHttpRequests().requestMatchers(...).permitAll()。后面第五节给出完整配置。
spring-boot-starter-security 只是 Boot 的自动装配外壳,spring-security-config 才是 Security 的内核。分不清这两层,你搜索时会被大量 "Boot 2 与 Boot 3 写法不同" 的旧文章带偏——本节所有代码均以 Boot 3.x 为准。
三个问题看下来,共同的源头其实只有一行依赖。动手勾一遍:只勾 Security,得到的就是第一问里「什么都不配、全站要登录」的最小复现;再补上 Web 与 Actuator,对照第六节的完整配置和探针接口该怎么排进来——生成器里每一项的说明都写着「加上它之后你会看到什么」:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version> <!-- 版本由 BOM 统管,子依赖不写 version -->
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>demo-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>pom 是「最小复现」的起点,不是终点。第四节的 PasswordEncoder、第六节的 SecurityFilterChain 都是在这行依赖之上补齐的——依赖解决「有没有」,配置解决「怎么放行」。

Spring Security 的主战场不在 Controller,而在 Servlet 过滤器链 里。请求进入 Tomcat 后,最先遇到的不是 DispatcherServlet,而是一个名为 springSecurityFilterChain 的 DelegatingFilterProxy。它本身只是个"门牌",真正的实现是内部的 FilterChainProxy。
// FilterChainProxy 的核心逻辑(简化示意)public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { // 1. 取出所有 SecurityFilterChain,按 RequestMatcher 找到第一条匹配的 List<FilterChain> chains = getFilters((HttpServletRequest) request); // 2. 执行该链上的 15 个过滤器,最后放行到原本的 Servlet 链 VirtualFilterChain vfc = new VirtualFilterChain(chain, chains); vfc.doFilter(request, response);}DelegatingFilterProxy:Servlet 容器与 Spring 容器之间的桥,把容器里的 Bean 挂进过滤器链。FilterChainProxy:真正的调度者,持有若干条SecurityFilterChain,按顺序匹配,第一条命中即用。VirtualFilterChain:把 15 个过滤器串成一个虚拟链,跑完后交还给原始过滤器链。
这 15 个过滤器的顺序是框架写死的,不能随意插队。下表列出最关键的 8 个(顺序即执行顺序):
| 过滤器 | 一句话职责 |
|---|---|
| SecurityContextHolderFilter | 从 SecurityContextRepository 取上下文,请求结束清空,避免线程复用串号 |
| UsernamePasswordAuthenticationFilter | 处理表单登录 POST /login,把用户名密码变成认证请求 |
| BasicAuthenticationFilter | 处理 HTTP Basic 头,解析出用户名密码交给认证管理器 |
| ExceptionTranslationFilter | 捕获下游异常,认证失败转 401、权限不足转 403 |
| AuthorizationFilter | 按 authorizeHttpRequests 规则做最终裁决,不放行就抛 AccessDeniedException |
| CsrfFilter | 校验 CSRF Token,前后端分离项目通常关闭 |
| LogoutFilter | 处理 POST /logout,清理上下文与会话 |
| CorsFilter | 处理跨域,处理 CORS 预检 OPTIONS 请求 |
这里说的"15 个"是默认配置下的数量,随你启用的功能(如 OAuth2、Remember-Me)会增减。写代码时不要依赖这个数字,只记住顺序和职责。
这条链到底长什么样、每个过滤器在第几位,用一次全景实验看得最清楚——注意认证类过滤器与授权过滤器的先后分界:
匿名访问是怎么判定的,也是新手最容易想错的一处——它不是「没登录」,而是「这次的 Authentication 为空」:
上面两个实验盯的都是安全链内部。把镜头往外拉一格:安全过滤器只是 Web 过滤器家族里的一支——你的项目里还有编码过滤器、跨域过滤器,以及 Spring MVC 的拦截器。它们与安全链谁先谁后、各自的能力边界在哪,决定了「这段逻辑该写在哪一层」:
记住这条边界:过滤器在 DispatcherServlet 之前,拦截器在它之后——所以凡是「还没进到 Controller 就得下结论」的事(安全上下文、编码、跨域预检),都只能由过滤器做;等安全链把身份备齐,拦截器与 Controller 里才谈得上「这个用户能干什么」。
一个应用可以有多条 SecurityFilterChain。框架按 @Order 从小到大依次匹配请求,一旦某条链的 securityMatcher 命中,就用这条链处理,后续链不再参与。
@Bean@Order(1)SecurityFilterChain apiChain(HttpSecurity http) throws Exception { http.securityMatcher("/api/**") .authorizeHttpRequests(a -> a.anyRequest().authenticated()); return http.build();}@Bean@Order(2)SecurityFilterChain webChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(a -> a.anyRequest().permitAll()); return http.build();}- 越具体的链
@Order越小,越靠前,才有机会被匹配到。 - 若把所有请求都塞进一条链,再用一堆 matcher 区分,顺序写错就会"全部要登录"。
这两条链之间到底是什么关系?别靠脑补——把它拆成一次匹配的五个瞬间,记住那句话:「第一条命中即独吞」,其余链连被评估的机会都没有:

所以「为什么 /api/admin/orders 上不了 webChain 的 permitAll()」的答案是:@Order(1) 链的 securityMatcher("/api/") 先命中,请求从此归 apiChain 独自处理。要放行后台接口,得在命中链自己的规则表里加更具体的 matcher(例如在 apiChain 里补一条 .requestMatchers("/api/admin/public/").permitAll()),而不是指望第二条链兜底——这正是第十二节第二道自测题的题眼。

认证的核心只有一句话:证明"你是你说的那个人"。这条链路涉及四五个类,最容易记混,我们按次序走一遍:
- 提交:用户提交用户名密码,
UsernamePasswordAuthenticationFilter拦下这个请求。 - 造令牌:过滤器把用户名密码包成一个未认证的
UsernamePasswordAuthenticationToken(authenticated=false)。 - 委派:令牌交给
AuthenticationManager(默认实现ProviderManager),它遍历所有AuthenticationProvider,找到支持该令牌类型的那个。 - 查用户:
DaoAuthenticationProvider调用UserDetailsService.loadUserByUsername(),按用户名查到UserDetails,里面含加密后的密码、权限列表、账号是否锁定等信息。 - 比对密码:
PasswordEncoder.matches(rawPassword, encodedPassword)判断是否一致;BCrypt 内部会解析出盐再计算哈希。 - 存入上下文:比对通过,用
UserDetails构造已认证的Authentication,放进SecurityContext。 - 放行:后续的
AuthorizationFilter依据其中的权限列表决定放行还是拒绝。
SecurityContext 存在哪里?SecurityContextHolder,底层是一个 ThreadLocal。这意味着:认证信息与"当前线程"绑定,同一次请求的后续代码都能读到它;请求结束由 SecurityContextHolderFilter 清理,防止线程池复用把别人的身份串过来。
ThreadLocal 绑定既是便利也是陷阱——新开的线程读不到 SecurityContext(比如 @Async 或手动 new Thread),因为它们不在同一个线程里。需要异步传递时,要么显式复制上下文,要么用 DelegatingSecurityContextExecutor。
身份住进 ThreadLocal 这件事,决定了 SecurityContext 的一生只有六个瞬间;其中两个最容易被忽略——请求结束时的清空,与换一条线程后的读不到:

对照动图记住两个空档:第 ⑤ 帧「线程被复用前已清空」是框架替你守的安全底线,别把它当成理所当然;第 ⑥ 帧的「读不到」是设计而非缺陷——@Async、线程池、定时任务里要读身份,只能显式搬运(用 DelegatingSecurityContextExecutor 把线程池包一层),或干脆把需要的字段当参数传过去。
第七步(授权裁决)的完整分岔值得单独走一遍:同一个请求,落点是 401 还是 403,取决于第一道门有没有过:

对照动图,把最容易记反的两条钉死:
| 现场 | 实际发生的事 | 谁写的状态码 |
|---|---|---|
没带任何凭证访问 /api/admin/** | 授权规则要 hasRole("ADMIN"),但此时 authentication 是 null,框架判定为「先解决身份问题」 | AuthenticationEntryPoint 写 401 |
带着合法令牌、角色是 USER,访问 /api/admin/** | 身份成立,规则也匹配上了,只是权限不够 | AccessDeniedHandler 写 403 |
表单登录那条完整链路(UsernamePasswordAuthenticationFilter 到 SecurityContextHolder)可以再走一遍,它正好是上面那张动图第 ② 到第 ⑥ 帧的展开:
一句话理解 BCrypt:给每个密码加一段随机盐,再用故意"慢"的哈希算法计算。慢是为了让暴力破解的成本高到不可行,随机的盐保证同样的密码得到不同的结果。
Spring Security 5 之后推荐 DelegatingPasswordEncoder,它在哈希前加上算法标识,例如 {bcrypt}$2a$10$...,将来换算法时可以新旧并存、逐步迁移:
@Configurationpublic class PasswordConfig { @Bean public PasswordEncoder passwordEncoder() { // 默认委托 bcrypt,可解析 {bcrypt}/{argon2}/{pbkdf2} 前缀 return PasswordEncoderFactories.createDelegatingPasswordEncoder(); }}- 存库时用
encode(),得到的一定是带{bcrypt}前缀的字符串,长度约 60。 - 校验时用
matches(),它会读取前缀自动选算法,永远不要自己比较哈希字符串。 - 数据库密码列必须给足长度(
varchar(100)以上),否则哈希会被截断。
授权回答的是"你能不能做这件事"。Spring Security 提供两层:
@BeanSecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(auth -> auth // 顺序即优先级:越具体的规则越靠前 .requestMatchers("/login", "/register", "/actuator/health").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .requestMatchers(HttpMethod.GET, "/api/posts/**").hasAuthority("post:read") .requestMatchers(HttpMethod.POST, "/api/posts/**").hasAuthority("post:write") .anyRequest().authenticated()); return http.build();}permitAll放行、authenticated要求登录、hasRole/hasAuthority要求权限。- 匹配顺序自上而下,第一个命中的规则生效——把
/**放前面,后面全部失效。
规则表读起来简单,跑起来的反直觉点却在命中之后:不命中的规则只是一行行划过,命中的那一刻就当场裁决——后面全部不再读。把一次 GET /api/posts/42 摊成单步执行,左边逐行走,右边同步刷新请求特征与裁决结果:
http.authorizeHttpRequests(auth -> auth.requestMatchers("/login", "/register", "/actuator/health").permitAll().requestMatchers("/admin/**").hasRole("ADMIN").requestMatchers(HttpMethod.GET, "/api/posts/**").hasAuthority("post:read").requestMatchers(HttpMethod.POST, "/api/posts/**").hasAuthority("post:write").anyRequest().authenticated());| 请求 | GET /api/posts/42 |
| 身份 | Bearer 令牌(roles=[USER],authorities=[post:read]) |
AuthorizationFilter.doFilter这里最容易写错的两处,一个是顺序(越具体越靠前),一个是命中的含义(命中 = 裁决完成,而不是「再往下看看有没有更宽松的规则」)。排查 403 时先找出「哪一条命中了」,再看它要求的字符串与库里存的差在哪。
方法级授权需要开启 @EnableMethodSecurity,然后用注解表达更细的规则:
@Configuration@EnableMethodSecurity // 开启 @PreAuthorize / @PostAuthorize / @PreFilterpublic class MethodSecurityConfig { }@Servicepublic class OrderService { @PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id") public Order findById(Long userId, Long orderId) { ... } @PostAuthorize("returnObject.ownerId == authentication.principal.id") public Order load(Long orderId) { ... } @PreFilter("filterObject.amount < 10000") public void batchPay(List<Order> orders) { ... }}@PreAuthorize:方法执行前校验,最常用。@PostAuthorize:拿到返回值后再校验,适合"只能看自己的数据"。@PreFilter:入参是集合时逐个过滤,只保留满足条件的元素。
角色与权限的区别,坑在一行前缀上:
| 写法 | 存入的 Authority | 匹配注解 | 说明 |
|---|---|---|---|
hasRole("ADMIN") | ROLE_ADMIN | hasRole("ADMIN") | 框架自动加 ROLE_ 前缀 |
hasAuthority("ROLE_ADMIN") | ROLE_ADMIN | hasAuthority("ROLE_ADMIN") | 必须手写完整字符串 |
hasAuthority("post:read") | post:read | hasAuthority("post:read") | 细粒度权限,无前缀 |
最常见的报错是"用户明明是 ADMIN 却 403"。原因几乎总是存进数据库的权限字符串没带 ROLE_ 前缀,而代码里用了 hasRole("ADMIN")——它期望的是 ROLE_ADMIN。统一约定:角色用 hasRole、权限用 hasAuthority,不要混用。
下面这份配置覆盖了前后端分离项目最典型的诉求,逐段解读:
@Configuration@EnableWebSecurity@EnableMethodSecuritypublic class SecurityConfig { private final JwtAuthFilter jwtAuthFilter; // 自定义的 JWT 过滤器,见下一篇 public SecurityConfig(JwtAuthFilter jwtAuthFilter) { this.jwtAuthFilter = jwtAuthFilter; } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // 1. 关闭 CSRF(无状态 + 前后端分离,见第八节) .csrf(AbstractHttpConfigurer::disable) // 2. 关闭默认表单登录页与 Basic 弹窗 .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) // 3. 会话策略:无状态,不创建 HttpSession .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 4. 放行与鉴权规则(顺序敏感) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**", "/api/doc/**", "/actuator/health").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated()) // 5. 异常处理:返回 JSON 而不是重定向登录页 .exceptionHandling(ex -> ex .authenticationEntryPoint(restAuthEntryPoint()) // 401 .accessDeniedHandler(restAccessDeniedHandler())) // 403 // 6. 跨域配置 .cors(cors -> cors.configurationSource(corsConfigurationSource())) // 7. 把 JWT 过滤器插到用户名密码过滤器之前 .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); }}- 第 4 步的
authorizeHttpRequests是顺序敏感的,放行接口必须写在兜底anyRequest之前。 - 第 5 步把默认的"重定向到登录页"换成 JSON,前端才能正确识别 401/403。
- 第 7 步的
addFilterBefore决定了自定义过滤器在链上的位置——JWT 必须在授权过滤器之前解析出身份。
第 7 步也是这段配置里最容易出事的一处:你亲手把过滤器插进了安全链。下面这段堆栈来自一个「照着教程抄 JWT 过滤器」的项目——过滤器插进去了、请求也进来了,却在认证管理器那里撞了墙。先别看答案,点出你认为的凶手帧:
按教程给项目加了 JwtAuthFilter:解析 Header 里的令牌,交给 authenticationManager.authenticate() 认证,再用 addFilterBefore 插进安全链。自测时发现:所有带合法令牌的请求全部 500,连 Controller 都没进——而令牌本身是刚签发的、绝没过期。
这段堆栈把第三节的类名全串了起来——ProviderManager → 你的过滤器 → OncePerRequestFilter → VirtualFilterChain → SecurityContextHolderFilter。从下往上读一遍,你会对「谁先谁后、谁调用谁」有一次完整的兑现。
这两个状态码经常被混为一谈:401 = 你没登录(或凭证无效);403 = 你登录了,但没权限。 对应两个处理器:
@BeanAuthenticationEntryPoint restAuthEntryPoint() { return (request, response, ex) -> { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"未认证或登录已过期\"}"); };}@BeanAccessDeniedHandler restAccessDeniedHandler() { return (request, response, ex) -> { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":403,\"msg\":\"权限不足\"}"); };}AuthenticationEntryPoint:由ExceptionTranslationFilter在捕获AuthenticationException时调用。AccessDeniedHandler:捕获AccessDeniedException时调用;注意匿名用户访问受限资源时,它会被转交 EntryPoint 处理成 401。
注意:如果这两个处理器没配,浏览器端会收到一个 302 重定向到 /login,前端 fetch 会因为跨域或 HTML 响应而报一个难懂的错——这也是"莫名 401(其实是 302)"的常见来源。
401 与 403 只是这个家族里最出名的两个。真实排查时你会撞上七八个名字各异的认证异常——它们其实是同一条流程在不同环节的失败。来玩一局:左列是异常类名,右列是它被抛出的现场,配错了当场告诉你为什么:
家族先分清,修法才不会南辕北辙——「密码比对没过」查的是凭据来源,「上下文为空」查的是线程(第三节),「权限字符串不够」查的是库里存的那几个字(第五节)。三类现场,三张不同的排查清单。
传统 JSP 时代 Security 的默认行为(表单登录、Session、CSRF Token)在 SPA + REST 架构下大多不再适用:
- 为什么关闭 session? 前后端分离常用 JWT 承载身份,服务端不保存会话,
STATELESS让每次请求都靠令牌自证。代价是注销/失效控制变复杂(见下一篇)。 - 为什么关闭 CSRF? CSRF 攻击依赖浏览器自动携带 Cookie 凭证;无状态 + 不依赖 Cookie 场景下,攻击者拿不到自动凭证。但风险是真实的:一旦你同时用 Cookie 存令牌,就必须重新开启 CSRF。
- CORS 预检放行:跨域的
OPTIONS预检请求不带凭证,必须让CorsFilter在认证之前放行,否则预检会先被安全链拦成 401。
@BeanCorsConfigurationSource corsConfigurationSource() { CorsConfiguration cfg = new CorsConfiguration(); cfg.setAllowedOriginPatterns(List.of("https://app.example.com")); cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); cfg.setAllowedHeaders(List.of("*")); cfg.setAllowCredentials(true); // 允许携带 Cookie / Authorization UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", cfg); return source;}而 CSRF 那一行值得亲手试一次:它默认开启,拦的时机也很特别——在令牌解析之前:
无状态之后,每次请求的身份都靠令牌自证——「令牌凭什么可信」这件事值得亲手验一遍。签发、验证、篡改一个字符、过期,四步走完你会彻底记住:签名是唯一那道「真伪」屏障:
令牌的 payload 只是 Base64 编码、不是加密——任何人都能还原出里面的明文。所以规矩是:只放 id 与角色这类低频变化的数据,密钥从环境变量注入,而且永远把验签失败当作「未认证」处理。
安全链解决了「谁能进来」,但有一种攻击它管不了:拿合法账号疯狂试密码(撞库),或匿名高频刷接口。这属于流量控制,和认证是两件事——但两条防线必须叠在一起:
一句话选型:固定窗口最简单,但请求跨在窗口边界上会放进来双倍流量;漏桶把流出钉成匀速,适合保护脆弱的第三方;令牌桶允许攒一点额度应对突发,是接口限流的通解。实现上别自己写计数器——单机用 Resilience4j 或 Bucket4j,多实例把计数放进 Redis 集中做,否则你的限流会被副本数悄悄乘掉。
上面这一段就是「安全链之外的一层」。读到这儿,拼图已经完整——该轮到自己敲了。这台控制台连着浏览器里的同款 Java 内核,回显全部由内核算出来:让安全链的过滤器排队走一遍,再把 401/403 的分岔、表单登录的全流程、令牌的签发与篡改、限流算法的对照依次敲出来:
控制台里的顺序就是安全链的顺序——lab sec deny 演示的是 401/403 分岔,lab jwt tamper 演示无状态方案里唯一那道「真伪」屏障。这七条敲完,从第二节到第八节的结论都可以自己复现一遍。
| 症状 | 根因 | 修复 |
|---|---|---|
| 所有接口都要登录 | 引入了 starter 但没自定义 SecurityFilterChain | 定义放行规则,或用自己的链覆盖默认链 |
| 401 但前端说"跨域错误" | 未认证被重定向到 /login(302),或预检被拦 | 配置 EntryPoint 返回 JSON;放行 OPTIONS 预检 |
| 用户有权限却 403 | hasRole("ADMIN") 期望 ROLE_ADMIN,库里没前缀 | 统一权限字符串规范,角色加 ROLE_ |
| 明明写了 permitAll 仍 401 | matcher 顺序错,被前面的规则先命中 | 把越具体的规则放到越前面 |
| 方法级注解不生效 | 类没有被代理(自调用 / 加了 final / 非 Spring Bean) | 通过代理调用,或改用 URL 级授权 |
| 登录成功后仍 401 | 无状态模式下没有回写上下文给后续请求 | 用令牌方案(JWT 过滤器)而非 Session |
启动就报 The bean 'springSecurityFilterChain' could not be created | 同一容器里注册了多条 SecurityFilterChain 却没区分 @Order,或链与链的 securityMatcher 有重叠导致构建期歧义 | 给每条链一个明确的 @Order 与互不重叠的 securityMatcher;只有一条链时不必写 @Order |
authorizeHttpRequests 的匹配是自上而下、首个命中生效。把 .anyRequest().authenticated() 写在 .requestMatchers("/login").permitAll() 前面,登录接口也会被要求认证——顺序就是语义。
@PreAuthorize 依赖 AOP 代理,而 @Transactional 也依赖代理。当两者出现在同一个 Bean 上时,代理顺序会影响行为:方法级安全通常在方法执行前生效,事务在其外层或内层取决于代理配置。排查"权限通过了但事务没回滚"这类怪象时,请意识到这是两层代理叠加的结果。
Spring Boot 3 已彻底移除 WebSecurityConfigurerAdapter。网上大量旧教程还在教你 extends WebSecurityConfigurerAdapter 并重写 configure(HttpSecurity)——在 Boot 3 里这段代码连编译都过不了。正确写法是注册 SecurityFilterChain Bean,本节全部代码均采用新写法。
排查 401 时最难顶的是「我明明写了 permitAll()」。下面这个沙盘把兜底规则的位置和请求路径做成两个开关,同屏看到匹配过程与最终状态码——它就是第九节前两行排查项的可操作版本:
第 1 条命中:.anyRequest().authenticated()结果:已登录用户 200;未登录用户 401# /api/admin/** 的 hasRole("ADMIN") 从未被读取
数字是示意的,机制是真的——authorizeHttpRequests 的匹配是自上而下、首个命中即生效,所以「兜底规则写了 authenticated() 却仍然全站 401」这类症状,根因永远在顺序而不在权限本身。沙盘里第二行(catch-all-first + /login)是新手最容易撞上的组合:连登录接口自己都要认证,前端表现却是「点了登录按钮没反应」。
先来一道热身题,考的是第五节那条 ROLE_ 前缀规则:
再来一道综合题,把第二节的多链匹配、第七节的 401/403 与第三节的 ThreadLocal 串起来:
说出一条请求从 Tomcat 进入到 Controller 之前,依次经过哪三类过滤器(认证 / 异常翻译 / 授权),以及 401 和 403 分别由谁写回?
hasRole("ADMIN") 与 hasAuthority("ROLE_ADMIN") 在库里的权限串不同(一个存 ADMIN、一个存 ROLE_ADMIN)时,分别能不能匹配上?为什么?
SecurityContext 为什么放在 ThreadLocal 里?它带来哪一个必须处理的副作用,@Async 方法里读不到身份该怎么绕?
为什么把 .anyRequest().authenticated() 写在 .requestMatchers("/login").permitAll() 前面会让登录接口也 401?
认证先问「你是谁」,授权再问「你能干什么」;401 是没进门,403 是进了门没权限;规则自上而下、首个命中即生效,兜底永远写最后。
Spring Security 看似复杂,本质是"一条过滤器链 + 两个问题"。认证回答"你是谁"(UsernamePasswordAuthenticationFilter → AuthenticationManager → UserDetailsService → PasswordEncoder → SecurityContext);授权回答"你能做什么"(AuthorizationFilter + 方法级注解)。把这两条主线抓住,再配合一份正确的 SecurityFilterChain 配置,401/403 就不再是玄学。