6.2 CORS、CSRF 与会话

Spring WebFlux 跨域与会话管理:CorsWebFilter 配置与预检处理、无状态 API 的 CSRF 禁用与替代防护、Spring Session Reactive 与 Redis 分布式会话,以及 JWT 无状态方案对比。

CORS 配置

跨域资源共享(CORS)是浏览器安全机制。WebFlux 提供 CorsWebFilter:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.CorsWebFilter;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;

@Configuration
public class CorsConfig {

    @Bean
    public CorsWebFilter corsWebFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        config.addAllowedOriginPattern("https://*.plumephp.com");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        config.setMaxAge(3600L);  // 预检结果缓存 1 小时

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);

        return new CorsWebFilter(source);
    }
}

关键点:

  • 不能同时用的是 setAllowCredentials(true) + addAllowedOrigin("*"):浏览器不允许 Access-Control-Allow-Origin: * 携带凭证。addAllowedOriginPattern("*") 正是为「带凭证 + 通配子域」设计的,可以一起用;但生产仍应显式列出域名,不要把 * 当默认值
  • setMaxAge 减少 OPTIONS 预检请求频率
  • 生产环境不要用 addAllowedOrigin("*"),显式列出允许的域名

eventd v2 的 CORS 配置:

config.addAllowedOriginPattern("https://app.plumephp.com");
config.addAllowedOriginPattern("https://admin.plumephp.com");
config.addAllowedOriginPattern("http://localhost:3000");  // 开发环境

预检请求处理

浏览器在「非简单请求」(如 PUT、带自定义头的请求)前自动发送 OPTIONS 预检。CorsWebFilter 拦截后返回 200 OK(Spring 不会把它设成 204),关键看响应头,而不是状态码:

OPTIONS /api/orders HTTP/1.1
Origin: https://app.plumephp.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Request-ID

← HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.plumephp.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: X-Request-ID
Access-Control-Max-Age: 3600

如果 CorsWebFilter 没有正确配置,浏览器会阻止实际请求,前端看到 CORS error。

CSRF 策略

CSRF(跨站请求伪造)利用用户已登录的身份发起恶意请求。传统表单应用依赖 CSRF token,但无状态 API 不同:

@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
    return http
        .csrf(csrf -> csrf.disable())  // 无状态 API 禁用
        // ...
}

为什么无状态 API 可以禁用 CSRF?

CSRF 攻击的前提是浏览器自动携带 cookie。无状态 API 使用 JWT(存储在 LocalStorage/内存),恶意网站无法自动携带它。但这也意味着你必须防范 XSS——如果攻击者能注入脚本,就能读取 LocalStorage 中的 token。

反过来,把 JWT 放进 HttpOnly Cookie 就不再是「无状态 API 可以无脑禁用 CSRF」的场景:浏览器会自动携带这个 cookie,跨站请求同样会带上它。此时必须依赖 SameSite(Lax/Strict)或额外的 CSRF token;SameSite=None; Secure 跨站场景下更应恢复 CSRF 校验。下面 eventd v2 选择的就是 cookie 方案,因此它的安全性来自 SameSite 和严格的 CORS,而不是「无状态」三个字。

防护机制防 CSRF防 XSS 窃取 token方案
会话 Cookie(HttpOnly)+ SameSite✅✅传统方案;XSS 仍可代用户发请求
JWT in LocalStorage✅(浏览器不自动携带)❌需额外 XSS 防护
JWT in HttpOnly Cookie + SameSite✅(SameSite)✅最佳平衡;SameSite=None 时要补 CSRF 校验

eventd v2 的方案:JWT 放在 HttpOnly SameSite=Strict Cookie 中,前端通过 cookie 自动携带,同时 XSS 脚本无法读取。注意 SameSite=Strict 只在同站请求中发送;如果前端和后端不同 site(例如前端在 app.example.com、API 在 api.example.org),跨站请求不会带这个 cookie,需要改用 SameSite=None; Secure,并重新评估 CSRF 防护。

