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 → 异步线程/线程池里取不到!自定义认证:短信验证码登录
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 维度)
@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 方法级授权
@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 实现
@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 的三个角色
| 角色 | 依赖 | 你在做什么 |
|---|---|---|
| 客户端 Client | spring-boot-starter-oauth2-client | 接入微信/GitHub 登录 |
| 资源服务器 Resource Server | spring-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 Security | Sa-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 模块化配置 |
迁移策略建议:
- 先在 Spring Security 6.5(Boot 3.5)下把所有弃用 API 清干净——6.5 会给出弃用警告,这是最舒服的过渡期
- 把所有
and()链式写法改成 Lambda DSL - 再升级到 7.x
别跳过第 1 步直接升 7。Security 的配置一旦编译不过,报错信息往往指向一堆内部类,排查成本远高于在 6.5 下按警告逐个修。
8. 密码存储:一个不能出错的细节
@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. 本篇自测
- 画出 Spring Security 过滤器链的核心顺序,说明自定义 Filter 该插在哪里。
- 为什么
@RestControllerAdvice捕获不到 401/403?该怎么统一处理? SecurityContextHolder在异步线程里为什么取不到用户?怎么解决?requestMatchers的顺序为什么重要?anyRequest()该放哪?- JWT 的三个真实代价是什么?什么情况下不该用 JWT?
- OAuth2 的四种授权模式现在还剩哪几种可用?为什么隐式模式被废弃?
- Spring Security 7 有哪些破坏性变更?推荐的迁移路径是什么?
- 为什么推荐
DelegatingPasswordEncoder而不是直接用BCryptPasswordEncoder?
下一篇(06 · Spring Cloud):服务从 1 个变成 100 个之后,注册发现、配置中心、网关、熔断限流、分布式事务、链路追踪……每一样都是新的复杂度。这一篇会给你一个反直觉的核心观点:大部分团队不需要微服务,而上了微服务的团队里,大部分不需要分布式事务。