JWT 接口 CSRF 防护:无状态架构如何防跨站请求伪造?Double Submit Cookie 方案

一、问题背景:JWT 的 CSRF 隐患

你是否认为使用 JWT 就天然安全?实际上,JWT 在某些场景下依然存在 CSRF 风险

当 JWT 存储在 Cookie 中(尤其是 HttpOnly=false 的情况),攻击者可以通过以下方式发起 CSRF 攻击:

  1. 用户登录你的网站,服务器返回 JWT 并存放在 Cookie 中
  2. 攻击者诱导用户访问恶意网站
  3. 恶意网站发起对目标网站的请求(如转账、修改密码)
  4. 浏览器自动携带 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 防护方案,核心思想是:

  1. 生成 CSRF Token:服务器生成随机 Token,同时存储在两个地方

    • Cookie 中(随请求自动携带)
    • 响应体中(前端需要手动获取)
  2. 验证 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 属性设置

属性说明
HttpOnlyJWT: true, CSRF: falseJWT 防 XSS,CSRF 需要 JS 读取
Securetrue仅在 HTTPS 下传输
SameSiteStrict/Lax防止跨站发送
Path/全站有效
Max-Age根据业务需求合理设置有效期

6.2 其他防护措施

  1. Referer 检查:验证请求来源
  2. Origin 检查:验证请求域名
  3. 请求频率限制:防止暴力攻击
  4. 验证码:敏感操作强制验证
  5. 双重认证:重要操作需要额外验证

6.3 安全检查清单

  •  JWT Cookie 设置为 HttpOnly
  •  CSRF Token Cookie 设置为 HttpOnly=false
  •  所有 Cookie 设置 SecureSameSite
  •  使用 HTTPS 协议
  •  验证请求头中的 CSRF Token
  •  对敏感操作增加额外验证

互动话题

您在使用 JWT 时遇到过安全问题吗?您是如何实现 CSRF 防护的?欢迎在评论区分享您的经验! 更多技术文章,欢迎关注公众号:服务端技术精选。


标题:JWT 接口 CSRF 防护:无状态架构如何防跨站请求伪造?Double Submit Cookie 方案
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/06/18/1781424455523.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消