Java 基础体系 · 第 27/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Spring Security 完整指南:Filter Chain、认证、授权、JWT 和 CSRF

Spring Security 解决的不是单一的“登录问题”,而是围绕一次请求建立一条安全处理链:

  1. 请求是否进入某个安全过滤链;
  2. 请求中的凭据能否被认证为某个主体;
  3. 该主体是否有权访问目标资源;
  4. 失败时应返回未认证、拒绝访问,还是跳转登录页;
  5. 请求是否可能被跨站伪造;
  6. 凭据、会话、令牌和安全上下文如何在请求之间传播。

理解这些因果关系,比记住几个配置方法更重要。


一、先区分身份、认证、授权和安全上下文

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)回答:

已识别的主体是否允许执行这个动作?

可以形式化为:

allow(subject,action,resource,context)allow(subject, action, resource, context)

其中:

  • subject 是认证后的主体;
  • action 是动作,例如 GETDELETE、转账;
  • 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. DelegatingFilterProxyFilterChainProxy

在 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 管理。典型流程包括:

  1. 处理跨请求上下文;
  2. 处理 CSRF;
  3. 读取 Basic、Bearer Token、表单登录等凭据;
  4. 构造并保存认证结果;
  5. 执行授权;
  6. 处理异常;
  7. 进入控制器。

不同版本、配置方式和功能模块会影响实际过滤器集合,不能依赖某个具体过滤器的内部名称作为业务契约。可靠的方式是理解阶段之间的关系,而不是手工复制过滤器顺序。

可以用以下时序理解一条正常请求:

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 登录页。

提交登录表单时,默认字段名通常是 usernamepassword

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 设置 HttpOnlySecure 和合适的 SameSite
  • 登出或会话失效后旧 Session 不能继续访问。

2. 密码为什么不能直接保存

密码认证的验证逻辑应是:

verify(raw,stored)=passwordEncoder.matches(raw,stored)verify(raw, stored) = passwordEncoder.matches(raw, stored)

而不是:

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. hasRolehasAuthority 的区别

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 的完整性条件可以简化为:

verify(Signkey(H.P),H.P)=trueverify(Sign_{key}(H \mathbin{.} P), H \mathbin{.} P) = true

其中:

  • HH 是 Header;
  • PP 是 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

常见启动和运行过程是:

  1. 应用根据 issuer-uri 发现发行者元数据;
  2. 获取 JWKS 公钥地址;
  3. Bearer Token 过滤器提取 Authorization Header;
  4. JWT 解码器验证签名和标准声明;
  5. 生成 JwtAuthenticationToken
  6. 将 Scope 映射为 SCOPE_ 前缀的权限;
  7. 进入 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 端点不可用;
  • 发行者或受众配置错误;
  • 时钟偏差导致 expnbf 验证失败。

诊断时应记录:

issuer
kid
alg
aud
exp/nbf 的验证结果

但不要记录完整 Bearer Token。完整 Token 等同于凭据。


七、CSRF:为什么“用户已登录”反而会有风险

1. CSRF 的条件

CSRF(Cross-Site Request Forgery,跨站请求伪造)利用的是:

浏览器会自动把某些凭据附加到请求中,但用户未意识到请求是由恶意站点发起的。

典型条件:

  1. 用户已登录银行站点;
  2. 浏览器保存了银行站点的 Session Cookie;
  3. 用户访问攻击者页面;
  4. 攻击者页面提交:
<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>
  1. 浏览器自动携带银行 Cookie;
  2. 银行服务若只检查 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;
  • 凭据只通过 Authorization Header 发送;
  • 浏览器不会自动附加该凭据;
  • 没有用 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 本身,而是三件事:

  1. apiChain 只匹配 /api/**
  2. API 使用 Bearer Token 和无状态策略;
  3. 浏览器链使用 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

按顺序检查:

  1. 是否已认证;
  2. 是否缺少 CSRF Token;
  3. hasRole("ADMIN") 与实际 ROLE_ADMIN 是否一致;
  4. JWT Scope 是否被映射成 SCOPE_xxx
  5. 是否命中了另一条 SecurityFilterChain
  6. 方法级 @PreAuthorize 是否额外拒绝;
  7. 是否存在租户或资源所有者检查。

如果 POST 请求没有认证问题但返回 403,CSRF 是高概率原因。

2. 总是 401

检查:

  • Authorization 是否确实是 Bearer <token>
  • Header 是否被代理剥离;
  • JWT 的签名算法和 JWKS 是否匹配;
  • issaudexpnbf 是否有效;
  • 服务端时钟是否正确;
  • Resource Server 是否实际加载了正确的发行者配置;
  • 请求是否命中了使用另一种认证方式的过滤链。

3. 登录成功但下一次请求未登录

检查:

  • 响应是否设置了 Cookie;
  • 客户端是否保存并回传 Cookie;
  • Cookie 的 SecureDomainPathSameSite 是否阻止发送;
  • 是否配置了无状态 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、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。