那些年背过的题:Spring Security- CSRF源码分析

576 阅读4分钟

在使用 Spring Security 时,CSRF(跨站请求伪造,Cross-Site Request Forgery)保护是一个非常重要的安全特性。它可以防止恶意网站通过冒充用户请求来执行未授权操作。Spring Security 默认启用了 CSRF 保护,但你也可以根据需要进行配置。

1. CSRF 基本原理

CSRF 攻击通过诱使已认证用户在目标网站上执行不希望的操作。例如,当用户登录到某个网站后,攻击者可能会诱使用户点击链接或访问某个页面,从而在不知情的情况下发送恶意请求。CSRF 保护通过在每个状态改变的请求(如POST、PUT、DELETE等)中加入一个随机令牌来防止这种攻击。

2. Spring Security 中的 CSRF 配置

在 Spring Security 中,CSRF 保护默认是开启的。如果你想自定义 CSRF 的行为,可以在你的 WebSecurityConfigurerAdapter 中进行配置。例如:

import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf()
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                .and()
            .authorizeRequests()
                .antMatchers("/public/**").permitAll()
                .anyRequest().authenticated();
    }
}

3. 获取和使用 CSRF 令牌

为了使 CSRF 保护生效,你需要在每个有状态变化的请求中包含 CSRF 令牌。通常,这可以通过以下几种方式实现:

表单提交

对于传统的基于表单的应用程序,Spring 提供了自动生成 CSRF 令牌的方法。在 Thymeleaf 模板中,可以使用如下代码:

<form th:action="@{/process}" method="post">
    <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
    <!-- 其他表单字段 -->
    <button type="submit">提交</button>
</form>

AJAX 请求

对于现代的 SPA 应用程序,通常通过 JavaScript 来发起 AJAX 请求。这时需要手动将 CSRF 令牌添加到请求头中。假设你已经将 CSRF 令牌放在页面的 meta 标签中:

<meta name="_csrf" content="${_csrf.token}"/>
<meta name="_csrf_header" content="${_csrf.headerName}"/>

然后在 JavaScript 中读取并添加到请求头:

const token = document.querySelector('meta[name="_csrf"]').getAttribute('content');
const header = document.querySelector('meta[name="_csrf_header"]').getAttribute('content');

fetch('/api/endpoint', {
    method: 'POST',
    headers: {
        [header]: token,
        'Content-Type': 'application/json'
    },
    body: JSON.stringify({ /* 数据 */ })
})
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error('Error:', error));

4. 禁用 CSRF 保护

在某些情况下,例如开发环境或者需要对无状态的 RESTful API 进行特殊处理时,可能需要临时禁用 CSRF 保护。可以在 HttpSecurity 配置中禁用 CSRF 保护:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        .csrf().disable()
        .authorizeRequests()
        .antMatchers("/public/**").permitAll()
        .anyRequest().authenticated();
}

请注意,禁用 CSRF 保护会带来安全风险,因此应谨慎使用。

核心源码分析

Spring Security 的 CSRF 保护机制涉及多个关键组件,其中 CsrfFilterCsrfTokenRepository 是核心部分。接下来,我将详细解析其源码实现的核心部分,包括各个组件之间如何协作来完成 CSRF 保护。

1. CsrfFilter

CsrfFilter 是负责处理 CSRF 校验的过滤器。它继承了 OncePerRequestFilter,确保每个请求只会执行一次过滤操作。核心逻辑分为几步:加载令牌、验证令牌、生成新令牌并保存。

public class CsrfFilter extends OncePerRequestFilter {
    private final CsrfTokenRepository tokenRepository;

    public CsrfFilter(CsrfTokenRepository tokenRepository) {
        Assert.notNull(tokenRepository, "tokenRepository cannot be null");
        this.tokenRepository = tokenRepository;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        CsrfToken csrfToken = tokenRepository.loadToken(request);
        boolean missingToken = csrfToken == null;
        
        if (missingToken) {
            csrfToken = tokenRepository.generateToken(request);
            tokenRepository.saveToken(csrfToken, request, response);
        }

        request.setAttribute(CsrfToken.class.getName(), csrfToken);
        request.setAttribute(csrfToken.getParameterName(), csrfToken);

        if (shouldDoCsrf(request)) {
            String actualToken = request.getHeader(csrfToken.getHeaderName());
            if (actualToken == null) {
                actualToken = request.getParameter(csrfToken.getParameterName());
            }
            if (!csrfToken.getToken().equals(actualToken)) {
                throw new CsrfException("Invalid CSRF Token");
            }
        }

        filterChain.doFilter(request, response);
    }
    
    private boolean shouldDoCsrf(HttpServletRequest request) {
        return !HttpMethod.GET.matches(request.getMethod()) && 
               !HttpMethod.HEAD.matches(request.getMethod()) && 
               !HttpMethod.TRACE.matches(request.getMethod()) && 
               !HttpMethod.OPTIONS.matches(request.getMethod());
    }
}

