Spring Security 核心:一条过滤器链管住所有请求
前面十章讲的是"怎么把功能写出来",这一章开始讲"怎么保证只有该来的人才能用"。Spring Security 是 Spring 生态里改动最频繁、版本差异最大的一块——同一个需求,Spring Security 5 和 6 的写法完全是两套。所以你学它,第一件事不是记注解,而是先搞清楚它在请求处理链路里到底站在哪一步。
先定位:过滤器链在 Servlet 请求处理中的位置
Servlet 规范定义了 Filter 机制:请求到达 Servlet 之前,会先穿过一串 Filter;响应返回时再反向穿回来。Spring Security 的全部魔法都装在这一串 Filter 里,它没有独立的入口,也没有拦截器那么"后置",它就是最前面的一道铁闸。
一个请求进 Spring Boot 应用的完整路径是这样的:
| 阶段 | 发生了什么 |
|---|---|
| ① Connector | Tomcat 的 Coyote 从 socket 读出字节,解析成 HttpServletRequest。 |
| ② Filter 链 | 依次经过 CharacterEncodingFilter、springSecurityFilterChain(DelegatingFilterProxy)、FormContentFilter 等。 |
| ③ DispatcherServlet | 拿到已经被"认证过"的请求,做 HandlerMapping、参数绑定。 |
| ④ HandlerInterceptor | preHandle → Controller → postHandle → afterCompletion。 |
| ⑤ Controller | 你的业务代码,终于跑到这里了。 |
论为什么安全要做在 Filter 层,而不是 Interceptor 层
① Filter 在 Servlet 之外:Filter 是 Servlet 容器级别的组件,DispatcherServlet 还没初始化就能生效。这意味着静态资源、错误页、甚至 Servlet 映射不到的路径,也一样被拦住。Interceptor 挂在 DispatcherServlet 上,管不到这些。
② Filter 能改写请求和响应:HttpServletRequestWrapper 可以包一层,把 JSON 里的 token 提前解析成认证信息塞进去。Interceptor 拿到的已经是成品请求,改起来别扭。
③ 安全是"先于业务"的事:没认证就该 401,根本不该走到参数绑定、日志打印、数据库查询。放在 Filter 层是最早能拦住的位置,省掉后面所有开销。
一句话:Filter 是门口的保安,Interceptor 是楼里的前台。保安不让你进,前台根本见不到你。
那 Spring Boot 里为什么你在配置文件里找不到任何 Filter 注册代码,请求却确实被拦住了?原因在自动配置:SecurityFilterAutoConfiguration 注册了一个名为 springSecurityFilterChain 的 DelegatingFilterProxy,它本身什么都不干,只负责把请求转交给 Spring 容器里那个真正的 FilterChainProxy。
而这个 FilterChainProxy 内部,是一条由十几个 Filter 组成的内部链,顺序是固定写死的:
| 序 | Filter | 职责 |
|---|---|---|
| 1 | DisableEncodeUrlFilter | 把 ;jsessionid=xxx 这种 URL 里的会话 ID 干掉,避免泄露。 |
| 2 | WebAsyncManagerIntegrationFilter | 让异步线程里也能拿到 SecurityContext。 |
| 3 | SecurityContextHolderFilter | 从 SecurityContextRepository 取出上下文,塞进 ThreadLocal。 |
| 4 | HeaderWriterFilter | 写 X-Content-Type-Options、X-Frame-Options 等安全响应头。 |
| 5 | CorsFilter | 跨域预检处理(cors() 开启时才加入)。 |
| 6 | CsrfFilter | 校验写请求里的 CSRF token。 |
| 7 | LogoutFilter | 处理 /logout。 |
| 8 | UsernamePasswordAuthenticationFilter | 表单登录:从请求里抠用户名密码,交给 AuthenticationManager。 |
| 9 | BearerTokenAuthenticationFilter | 资源服务器:解析 Authorization: Bearer <jwt>。 |
| 10 | RequestCacheAwareFilter | 记住"登录前想访问哪个页面",登录后跳回去。 |
| 11 | AnonymousAuthenticationFilter | 没人认领时塞一个匿名身份,避免到处判 null。 |
| 12 | SessionManagementFilter | 会话固定攻击防护、并发登录控制。 |
| 13 | ExceptionTranslationFilter | 捕获下游的 认证异常 和 授权异常,转成 302 登录页或 403。 |
| 14 | AuthorizationFilter | 真正的鉴权:按 authorizeHttpRequests 的规则放行或拒绝。 |
| 15 | FilterSecurityInterceptor | 老式鉴权入口,6.x 中默认已被 14 取代。 |
看这张表的顺序:第 8/9 步是认证(你是谁),第 14 步才是鉴权(你能干啥)。这个顺序不能颠倒——先知道你是谁,才谈得上你有没有权限。所以你写 @PreAuthorize 拿不到用户名,八成是认证那一步就没成功,而不是注解写错了。
另外记住:ExceptionTranslationFilter 在第 13 步、AuthorizationFilter 在第 14 步,是故意的。鉴权抛出的异常能被外层那圈 try-catch 接住,转成 401/403 而不是 500。
SecurityContextHolder:当前用户到底存在哪
认证成功后,"你是谁"这个东西放在 SecurityContextHolder 里。它默认是一个 ThreadLocal——这是理解 Spring Security 一切"诡异现象"的钥匙。
关于"用户信息"的存放,还有几个容易混的地方:
| 存取方式 | 含义与适用场景 |
|---|---|
SecurityContextHolder.getContext() | 当前线程的上下文,方法里随取随用。 |
@AuthenticationPrincipal | Controller 方法参数注入 UserDetails,比手动取干净。 |
Authentication 参数 | 直接在 Controller 方法上声明 Authentication 参数,Spring Security 自动注入。 |
Authentication.getPrincipal() | 拿主体对象。匿名用户时它的类型是 String("anonymousUser")而不是 UserDetails,强转就 ClassCastException。 |
Principal 参数 | 标准 Servlet 接口,值同 getPrincipal(),同样有匿名类型坑。 |
论ThreadLocal 的三个后果,必须背下来
① 子线程拿不到:new Thread(...).start() 里调用 SecurityContextHolder.getContext().getAuthentication(),得到的是 null。因为 ThreadLocal 是线程私有的,子线程没有那段内存。要传就手动 set,或者用 DelegatingSecurityContextExecutor 包装线程池。
② 线程池会串号:这才是最要命的。Tomcat 的线程是复用的,如果某个 Filter 忘了清理上下文,上一个用户的身份就留在 ThreadLocal 里,下一个请求可能"继承"它。Spring Security 的 SecurityContextHolderFilter 会在 finally 里清理,所以你千万别自己往 ThreadLocal 塞用户对象而不清理。
③ 异步接口要特殊照顾:Spring MVC 的 DeferredResult / Callable 会切线程,Spring Security 专门有 WebAsyncManagerIntegrationFilter 来传递上下文。而在 Java 21 虚拟线程里,因为每个请求可能挂在不同的载体线程上,你更不能依赖"某个线程的 ThreadLocal"——JDK 的 ThreadLocal 在虚拟线程上仍按虚拟线程隔离,行为是对的,但跨虚拟线程一样传不过去。
记住一句:SecurityContextHolder 是"当前线程的当前用户",不是"当前请求的当前用户"。这两者在同步阻塞模型下恰好相等,一旦涉及多线程就不等。
有人发现子线程拿不到用户,就去改成 SecurityContextHolder.setStrategyName(MODE_INHERITABLE_THREAD_LOCAL)。这是个坑:它会让线程池里所有线程共享创建者那一瞬间的上下文快照,在自定义线程池场景下极易出现身份污染。官方明确建议:不要开 Inheritable,用 DelegatingSecurityContextExecutor 显式包装。少写两行代码,换来一个偶发的越权 bug,不值。
认证与授权:两个必须分清的阶段
面试里最能看出一个人是否真用过 Spring Security 的问题就是:"认证和授权什么区别?"标准答案不长,但要说清它们各自的对象和异常。
| 维度 | 认证 Authentication | 授权 Authorization |
|---|---|---|
| 回答的问题 | 你是谁 | 你能干什么 |
| 核心对象 | Authentication、UserDetails、AuthenticationManager | GrantedAuthority、AuthorizationManager |
| 失败异常 | AuthenticationException(BadCredentialsException 等) | AccessDeniedException |
| 失败响应 | 未登录 → 302 跳登录页;API → 401 Unauthorized | 已登录但无权限 → 403 Forbidden |
| 发生时机 | 链中第 8/9 个 Filter | 链中第 14 个 Filter 或方法调用时 |
| 典型配置 | formLogin() / oauth2ResourceServer() | authorizeHttpRequests() / @PreAuthorize |
论401 和 403 千万别混着用
① 401 Unauthorized 的真实含义是"未认证":HTTP 规范里它叫 Unauthorized,但语义是 "you need to authenticate"。响应要带 WWW-Authenticate 头告诉客户端怎么认证。用户没登录、token 过期、token 签名错,都该回 401。
② 403 Forbidden 的含义是"认证过了,但不该给你":用户已登录、身份有效,只是角色不够(比如普通用户去调管理员接口)。不带 WWW-Authenticate,因为再登录一次也没用。
③ 前端是根据状态码做不同动作的:401 → 跳登录页 / 刷新 token;403 → 弹"无权限"提示,别跳登录。你要是把 403 也返回 401,前端会陷入"刷新 token → 还是 401 → 再刷新"的死循环。
④ 前后端分离时最容易踩:默认 ExceptionTranslationFilter 对未认证请求是 302 重定向到登录页,前端拿到一个 HTML 登录页的响应体,JSON.parse 直接炸。必须配 authenticationEntryPoint 改成返回 401 JSON。这是前后端分离项目上线前必查项。
UserDetailsService 与 PasswordEncoder:账号从哪来,密码怎么存
AuthenticationManager 是认证的总指挥,但它自己不会查数据库。它把活派给 AuthenticationProvider,而 DaoAuthenticationProvider 又会做两件事:用 UserDetailsService 按用户名捞出账号,再用 PasswordEncoder 比对密码。这两个接口就是你日常要实现的全部。
从数据库读用户:实现 UserDetailsService
密码编码器这块,2026 年的选择其实很清晰了。先看对比:
| 算法 | 类型 | 强度 / 开销 | 什么时候用 |
|---|---|---|---|
| BCrypt | 自适应哈希 | 中等,strength 默认 10 | 默认首选。工业界用了二十年,成熟、库多、CPU 开销可控。 |
| Argon2 | 内存硬哈希 | 高,吃内存(可配) | 新项目、安全要求高、有足够内存时。能抵抗 GPU/ASIC 暴力破解,是密码哈希竞赛冠军。需要 bouncycastle 依赖。 |
| PBKDF2 | 迭代哈希 | 可调 | FIPS 合规场景(FIPS 不认 BCrypt/Argon2)。 |
| SCrypt | 内存硬哈希 | 高 | Argon2 不可用时的替代。 |
| NoOpPasswordEncoder | 明文 | 无 | 只允许在测试里用。生产用了就是不设防。 |
| MD5 / SHA-256 裸哈希 | 快速哈希 | 形同虚设 | 永远不要。无盐、速度快,彩虹表和 GPU 一秒几十亿次。 |
密码编码器:委托式配置,平滑升级老密码
① 两个 PasswordEncoder Bean:项目里既有自建的 BCryptPasswordEncoder,Spring Boot 又自动配了一个 DelegatingPasswordEncoder,注入时按类型找不到唯一的 Bean 直接启动失败。解法:只留一个 @Bean,或者给自定义的加 @Primary。
② 用户不存在时抛错太直接:UsernameNotFoundException 会被 DaoAuthenticationProvider 统一翻译成 BadCredentialsException,所以外部无法据此判断"用户是否存在"。这是好事——你自己写 Controller 时也别返回"用户不存在",那等于给攻击者当用户名探测器,统一回"用户名或密码错误"。
③ 忘了加盐就换算法:BCrypt 自带随机盐(同一密码两次 encode 结果不同,这是正常的,别拿字符串相等去判断),所以你不需要自己加盐。但如果你用了 MessageDigestPasswordEncoder 这类老式编码器,就得自己配 salt,一旦换 salt,全库密码作废。
④ 强度参数调到 14 以上:BCrypt strength 每加 1,耗时翻倍。设成 14 会让单次登录耗时超过 1 秒,登录接口瞬间变成 DoS 放大器——攻击者拿 100 个并发就能打满你的 CPU。测好你的机器,10~12 足够了。
方法级鉴权:@PreAuthorize 与 SpEL
URL 级鉴权管的是"哪个路径谁能进",但真实业务里权限往往和数据有关:同一个 /orders/{id},订单主人能看,别人不能看。这种判断只能写在方法上。
注意一个前提:方法级鉴权默认是不开的,必须在配置类上加 @EnableMethodSecurity(Spring Security 5.6 之前叫 @EnableGlobalMethodSecurity(prePostEnabled = true),这个名字在 6.x 已被移除)。
方法级鉴权:从最简单的角色到数据级所有权判断
| 注解 | 什么时候用 / 注意事项 |
|---|---|
@PreAuthorize | 调用前判断,最常用。方法与参数都能写进 SpEL。 |
@PostAuthorize | 调用后判断,能拿 returnObject。缺点:业务代码已经执行了,如果它写了库,异常回滚也来不及了。 |
@PreFilter | 过滤入参集合,只让有权限的元素进方法。 |
@PostFilter | 过滤返回值集合。逐元素反射调用 SpEL,大集合时性能很差,几千条以上就别用了,改在 SQL 里过滤。 |
hasRole('X') | 实际判断 ROLE_X。传参别自己带前缀,写成 hasRole('ROLE_X') 会变成 ROLE_ROLE_X。 |
hasAuthority('X') | 精确匹配,不补前缀。适合 order:read 这种细粒度权限码。 |
authentication | SpEL 内置变量,指向当前 Authentication,可取 .name、.principal。 |
@beanName.method(...) | 调用容器里自己的 Bean 做复杂判断(如上面例 3 的 @orderAuth)。 |
① 忘了 @EnableMethodSecurity:注解写了但不生效,还不报错,最隐蔽。
② 同类内部调用:this.internalMethod() 绕过代理。方法级鉴权跟 @Transactional 一样走 AOP 代理,自调用等于没写。
③ 注解贴在接口上而代理是 CGLIB 类代理:默认 proxyTargetClass=true,注解要写在实现类方法上才稳。
④ Controller 上写 @PreAuthorize 却返回 500 而不是 403:因为 AccessDeniedException 抛在 DispatcherServlet 里,ExceptionTranslationFilter 已经不在栈上了。要么加 @ExceptionHandler(AccessDeniedException.class) 自己处理,要么给 @RestControllerAdvice 加一个统一处理。这是方法级鉴权最容易漏的收尾工作。
CSRF 到底该不该关:一句话判断法
CSRF(跨站请求伪造)的攻击场景是:用户已登录 A 站,浏览器里存着 A 站的 Cookie;此时访问恶意 B 站,B 站悄悄向 A 站发一个 POST /transfer。浏览器会自动带上 A 站的 Cookie,A 站以为是用户本人操作,钱就转出去了。
关键在于攻击的成立条件,只有一条:认证凭据是浏览器自动携带的。抓住这一条,关不关 CSRF 立刻就能判断。
| 认证方式 | CSRF 该开吗 | 原因 |
|---|---|---|
| Session / Cookie(含 JSESSIONID) | 必须开 | 浏览器自动带 Cookie,攻击者的表单提交天然生效。必须校验 token。 |
JWT 放在 Authorization 头 | 可以关 | 头部需要 JS 显式设置,跨站表单无法伪造。但前提是你真的没把 token 存 Cookie。 |
| JWT 存在 Cookie 里 | 必须开 | 虽然名字叫 JWT,但存储载体是 Cookie,浏览器照样自动带。这种情况下 CSRF 风险与 Session 完全一致。 |
| 纯静态资源 / 只读公开接口 | 可以关 | 无状态、无凭据,没什么可伪造的。 |
| 网关统一处理鉴权 | 看情况 | 如果浏览器到网关还是带 Cookie 的,网关要开;网关后面服务间调用是内网 token,可以关。 |
论不想关 CSRF?SPA 的正确配置方式
① 官方推荐 CookieCsrfTokenRepository.withHttpOnlyFalse():把 CSRF token 写进一个可被 JS 读取的 Cookie(默认名 XSRF-TOKEN),前端读到后放进请求头,Spring Security 6.1 默认接受的头名是 X-XSRF-TOKEN。
② 关键点是"双重提交":攻击者的跨站请求能自动带 Cookie,但读不到 Cookie 内容(同源策略),所以伪造不出请求头。Cookie 里有一份、请求头里有一份,两份对上才放行——这就是它安全的原因。
③ 6.x 有个容易卡住人的变化:CSRF token 从 6.0 起改成延迟加载。如果你自定义了 CsrfTokenRequestAttributeHandler 但没处理延迟,前端第一次 GET 拿不到 XSRF-TOKEN Cookie,于是后续 POST 全部 403。6.1 里的解法是改用 CsrfTokenRequestAttributeHandler 并把 setCsrfRequestAttributeName(null),强制提前生成 token;或者干脆给 /csrf 加一个只读接口让前端主动去拿一次。
④ 记住这条经验:你的 403 报错信息里出现 "Invalid CSRF token" 或者 "Expected CSRF token not found",八成不是权限问题,是 token 没传对。先去看浏览器有没有 XSRF-TOKEN 这个 Cookie,比翻代码快得多。
SecurityFilterChain 函数式配置:一条链还是一个 Bean
Spring Security 6 的配置方式只有一种:声明一个 SecurityFilterChain 类型的 @Bean,用 Lambda DSL 描述规则。老的 WebSecurityConfigurerAdapter(继承 + configure(HttpSecurity))在 6.0 里已经被彻底删除——注意是删除,不是废弃,抄老代码会直接编译不过。
标准的 SecurityFilterChain:纯 API,无 Session,返回 JSON 错误
① requestMatchers 顺序就是优先级:它从上往下匹配,命中就停。把 anyRequest() 写在前面,后面所有规则全废。
② antMatchers / mvcMatchers 已删除:6.x 统一成 requestMatchers。看到教程里写 antMatchers,那教程至少五年前的。
③ hasRole("ADMIN") vs hasAuthority("ADMIN") 差一个前缀:前者查 ROLE_ADMIN,后者查 ADMIN。数据库里存的角色名如果没带前缀,用 hasRole 就永远 403。统一约定:库里存 ADMIN,UserDetailsService 里拼 ROLE_,配置里用 hasRole。
④ 多条链时忘了 securityMatcher:不写就等于接管全部请求,后面的链永远不执行,你会看到"配置明明加了却不生效"。
⑤ 自定义 Filter 忘了插在正确位置:http.addFilterBefore(myFilter, UsernamePasswordAuthenticationFilter.class)。如果插在 AuthorizationFilter 之后,你的认证逻辑白写——因为鉴权已经判完了。
① 它是一条 Servlet 过滤器链:安全放在 Filter 层而不是 Interceptor 层,因为要拦住所有请求、且要最早拦。
② 认证在前、鉴权在后:第 8/9 个 Filter 认人,第 14 个 Filter 判权。401 是"没认出来",403 是"认出来了但不给"。
③ SecurityContextHolder 是 ThreadLocal:子线程和线程池都不安全,异步场景必须显式传递。
④ 账号靠 UserDetailsService,密码靠 PasswordEncoder:BCrypt 是默认答案,Argon2 是升级答案,明文哈希是错误答案。
⑤ CSRF 只看一条:认证凭据是不是浏览器自动带的。Cookie 就开,纯请求头 token 才能关。
⑥ 配置只有一个入口:@Bean SecurityFilterChain,Lambda DSL,WebSecurityConfigurerAdapter 已经删了。
1.(概念题)为什么 Spring Security 要基于 Servlet Filter 实现,而不是 Spring MVC 的 HandlerInterceptor?
查看答案
答案:① Filter 在 Servlet 容器层,DispatcherServlet 初始化前就生效,能覆盖静态资源、错误页、未映射路径;Interceptor 只管得到 DispatcherServlet 分发的请求。② Filter 能包装并改写请求/响应对象。③ 安全必须最早拦截,避免无谓的解析与查询开销。解析:面试官想听的是"能拦得更早、拦得更全"。
2.(排错题)某个接口错了会返回 401,但前端日志显示浏览器收到了一个 302 跳到 /login 的 HTML 响应。什么问题?
查看答案
答案:ExceptionTranslationFilter 默认的 AuthenticationEntryPoint 是 LoginUrlAuthenticationEntryPoint,未认证时走 302 重定向到登录页。前后端分离要配 exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))),或者自定义返回 JSON 的输出流。解析:这是前后端分离项目上线前的必查项,否则前端 JSON.parse 一定炸。
3.(选择题)数据库里角色字段存 ADMIN,UserDetailsService 里用 new SimpleGrantedAuthority("ADMIN"),配置写 hasRole("ADMIN")。结果如何?A. 正常放行 B. 始终 403 C. 启动报错
查看答案
答案:B,始终 403。hasRole("ADMIN") 会去找 ROLE_ADMIN,而权限集合里只有 ADMIN,匹配不上。解析:改成 hasAuthority("ADMIN"),或者构造权限时拼上 ROLE_ 前缀。这是最高频的 403 原因。
4.(判断题)前后端分离、JWT 放在 Authorization 头里,所以 CSRF 可以放心关。这个判断需要补什么前提?
查看答案
答案:前提是 token 真的只存在 JS 内存或 localStorage,绝不能放在 Cookie 里。如果为了"自动携带方便"把 JWT 写进 Cookie,浏览器就会自动带上,CSRF 风险与 Session 方案一模一样,必须重新开启 CSRF 防护。解析:决定 CSRF 开关的是"凭据载体"而不是"凭据格式"。
5.(场景题)接口先用 @PreAuthorize 拦住了,无权限时返回 500 而不是 403。为什么?怎么修?
查看答案
答案:方法级鉴权发生在 Controller 方法调用时,此时请求早已穿过 ExceptionTranslationFilter,抛出的 AccessDeniedException 没人翻译,被全局异常处理器当成未知异常返回 500。修法:在 @RestControllerAdvice 里加 @ExceptionHandler(AccessDeniedException.class) 返回 403。注意不要在 Handler 里把 AccessDeniedException 直接吞掉返回 200,那等于自废武功。解析:URL 级鉴权由 Filter 兜底所以是 403,方法级鉴权要自己收尾。