Skip to content

Spring 全栈硬核教程 · 05:Spring Security(过滤器链 / 认证 / 授权 / OAuth2 / JWT) ​

Spring Security 是整个生态里劝退率最高的模块。原因不是它难,是大多数教程一上来就甩配置代码,让你以为它是一堆魔法开关。这一篇反过来——先讲清楚它的心智模型,你会发现所有配置都是这个模型的自然推论。

⚠️ Spring Security 7 有大量破坏性变更,本篇所有代码按 7.x 写,并在第 7 节给出完整迁移清单。


1. 心智模型:一条过滤器链,两个核心动作 ​

Spring Security 的全部本质就两句话:

① 它是一串 Servlet Filter(过滤器链),排在你的业务代码之前。② 它只干两件事:认证(你是谁)和授权(你能干什么)。

HTTP 请求
   ↓
DelegatingFilterProxy (Spring Security 在 Servlet 容器里的唯一入口)
   ↓
FilterChainProxy → 选择匹配的 SecurityFilterChain
   ↓
┌──────── 过滤器链(顺序极其重要)────────┐
│ 1.  DisableEncodeUrlFilter                  │
│ 2.  ForceEagerSessionCreationFilter         │
│ 3.  ChannelProcessingFilter    (HTTPS 强制) │
│ 4.  WebAsyncManagerIntegrationFilter        │
│ 5.  SecurityContextHolderFilter ⭐ 加载安全上下文 │
│ 6.  HeaderWriterFilter         (安全响应头) │
│ 7.  CorsFilter                 ⭐ 跨域       │
│ 8.  CsrfFilter                              │
│ 9.  LogoutFilter                            │
│ 10. UsernamePasswordAuthenticationFilter ⭐ 表单登录 │
│     ...(你的 JwtAuthFilter 通常插在这附近)│
│ 11. BearerTokenAuthenticationFilter  (OAuth2资源服务器) │
│ 12. RequestCacheAwareFilter                 │
│ 13. SecurityContextHolderAwareRequestFilter │
│ 14. AnonymousAuthenticationFilter  (兜底匿名身份) │
│ 15. ExceptionTranslationFilter ⭐ 认证/授权异常转换 │
│ 16. AuthorizationFilter        ⭐ 最终授权决策 │
└─────────────────────────────────────────────┘
   ↓
DispatcherServlet → 你的 Controller

这张图能解释的高频问题 ​

现象根因
自定义 Filter 里拿不到登录用户Filter 加在了 SecurityContextHolderFilter/认证过滤器之前,此时上下文还没填充
全局异常处理器捕获不到 401/403这两个异常由 ExceptionTranslationFilter 在 MVC 之外处理,@RestControllerAdvice 根本够不着
跨域配置不生效CorsFilter 在链上靠前,如果没在 Security 里开启 http.cors(),预检请求 OPTIONS 会被后面的授权过滤器直接拒掉
登录成功但下个请求又要登录无状态模式下没带 Token,或 SecurityContextRepository 配置不对

记住这条规律:凡是"Security 相关的诡异问题",99% 是过滤器顺序问题。定位方法:开启 logging.level.org.springframework.security: DEBUG,启动日志会打印完整的过滤器链顺序。


2. 认证:核心对象模型 ​

Authentication (认证信息载体,贯穿始终)
  ├─ principal    : 你是谁(通常是 UserDetails 或自定义对象)
  ├─ credentials  : 凭证(密码,认证后一般会被清空)
  ├─ authorities  : 你有哪些权限
  └─ authenticated: 认证通过了吗

AuthenticationManager (认证入口)
  └─ ProviderManager (默认实现,持有多个 Provider)
       ├─ DaoAuthenticationProvider  → 调 UserDetailsService 查用户 + PasswordEncoder 校验密码
       ├─ JwtAuthenticationProvider
       └─ 你的自定义 Provider(短信验证码登录、扫码登录……)

