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

本文聚焦服务端应用层安全框架。CSRF 的客户端成因与 XSS 防御手段已在《前端安全:XSS 与 CSRF 攻防实战》铺开,这里只在”表单登录要不要开 CsrfFilter”处一笔带过,不再复述;网关层的统一鉴权过滤器在《微服务架构与 Spring Cloud Alibaba 实战》讲过,本文只点二者边界。读前请知悉。

一、没有安全框架之前

早期 Web 项目里,登录和权限往往是这样的:

1
2
3
4
5
6
7
8
9
// 手写滤镜式鉴权:每个接口自己判
if (session.getAttribute("user") == null) {
response.sendRedirect("/login");
return;
}
if (!user.hasRole("ADMIN")) {
response.sendError(403);
return;
}

这种写法有三个结构性问题,不是加几个 if 能解决的:

  1. 横切逻辑散落各处。鉴权代码和业务流程耦合,漏写一处就是越权漏洞。
  2. 认证与授权混在一起。谁是”登录成功”、谁有”访问权限”,没有统一抽象,难以复用和测试。
  3. 缺乏标准扩展点。换一种登录方式(短信、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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
        HTTP 请求
│
▼
┌─────────────────────────────────────────────┐
│ SecurityFilterChain │
│ ┌───────────────────────────────────────┐ │
│ │ SecurityContextPersistenceFilter │ │ 取/存 认证信息
│ │ UsernamePasswordAuthenticationFilter │ │ 拦截 /login 登录
│ │ ... 其他过滤器(见第六节) │ │
│ │ AuthorizationFilter │ │ 判定是否有权访问
│ └───────────────────────────────────────┘ │
└──────────────────────┬──────────────────────┘
│ 通过
▼
你的 @RestController

四、认证全流程拆解

把”用户名 + 密码登录”这件事拆开,链路是这样走的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
用户提交表单
│
▼
UsernamePasswordAuthenticationFilter
│ 封装成 UsernamePasswordAuthenticationToken(未认证)
▼
AuthenticationManager(实际是 ProviderManager)
│ 遍历 AuthenticationProvider
▼
DaoAuthenticationProvider
│ ① 调 UserDetailsService.loadUserByUsername() 取用户信息
│ ② 用 PasswordEncoder 比对密码
│ ③ 比对成功 → 返回已认证的 Authentication
▼
SecurityContextHolder.getContext().setAuthentication(...) ← 存入上下文
│
▼
后续请求通过 SecurityContextPersistenceFilter 自动从 Session/Token 恢复

关键组件各自的角色:

  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Configuration
@EnableWebSecurity
public class SecurityConfig {

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN") // 注意:hasRole 会自动补 ROLE_ 前缀
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/home")
)
.csrf(csrf -> csrf.disable()); // 纯无状态 API 可关;带 Session 的表单站点不建议关
return http.build();
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // 自适应成本因子,推荐
}
}

前后端分离(前端 Vue/React、后端只出 JSON)时,表单登录那套”重定向到页面”就不合适了,典型做法是自定义登录接口 + 返回 JWT:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RestController
@RequestMapping("/api/auth")
public class AuthController {

private final AuthenticationManager authenticationManager;
private final JwtService jwtService;

@PostMapping("/login")
public Map<String, String> login(@RequestBody LoginReq req) {
// ① 构造未认证凭证并交给 AuthenticationManager
Authentication auth = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(req.username(), req.password())
);
// ② 认证成功 → 生成 JWT 返回(框架已把认证结果写入上下文,但无状态 API 不必依赖 Session)
String token = jwtService.generate(auth.getName(), auth.getAuthorities());
return Map.of("token", token);
}
}

无状态 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
2
3
// BCrypt:$2a$10$... 前缀自带盐与成本因子,相同明文每次结果都不同
String hash = passwordEncoder.encode("123456");
boolean ok = passwordEncoder.matches("123456", hash); // true

BCrypt 的”慢”是故意的——拖慢暴力破解速度。成本因子(默认 10)越高越安全也越慢,可在性能与安全性之间权衡。千万不要自己用 MD5/SHA1 裸哈希,那在彩虹表面前几秒就被破。

九、鉴权模型:RBAC 与表达式

主流权限模型是 RBAC(用户 → 角色 → 权限)。Spring Security 把”角色”当成一种带 ROLE_ 前缀的 GrantedAuthority。

1
2
3
4
5
6
// 基于请求路径的规则(配置期)
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasAuthority("ROLE_ADMIN") // 等价 hasRole("ADMIN")
.requestMatchers("/api/user/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.anyRequest().authenticated()
);

更灵活的是方法级安全——直接把规则写在业务方法上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Configuration
@EnableMethodSecurity // 6.x 推荐;旧版用 @EnableGlobalMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig {}

