JWT 接口 CSRF 防护:无状态架构如何防跨站请求伪造?Double Submit Cookie 方案
一、问题背景:JWT 的 CSRF 隐患
你是否认为使用 JWT 就天然安全?实际上,JWT 在某些场景下依然存在 CSRF 风险!
当 JWT 存储在 Cookie 中(尤其是 HttpOnly=false 的情况),攻击者可以通过以下方式发起 CSRF 攻击:
- 用户登录你的网站,服务器返回 JWT 并存放在 Cookie 中
- 攻击者诱导用户访问恶意网站
- 恶意网站发起对目标网站的请求(如转账、修改密码)
- 浏览器自动携带 Cookie 中的 JWT,请求成功执行
真实案例:某电商平台的支付接口使用 JWT 认证,但未做 CSRF 防护。攻击者通过构造恶意页面,诱导用户点击后成功发起支付请求,造成用户资金损失。
二、核心概念:CSRF 与 JWT 的博弈
2.1 CSRF 攻击原理
┌──────────────────────────────────────────────────────────────────┐
│ CSRF 攻击流程 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 用户浏览器 │────►│ 恶意网站 │────►│ 目标服务器 │ │
│ │ (已登录状态) │ │ (CSRF代码) │ │ (验证JWT) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │
│ │──────────────────────────────────────────│ │
│ │ Cookie自动携带 │ │
│ ▼ ▼ │
│ JWT存储在Cookie中 服务器验证通过 │
└──────────────────────────────────────────────────────────────────┘
2.2 JWT 的三种存储方式对比
| 存储方式 | CSRF 风险 | XSS 风险 | 适用场景 |
|---|---|---|---|
| Cookie (HttpOnly) | 低(需配合其他措施) | 低 | 服务端渲染 |
| Cookie (非HttpOnly) | 高 | 高 | 不推荐 |
| LocalStorage | 低(需手动添加到请求头) | 高 | SPA 应用 |
| SessionStorage | 低 | 高 | 单页会话 |
三、实现方案:Double Submit Cookie
3.1 方案原理
Double Submit Cookie 是一种无状态的 CSRF 防护方案,核心思想是:
-
生成 CSRF Token:服务器生成随机 Token,同时存储在两个地方
- Cookie 中(随请求自动携带)
- 响应体中(前端需要手动获取)
-
验证 Token:客户端发起请求时,需要在请求头或请求体中携带 CSRF Token,服务器验证 Cookie 中的 Token 与请求中的 Token 是否一致
┌──────────────────────────────────────────────────────────────────┐
│ Double Submit Cookie 原理 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ 服务器 客户端 │
│ │ │ │
│ │─── 1. 设置 Cookie: XSRF-TOKEN ──►│ │
│ │ │ │
│ │─── 2. 返回 Token 在响应中 ──────►│ │
│ │ │ │
│ │ │─── 3. 读取 Cookie + Token │
│ │ │ │
│ │◄─── 4. 请求携带 X-XSRF-TOKEN ────│ │
│ │ │ │
│ │─── 5. 验证两个 Token 是否一致 ────│ │
│ │ │ │
└──────────────────────────────────────────────────────────────────┘
3.2 CSRF 配置类
@Configuration
public class CsrfConfig {
/**
* CSRF Token 的 Cookie 名称
*/
public static final String CSRF_COOKIE_NAME = "XSRF-TOKEN";
/**
* CSRF Token 的请求头名称
*/
public static final String CSRF_HEADER_NAME = "X-XSRF-TOKEN";
/**
* Token 长度
*/
private static final int TOKEN_LENGTH = 32;
/**
* Token 有效期(1小时)
*/
private static final int TOKEN_EXPIRE_SECONDS = 3600;
/**
* 生成 CSRF Token
*/
public String generateToken() {
return UUID.randomUUID().toString().replace("-", "").substring(0, TOKEN_LENGTH);
}
/**
* 创建 CSRF Cookie
*/
public Cookie createCsrfCookie(String token) {
Cookie cookie = new Cookie(CSRF_COOKIE_NAME, token);
cookie.setHttpOnly(false); // 必须设为 false,允许 JS 读取
cookie.setSecure(true); // 生产环境建议启用 HTTPS
cookie.setPath("/");
cookie.setMaxAge(TOKEN_EXPIRE_SECONDS);
cookie.setSameSite(Cookie.SameSite.STRICT); // 防止跨站发送
return cookie;
}
}
3.3 CSRF 过滤器实现
@Component
@Slf4j
public class CsrfFilter extends OncePerRequestFilter {
@Autowired
private CsrfConfig csrfConfig;
/**
* 不需要 CSRF 防护的路径
*/
private static final Set<String> EXCLUDED_PATHS = Set.of(
"/api/auth/login",
"/api/auth/logout",
"/api/health",
"/static/",
"/public/"
);
/**
* 需要 CSRF 防护的 HTTP 方法
*/
private static final Set<String> PROTECTED_METHODS = Set.of(
"POST",
"PUT",
"DELETE",
"PATCH"
);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String requestURI = request.getRequestURI();
String method = request.getMethod();
// 跳过不需要防护的路径
if (isExcludedPath(requestURI)) {
filterChain.doFilter(request, response);
return;
}
// 跳过不需要防护的方法
if (!PROTECTED_METHODS.contains(method)) {
filterChain.doFilter(request, response);
return;
}
// 获取 Cookie 中的 Token
String cookieToken = getCookieValue(request, CsrfConfig.CSRF_COOKIE_NAME);
// 获取请求头中的 Token
String headerToken = request.getHeader(CsrfConfig.CSRF_HEADER_NAME);
// 如果 Cookie 中没有 Token,生成新的 Token
if (cookieToken == null) {
String newToken = csrfConfig.generateToken();
response.addCookie(csrfConfig.createCsrfCookie(newToken));
filterChain.doFilter(request, response);
return;
}
// 验证 Token
if (headerToken == null || !headerToken.equals(cookieToken)) {
log.warn("CSRF token validation failed for URI: {}", requestURI);
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getWriter().write("CSRF token mismatch");
return;
}
// Token 验证通过
filterChain.doFilter(request, response);
}
private boolean isExcludedPath(String requestURI) {
return EXCLUDED_PATHS.stream()
.anyMatch(path -> requestURI.startsWith(path));
}
private String getCookieValue(HttpServletRequest request, String cookieName) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return null;
}
for (Cookie cookie : cookies) {
if (cookieName.equals(cookie.getName())) {
return cookie.getValue();
}
}
return null;
}
}
3.4 登录时返回 CSRF Token
@RestController
@RequestMapping("/api/auth")
@Slf4j
public class AuthController {
@Autowired
private CsrfConfig csrfConfig;
@Autowired
private JwtTokenProvider jwtTokenProvider;
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request,
HttpServletResponse response) {
// 验证用户
User user = userService.findByUsername(request.getUsername());
if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new AuthenticationException("用户名或密码错误");
}
// 生成 JWT Token
String jwtToken = jwtTokenProvider.generateToken(user.getId(), user.getUsername());
// 设置 JWT Cookie(HttpOnly)
Cookie jwtCookie = new Cookie("JWT-TOKEN", jwtToken);
jwtCookie.setHttpOnly(true); // 重要:防止 XSS 攻击
jwtCookie.setSecure(true);
jwtCookie.setPath("/");
jwtCookie.setMaxAge(86400); // 24小时
jwtCookie.setSameSite(Cookie.SameSite.STRICT);
response.addCookie(jwtCookie);
// 生成并设置 CSRF Token
String csrfToken = csrfConfig.generateToken();
response.addCookie(csrfConfig.createCsrfCookie(csrfToken));
// 返回 CSRF Token(供前端使用)
return ResponseEntity.ok(LoginResponse.builder()
.userId(user.getId())
.username(user.getUsername())
.csrfToken(csrfToken)
.build());
}
}
3.5 前端集成示例
// axios 拦截器配置
const axiosInstance = axios.create({
baseURL: '/api',
withCredentials: true // 重要:携带 Cookie
});
// 请求拦截器:添加 CSRF Token
axiosInstance.interceptors.request.use(config => {
// 从 Cookie 中读取 CSRF Token
const csrfToken = getCookie('XSRF-TOKEN');
if (csrfToken) {
config.headers['X-XSRF-TOKEN'] = csrfToken;
}
return config;
});
// 响应拦截器:处理 Token 刷新
axiosInstance.interceptors.response.use(
response => response,
error => {
if (error.response.status === 403 && error.response.data === 'CSRF token mismatch') {
// CSRF Token 失效,重新获取
location.reload();
}
return Promise.reject(error);
}
);
// 工具函数:读取 Cookie
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
3.6 Spring Security 集成配置
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CsrfFilter csrfFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// 禁用默认的 CSRF 保护(我们使用自定义实现)
.csrf().disable()
// 添加自定义 CSRF 过滤器
.addFilterBefore(csrfFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeRequests()
.antMatchers("/api/auth/login", "/api/health", "/public/**").permitAll()
.anyRequest().authenticated()
// 配置 Session 策略为无状态
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/static/**", "/favicon.ico");
}
}
四、JWT 认证过滤器
@Component
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Autowired
private UserDetailsService userDetailsService;
private static final String JWT_COOKIE_NAME = "JWT-TOKEN";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 从 Cookie 中获取 JWT Token
String jwtToken = getCookieValue(request, JWT_COOKIE_NAME);
if (jwtToken != null && jwtTokenProvider.validateToken(jwtToken)) {
try {
// 解析 Token 获取用户信息
String username = jwtTokenProvider.getUsernameFromToken(jwtToken);
// 加载用户详情
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// 创建认证对象
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
// 设置认证上下文
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("JWT authentication successful for user: {}", username);
} catch (Exception e) {
log.warn("JWT authentication failed: {}", e.getMessage());
}
}
filterChain.doFilter(request, response);
}
private String getCookieValue(HttpServletRequest request, String cookieName) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return null;
}
for (Cookie cookie : cookies) {
if (cookieName.equals(cookie.getName())) {
return cookie.getValue();
}
}
return null;
}
}
五、配置文件示例
server:
port: 8080
spring:
application:
name: jwt-csrf-demo
servlet:
session:
cookie:
secure: true
same-site: strict
# JWT 配置
jwt:
secret: your-256-bit-secret-key-here-must-be-at-least-32-characters
expiration: 86400000 # 24小时
# CSRF 配置
csrf:
enabled: true
token-expire-seconds: 3600
excluded-paths:
- /api/auth/login
- /api/auth/logout
- /api/health
logging:
level:
com.example.csrf: DEBUG
六、安全加固建议
6.1 Cookie 属性设置
| 属性 | 值 | 说明 |
|---|---|---|
HttpOnly | JWT: true, CSRF: false | JWT 防 XSS,CSRF 需要 JS 读取 |
Secure | true | 仅在 HTTPS 下传输 |
SameSite | Strict/Lax | 防止跨站发送 |
Path | / | 全站有效 |
Max-Age | 根据业务需求 | 合理设置有效期 |
6.2 其他防护措施
- Referer 检查:验证请求来源
- Origin 检查:验证请求域名
- 请求频率限制:防止暴力攻击
- 验证码:敏感操作强制验证
- 双重认证:重要操作需要额外验证
6.3 安全检查清单
- JWT Cookie 设置为
HttpOnly - CSRF Token Cookie 设置为
HttpOnly=false - 所有 Cookie 设置
Secure和SameSite - 使用 HTTPS 协议
- 验证请求头中的 CSRF Token
- 对敏感操作增加额外验证
互动话题
您在使用 JWT 时遇到过安全问题吗?您是如何实现 CSRF 防护的?欢迎在评论区分享您的经验! 更多技术文章,欢迎关注公众号:服务端技术精选。
标题:JWT 接口 CSRF 防护:无状态架构如何防跨站请求伪造?Double Submit Cookie 方案
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/06/18/1781424455523.html
公众号:服务端技术精选
评论
0 评论