SecurityContextHolder (ThreadLocal 存储当前请求的 Authentication)
  ⚠️ 基于 ThreadLocal → 异步线程/线程池里取不到!

自定义认证:短信验证码登录 ​

java
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {

    private final UserDetailsService userDetailsService;
    private final SmsCodeService smsCodeService;

    @Override
    public Authentication authenticate(Authentication auth) {
        String phone = (String) auth.getPrincipal();
        String code  = (String) auth.getCredentials();

        if (!smsCodeService.verify(phone, code)) {
            throw new BadCredentialsException("验证码错误或已过期");
        }
        UserDetails user = userDetailsService.loadUserByUsername(phone);
        // 认证成功后返回一个 authenticated=true 的新对象(不要修改原对象)
        return new SmsCodeAuthenticationToken(user, null, user.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

SecurityContextHolder 的 ThreadLocal 陷阱:@Async 方法、CompletableFuture、自己创建的线程里,SecurityContextHolder.getContext() 是空的。解法是设置 MODE_INHERITABLETHREADLOCAL,或用 DelegatingSecurityContextExecutor 包装线程池。这是"异步任务里突然拿不到当前用户"的标准答案。


3. 授权:三个层次 ​

3.1 请求级授权(URL 维度) ​

java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http
        .csrf(AbstractHttpConfigurer::disable)                    // 前后端分离 + Token 认证时禁用
        .cors(Customizer.withDefaults())                          // ⭐ 必须显式开启,否则跨域会挂
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(auth -> auth                       // ⚠️ Security 7 只有这个方法了
            .requestMatchers("/api/auth/**", "/actuator/health").permitAll()
            .requestMatchers("/api/admin/**").hasRole("ADMIN")
            .requestMatchers(HttpMethod.GET, "/api/books/**").hasAuthority("book:read")
            .anyRequest().authenticated()                         // ⭐ 兜底规则,必须放最后
        )
        .exceptionHandling(e -> e
            .authenticationEntryPoint(restAuthEntryPoint)         // 401:未认证
            .accessDeniedHandler(restAccessDeniedHandler)         // 403:认证了但无权限
        )
        .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
        .build();
}

规则顺序即优先级:requestMatchers 按声明顺序匹配,第一个命中的生效。把 anyRequest() 写在前面会让后面所有规则失效——这是极高频的配置错误。

3.2 方法级授权 ​

java
@EnableMethodSecurity   // Security 6+ 起用这个(原 @EnableGlobalMethodSecurity 已移除)
public class MethodSecurityConfig {}

@Service
public class OrderService {

    @PreAuthorize("hasRole('ADMIN')")
    public void deleteAll() {}

    // ⭐ SpEL 能引用方法参数和当前登录用户,实现"只能操作自己的数据"
    @PreAuthorize("#userId == authentication.principal.id or hasRole('ADMIN')")
    public UserDTO getUser(Long userId) {}

    // 对返回值做过滤:只保留当前用户有权看的订单
    @PostFilter("filterObject.ownerId == authentication.principal.id")
    public List<Order> listOrders() {}
}

3.3 数据级授权(最难的一层) ​

URL 和方法级授权解决"能不能调这个接口",但解决不了"能看哪些数据"。这一层通常需要业务层自己实现——常见方案是在 MyBatis 拦截器里自动给 SQL 拼上数据权限条件(WHERE dept_id IN (...)),或者用 Spring Security ACL(重,国内少用)。

实战建议:数据权限是业务逻辑,别硬塞进 Security 框架。用 MyBatis 插件 + 自定义注解声明"这个方法需要按部门过滤",比在 Security 里绕要清晰得多。


4. JWT:无状态认证的取舍 ​

4.1 实现 ​

java
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtService jwtService;

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {
        String header = req.getHeader(HttpHeaders.AUTHORIZATION);
        if (header != null && header.startsWith("Bearer ")) {
            try {
                Claims claims = jwtService.parse(header.substring(7));
                LoginUser user = LoginUser.from(claims);
                var auth = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
                auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(req));
                SecurityContextHolder.getContext().setAuthentication(auth);
            } catch (JwtException e) {
                // ⚠️ 不要在这里直接返回 401,交给 ExceptionTranslationFilter 统一处理
                SecurityContextHolder.clearContext();
            }
        }
        chain.doFilter(req, res);
    }
}