@Service
public class OrderService {

// 只有 ADMIN 能调用
@PreAuthorize("hasRole('ADMIN')")
public void deleteAll() { /* ... */ }

// 只能操作属于自己的订单(SpEL 访问方法参数)
@PreAuthorize("#order.owner == authentication.name")
public void cancel(Order order) { /* ... */ }

// 调用后才校验返回值
@PostAuthorize("returnObject.owner == authentication.name")
public Order get(Long id) { /* ... */ }
}
注解 时机 用法
@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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Component
public class JwtService {
private final String secret = "至少32字节的强随机密钥-base64-...";
private final long EXPIRE = 3600_000L; // 1 小时

public String generate(String username, Collection<? extends GrantedAuthority> authorities) {
return Jwts.builder()
.setSubject(username)
.claim("authorities", authorities.stream().map(GrantedAuthority::getAuthority).toList())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRE))
.signWith(SignatureAlgorithm.HS256, secret) // 对称签名
.compact();
}

public Claims parse(String token) {
return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
}
}

无状态场景下,用一个过滤器把 Token 还原成 Authentication 并塞进上下文:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class JwtAuthFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
String token = resolveToken(req);
if (token != null && jwtService.validate(token)) {
Claims c = jwtService.parse(token);
Authentication auth = new UsernamePasswordAuthenticationToken(
c.getSubject(), null,
((List<?>) c.get("authorities")).stream()
.map(a -> new SimpleGrantedAuthority(a.toString())).toList());
SecurityContextHolder.getContext().setAuthentication(auth);
}
chain.doFilter(req, res);
}
}

JWT 的”吊销”是公认难点:令牌自包含、服务端无状态,无法像删 Session 那样即时作废。常见解法:① 短过期(如 15 分钟)+ 刷新令牌;② 维护一个 Redis 黑名单直到原令牌过期;③ 改签密钥强制全体下线(代价大)。别把 JWT 当 Session 用——它适合”一次性授权凭证”,不适合需要精细实时管控的场景。

十二、OAuth2 / OIDC 实战

OAuth2 解决的是”委托授权“:让用户授权你的应用去访问他在第三方(如 GitHub)的资源,而不把他的账号密码告诉你。四种授权模式:

模式 适用 现状
授权码(Authorization Code) 有后端的常规应用 最安全,标配 + PKCE
隐式(Implicit) 纯前端 SPA 已废弃
密码(Password) 同一公司的一二方应用 不推荐,泄露密码
客户端凭证(Client Credentials) 服务间调用 机器对机器

现代标准做法是 授权码 + PKCE(连原生 App / SPA 都不再用隐式模式)。作为资源服务器(保护你自己的 API)配置非常轻量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Bean
public SecurityFilterChain resourceServer(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth
.jwt(jwt -> jwt.decoder(jwtDecoder())) // 用授权服务器的 JWK 验签
);
return http.build();
}

@Bean
public JwtDecoder jwtDecoder() {
// 从授权服务器的 /.well-known/jwks.json 拉取公钥验签,无需共享密钥
return NimbusJwtDecoder.withIssuerLocation("https://auth.example.com").build();
}

scope 和 role 不是一回事:OAuth2 的 scope 是”能访问哪些接口范围“(如 read:user),而 Spring Security 的 role 是”用户身份角色“。资源服务器里用 hasAuthority("SCOPE_read:user") 判 scope,别和 hasRole 混淆。

十三、与 Spring Cloud Gateway 的边界

微服务架构里容易混淆:网关(Gateway)做的是”统一入口鉴权”——在流量进集群前做一次 JWT 校验、路由、限流;Spring Security 做的是”应用内细粒度安全”——具体到某个服务的方法级 @PreAuthorize、数据级权限。二者互补而非替代:

1
2
浏览器 → Gateway(验 JWT、路由) → 服务 A(Spring Security 方法级鉴权)
→ 服务 B(Spring Security 数据级权限)

网关拦掉非法令牌,应用再基于已经可信的身份做精细控制。具体网关过滤器写法见《微服务架构与 Spring Cloud Alibaba 实战》,本文不重复。

十四、10 条生产避坑清单

  1. 密码必须密文存储,用 BCryptPasswordEncoder 而非 MD5/SHA 裸哈希。
  2. hasRole("ADMIN") 会自动补 ROLE_ 前缀,而 hasAuthority("ADMIN") 不会——混用导致 403。
  3. 无状态 API 记得 csrf().disable(),否则前端 POST 会被 CsrfFilter 拦;但带 Session 的站点别乱关。
  4. JWT 别存敏感信息到 payload(可被解码),签名密钥至少 32 字节且不能硬编码进代码库。
  5. JWT 设置合理短过期 + 刷新机制,不要在客户端无限期持有。
  6. 方法级安全要让 @EnableMethodSecurity 生效,且确保类被 Spring 代理(内部 this.method() 调用不会走 AOP,注解失效)。
  7. 异步线程/线程池会丢 SecurityContext,跨线程用 SecurityContextHolderStrategy 或手动传递。
  8. UserDetailsService 查库要做缓存,高并发下每个请求都查用户表会打爆 DB。
  9. OAuth2 用授权码 + PKCE,别再用已废弃的隐式模式;资源服务器用 JWK 公钥验签而非共享密钥。
  10. 别把所有接口 permitAll() 做调试后遗留上线,上线前用自动化脚本回归”未登录是否真 401”。