// 登录成功后设置 cookie
ResponseCookie jwtCookie = ResponseCookie.from("jwt", token)
    .httpOnly(true)
    .secure(true)  // HTTPS only
    .sameSite("Strict")
    .maxAge(Duration.ofHours(24))
    .path("/")
    .build();

exchange.getResponse().addCookie(jwtCookie);

无状态会话 vs 分布式会话

方案 A:完全无状态(推荐)

不存储任何会话信息。每次请求携带完整的用户上下文(JWT):

// 优势:水平扩展无限制,不需要共享存储
// 劣势:token 签发后无法主动失效(除非维护黑名单)

方案 B:Spring Session Reactive + Redis

如果需要服务端控制会话(如强制登出所有设备):

<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
@Configuration
@EnableRedisWebSession(maxInactiveIntervalInSeconds = 1800)
public class SessionConfig {
    @Bean
    public ReactiveRedisConnectionFactory redisFactory() {
        return new LettuceConnectionFactory("localhost", 6379);
    }
}

Spring Session Reactive 把会话数据存到 Redis,SESSION cookie 只保存 session ID。服务端可以随时删除 Redis 中的会话记录,实现强制登出。

选择建议

场景推荐方案
纯 API 服务JWT 无状态
需要强制登出JWT + Redis 黑名单 或 Spring Session
多设备管理Spring Session Reactive + Redis
极简架构JWT 无状态

eventd v2 使用 JWT 无状态 + Redis 黑名单:

  • 正常请求:验证 JWT 签名即可
  • 强制登出:把 JWT jti(唯一标识)写入 Redis 黑名单,验证时检查
  • 黑名单 TTL = JWT 剩余有效期,自动过期
public Mono<Boolean> isTokenRevoked(String jti) {
    return redisTemplate.hasKey("token:blacklist:" + jti);
}

会话 timeout 与刷新

JWT 设置较短有效期(如 15 分钟),配合 refresh token(7 天):

// access token: 15 分钟
// refresh token: 7 天,存在 HttpOnly cookie

@PostMapping("/refresh")
public Mono<ResponseEntity<?>> refresh(ServerWebExchange exchange) {
    var cookie = exchange.getRequest().getCookies().getFirst("refresh_token");
    if (cookie == null) {
        return Mono.just(ResponseEntity.status(401).build());
    }
    String refreshToken = cookie.getValue();
    return validateRefreshToken(refreshToken)
        .flatMap(user -> {
            String newAccessToken = generateAccessToken(user);
            return Mono.just(ResponseEntity.ok()
                .header("Authorization", "Bearer " + newAccessToken)
                .build());
        });
}

refresh token 轮转(每次刷新生成新的 refresh token)可防止 token 被盗后的持续滥用。

上面把新 access token 放在 Authorization 响应头里。浏览器里的前端脚本默认读不到响应头,除非 CORS 配置了 Access-Control-Expose-Headers: Authorization;更常见的做法是放在 JSON 响应体,或者直接用 Set-Cookie 下发。无论哪种方式,refresh token 都应轮转并做重放检测:旧 token 被再次使用时,应吊销整条 token 家族,而不是静默续签。

小结

概念方案注意
CORSCorsWebFilter + UrlBasedCorsConfigurationSource生产不用 *,用 AllowedOriginPattern
预检自动处理MaxAge 减少 OPTIONS 频率
CSRF无状态 API 禁用依赖 JWT + XSS 防护替代
Token 存储HttpOnly SameSite=Strict Cookie防 XSS 读取
会话JWT 无状态 或 Spring Session Redis需强制登出时用 Redis
黑名单Redis SET token:blacklist:jtiTTL = token 剩余有效期

下一节 6.3 WebFlux 测试 :WebTestClient、bindToController、绑定 RouterFunction、Mock 服务与安全上下文注入。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 15.3 全书总结与学习路径回顾
  2. 15.2 响应式流处理的演进趋势
  3. 15.1 引擎选型指南