4.2 JWT 的三个真实代价 ​

问题说明缓解方案
无法主动失效签发后到期前一直有效,改密码/踢下线都拿它没办法Redis 黑名单(但这样又有状态了,失去了 JWT 的核心卖点)
无法即时更新权限权限变更后,旧 Token 里的权限信息还是老的短 Token(15 分钟)+ Refresh Token 机制
体积大每个请求都携带,放太多信息会显著增加带宽Token 里只放 userId,其余信息服务端查缓存

务实的结论:很多团队为了"无状态"上 JWT,结果为了支持踢人下线又加了 Redis 黑名单——这时候你已经是有状态的了,不如老老实实用 Redis + 不透明 Token(opaque token),实现简单、可控性强、能即时失效。只有真正的跨服务、跨域、无共享存储场景,JWT 的无状态才有不可替代的价值。 这是一个被"技术时髦度"严重误导的选型领域。


5. OAuth2 与 OIDC ​

5.1 先搞清楚它到底在解决什么问题 ​

OAuth2 解决的不是"登录",而是"授权委托":让第三方应用在不拿到你密码的前提下,访问你在某个平台上的部分资源。"用微信登录某网站"只是它的一个衍生用法。

OIDC(OpenID Connect) 才是专门做身份认证的——它在 OAuth2 之上加了一个标准化的 id_token,明确告诉你"这个用户是谁"。

5.2 四种授权模式的现状 ​

模式状态场景
授权码 + PKCE✅ 当前唯一推荐Web 应用、移动 App、SPA 全部用这个
客户端凭证✅ 有效服务间调用(没有用户参与)
隐式模式(Implicit)❌ 已废弃安全性差,Token 暴露在 URL 里
密码模式(Password)❌ 已废弃需要客户端接触用户密码,违背 OAuth2 初衷

PKCE 原本是给移动端设计的防授权码拦截机制,现在已被推荐用于所有客户端类型,包括传统 Web 后端。Spring Security 6.5 起支持为机密客户端启用 PKCE(clientSettings.requireProofKey=true)。

5.3 Spring 的三个角色 ​

角色依赖你在做什么
客户端 Clientspring-boot-starter-oauth2-client接入微信/GitHub 登录
资源服务器 Resource Serverspring-boot-starter-oauth2-resource-server你的 API 校验别人发的 Token
授权服务器⭐ Spring Security 7 起已并入 Spring Security 本体自建 SSO / 统一认证中心

Security 7 的重要变化:Spring Authorization Server 从独立项目合并进了 Spring Security,Kerberos 扩展也一并并入。这意味着自建授权服务器的门槛降低了,不用再单独管理一个项目的版本兼容。


6. Spring Security vs Sa-Token:务实对比 ​

维度Spring SecuritySa-Token
上手成本高(过滤器链、Provider、UserDetails 一堆概念)低(StpUtil.login(id) 一行登录)
功能完备度认证/授权/OAuth2/SAML/CAS 全覆盖,企业合规级登录、权限、会话、踢人、二级认证、临时 Token,覆盖多数业务需求
踢人下线 / 强制注销要自己实现(Session 注册表或 Redis 黑名单)内置,一个 API 搞定
生态与长期支持Spring 官方,天花板级国产社区活跃,中文文档极佳,但海外/大厂验证少
定制复杂需求强但繁琐简单需求极快,非常复杂的场景不如 Security 灵活
适合大型企业、SSO、合规要求高、需要标准 OAuth2中小项目、教学、追求开发效率