2. CsrfTokenRepository

CsrfTokenRepository 是一个接口,用于管理 CSRF 令牌的生成、存储和加载。主要有两种常见实现:HttpSessionCsrfTokenRepositoryCookieCsrfTokenRepository

HttpSessionCsrfTokenRepository

该类使用 HTTP 会话来存储 CSRF 令牌。其关键方法包括:

  • generateToken: 生成新的 CSRF 令牌。
  • saveToken: 将 CSRF 令牌保存到会话中。
  • loadToken: 从会话中加载 CSRF 令牌。
public class HttpSessionCsrfTokenRepository implements CsrfTokenRepository {
    static final String DEFAULT_CSRF_PARAMETER_NAME = "_csrf";
    static final String DEFAULT_CSRF_HEADER_NAME = "X-CSRF-TOKEN";
    static final String DEFAULT_CSRF_TOKEN_ATTR_NAME = HttpSessionCsrfTokenRepository.class.getName().concat(".CSRF_TOKEN");

    private String parameterName = DEFAULT_CSRF_PARAMETER_NAME;
    private String headerName = DEFAULT_CSRF_HEADER_NAME;
    private String sessionAttributeName = DEFAULT_CSRF_TOKEN_ATTR_NAME;

    @Override
    public CsrfToken generateToken(HttpServletRequest request) {
        return new DefaultCsrfToken(this.headerName, this.parameterName, createNewToken());
    }

    @Override
    public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) {
        HttpSession session = request.getSession(false);
        if (session != null) {
            session.setAttribute(this.sessionAttributeName, token);
        }
    }

    @Override
    public CsrfToken loadToken(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        if (session != null) {
            return (CsrfToken) session.getAttribute(this.sessionAttributeName);
        }
        return null;
    }

    private String createNewToken() {
        return UUID.randomUUID().toString();
    }
}

CookieCsrfTokenRepository

该类使用 Cookie 来存储 CSRF 令牌。其关键方法包括:

  • generateToken: 生成新的 CSRF 令牌。
  • saveToken: 将 CSRF 令牌保存到 Cookie 中。
  • loadToken: 从 Cookie 中加载 CSRF 令牌。
public class CookieCsrfTokenRepository implements CsrfTokenRepository {
    private String parameterName = "_csrf";
    private String headerName = "X-CSRF-TOKEN";
    private String cookieName = "XSRF-TOKEN";
    private boolean httpOnly = true;
    private String cookiePath = "/";
    private boolean secure = false;
    private int cookieMaxAge = -1;

    @Override
    public CsrfToken generateToken(HttpServletRequest request) {
        return new DefaultCsrfToken(this.headerName, this.parameterName, createNewToken());
    }

    @Override
    public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) {
        if (token == null) {
            Cookie cookie = new Cookie(this.cookieName, "");
            cookie.setMaxAge(0);
            cookie.setPath(this.cookiePath);
            response.addCookie(cookie);
        } else {
            String tokenValue = token.getToken();
            Cookie cookie = new Cookie(this.cookieName, tokenValue);
            cookie.setSecure(this.secure);
            cookie.setPath(this.cookiePath);
            cookie.setHttpOnly(this.httpOnly);
            if (this.cookieMaxAge > -1) {
                cookie.setMaxAge(this.cookieMaxAge);
            }
            response.addCookie(cookie);
        }
    }

    @Override
    public CsrfToken loadToken(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();
        if (cookies == null) {
            return null;
        }
        for (Cookie cookie : cookies) {
            if (this.cookieName.equals(cookie.getName())) {
                String token = cookie.getValue();
                return new DefaultCsrfToken(this.headerName, this.parameterName, token);
            }
        }
        return null;
    }

    private String createNewToken() {
        return UUID.randomUUID().toString();
    }

    // Getters and setters for the various properties can be added here...
}

核心组件解析

1. CsrfFilter

  • 职责:负责拦截 HTTP 请求并执行 CSRF 令牌的验证。

  • 流程

    1. 加载请求中的 CSRF 令牌。
    2. 如果没有找到令牌,则生成一个新的令牌并保存。
    3. 将令牌设置到请求属性中,便于后续处理使用。
    4. 对非安全方法(如 POST、PUT、DELETE)进行 CSRF 校验。
    5. 若校验失败则抛出异常,否则继续过滤链。

2. CsrfTokenRepository

  • 职责:管理 CSRF 令牌的生命周期,包括生成、保存和加载令牌。

  • 常见实现

    • HttpSessionCsrfTokenRepository:通过会话存储令牌。
    • CookieCsrfTokenRepository:通过 Cookie 存储令牌。