Spring Security 内核:过滤器链与认证授权

bee2026-10-0851 分钟0 次阅读
15 个过滤器串成一条安全链,认证与授权在链上各司其职。从 FilterChainProxy 拆到 SecurityFilterChain 配置,把「为什么我的接口突然 401/403」彻底解决。
1 / 120
小节
〇、30 秒看懂
2 / 120

Spring Security 干的事,用一句话说完是:在每个请求到达你的业务代码之前,插一排关卡,先问「你是谁」,再问「你能干什么」,两问都过了才放行。它不是一个注解、不是一个拦截器,而是一整条 Servlet 过滤器链——这决定了后面所有的坑:为什么请求还没到 Controller 就被拦、为什么 401 和 403 会在两个不同地方冒出来、为什么配一条规则能影响全局。

3 / 120

先给六个词一句话解释(全文都会用到):

4 / 120
  • 认证(Authentication):证明「你是你说的那个人」,产出的是一个装着身份与权限的 Authentication 对象
  • 授权(Authorization):证明「这件事你可以做」,依据是上一步产出的权限列表
  • 过滤器(Filter):Servlet 规范里的请求拦截器,比 Controller 更早执行,所以能在业务代码之前动手
  • SecurityContext:当前请求的「身份Holder」,底层是 ThreadLocal,请求结束就清空
  • 401 / 403:401 = 没认证(或凭证无效),403 = 已认证但没权限;两者由不同的处理器写回
  • SecurityFilterChain:一条安全链的配置对象,多条链可以并存,按 @Order 依次匹配
5 / 120
类比

这一整篇就是小区门禁 + 快递安检 + 钥匙三件事。门禁卡是认证——刷脸、输密码,证明你住这栋楼;快递安检是授权——你就是住户,但包裹里有没有违禁品、你的货架权限覆盖哪一层,是另一套检查;最后那把钥匙就是 SecurityContext 里那份「你是谁、你能到哪一层」的凭证,由保安(SecurityContextHolderFilter)在你进门时发给你、离开时收回。三者缺一不可:没有门禁卡(未认证)你连楼都进不了;进了楼没权限(已认证但无权限)只能停在门口——这就是 401 与 403 的差别,也是它们必须分开的那条线。

6 / 120
架构图
图 · 认证与授权:两道门,各问一件事
图 · 认证与授权:两道门,各问一件事
7 / 120

这张流程图是全篇的主轴:上半段是「问题一 · 你是谁」,下半段是「问题二 · 你能干什么」。第三节走通上半段,第五节走通下半段,第七节专门讲两道门各自的失败长什么样(401 与 403)。

8 / 120

学完这一篇,你应该能回答三个问题:

9 / 120
  • 我只加了一个依赖、什么配置都没写,为什么所有接口都开始要登录?
  • 用户明明在数据库里是 ADMIN,访问 /api/admin/** 却拿到 403——框架到底在比对什么字符串?
  • 为什么配了 permitAll() 的 /actuator/health 还是会 401?规则顺序错在哪一步?
10 / 120
小节
一、三个让新手抓狂的问题
11 / 120

很多人第一次给项目加上 spring-boot-starter-security 依赖,会遇到一串灵异事件:什么配置都没写,所有接口突然都要登录了;浏览器弹出登录框,可不知道用户名密码;想放行一个健康检查接口,怎么配都不生效。 先把这三个问题集中回答掉,它们指向的是同一套机制。

12 / 120

为什么加了依赖所有接口都要登录? 因为 Spring Boot 的自动配置里有一个 SecurityAutoConfiguration,它一旦发现 classpath 上存在 Spring Security 的类,就导入 SpringBootWebSecurityConfiguration,为你注册一条默认安全链——任何请求都必须经过认证。这不是你配错了,而是框架怕你忘关门,先把门锁上了。

13 / 120

默认密码在哪? 在启动日志里搜 Using generated security password。它来自 UserDetailsServiceAutoConfiguration:在没有任何自定义用户时,框架创建一个内存用户 user,密码随机生成。它只用于本地体验,绝对不能进生产环境。

14 / 120

怎么放行接口? 在 Spring Boot 3 里,定义一个 SecurityFilterChain Bean,配合 authorizeHttpRequests().requestMatchers(...).permitAll()。后面第五节给出完整配置。

15 / 120
提示

spring-boot-starter-security 只是 Boot 的自动装配外壳,spring-security-config 才是 Security 的内核。分不清这两层,你搜索时会被大量 "Boot 2 与 Boot 3 写法不同" 的旧文章带偏——本节所有代码均以 Boot 3.x 为准。

16 / 120

三个问题看下来,共同的源头其实只有一行依赖。动手勾一遍:只勾 Security,得到的就是第一问里「什么都不配、全站要登录」的最小复现;再补上 Web 与 Actuator,对照第六节的完整配置和探针接口该怎么排进来——生成器里每一项的说明都写着「加上它之后你会看到什么」:

17 / 120
生成器
生成器Security 依赖到底带进来什么pom.xml2 / 4
先只勾「Security」看最小改动——它会立刻锁上所有接口;再补「Web」让接口能跑、补「Actuator」接上健康检查、补「Test」为动手练习准备测试依赖。每一项的说明都对应上面三个问题里的一个
产物
<?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>
勾了这些,代价与理由在这里
parent继承 3.3.4 的 starter-parent 之后,所有 spring-boot-starter-* 都不用写版本号;一旦有人手写给某个 starter 加 version,就以那条为准——这是依赖版本漂移最常见的原因。
Web做接口就绕不开它: DispatcherServlet、内嵌 Tomcat、JSON 序列化全在这个 starter 里。
Security一加上去,全站默认要认证,接口立刻 401——这是新手最常见的「我代码没改怎么全挂了」。
18 / 120
说明

pom 是「最小复现」的起点,不是终点。第四节的 PasswordEncoder、第六节的 SecurityFilterChain 都是在这行依赖之上补齐的——依赖解决「有没有」,配置解决「怎么放行」。

19 / 120
小节
二、整体架构:一条链,十五个过滤器
20 / 120
架构图
图 1 · 安全过滤器链
图 1 · 安全过滤器链
21 / 120

Spring Security 的主战场不在 Controller,而在 Servlet 过滤器链 里。请求进入 Tomcat 后,最先遇到的不是 DispatcherServlet,而是一个名为 springSecurityFilterChain 的 DelegatingFilterProxy。它本身只是个"门牌",真正的实现是内部的 FilterChainProxy。

22 / 120
代码对照
代码java
// 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 个过滤器串成一个虚拟链,跑完后交还给原始过滤器链。
23 / 120

这 15 个过滤器的顺序是框架写死的,不能随意插队。下表列出最关键的 8 个(顺序即执行顺序):

24 / 120
对照表
过滤器一句话职责
SecurityContextHolderFilter从 SecurityContextRepository 取上下文,请求结束清空,避免线程复用串号
UsernamePasswordAuthenticationFilter处理表单登录 POST /login,把用户名密码变成认证请求
BasicAuthenticationFilter处理 HTTP Basic 头,解析出用户名密码交给认证管理器
ExceptionTranslationFilter捕获下游异常,认证失败转 401、权限不足转 403
AuthorizationFilter按 authorizeHttpRequests 规则做最终裁决,不放行就抛 AccessDeniedException
CsrfFilter校验 CSRF Token,前后端分离项目通常关闭
LogoutFilter处理 POST /logout,清理上下文与会话
CorsFilter处理跨域,处理 CORS 预检 OPTIONS 请求
25 / 120
说明

这里说的"15 个"是默认配置下的数量,随你启用的功能(如 OAuth2、Remember-Me)会增减。写代码时不要依赖这个数字,只记住顺序和职责。

26 / 120

这条链到底长什么样、每个过滤器在第几位,用一次全景实验看得最清楚——注意认证类过滤器与授权过滤器的先后分界:

27 / 120
内核实验
TeaVM过滤器顺序全景:谁在谁前面未启动
切到「过滤器顺序全景」:认证类过滤器全部排在 AuthorizationFilter 之前,这个次序就是 401 早于 403 的原因
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
28 / 120

匿名访问是怎么判定的,也是新手最容易想错的一处——它不是「没登录」,而是「这次的 Authentication 为空」:

29 / 120
内核实验
TeaVM匿名访问到底是怎么判定的未启动
切到「匿名访问怎么判定」:注意匿名判定与「认证失败」是两件事,前者放行到授权阶段,后者直接抛 AuthenticationException
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
30 / 120

上面两个实验盯的都是安全链内部。把镜头往外拉一格:安全过滤器只是 Web 过滤器家族里的一支——你的项目里还有编码过滤器、跨域过滤器,以及 Spring MVC 的拦截器。它们与安全链谁先谁后、各自的能力边界在哪,决定了「这段逻辑该写在哪一层」:

31 / 120
内核实验
TeaVM安全过滤器在整条 Web 链里的位置未启动
先选「完整执行顺序」看 Filter 与 Interceptor 谁先谁后,再切「能力边界对比」弄清过滤器能做的事拦截器为什么做不到——你以后加逻辑时选哪一层,就看这个边界
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
32 / 120

记住这条边界:过滤器在 DispatcherServlet 之前,拦截器在它之后——所以凡是「还没进到 Controller 就得下结论」的事(安全上下文、编码、跨域预检),都只能由过滤器做;等安全链把身份备齐,拦截器与 Controller 里才谈得上「这个用户能干什么」。

33 / 120
小节
2.1 多链匹配:为什么有的接口不用登录
34 / 120

一个应用可以有多条 SecurityFilterChain。框架按 @Order 从小到大依次匹配请求,一旦某条链的 securityMatcher 命中,就用这条链处理,后续链不再参与。

35 / 120
代码对照
代码java
@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 区分,顺序写错就会"全部要登录"。
36 / 120

这两条链之间到底是什么关系?别靠脑补——把它拆成一次匹配的五个瞬间,记住那句话:「第一条命中即独吞」,其余链连被评估的机会都没有:

37 / 120
架构图
图 · 多链匹配:一条请求只认一条链
图 · 多链匹配:一条请求只认一条链
38 / 120

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

39 / 120
小节
三、认证主流程:从用户名密码到 SecurityContext
40 / 120
原理动画
动图 · 一次登录认证的完整流程
动图 · 一次登录认证的完整流程
41 / 120

认证的核心只有一句话:证明"你是你说的那个人"。这条链路涉及四五个类,最容易记混,我们按次序走一遍:

42 / 120
  1. 提交:用户提交用户名密码,UsernamePasswordAuthenticationFilter 拦下这个请求。
  2. 造令牌:过滤器把用户名密码包成一个未认证的 UsernamePasswordAuthenticationToken(authenticated=false)。
  3. 委派:令牌交给 AuthenticationManager(默认实现 ProviderManager),它遍历所有 AuthenticationProvider,找到支持该令牌类型的那个。
  4. 查用户:DaoAuthenticationProvider 调用 UserDetailsService.loadUserByUsername(),按用户名查到 UserDetails,里面含加密后的密码、权限列表、账号是否锁定等信息。
  5. 比对密码:PasswordEncoder.matches(rawPassword, encodedPassword) 判断是否一致;BCrypt 内部会解析出盐再计算哈希。
  6. 存入上下文:比对通过,用 UserDetails 构造已认证的 Authentication,放进 SecurityContext。
  7. 放行:后续的 AuthorizationFilter 依据其中的权限列表决定放行还是拒绝。
43 / 120

SecurityContext 存在哪里?SecurityContextHolder,底层是一个 ThreadLocal。这意味着:认证信息与"当前线程"绑定,同一次请求的后续代码都能读到它;请求结束由 SecurityContextHolderFilter 清理,防止线程池复用把别人的身份串过来。

44 / 120
坑

ThreadLocal 绑定既是便利也是陷阱——新开的线程读不到 SecurityContext(比如 @Async 或手动 new Thread),因为它们不在同一个线程里。需要异步传递时,要么显式复制上下文,要么用 DelegatingSecurityContextExecutor。

45 / 120

身份住进 ThreadLocal 这件事,决定了 SecurityContext 的一生只有六个瞬间;其中两个最容易被忽略——请求结束时的清空,与换一条线程后的读不到:

46 / 120
原理动画
动图 · SecurityContext 的一生:从进链到清空
动图 · SecurityContext 的一生:从进链到清空
47 / 120

对照动图记住两个空档:第 ⑤ 帧「线程被复用前已清空」是框架替你守的安全底线,别把它当成理所当然;第 ⑥ 帧的「读不到」是设计而非缺陷——@Async、线程池、定时任务里要读身份,只能显式搬运(用 DelegatingSecurityContextExecutor 把线程池包一层),或干脆把需要的字段当参数传过去。

48 / 120

第七步(授权裁决)的完整分岔值得单独走一遍:同一个请求,落点是 401 还是 403,取决于第一道门有没有过:

49 / 120
原理动画
动图 · 一次请求为什么拿到 401 而不是 403
动图 · 一次请求为什么拿到 401 而不是 403
50 / 120

对照动图,把最容易记反的两条钉死:

51 / 120
对照表
现场实际发生的事谁写的状态码
没带任何凭证访问 /api/admin/**授权规则要 hasRole("ADMIN"),但此时 authentication 是 null,框架判定为「先解决身份问题」AuthenticationEntryPoint 写 401
带着合法令牌、角色是 USER,访问 /api/admin/**身份成立,规则也匹配上了,只是权限不够AccessDeniedHandler 写 403
52 / 120

表单登录那条完整链路(UsernamePasswordAuthenticationFilter 到 SecurityContextHolder)可以再走一遍,它正好是上面那张动图第 ② 到第 ⑥ 帧的展开:

53 / 120
内核实验
TeaVM表单登录全流程:从 POST 到 SecurityContext未启动
切到「表单登录全流程」:重点看过滤器如何把用户名密码包成未认证 Token,再由 ProviderManager 委派出去
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
54 / 120
小节
四、密码存储规范:绝不明文
55 / 120

一句话理解 BCrypt:给每个密码加一段随机盐,再用故意"慢"的哈希算法计算。慢是为了让暴力破解的成本高到不可行,随机的盐保证同样的密码得到不同的结果。

56 / 120

Spring Security 5 之后推荐 DelegatingPasswordEncoder,它在哈希前加上算法标识,例如 {bcrypt}$2a$10$...,将来换算法时可以新旧并存、逐步迁移:

57 / 120
代码对照
代码java
@Configurationpublic class PasswordConfig {    @Bean    public PasswordEncoder passwordEncoder() {        // 默认委托 bcrypt,可解析 {bcrypt}/{argon2}/{pbkdf2} 前缀        return PasswordEncoderFactories.createDelegatingPasswordEncoder();    }}
解读
  • 存库时用 encode(),得到的一定是带 {bcrypt} 前缀的字符串,长度约 60。
  • 校验时用 matches(),它会读取前缀自动选算法,永远不要自己比较哈希字符串。
  • 数据库密码列必须给足长度(varchar(100) 以上),否则哈希会被截断。
58 / 120
小节
五、授权体系:URL 级与方法级
59 / 120

授权回答的是"你能不能做这件事"。Spring Security 提供两层:

60 / 120
代码对照
代码java
@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 要求权限。
  • 匹配顺序自上而下,第一个命中的规则生效——把 /** 放前面,后面全部失效。
61 / 120

规则表读起来简单,跑起来的反直觉点却在命中之后:不命中的规则只是一行行划过,命中的那一刻就当场裁决——后面全部不再读。把一次 GET /api/posts/42 摊成单步执行,左边逐行走,右边同步刷新请求特征与裁决结果:

62 / 120
单步调试台
单步台单步走一遍:一条请求是怎么被规则表裁决的1 / 7
点「下一步」七次。注意第 4—7 步:不命中的规则只是一行行划过,一旦命中就当场裁决、后面全部不再读
被调试的代码
1http.authorizeHttpRequests(auth -> auth
2.requestMatchers("/login", "/register", "/actuator/health").permitAll()
3.requestMatchers("/admin/**").hasRole("ADMIN")
4.requestMatchers(HttpMethod.GET, "/api/posts/**").hasAuthority("post:read")
5.requestMatchers(HttpMethod.POST, "/api/posts/**").hasAuthority("post:write")
6.anyRequest().authenticated());
此刻的变量
请求GET /api/posts/42
身份Bearer 令牌(roles=[USER],authorities=[post:read])
调用栈
1AuthorizationFilter.doFilter
1规则表开始评估。此刻没有人知道结果——AuthorizationFilter 手里只有请求特征(方法、路径、身份),它从第一行开始往下比。
63 / 120
要点

这里最容易写错的两处,一个是顺序(越具体越靠前),一个是命中的含义(命中 = 裁决完成,而不是「再往下看看有没有更宽松的规则」)。排查 403 时先找出「哪一条命中了」,再看它要求的字符串与库里存的差在哪。

64 / 120

方法级授权需要开启 @EnableMethodSecurity,然后用注解表达更细的规则:

65 / 120
代码对照
代码java
@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:入参是集合时逐个过滤,只保留满足条件的元素。
66 / 120

角色与权限的区别,坑在一行前缀上:

67 / 120
对照表
写法存入的 Authority匹配注解说明
hasRole("ADMIN")ROLE_ADMINhasRole("ADMIN")框架自动加 ROLE_ 前缀
hasAuthority("ROLE_ADMIN")ROLE_ADMINhasAuthority("ROLE_ADMIN")必须手写完整字符串
hasAuthority("post:read")post:readhasAuthority("post:read")细粒度权限,无前缀
68 / 120
坑

最常见的报错是"用户明明是 ADMIN 却 403"。原因几乎总是存进数据库的权限字符串没带 ROLE_ 前缀,而代码里用了 hasRole("ADMIN")——它期望的是 ROLE_ADMIN。统一约定:角色用 hasRole、权限用 hasAuthority,不要混用。

69 / 120
小节
六、自定义配置实战:一个能上生产的 SecurityFilterChain
70 / 120

下面这份配置覆盖了前后端分离项目最典型的诉求,逐段解读:

71 / 120
代码对照
代码java
@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 必须在授权过滤器之前解析出身份。
72 / 120

第 7 步也是这段配置里最容易出事的一处:你亲手把过滤器插进了安全链。下面这段堆栈来自一个「照着教程抄 JWT 过滤器」的项目——过滤器插进去了、请求也进来了,却在认证管理器那里撞了墙。先别看答案,点出你认为的凶手帧:

73 / 120
报错急救
报错急救ProviderNotFoundException: No AuthenticationProvider found
自定义 JWT 过滤器一进链就抛「没有 Provider」

按教程给项目加了 JwtAuthFilter:解析 Header 里的令牌,交给 authenticationManager.authenticate() 认证,再用 addFilterBefore 插进安全链。自测时发现:所有带合法令牌的请求全部 500,连 Controller 都没进——而令牌本身是刚签发的、绝没过期。

org.springframework.security.authentication.ProviderNotFoundException: No AuthenticationProvider found for com.bee.security.JwtAuthenticationToken
at org.springframework.security.authentication.ProviderManager.authenticate(ProviderManager.java:236)
at com.bee.security.JwtAuthFilter.doFilterInternal(JwtAuthFilter.java:63)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:374)
at org.springframework.security.web.context.SecurityContextHolderFilter.doFilter(SecurityContextHolderFilter.java:82)
at org.springframework.security.web.FilterChainProxy.doFilterInternal(FilterChainProxy.java:233)
at org.springframework.security.web.FilterChainProxy.doFilter(FilterChainProxy.java:191)
at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:352)
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
74 / 120
提示

这段堆栈把第三节的类名全串了起来——ProviderManager → 你的过滤器 → OncePerRequestFilter → VirtualFilterChain → SecurityContextHolderFilter。从下往上读一遍,你会对「谁先谁后、谁调用谁」有一次完整的兑现。

75 / 120
小节
七、认证异常处理:把 401 和 403 分清楚
76 / 120

这两个状态码经常被混为一谈:401 = 你没登录(或凭证无效);403 = 你登录了,但没权限。 对应两个处理器:

77 / 120
代码对照
代码java
@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)"的常见来源。

78 / 120

401 与 403 只是这个家族里最出名的两个。真实排查时你会撞上七八个名字各异的认证异常——它们其实是同一条流程在不同环节的失败。来玩一局:左列是异常类名,右列是它被抛出的现场,配错了当场告诉你为什么:

79 / 120
配对闯关
闯关认证异常家族:类名配现场已配对 0/6 · 配错 0
左列六个异常都出自 Spring Security 的标准包,右列是它们各自的「案发现场」——别靠位置猜,两列都打乱了
先点左边一个
80 / 120
要点

家族先分清,修法才不会南辕北辙——「密码比对没过」查的是凭据来源,「上下文为空」查的是线程(第三节),「权限字符串不够」查的是库里存的那几个字(第五节)。三类现场,三张不同的排查清单。

81 / 120
小节
八、与前后端分离的适配
82 / 120

传统 JSP 时代 Security 的默认行为(表单登录、Session、CSRF Token)在 SPA + REST 架构下大多不再适用:

83 / 120
  • 为什么关闭 session? 前后端分离常用 JWT 承载身份,服务端不保存会话,STATELESS 让每次请求都靠令牌自证。代价是注销/失效控制变复杂(见下一篇)。
  • 为什么关闭 CSRF? CSRF 攻击依赖浏览器自动携带 Cookie 凭证;无状态 + 不依赖 Cookie 场景下,攻击者拿不到自动凭证。但风险是真实的:一旦你同时用 Cookie 存令牌,就必须重新开启 CSRF。
  • CORS 预检放行:跨域的 OPTIONS 预检请求不带凭证,必须让 CorsFilter 在认证之前放行,否则预检会先被安全链拦成 401。
84 / 120
java
@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;}
85 / 120
内核实验
TeaVM请求在过滤器链上的行进未启动
想想安全过滤器在 DispatcherServlet 之前还是之后拦截请求
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
86 / 120

而 CSRF 那一行值得亲手试一次:它默认开启,拦的时机也很特别——在令牌解析之前:

87 / 120
内核实验
TeaVMCSRF 到底在拦什么未启动
切到「CSRF 拦截」:注意它在认证之前就动手,所以无状态 API 关闭它不会影响登录;一旦改用 Cookie 存令牌就必须重新开启
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
88 / 120

无状态之后,每次请求的身份都靠令牌自证——「令牌凭什么可信」这件事值得亲手验一遍。签发、验证、篡改一个字符、过期,四步走完你会彻底记住:签名是唯一那道「真伪」屏障:

89 / 120
内核实验
TeaVM令牌凭什么可信:签发、验签与篡改未启动
依次切「签发 / 校验 / 篡改载荷 / 过期」:重点看篡改一个字符之后验签在哪里失败——这是无状态方案里唯一的「真伪」屏障,也是下一篇双令牌方案的地基
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
90 / 120
提示

令牌的 payload 只是 Base64 编码、不是加密——任何人都能还原出里面的明文。所以规矩是:只放 id 与角色这类低频变化的数据,密钥从环境变量注入,而且永远把验签失败当作「未认证」处理。

91 / 120
小节
8.1 认证之外的一层:给接口装上限流
92 / 120

安全链解决了「谁能进来」,但有一种攻击它管不了:拿合法账号疯狂试密码(撞库),或匿名高频刷接口。这属于流量控制,和认证是两件事——但两条防线必须叠在一起:

93 / 120
内核实验
TeaVM限流三兄弟:固定窗口、漏桶、令牌桶未启动
依次跑三个算法:固定窗口看「窗口边界上的双倍流量」,漏桶看「匀速出水怎么磨平突发」,令牌桶看「攒下的令牌如何应对尖峰」——登录接口防爆破一般选令牌桶,按 IP 计数
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
94 / 120

一句话选型:固定窗口最简单,但请求跨在窗口边界上会放进来双倍流量;漏桶把流出钉成匀速,适合保护脆弱的第三方;令牌桶允许攒一点额度应对突发,是接口限流的通解。实现上别自己写计数器——单机用 Resilience4j 或 Bucket4j,多实例把计数放进 Redis 集中做,否则你的限流会被副本数悄悄乘掉。

95 / 120

上面这一段就是「安全链之外的一层」。读到这儿,拼图已经完整——该轮到自己敲了。这台控制台连着浏览器里的同款 Java 内核,回显全部由内核算出来:让安全链的过滤器排队走一遍,再把 401/403 的分岔、表单登录的全流程、令牌的签发与篡改、限流算法的对照依次敲出来:

96 / 120
内核控制台
97 / 120
说明

控制台里的顺序就是安全链的顺序——lab sec deny 演示的是 401/403 分岔,lab jwt tamper 演示无状态方案里唯一那道「真伪」屏障。这七条敲完,从第二节到第八节的结论都可以自己复现一遍。

98 / 120
小节
九、401 / 403 排查速查表
99 / 120
对照表
症状根因修复
所有接口都要登录引入了 starter 但没自定义 SecurityFilterChain定义放行规则,或用自己的链覆盖默认链
401 但前端说"跨域错误"未认证被重定向到 /login(302),或预检被拦配置 EntryPoint 返回 JSON;放行 OPTIONS 预检
用户有权限却 403hasRole("ADMIN") 期望 ROLE_ADMIN,库里没前缀统一权限字符串规范,角色加 ROLE_
明明写了 permitAll 仍 401matcher 顺序错,被前面的规则先命中把越具体的规则放到越前面
方法级注解不生效类没有被代理(自调用 / 加了 final / 非 Spring Bean)通过代理调用,或改用 URL 级授权
登录成功后仍 401无状态模式下没有回写上下文给后续请求用令牌方案(JWT 过滤器)而非 Session
启动就报 The bean 'springSecurityFilterChain' could not be created同一容器里注册了多条 SecurityFilterChain 却没区分 @Order,或链与链的 securityMatcher 有重叠导致构建期歧义给每条链一个明确的 @Order 与互不重叠的 securityMatcher;只有一条链时不必写 @Order
100 / 120
决策
决策一个内部管理后台,用户数量有限、部署在单台服务器、只对接自家前端,需要接入 OAuth2 吗?
101 / 120
小节
十、三个必须记住的坑
102 / 120
坑

authorizeHttpRequests 的匹配是自上而下、首个命中生效。把 .anyRequest().authenticated() 写在 .requestMatchers("/login").permitAll() 前面,登录接口也会被要求认证——顺序就是语义。

103 / 120
坑

@PreAuthorize 依赖 AOP 代理,而 @Transactional 也依赖代理。当两者出现在同一个 Bean 上时,代理顺序会影响行为:方法级安全通常在方法执行前生效,事务在其外层或内层取决于代理配置。排查"权限通过了但事务没回滚"这类怪象时,请意识到这是两层代理叠加的结果。

104 / 120
坑

Spring Boot 3 已彻底移除 WebSecurityConfigurerAdapter。网上大量旧教程还在教你 extends WebSecurityConfigurerAdapter 并重写 configure(HttpSecurity)——在 Boot 3 里这段代码连编译都过不了。正确写法是注册 SecurityFilterChain Bean,本节全部代码均采用新写法。

105 / 120
小节
十一、沙盘:规则顺序决定放行结果
106 / 120

排查 401 时最难顶的是「我明明写了 permitAll()」。下面这个沙盘把兜底规则的位置和请求路径做成两个开关,同屏看到匹配过程与最终状态码——它就是第九节前两行排查项的可操作版本:

107 / 120
沙盘
沙盘兜底规则的位置 × 请求路径:为什么放了 permitAll 还 401
运行结果
第 1 条命中:.anyRequest().authenticated()
结果:已登录用户 200;未登录用户 401
# /api/admin/** 的 hasRole("ADMIN") 从未被读取
匿名探测时看到 401,容易误以为「规则生效了」;实际生效的是兜底,权限规则根本没参与。
108 / 120
说明

数字是示意的,机制是真的——authorizeHttpRequests 的匹配是自上而下、首个命中即生效,所以「兜底规则写了 authenticated() 却仍然全站 401」这类症状,根因永远在顺序而不在权限本身。沙盘里第二行(catch-all-first + /login)是新手最容易撞上的组合:连登录接口自己都要认证,前端表现却是「点了登录按钮没反应」。

109 / 120
小节
十二、随堂自测
110 / 120

先来一道热身题,考的是第五节那条 ROLE_ 前缀规则:

111 / 120
随堂自测
随堂自测数据库里某用户的权限列存的是字符串 `ADMIN`,业务代码用 `requestMatchers("/api/admin/**").hasRole("ADMIN")`。这个用户访问 `/api/admin/orders` 会得到什么,为什么?
先自己选一个,选中立刻告诉你对不对
112 / 120

再来一道综合题,把第二节的多链匹配、第七节的 401/403 与第三节的 ThreadLocal 串起来:

113 / 120
随堂自测
随堂自测一个服务注册了两条链:`@Order(1)` 的 `apiChain` 用 `securityMatcher("/api/**")` 且 `anyRequest().authenticated()`,`@Order(2)` 的 `webChain` 对所有请求 `permitAll()`。运维抱怨「后台管理页面 `/api/admin/orders` 要登录,但前端说这个接口我们根本没设权限」。下列判断正确的是?
先自己选一个,选中立刻告诉你对不对
114 / 120
小节
十三、要点自查
115 / 120
自检

说出一条请求从 Tomcat 进入到 Controller 之前,依次经过哪三类过滤器(认证 / 异常翻译 / 授权),以及 401 和 403 分别由谁写回?

116 / 120
自检

hasRole("ADMIN") 与 hasAuthority("ROLE_ADMIN") 在库里的权限串不同(一个存 ADMIN、一个存 ROLE_ADMIN)时,分别能不能匹配上?为什么?

117 / 120
自检

SecurityContext 为什么放在 ThreadLocal 里?它带来哪一个必须处理的副作用,@Async 方法里读不到身份该怎么绕?

118 / 120
自检

为什么把 .anyRequest().authenticated() 写在 .requestMatchers("/login").permitAll() 前面会让登录接口也 401?

119 / 120
口诀

认证先问「你是谁」,授权再问「你能干什么」;401 是没进门,403 是进了门没权限;规则自上而下、首个命中即生效,兜底永远写最后。

120 / 120
总结

Spring Security 看似复杂,本质是"一条过滤器链 + 两个问题"。认证回答"你是谁"(UsernamePasswordAuthenticationFilter → AuthenticationManager → UserDetailsService → PasswordEncoder → SecurityContext);授权回答"你能做什么"(AuthorizationFilter + 方法级注解)。把这两条主线抓住,再配合一份正确的 SecurityFilterChain 配置,401/403 就不再是玄学。