教学场景的建议路径:先用 Sa-Token 让学生在两小时内跑通一个完整的登录鉴权系统,建立"权限系统解决什么问题"的直觉;再用 Spring Security 讲清楚企业级实现的底层机制(过滤器链、认证流程、OAuth2)。直接上 Spring Security 的结果通常是:学生抄完配置,然后什么都没懂。


7. ⭐ Spring Security 7 迁移清单(升级 Boot 4 必读) ​

变更老写法新写法
and() 被移除.csrf().disable().and().cors()全部改 Lambda DSL:.csrf(c -> c.disable()).cors(...)
authorizeRequests 被移除.authorizeRequests().authorizeHttpRequests()
Access API 迁出AccessDecisionManager/AccessDecisionVoter移到了独立模块 spring-security-access;新代码应改用 AuthorizationManager
Authorization Server 并入独立 spring-security-oauth2-authorization-server 项目已成为 Spring Security 的一部分
Kerberos 扩展并入独立扩展项目已并入本体
新增能力——AllAuthoritiesAuthorizationManager(要求同时具备多个权限)、AuthorizationManagerFactory、Password4j 密码编码器、NimbusJwtDecoder 自定义 JwkSource、Servlet/WebFlux 模块化配置

迁移策略建议:

  1. 先在 Spring Security 6.5(Boot 3.5)下把所有弃用 API 清干净——6.5 会给出弃用警告,这是最舒服的过渡期
  2. 把所有 and() 链式写法改成 Lambda DSL
  3. 再升级到 7.x

别跳过第 1 步直接升 7。Security 的配置一旦编译不过,报错信息往往指向一堆内部类,排查成本远高于在 6.5 下按警告逐个修。


8. 密码存储:一个不能出错的细节 ​

java
@Bean
public PasswordEncoder passwordEncoder() {
    // ⭐ 用 DelegatingPasswordEncoder,密文自带 {bcrypt} 前缀
    // 好处:未来换算法时,老密码仍能校验通过,可以平滑迁移
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
算法评价
MD5 / SHA-1❌ 绝对不能用,彩虹表秒破
BCrypt✅ 当前主流,自带盐,强度可调
Argon2✅ 密码哈希竞赛冠军,抗 GPU 破解能力最强
SCrypt✅ 内存困难型,也是不错的选择

Spring Security 7 新增了基于 Password4j 的编码器实现,为这些主流算法提供了替代实现选择。

另一个 7.x 的实用能力:内置的 CompromisedPasswordChecker 能对接 Have I Been Pwned 数据库,在用户注册/改密时检查密码是否已在已知泄露库中。这是一个成本极低、安全收益很高的功能,值得默认开启。


9. 本篇自测 ​

  1. 画出 Spring Security 过滤器链的核心顺序,说明自定义 Filter 该插在哪里。
  2. 为什么 @RestControllerAdvice 捕获不到 401/403?该怎么统一处理?
  3. SecurityContextHolder 在异步线程里为什么取不到用户?怎么解决?
  4. requestMatchers 的顺序为什么重要?anyRequest() 该放哪?
  5. JWT 的三个真实代价是什么?什么情况下不该用 JWT?
  6. OAuth2 的四种授权模式现在还剩哪几种可用?为什么隐式模式被废弃?
  7. Spring Security 7 有哪些破坏性变更?推荐的迁移路径是什么?
  8. 为什么推荐 DelegatingPasswordEncoder 而不是直接用 BCryptPasswordEncoder?

下一篇(06 · Spring Cloud):服务从 1 个变成 100 个之后,注册发现、配置中心、网关、熔断限流、分布式事务、链路追踪……每一样都是新的复杂度。这一篇会给你一个反直觉的核心观点:大部分团队不需要微服务,而上了微服务的团队里,大部分不需要分布式事务。

基于 Vite 强力驱动 | 纯静态轻量托管