Java 基础体系 · 第 27/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Spring Security 完整指南:Filter Chain、认证、授权、JWT 和 CSRF
Spring Security 解决的不是单一的“登录问题”,而是围绕一次请求建立一条安全处理链:
- 请求是否进入某个安全过滤链;
- 请求中的凭据能否被认证为某个主体;
- 该主体是否有权访问目标资源;
- 失败时应返回未认证、拒绝访问,还是跳转登录页;
- 请求是否可能被跨站伪造;
- 凭据、会话、令牌和安全上下文如何在请求之间传播。
理解这些因果关系,比记住几个配置方法更重要。
一、先区分身份、认证、授权和安全上下文
1. 身份
身份是系统认为“当前请求代表谁”。例如:
alice
user-42
tenant-a/user-42
身份本身不是证明。客户端在请求中写入 alice,并不意味着服务端应当相信它。
2. 认证
认证(Authentication)回答:
你是谁?你提供的凭据是否能证明这个身份?
常见凭据包括:
- 用户名和密码;
- Session ID;
- Basic Authentication;
- JWT;
- OAuth 2.0 Access Token;
- mTLS 客户端证书。
认证成功后,Spring Security 通常会产生一个 Authentication 对象,其中包含:
principal:主体,例如用户名或 JWT 主体;credentials:凭据,认证后通常应清理或不再使用;authorities:权限集合;authenticated:是否已认证。
认证失败并不等于“没有权限”。例如:
- 没有提供凭据:未认证;
- 提供了错误密码:认证失败;
- 已经登录,但缺少
ADMIN权限:授权失败。
3. 授权
授权(Authorization)回答:
已识别的主体是否允许执行这个动作?
可以形式化为:
其中:
subject是认证后的主体;action是动作,例如GET、DELETE、转账;resource是目标资源;context是租户、时间、来源 IP、资源所属者等上下文。
例如:
alice 已认证
alice 具有 USER 权限
alice 请求 DELETE /admin/users/42
认证条件成立,但授权条件不成立,因此应返回 403 Forbidden,而不是 401 Unauthorized。
4. 安全上下文
Spring Security 使用 SecurityContext 保存当前请求的认证结果,常见访问方式是:
Authentication authentication =
SecurityContextHolder.getContext().getAuthentication();
在 Servlet 应用中,SecurityContextHolder 默认使用线程相关策略。它适合在当前请求线程中读取身份,但不能把它当作普通全局变量:
- 不应把用户身份写入静态字段;
- 异步任务切换线程后,不能假设上下文自动存在;
- Reactor 中不能直接依赖 ThreadLocal;
- 请求结束后,上下文必须被清理,否则可能发生身份串扰。
二、一次请求如何经过 Spring Security Filter Chain
1. DelegatingFilterProxy 和 FilterChainProxy
在 Spring MVC 的 Servlet 环境中,Web 容器首先调用注册的 Servlet Filter。Spring Security 通常通过:
Servlet 容器
-> DelegatingFilterProxy
-> FilterChainProxy
-> SecurityFilterChain
-> 多个 Security Filter
-> DispatcherServlet
-> Controller
DelegatingFilterProxy 是容器 Filter 与 Spring Bean 之间的桥梁。真正负责安全逻辑的是 FilterChainProxy。
FilterChainProxy 可以管理多条 SecurityFilterChain。每条链有一个请求匹配器和一组过滤器:
请求
-> chain-1 matcher 是否匹配?
是:执行 chain-1,不再选择后续链
否:尝试 chain-2
-> 没有匹配链:继续进入应用,除非其他机制拒绝
因此,多条过滤链的顺序很重要。更具体的匹配规则应放在更宽泛的规则之前。
2. 过滤器顺序不是授权规则顺序
过滤器的执行顺序由 Spring Security 管理。典型流程包括:
- 处理跨请求上下文;
- 处理 CSRF;
- 读取 Basic、Bearer Token、表单登录等凭据;
- 构造并保存认证结果;
- 执行授权;
- 处理异常;
- 进入控制器。
不同版本、配置方式和功能模块会影响实际过滤器集合,不能依赖某个具体过滤器的内部名称作为业务契约。可靠的方式是理解阶段之间的关系,而不是手工复制过滤器顺序。
可以用以下时序理解一条正常请求:
sequenceDiagram
participant C as Client
participant F as FilterChainProxy
participant A as Authentication Filter
participant S as SecurityContext
participant Z as AuthorizationManager
participant MVC as DispatcherServlet
participant H as Controller
C->>F: HTTP Request
F->>S: 创建或恢复当前上下文
F->>A: 提取 Cookie/Basic/Bearer 凭据
A->>A: AuthenticationManager 认证
A->>S: 写入 Authentication
F->>Z: 检查请求权限
Z-->>F: 允许
F->>MVC: 继续处理
MVC->>H: 调用 Controller
H-->>C: HTTP Response
拒绝路径不同:
sequenceDiagram
participant C as Client
participant F as Security Filter
participant E as ExceptionTranslationFilter
participant H as EntryPoint
participant D as DeniedHandler
C->>F: 请求受保护资源
F->>E: 发现认证或授权异常
alt 未认证
E->>H: AuthenticationEntryPoint
H-->>C: 401 或登录跳转
else 已认证但权限不足
E->>D: AccessDeniedHandler
D-->>C: 403
end
ExceptionTranslationFilter 并不负责判断所有权限,它主要把安全异常翻译成 HTTP 响应:
AuthenticationException:调用AuthenticationEntryPoint;AccessDeniedException:如果当前用户未认证,通常仍进入认证入口;如果已认证,则调用AccessDeniedHandler。
三、从配置到可运行的表单登录示例
下面示例适合演示 Session、表单登录和请求授权。依赖至少包括 Spring Boot Web 和 Spring Security。具体 starter 版本应由项目使用的 Spring Boot BOM 管理,不应手工混用 Spring Security 模块版本。
package com.example.demo;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain webSecurity(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**", "/error").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form
.defaultSuccessUrl("/home", true)
)
.logout(logout -> logout
.logoutSuccessUrl("/public/logout-success")
);
return http.build();
}
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
UserDetailsService users(PasswordEncoder encoder) {
UserDetails alice = User.withUsername("alice")
.password(encoder.encode("correct-password"))
.roles("USER")
.build();
UserDetails admin = User.withUsername("admin")
.password(encoder.encode("admin-password"))
.roles("USER", "ADMIN")
.build();
return new InMemoryUserDetailsManager(alice, admin);
}
}
1. 请求结果
启动应用后:
curl -i http://localhost:8080/public/ping
预期是进入控制器并返回 200,因为 /public/** 被放行。
访问受保护路径:
curl -i http://localhost:8080/home
未配置为 API 风格时,表单登录可能返回 302,将浏览器导向登录页。浏览器场景适合这种行为;纯 JSON API 通常应配置返回 401 JSON,而不是返回 HTML 登录页。
提交登录表单时,默认字段名通常是 username 和 password:
curl -i -c cookies.txt \
-X POST http://localhost:8080/login \
-d 'username=alice&password=correct-password'
成功后服务端创建认证会话,并通过 Set-Cookie 返回 Session Cookie。再携带 Cookie 请求:
curl -i -b cookies.txt http://localhost:8080/home
请求中的 Session ID 本身不是用户身份,它只是服务端查找认证上下文的索引。服务端仍需保证:
- Session ID 不可预测;
- 登录成功后更换 Session ID,防止 Session Fixation;
- Cookie 设置
HttpOnly、Secure和合适的SameSite; - 登出或会话失效后旧 Session 不能继续访问。
2. 密码为什么不能直接保存
密码认证的验证逻辑应是:
而不是:
rawPassword.equals(storedPassword)
安全密码存储通常使用带随机盐和成本参数的自适应哈希。DelegatingPasswordEncoder 会在存储值中带上算法标识,例如:
{bcrypt}$2a$...
它不是加密:无法依靠密钥“解密出原密码”,验证时只能重新计算并比较。
生产系统还需要考虑:
- 密码哈希成本与登录延迟;
- 密码泄露后的轮换;
- 失败次数和账户锁定;
- MFA;
- 不把密码写入日志;
- 不自行实现盐、哈希和恒定时间比较。
四、认证的内部过程:AuthenticationManager
认证过滤器一般不会直接验证所有凭据,而是把凭据包装成 Authentication,交给 AuthenticationManager:
UsernamePasswordAuthenticationFilter
-> UsernamePasswordAuthenticationToken(未认证)
-> AuthenticationManager
-> AuthenticationProvider
-> UserDetailsService
-> PasswordEncoder
-> UsernamePasswordAuthenticationToken(已认证)
-> SecurityContext
AuthenticationProvider 表示一种认证方式:
- 用户名密码 Provider;
- JWT Provider;
- LDAP Provider;
- SAML Provider;
- 自定义证书或外部系统 Provider。
认证前后的对象状态可以抽象为:
输入:
principal = alice
credentials = correct-password
authenticated = false
验证:
UserDetailsService.loadUserByUsername("alice")
PasswordEncoder.matches(raw, encoded)
输出:
principal = UserDetails(alice)
credentials = 通常不再保留
authorities = ROLE_USER
authenticated = true
认证失败时,系统不应把“用户不存在”和“密码错误”直接暴露给外部调用者,否则可用于枚举账户。日志中可以记录足够的内部诊断信息,但响应一般应保持统一。
五、授权:URL 规则、角色、权限和方法安全
1. hasRole 和 hasAuthority 的区别
Spring Security 中,角色通常以 ROLE_ 前缀表示:
.hasRole("ADMIN")
等价于检查:
ROLE_ADMIN
而:
.hasAuthority("invoice:read")
检查的是精确字符串 invoice:read,不会自动添加 ROLE_。
错误示例:
.roles("ADMIN")
.hasAuthority("ADMIN")
前者产生 ROLE_ADMIN,后者查找 ADMIN,结果不会匹配。
2. 请求授权的匹配语义
以下规则按声明顺序匹配:
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/assets/**").permitAll()
.requestMatchers(HttpMethod.GET, "/reports/**")
.hasAuthority("report:read")
.requestMatchers("/admin/**")
.hasRole("ADMIN")
.anyRequest().authenticated()
);
anyRequest() 是兜底规则。若没有明确配置,不能凭感觉推断默认行为,应通过测试验证。
授权判断的关键不是“路径像不像敏感路径”,而是请求方法、资源标识和业务动作是否对应。例如只限制:
DELETE /users/{id}
并不能自动保证:
POST /users/{id}/disable
也不能防止用户读取另一个租户的数据。
3. 方法级授权
启用:
@EnableMethodSecurity
即可使用:
@Service
public class DocumentService {
@PreAuthorize("hasAuthority('document:read')")
public Document read(long id) {
return load(id);
}
@PreAuthorize("#ownerId == authentication.name")
public void update(long ownerId, UpdateCommand command) {
updateOwnedDocument(ownerId, command);
}
private Document load(long id) {
throw new UnsupportedOperationException("示例");
}
private void updateOwnedDocument(long ownerId, UpdateCommand command) {
// 示例
}
}
方法安全解决的是服务方法边界,不等于数据库行级安全。#ownerId == authentication.name 只比较参数和当前身份;服务仍需保证参数确实对应资源所有者,并在查询时加入租户或所有者条件。
表达式授权应特别谨慎:
- 不把用户输入拼接成 SpEL;
- 不开放任意表达式执行;
- 复杂规则优先写成可测试的授权组件;
- 注意方法参数名是否能被 Spring 正确发现;
- 方法安全发生在方法调用附近,不能替代请求解析、CSRF 和输入校验。
六、JWT:结构、签名验证和资源服务器
1. JWT 是什么
JWT(JSON Web Token)是由若干 Base64URL 部分组成的令牌,常见格式为:
header.payload.signature
Header 可能包含:
{
"alg": "RS256",
"typ": "JWT",
"kid": "key-2025-01"
}
Payload 可能包含:
{
"iss": "https://issuer.example.com",
"sub": "user-42",
"aud": ["orders-api"],
"scope": "orders:read",
"exp": 1735689600,
"nbf": 1735686000
}
Signature 不是对整个 JWT 的加密,而是对:
base64url(header) + "." + base64url(payload)
进行签名。任何人通常都能读取 Payload,因此不要把密码、密钥或不应公开的隐私数据放入 JWT。
JWT 的完整性条件可以简化为:
其中:
- 是 Header;
- 是 Payload;
key是验证密钥;H . P是两个编码部分的拼接。
但签名有效还不够,还必须验证声明:
iss 是否是信任的发行者
aud 是否包含当前 API
exp 是否未过期
nbf 是否已生效
alg 是否为允许的算法
kid 是否映射到可信密钥
2. JWT 常见误解
JWT 不是自动注销机制
JWT 通常是自包含的。服务端验证通过后,不必访问会话存储。因此签发后,除非:
- 等待过期;
- 使用黑名单或撤销列表;
- 改变密钥;
- 检查用户或会话状态;
否则很难立即撤销单个令牌。
JWT 不等于无状态
验证签名可以无状态,但业务仍可能依赖:
- 用户是否被禁用;
- 租户状态;
- 权限是否已改变;
- refresh token 是否撤销;
- 密钥是否轮换。
Access Token 不一定适合放 Cookie
如果令牌放在 HttpOnly Cookie 中,JavaScript 不易读取,但浏览器会自动携带它,此时仍需考虑 CSRF。
如果令牌放在:
Authorization: Bearer <token>
浏览器不会对任意跨站表单自动添加这个 Header,CSRF 风险模型不同;但令牌可能被 XSS 代码读取,存储位置和 XSS 防护仍很重要。
3. Spring Resource Server 的端到端配置
资源服务器负责验证客户端带来的 Bearer Token。典型配置:
@Configuration
@EnableMethodSecurity
public class JwtSecurityConfig {
@Bean
SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.requestMatchers(HttpMethod.GET, "/orders/**")
.hasAuthority("SCOPE_orders:read")
.requestMatchers(HttpMethod.POST, "/orders/**")
.hasAuthority("SCOPE_orders:write")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> {})
);
return http.build();
}
}
配置发行者,例如:
spring.security.oauth2.resourceserver.jwt.issuer-uri=https://issuer.example.com
常见启动和运行过程是:
- 应用根据
issuer-uri发现发行者元数据; - 获取 JWKS 公钥地址;
- Bearer Token 过滤器提取
AuthorizationHeader; - JWT 解码器验证签名和标准声明;
- 生成
JwtAuthenticationToken; - 将 Scope 映射为
SCOPE_前缀的权限; - 进入 URL 或方法授权。
请求示例:
curl -i \
-H "Authorization: Bearer eyJ..." \
http://localhost:8080/orders/123
如果 Token 具有:
{
"scope": "orders:read"
}
则默认可映射为:
SCOPE_orders:read
没有令牌通常得到 401;令牌有效但没有 orders:read 通常得到 403。
4. 自定义权限映射
如果授权服务器使用 roles 或自定义 Claim,而不是 scope,可以设置 JwtAuthenticationConverter。示例把 roles: ["ADMIN"] 映射成 ROLE_ADMIN:
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter authorities =
new JwtGrantedAuthoritiesConverter();
authorities.setAuthoritiesClaimName("roles");
authorities.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter converter =
new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authorities);
return converter;
}
接入:
@Bean
SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(
jwtAuthenticationConverter()))
);
return http.build();
}
必须确认 Claim 的来源和含义是可信的。客户端自带一个名为 roles 的 JSON,不代表它已被受信任的授权服务器签名。
5. JWT 密钥轮换和故障
使用 JWKS 时,验证服务会缓存公钥。密钥轮换期间可能出现:
- 旧
kid仍在缓存中; - 新
kid尚未刷新; - JWKS 端点不可用;
- 发行者或受众配置错误;
- 时钟偏差导致
exp或nbf验证失败。
诊断时应记录:
issuer
kid
alg
aud
exp/nbf 的验证结果
但不要记录完整 Bearer Token。完整 Token 等同于凭据。
七、CSRF:为什么“用户已登录”反而会有风险
1. CSRF 的条件
CSRF(Cross-Site Request Forgery,跨站请求伪造)利用的是:
浏览器会自动把某些凭据附加到请求中,但用户未意识到请求是由恶意站点发起的。
典型条件:
- 用户已登录银行站点;
- 浏览器保存了银行站点的 Session Cookie;
- 用户访问攻击者页面;
- 攻击者页面提交:
<form action="https://bank.example/transfer" method="post">
<input name="to" value="attacker">
<input name="amount" value="1000">
</form>
<script>document.forms[0].submit()</script>
- 浏览器自动携带银行 Cookie;
- 银行服务若只检查 Cookie,就可能误认为这是用户主动操作。
攻击者不需要读取响应内容,只要能诱发状态变更即可。
2. CSRF Token 如何阻断攻击
服务端额外要求一个攻击者页面无法获得的随机值:
请求必须同时满足:
有效 Session Cookie
且有效 CSRF Token
攻击者可以诱发请求,但通常不能读取同源页面中的 Token,因此第二个条件不成立。
Spring Security 对不安全方法通常保护:
POST
PUT
PATCH
DELETE
安全方法通常是只读语义:
GET
HEAD
OPTIONS
这里的“安全”是 HTTP 语义中的 safe method,不表示该接口没有敏感数据。
3. MVC 表单中的 CSRF
启用表单登录和 Session 时,默认 CSRF 保护通常应保留。服务器渲染模板需要把 Token 放入表单,例如使用 Spring Security 提供的模板集成机制,或者显式提交隐藏字段:
<form method="post" action="/profile">
<input type="hidden"
name="_csrf"
value="从当前请求的 CsrfToken 取得">
<input name="displayName">
<button type="submit">保存</button>
</form>
Token 名称和值应由当前应用的 CsrfTokenRepository 提供,不应硬编码。
4. SPA 的 Cookie/Header 模式
对于前后端分离应用,可以让服务端通过 Cookie 提供 Token,前端读取后放入 Header。常见配置:
@Bean
SecurityFilterChain spaSecurity(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(
CookieCsrfTokenRepository.withHttpOnlyFalse()
)
)
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
)
.formLogin(form -> {});
return http.build();
}
withHttpOnlyFalse() 是为了让前端 JavaScript 读取 CSRF Cookie;这不是把 Session Cookie 暴露给 JavaScript。应区分:
- CSRF Token Cookie:设计上可能需要被前端读取;
- Session Cookie:通常应保持
HttpOnly。
前端随后发送:
X-XSRF-TOKEN: <token-value>
具体 Header 名称取决于仓库和应用配置。前端必须确保只有向本应用发出的请求才附加这个 Header,不能把 Token 发往第三方域名。
5. 为什么不能随意 csrf.disable()
以下配置经常被错误复制:
http.csrf(csrf -> csrf.disable());
它只在明确建立了不同的威胁模型时才合理,例如:
- 纯服务到服务 API;
- 凭据只通过
AuthorizationHeader 发送; - 浏览器不会自动附加该凭据;
- 没有用 Cookie 自动认证;
- 已分析 CORS、XSS 和浏览器调用方式。
即使是 JWT API,也不能仅因为“使用 JWT”就关闭 CSRF。判断依据是凭据是否会被浏览器自动附加,而不是令牌格式叫 JWT 还是 Session。
6. CORS 与 CSRF 不是一回事
CORS 控制浏览器脚本能否跨源读取响应,CSRF 防止跨站诱发状态变更。
例如,攻击者可能无法读取响应,但仍能提交一个跨站表单。因此:
CORS 配置正确 ≠ CSRF 已解决
反过来,CSRF Token 也不会允许前端跨域读取响应。两者应分别配置和测试。
八、异常处理:401、403、登录跳转和 API 响应
API 不应因为默认表单登录配置而把认证失败返回成 HTML。可以分别配置:
@Bean
SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated()
)
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint((request, response, ex) -> {
response.sendError(
HttpServletResponse.SC_UNAUTHORIZED,
"Authentication required"
);
})
.accessDeniedHandler((request, response, ex) -> {
response.sendError(
HttpServletResponse.SC_FORBIDDEN,
"Access denied"
);
})
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> {}));
return http.build();
}
这段代码的语义是:
- 没有有效认证:
401; - 已认证但授权不足:
403。
Bearer Token 还应按 OAuth 2.0 资源服务器语义返回合适的 WWW-Authenticate 信息。不同异常处理器组合可能产生不同 Header 和响应体,因此应使用集成测试确认实际协议行为。
不要在响应中区分:
用户不存在
密码错误
Token 签名错误
Token 已过期
除非该信息确实是受信任的内部诊断通道。对外过度详细会帮助账户枚举或令牌探测。
九、Spring Web MVC 与 WebFlux 的边界
1. Servlet MVC
Spring Web MVC 运行在 Servlet 模型中,安全入口是 Servlet Filter。典型组件是:
FilterChainProxy
SecurityFilterChain
HttpSecurity
SecurityContextHolder
阻塞式的 UserDetailsService、数据库访问和传统 Session 配置通常适合这一模型。
2. WebFlux
WebFlux 使用 Reactor,不以 Servlet Filter 作为核心扩展点。对应模型是:
WebFilter
SecurityWebFilterChain
ServerHttpSecurity
ReactiveAuthenticationManager
ReactiveUserDetailsService
ReactiveSecurityContextRepository
示例:
@Bean
SecurityWebFilterChain springSecurityFilterChain(
ServerHttpSecurity http) {
return http
.csrf(ServerHttpSecurity.CsrfSpec::disable)
.authorizeExchange(exchange -> exchange
.pathMatchers("/public/**").permitAll()
.pathMatchers("/admin/**").hasRole("ADMIN")
.anyExchange().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> {}))
.build();
}
这里的 authorizeExchange 是 WebFlux API,与 MVC 中的 authorizeHttpRequests 不应混用。
3. Reactor 上下文与 ThreadLocal
WebFlux 请求可能在线程之间切换。下面的代码存在风险:
// 不应假设任何异步线程都能直接读取到身份
SecurityContextHolder.getContext()
Reactive 安全上下文通常通过 Reactor Context 传播。应使用响应式 API 或框架提供的上下文访问方式,避免在 publishOn、异步回调、调度器切换后丢失或错误复用身份。
阻塞认证服务直接放到事件循环线程会造成吞吐和延迟问题。若必须调用阻塞系统,应使用合适的调度策略,或者采用响应式数据访问和认证实现。
十、多个 SecurityFilterChain 的设计
当一个应用同时提供浏览器页面和 API 时,可以使用不同安全链:
@Bean
@Order(1)
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(
SessionCreationPolicy.STATELESS
)
)
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> {}));
return http.build();
}
@Bean
@Order(2)
SecurityFilterChain browserChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> {});
return http.build();
}
这里的关键不是 @Order 本身,而是三件事:
apiChain只匹配/api/**;- API 使用 Bearer Token 和无状态策略;
- 浏览器链使用 Session 和表单登录。
若 API 链没有匹配限制,可能吞掉所有请求,使第二条链永远不执行。若两条链都能匹配同一路径,必须通过测试确认第一条链的行为。
十一、Session、无状态和并发模型
1. 有状态 Session
流程是:
登录请求
-> 服务端验证密码
-> 创建认证上下文
-> 保存到 Session
-> 客户端只携带 Session ID
优点是:
- 服务端可以主动撤销会话;
- 权限变更容易立即生效;
- 浏览器集成自然;
- 令牌不必携带全部声明。
代价是:
- 需要共享 Session 存储或会话粘滞;
- 多节点部署需考虑失效同步;
- Cookie、CSRF、Session Fixation 都是重要风险。
2. 无状态 Bearer Token
流程是:
每次请求
-> 读取 Bearer Token
-> 验证签名和声明
-> 构造 Authentication
-> 请求结束
-> 不保存服务器 Session
优点是服务间调用简单、扩展方便;代价是撤销和即时权限变化更困难,且 Token 泄露影响可能持续到过期。
SessionCreationPolicy.STATELESS 的含义是 Spring Security 不使用 Session 保存认证上下文。它不代表应用的所有代码都绝不会使用 Session,也不代表客户端不会发送 Cookie。
十二、常见失败表现和诊断方法
1. 总是 403
按顺序检查:
- 是否已认证;
- 是否缺少 CSRF Token;
hasRole("ADMIN")与实际ROLE_ADMIN是否一致;- JWT Scope 是否被映射成
SCOPE_xxx; - 是否命中了另一条
SecurityFilterChain; - 方法级
@PreAuthorize是否额外拒绝; - 是否存在租户或资源所有者检查。
如果 POST 请求没有认证问题但返回 403,CSRF 是高概率原因。
2. 总是 401
检查:
Authorization是否确实是Bearer <token>;- Header 是否被代理剥离;
- JWT 的签名算法和 JWKS 是否匹配;
iss、aud、exp、nbf是否有效;- 服务端时钟是否正确;
- Resource Server 是否实际加载了正确的发行者配置;
- 请求是否命中了使用另一种认证方式的过滤链。
3. 登录成功但下一次请求未登录
检查:
- 响应是否设置了 Cookie;
- 客户端是否保存并回传 Cookie;
- Cookie 的
Secure、Domain、Path、SameSite是否阻止发送; - 是否配置了无状态 Session 策略;
- 多节点部署时 Session 是否能在节点间共享;
- 反向代理是否改变了协议和 Host 判断。
4. 开启安全日志
可以临时提高日志级别:
logging.level.org.springframework.security=DEBUG
更详细的 TRACE 日志可能包含请求和认证流程信息,生产环境应谨慎启用并审查脱敏策略。不要把密码、完整 JWT、Session ID 写入日志。
也可以检查启动时输出的过滤器链,确认请求实际命中了哪条链。诊断配置本身必须可回滚,避免长期保留过高日志级别。
十三、与其他 Java 服务安全问题的关系
1. 反序列化
认证通过并不意味着请求体安全。反序列化发生在控制器参数绑定之前或过程中,不能靠 URL 授权规则自动防止危险对象构造。
应:
- 使用明确的数据传输对象;
- 禁止不可信来源的原生 Java 序列化;
- 限制多态类型;
- 对字段做结构和业务校验;
- 升级存在反序列化漏洞的依赖。
2. 表达式注入
@PreAuthorize 中的表达式属于应用配置,不应把用户输入拼接成表达式。否则攻击者可能改变表达式语义,甚至调用不应暴露的对象。
正确方向是把用户输入作为普通参数:
@PreAuthorize("@documentAuth.canRead(authentication, #id)")
public Document get(long id) {
return repository.findById(id);
}
而不是生成一段包含 id 的表达式字符串。
3. SSRF
即使请求主体已认证,服务端根据用户提供的 URL 去访问内部地址仍可能产生 SSRF。Spring Security 不会自动验证出站目标。需要单独限制:
- 协议;
- DNS 解析结果;
- 私有地址和环回地址;
- 重定向;
- 云元数据地址;
- 出站网络策略。
4. TLS
JWT 的签名解决的是令牌完整性,不替代传输加密。没有 TLS 时,密码、Session Cookie、Bearer Token 都可能被窃取。
生产环境应验证:
- 客户端到网关的 TLS;
- 网关到应用的 TLS 边界;
- 证书和密钥轮换;
- 反向代理转发的安全协议头;
- Cookie 的
Secure属性是否与实际部署一致。
5. 供应链
Spring Security 配置正确,也不能消除依赖漏洞。应使用项目 BOM 管理版本,定期扫描:
- Spring Framework;
- Spring Boot;
- Spring Security;
- JWT/JWK 相关库;
- Web Server;
- 日志和 JSON 解析库;
- 构建插件及其传递依赖。
升级时应运行认证、授权、CSRF、错误响应和多链匹配测试,而不是只验证应用能否启动。
十四、最小但完整的测试矩阵
安全配置必须通过请求测试验证,而不是只看配置代码。至少覆盖:
| 场景 | 预期 |
|---|---|
| 公开 GET | 200 |
| 未认证访问保护资源 | 401 或明确的登录跳转 |
| 错误密码 | 认证失败 |
| 正确密码后访问资源 | 200 |
| 普通用户访问管理员资源 | 403 |
| 管理员访问管理员资源 | 200 |
| 缺少 Bearer Token | 401 |
| JWT 签名错误 | 401 |
| JWT 过期 | 401 |
| JWT 权限不足 | 403 |
| Session Cookie 被盗用或失效 | 不能继续访问 |
| Cookie 认证的跨站 POST | 缺少 CSRF 时失败 |
| 正确 CSRF Token 的 POST | 成功 |
/api/** 和页面路径 |
命中预期过滤链 |
| WebFlux 异步切线程 | 身份上下文不串扰 |
例如使用 MockMvc 时,可验证请求授权:
mockMvc.perform(get("/admin/report"))
.andExpect(status().isUnauthorized());
mockMvc.perform(get("/admin/report")
.with(user("alice").roles("USER")))
.andExpect(status().isForbidden());
mockMvc.perform(get("/admin/report")
.with(user("admin").roles("ADMIN")))
.andExpect(status().isOk());
测试中的 user() 是测试注入的认证主体,不等于真实登录流程。因此仍应补充密码登录、Session、JWT 解码和 CSRF 的集成测试。
十五、选择模型:何时使用 Session、JWT 和 CSRF
可以按凭据传播方式做决定:
浏览器 + Cookie 自动认证
-> Session 或 Cookie 中的令牌
-> 默认保留 CSRF 防护
服务间调用 + Authorization Header
-> Bearer Token / JWT
-> 通常可采用无状态资源服务器
-> 仍需验证签名、issuer、audience、过期时间和权限
同一应用同时服务页面和 API
-> 分离 SecurityFilterChain
-> 分别定义会话、认证入口、CSRF 和授权规则
最终边界可以概括为:
- Filter Chain 决定请求如何进入安全处理流程;
- Authentication 建立“请求代表谁”;
- Authorization 判断“这个主体能做什么”;
- JWT 提供一种可验证的令牌格式,但不自动解决撤销、保密和 CSRF;
- CSRF 防护针对的是浏览器自动附加凭据造成的跨站状态变更;
- MVC 使用 Servlet Filter 模型,WebFlux 使用响应式 WebFilter 模型;
- URL 规则、方法安全、资源所有者检查和业务校验必须共同构成授权边界。
当一次请求被拒绝时,沿着“选中哪条链 → 是否提取凭据 → 是否认证成功 → 是否生成权限 → 是否通过授权 → 是否通过 CSRF → 如何翻译异常”的顺序排查,通常比盲目增加 permitAll() 或关闭安全功能更接近真实原因。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Spring Web MVC 与 WebFlux:请求链、校验、异常、流式和选择边界
- 下一篇:Spring Data 数据访问:Repository、事务、分页、审计和缓存边界
- 延伸:Java 服务安全:反序列化、表达式注入、SSRF、TLS 和供应链
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论