Spring Security 核心原理与实战:认证授权、JWT、OAuth2 与方法级安全

Spring Security 核心原理与实战:认证授权、JWT、OAuth2 与方法级安全
神经蛙本文聚焦服务端应用层安全框架。CSRF 的客户端成因与 XSS 防御手段已在《前端安全:XSS 与 CSRF 攻防实战》铺开,这里只在”表单登录要不要开 CsrfFilter”处一笔带过,不再复述;网关层的统一鉴权过滤器在《微服务架构与 Spring Cloud Alibaba 实战》讲过,本文只点二者边界。读前请知悉。
一、没有安全框架之前
早期 Web 项目里,登录和权限往往是这样的:
1 | // 手写滤镜式鉴权:每个接口自己判 |
这种写法有三个结构性问题,不是加几个 if 能解决的:
- 横切逻辑散落各处。鉴权代码和业务流程耦合,漏写一处就是越权漏洞。
- 认证与授权混在一起。谁是”登录成功”、谁有”访问权限”,没有统一抽象,难以复用和测试。
- 缺乏标准扩展点。换一种登录方式(短信、OAuth2、CAS)、换一种存储(Redis 共享 Session),几乎要重写一遍。
Spring Security 的本质,就是把”认证(你是谁)+ 授权(你能做什么)“这两件事抽象成一套可插拔的过滤器链与组件模型,让你用声明式的方式配置安全策略,而不是在每个接口里手写
if。
二、Spring Security 在保护什么:认证 vs 授权
这两个词经常被混用,但含义完全不同:
| 概念 | 英文 | 回答的问题 | 典型失败状态码 |
|---|---|---|---|
| 认证 | Authentication | 你是谁?(身份可信) | 401 Unauthorized |
| 授权 | Authorization | 你被允许做什么?(权限够不够) | 403 Forbidden |
一个直观类比:进公司大楼刷工牌是认证(确认你是员工);工牌里写”只能进 3 楼”是授权(限制你能去哪)。Spring Security 把这两步拆成两条独立的处理链路,后面会看到它们分别对应 AuthenticationManager 和 AuthorizationManager。
三、核心架构:一条过滤器链 + 三套核心抽象
Spring Security 运行时就是一条插入到 Servlet 容器、位于业务 Controller 之前的过滤器链(SecurityFilterChain)。请求进来后,依次穿过链上的过滤器;任何一环判定”未认证”或”无权限”,就短路返回 401/403,根本到不了你的 @RestController。
整条链由三套核心抽象驱动:
| 抽象 | 职责 | 默认实现 |
|---|---|---|
AuthenticationManager |
认证总入口,接收 Authentication 并返回已认证的 Authentication |
ProviderManager |
AuthenticationProvider |
具体的认证逻辑(账号密码、短信、OAuth2……),可多个并存 | DaoAuthenticationProvider |
AuthorizationManager |
判定某个 Authentication 是否有访问某资源的权限(Spring Security 6 起取代旧的 AccessDecisionManager) |
RequestMatcherDelegatingAuthorizationManager |
1 | HTTP 请求 |
四、认证全流程拆解
把”用户名 + 密码登录”这件事拆开,链路是这样走的:
1 | 用户提交表单 |
关键组件各自的角色:
Authentication:一个”凭证信封”,包含principal(身份,如 UserDetails)、credentials(密码,认证后清空)、authorities(权限集合)。isAuthenticated()标志它是否已通过认证。UserDetailsService:唯一的”按用户名查用户”接口,你只要实现loadUserByUsername,从 DB / Redis / LDAP 返回UserDetails即可。UserDetails:框架视角的用户模型,提供用户名、密码、权限列表、是否过期/锁定。SecurityContextHolder:用ThreadLocal把当前请求的认证信息挂在线程上,Controller / Service 里随时能取到;Spring Security 6 引入SecurityContextHolderStrategy让你换成 InheritableThreadLocal 或其他策略(应对异步线程透传)。
五、核心组件逐个看
| 组件 | 作用 | 你需要做什么 |
|---|---|---|
SecurityFilterChain |
声明整条链与规则 | 用 @Bean 配置 authorizeHttpRequests、formLogin、csrf 等 |
UserDetailsService |
按用户名加载用户 | 实现它,从你的用户表查询 |
PasswordEncoder |
密码加密与比对 | 选 BCryptPasswordEncoder 等,注入为 Bean |
AuthenticationProvider |
自定义认证逻辑 | 默认 DaoAuthenticationProvider 够用;第三方登录才需自定义 |
GrantedAuthority |
一个权限/角色单元 | 返回 ROLE_ADMIN 形式的权限 |
SecurityContextHolder |
存取当前登录态 | 一般不用手动操作,框架自动管理 |
UserDetailsService 返回的用户密码必须是密文(由 PasswordEncoder 处理)。如果你存的是明文,又配了 BCryptPasswordEncoder,登录必然失败——这是新手最高频的坑。
六、过滤器链顺序与职责
链上过滤器有固定顺序,理解顺序才能排查”为什么我的规则不生效”。常见几个(节选):
| 顺序(靠前先执行) | 过滤器 | 职责 |
|---|---|---|
| 1 | SecurityContextPersistenceFilter |
从 Session/Token 恢复 SecurityContext |
| 2 | CsrfFilter |
防 CSRF(表单/状态化场景默认开) |
| 3 | UsernamePasswordAuthenticationFilter |
拦截表单登录 /login 提交 |
| 4 | AnonymousAuthenticationFilter |
未登录时塞一个匿名身份,避免 NPE |
| 5 | ExceptionTranslationFilter |
捕获 AuthenticationEntryPoint(401)和 AccessDeniedHandler(403) |
| 6 | AuthorizationFilter |
最终判定是否有访问权限(6.x 新名,旧版叫 FilterSecurityInterceptor) |
记忆口诀:先恢复上下文 → 再挡 CSRF → 再处理登录 → 匿名兜底 → 异常翻译 → 最后授权判定。任何一环在授权之前抛”未认证”,都会被 ExceptionTranslationFilter 翻译成 401 弹登录。
七、配置入门:从 formLogin 到前后端分离
最基础的 SecurityFilterChain Bean:
1 |
|
前后端分离(前端 Vue/React、后端只出 JSON)时,表单登录那套”重定向到页面”就不合适了,典型做法是自定义登录接口 + 返回 JWT:
1 |
|
无状态 API 通常
csrf().disable()并改用 JWT/Token 鉴权——因为 CSRF 针对的是”浏览器自动带 Cookie 的状态化请求”,而 JSON Token 方案天然不依赖 Cookie 自动携带,风险模型不同(CSRF 的客户端成因与防御细节请看前端安全篇,这里不展开)。
八、密码安全:PasswordEncoder
永远不要明文存密码。Spring Security 提供多种 PasswordEncoder:
| 实现 | 特点 | 适用 |
|---|---|---|
BCryptPasswordEncoder |
自适应成本因子,内置随机盐,每次哈希不同 | 默认推荐 |
Argon2PasswordEncoder |
抗 GPU/ASIC 暴力破解,内存硬 | 安全要求极高 |
Pbkdf2PasswordEncoder |
FIPS 合规,CPU 密集型 | 政企合规场景 |
DelegatingPasswordEncoder |
兼容多种格式前缀 {bcrypt}... |
老系统平滑迁移 |
1 | // BCrypt:$2a$10$... 前缀自带盐与成本因子,相同明文每次结果都不同 |
BCrypt 的”慢”是故意的——拖慢暴力破解速度。成本因子(默认 10)越高越安全也越慢,可在性能与安全性之间权衡。千万不要自己用 MD5/SHA1 裸哈希,那在彩虹表面前几秒就被破。
九、鉴权模型:RBAC 与表达式
主流权限模型是 RBAC(用户 → 角色 → 权限)。Spring Security 把”角色”当成一种带 ROLE_ 前缀的 GrantedAuthority。
1 | // 基于请求路径的规则(配置期) |
更灵活的是方法级安全——直接把规则写在业务方法上:
1 |
|
| 注解 | 时机 | 用法 |
|---|---|---|
@PreAuthorize |
方法执行前 | 最常用,可访问入参 |
@PostAuthorize |
方法执行后 | 可基于返回值判定 |
@Secured |
方法前 | 仅支持角色,不支持 SpEL |
@RolesAllowed |
方法前 | JSR-250 标准注解 |
十、三种主流方案对比
| 维度 | Session-Cookie | JWT | OAuth2 |
|---|---|---|---|
| 状态 | 服务端有状态(存 Session) | 无状态(信息自包含) | 无状态(令牌授权) |
| 存储位置 | 服务端 Session + 客户端 Cookie | 客户端(localStorage/Cookie) | 客户端令牌 |
| 横向扩展 | 需 Session 共享(Redis) | 天然易扩展 | 天然易扩展 |
| 注销/吊销 | 删 Session 即时生效 | 需黑名单/短过期+刷新 | 撤销授权/短令牌 |
| 典型场景 | 传统单体、后台管理 | 前后端分离 SPA | 第三方授权、SSO |
经验法则:单体后台用 Session-Cookie 最省心;前后端分离用 JWT 减少 Session 共享负担;需要”让用户用微信/GitHub 登录你的应用”或做统一 SSO,再上 OAuth2。 别一上来就 JWT,Session 方案在单体里反而更简单可靠。
十一、JWT 实战
JWT 由三段组成 header.payload.signature,中间用 . 连接,payload 是 Base64Url 的 JSON(可被任何人解码,所以绝对不能放密码等敏感信息)。
1 |
|
无状态场景下,用一个过滤器把 Token 还原成 Authentication 并塞进上下文:
1 | public class JwtAuthFilter extends OncePerRequestFilter { |
JWT 的”吊销”是公认难点:令牌自包含、服务端无状态,无法像删 Session 那样即时作废。常见解法:① 短过期(如 15 分钟)+ 刷新令牌;② 维护一个 Redis 黑名单直到原令牌过期;③ 改签密钥强制全体下线(代价大)。别把 JWT 当 Session 用——它适合”一次性授权凭证”,不适合需要精细实时管控的场景。
十二、OAuth2 / OIDC 实战
OAuth2 解决的是”委托授权“:让用户授权你的应用去访问他在第三方(如 GitHub)的资源,而不把他的账号密码告诉你。四种授权模式:
| 模式 | 适用 | 现状 |
|---|---|---|
| 授权码(Authorization Code) | 有后端的常规应用 | 最安全,标配 + PKCE |
| 隐式(Implicit) | 纯前端 SPA | 已废弃 |
| 密码(Password) | 同一公司的一二方应用 | 不推荐,泄露密码 |
| 客户端凭证(Client Credentials) | 服务间调用 | 机器对机器 |
现代标准做法是 授权码 + PKCE(连原生 App / SPA 都不再用隐式模式)。作为资源服务器(保护你自己的 API)配置非常轻量:
1 |
|
scope 和 role 不是一回事:OAuth2 的 scope 是”能访问哪些接口范围“(如 read:user),而 Spring Security 的 role 是”用户身份角色“。资源服务器里用 hasAuthority("SCOPE_read:user") 判 scope,别和 hasRole 混淆。
十三、与 Spring Cloud Gateway 的边界
微服务架构里容易混淆:网关(Gateway)做的是”统一入口鉴权”——在流量进集群前做一次 JWT 校验、路由、限流;Spring Security 做的是”应用内细粒度安全”——具体到某个服务的方法级 @PreAuthorize、数据级权限。二者互补而非替代:
1 | 浏览器 → Gateway(验 JWT、路由) → 服务 A(Spring Security 方法级鉴权) |
网关拦掉非法令牌,应用再基于已经可信的身份做精细控制。具体网关过滤器写法见《微服务架构与 Spring Cloud Alibaba 实战》,本文不重复。
十四、10 条生产避坑清单
- 密码必须密文存储,用
BCryptPasswordEncoder而非 MD5/SHA 裸哈希。 hasRole("ADMIN")会自动补ROLE_前缀,而hasAuthority("ADMIN")不会——混用导致 403。- 无状态 API 记得
csrf().disable(),否则前端 POST 会被 CsrfFilter 拦;但带 Session 的站点别乱关。 - JWT 别存敏感信息到 payload(可被解码),签名密钥至少 32 字节且不能硬编码进代码库。
- JWT 设置合理短过期 + 刷新机制,不要在客户端无限期持有。
- 方法级安全要让
@EnableMethodSecurity生效,且确保类被 Spring 代理(内部this.method()调用不会走 AOP,注解失效)。 - 异步线程/线程池会丢
SecurityContext,跨线程用SecurityContextHolderStrategy或手动传递。 UserDetailsService查库要做缓存,高并发下每个请求都查用户表会打爆 DB。- OAuth2 用授权码 + PKCE,别再用已废弃的隐式模式;资源服务器用 JWK 公钥验签而非共享密钥。
- 别把所有接口
permitAll()做调试后遗留上线,上线前用自动化脚本回归”未登录是否真 401”。
















