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 家族,而不是静默续签。
小结
| 概念 | 方案 | 注意 |
|---|---|---|
| CORS | CorsWebFilter + UrlBasedCorsConfigurationSource | 生产不用 *,用 AllowedOriginPattern |
| 预检 | 自动处理 | MaxAge 减少 OPTIONS 频率 |
| CSRF | 无状态 API 禁用 | 依赖 JWT + XSS 防护替代 |
| Token 存储 | HttpOnly SameSite=Strict Cookie | 防 XSS 读取 |
| 会话 | JWT 无状态 或 Spring Session Redis | 需强制登出时用 Redis |
| 黑名单 | Redis SET token:blacklist:jti | TTL = token 剩余有效期 |
下一节 6.3 WebFlux 测试 :WebTestClient、bindToController、绑定 RouterFunction、Mock 服务与安全上下文注入。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。