【Spring Security】 CSRF
【Spring Security】 CSRF
Metadata
title: 【Spring Security】 CSRF
date: 2023-02-02 17:17
tags:
- 行动阶段/完成
- 主题场景/组件
- 笔记空间/KnowladgeSpace/ProgramSpace/ModuleSpace
- 细化主题/Module/SpringSecurity
categories:
- SpringSecurity
keywords:
- SpringSecurity
description: 【Spring Security】 CSRF
【Spring Security】 CSRF
[[../../../DictSpace/CSRF|CSRF]]
Spring Security 对 servlet 环境的跨站点请求伪造 (CSRF) 支持
Security 默认开启 csrf 防范,可以通过以下配置关闭:
http.csrf().disable();// 关闭防范csrf攻击
CSRF 的原理
- 生成 csrfToken 保存到 HttpSession 或者 Cookie 中
- 请求到来时,从请求中提取 csrfToken,和保存的 csrfToken 做比较,进而判断当
前请求是否合法。主要通过 CsrfFilter 过滤器来完成。
核心类
CsrfToken
CsrfToken 接口定义了获取消息头、请求参数、令牌等方法。
public interface CsrfToken extends Serializable {
/**
* Gets the HTTP header that the CSRF is populated on the response and can be placed
* on requests instead of the parameter. Cannot be null.
* @return the HTTP header that the CSRF is populated on the response and can be
* placed on requests instead of the parameter
*/
String getHeaderName();
/**
* Gets the HTTP parameter name that should contain the token. Cannot be null.
* @return the HTTP parameter name that should contain the token.
*/
String getParameterName();
/**
* Gets the token value. Cannot be null.
* @return the token value
*/
String getToken();
}
默认的实现类为 DefaultCsrfToken。
public final class DefaultCsrfToken implements CsrfToken {
private final String token;
private final String parameterName;
private final String headerName;
/**
* Creates a new instance
* @param headerName the HTTP header name to use
* @param parameterName the HTTP parameter name to use
* @param token the value of the token (i.e. expected value of the HTTP parameter of
* parametername).
*/
public DefaultCsrfToken(String headerName, String parameterName, String token) {
Assert.hasLength(headerName, "headerName cannot be null or empty");
Assert.hasLength(parameterName, "parameterName cannot be null or empty");
Assert.hasLength(token, "token cannot be null or empty");
this.headerName = headerName;
this.parameterName = parameterName;
this.token = token;
}
@Override
public String getHeaderName() {
return this.headerName;
}
@Override
public String getParameterName() {
return this.parameterName;
}
@Override
public String getToken() {
return this.token;
}
}
CsrfTokenRepository
CsrfTokenRepository 是一个接口,定义了生成、保存、加载 CsrfToken 等 API。
public interface CsrfTokenRepository {
/**
* Generates a {@link CsrfToken}
* @param request the {@link HttpServletRequest} to use
* @return the {@link CsrfToken} that was generated. Cannot be null.
*/
CsrfToken generateToken(HttpServletRequest request);
/**
* Saves the {@link CsrfToken} using the {@link HttpServletRequest} and
* {@link HttpServletResponse}. If the {@link CsrfToken} is null, it is the same as
* deleting it.
* @param token the {@link CsrfToken} to save or null to delete
* @param request the {@link HttpServletRequest} to use
* @param response the {@link HttpServletResponse} to use
*/
void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response);
/**
* Loads the expected {@link CsrfToken} from the {@link HttpServletRequest}
* @param request the {@link HttpServletRequest} to use
* @return the {@link CsrfToken} or null if none exists
*/
CsrfToken loadToken(HttpServletRequest request);
}
实现类:
HttpSessionCsrfTokenRepository 是一个基于 HttpSession 保存 csrf token 的存储库实现。
public final class HttpSessionCsrfTokenRepository implements CsrfTokenRepository {
private static final String DEFAULT_CSRF_PARAMETER_NAME = "_csrf";
private static final String DEFAULT_CSRF_HEADER_NAME = "X-CSRF-TOKEN";
private 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;
// 保存csrf token到http session
@Override
public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) {
if (token == null) {
HttpSession session = request.getSession(false);
if (session != null) {
session.removeAttribute(this.sessionAttributeName);
}
}
else {
HttpSession session = request.getSession();
session.setAttribute(this.sessionAttributeName, token);
}
}
// 从http session中提取csrf token
@Override
public CsrfToken loadToken(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return null;
}
return (CsrfToken) session.getAttribute(this.sessionAttributeName);
}
// 生成csrf token,值为一个随机UUID
@Override
public CsrfToken generateToken(HttpServletRequest request) {
return new DefaultCsrfToken(this.headerName, this.parameterName, createNewToken());
}
}
CookieCsrfTokenRepository 默认会将 csrf token 以名 XSRF-TOKEN 写入 cookie
,然后接收请求时从名为 X-XSRF-TOKEN 的 header 或者名为_csrf 的 http 请求参数读取 csrf token。但是 CookieCsrfTokenRepository 写如的 cookie 默认具有 cookieHttpOnly 属性,前端 js 是不能操作它的,这就需要在 spring security 的配置中将 cookieHttpOnly 属性设为 false。
public final class CookieCsrfTokenRepository implements CsrfTokenRepository {
static final String DEFAULT_CSRF_COOKIE_NAME = "XSRF-TOKEN";
static final String DEFAULT_CSRF_PARAMETER_NAME = "_csrf";
static final String DEFAULT_CSRF_HEADER_NAME = "X-XSRF-TOKEN";
private String parameterName = DEFAULT_CSRF_PARAMETER_NAME;
private String headerName = DEFAULT_CSRF_HEADER_NAME;
private String cookieName = DEFAULT_CSRF_COOKIE_NAME;
private boolean cookieHttpOnly = true;
private String cookiePath;
private String cookieDomain;
private Boolean secure;
private int cookieMaxAge = -1;
public CookieCsrfTokenRepository() {
}
@Override
public CsrfToken generateToken(HttpServletRequest request) {
return new DefaultCsrfToken(this.headerName, this.parameterName, createNewToken());
}
@Override
public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) {
String tokenValue = (token != null) ? token.getToken() : "";
Cookie cookie = new Cookie(this.cookieName, tokenValue);
cookie.setSecure((this.secure != null) ? this.secure : request.isSecure());
cookie.setPath(StringUtils.hasLength(this.cookiePath) ? this.cookiePath : this.getRequestContext(request));
cookie.setMaxAge((token != null) ? this.cookieMaxAge : 0);
cookie.setHttpOnly(this.cookieHttpOnly);
if (StringUtils.hasLength(this.cookieDomain)) {
cookie.setDomain(this.cookieDomain);
}
response.addCookie(cookie);
}
@Override
public CsrfToken loadToken(HttpServletRequest request) {
Cookie cookie = WebUtils.getCookie(request, this.cookieName);
if (cookie == null) {
return null;
}
String token = cookie.getValue();
if (!StringUtils.hasLength(token)) {
return null;
}
return new DefaultCsrfToken(this.headerName, this.parameterName, token);
}
}
CsrfFilter
CsrfFilter 用于处理跨站请求伪造。检查表单提交的_csrf 隐藏域的 value 与内存中保存的的是否一致,如果一致框架则认为当然登录页面是安全的,如果不一致,会报 403forbidden 错误。但是使用自定义的登录页面时,需要关闭 CsrfFilter 过滤器,因为自定义的页面并没有那样的一个隐藏域。
可以看到默认登录页面是加了隐藏域。
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 1. request中获取csrf令牌
request.setAttribute(HttpServletResponse.class.getName(), response);
CsrfToken csrfToken = this.tokenRepository.loadToken(request);
boolean missingToken = (csrfToken == null);
// 2. request中没有令牌,调用tokenRepository生成保存令牌
if (missingToken) {
csrfToken = this.tokenRepository.generateToken(request);
this.tokenRepository.saveToken(csrfToken, request, response);
}
// 3. request中设置令牌
request.setAttribute(CsrfToken.class.getName(), csrfToken);
request.setAttribute(csrfToken.getParameterName(), csrfToken);
if (!this.requireCsrfProtectionMatcher.matches(request)) {
if (this.logger.isTraceEnabled()) {
this.logger.trace("Did not protect against CSRF since request did not match "
+ this.requireCsrfProtectionMatcher);
}
filterChain.doFilter(request, response);
return;
}
String actualToken = request.getHeader(csrfToken.getHeaderName());
if (actualToken == null) {
actualToken = request.getParameter(csrfToken.getParameterName());
}
// 4. 校验不通过,抛出CsrfToken非法异常
if (!equalsConstantTime(csrfToken.getToken(), actualToken)) {
this.logger.debug(
LogMessage.of(() -> "Invalid CSRF token found for " + UrlUtils.buildFullRequestUrl(request)));
AccessDeniedException exception = (!missingToken) ? new InvalidCsrfTokenException(csrfToken, actualToken)
: new MissingCsrfTokenException(actualToken);
this.accessDeniedHandler.handle(request, response, exception);
return;
}
filterChain.doFilter(request, response);
}
流程分析
点击登录,我们 F12 可以看到当前 login 接口,传输了一个_csrf 令牌。
之后请求进入 CsrfFilter,首先会在 tokenRepository 查询后端保存的令牌。
然后从 Header 或者 Parameter 中获取 token
接下来调用 equalsConstantTime 方法,对前后端的令牌进行校验。
如果校验不通过,抛出 InvalidCsrfTokenException 异常,并调用访问拒绝处理器进行处理。
对于非登录接口,我们这里配置的是 GET 请求,而调用 requireCsrfProtectionMatcher.matches 判断请求方法是否是 GET,HEAD,TRACE,OPTIONS,那么我们这个 GET 请求则不会使用 CSRF 防御。
也就是说只有 GET|HEAD|TRACE|OPTIONS 这 4 类方法会被放行,其它 Method 的 http 请求,都要验证_csrf 的 token 是否正确,而通常 post 方式调用 rest 服务时,又没有_csrf 的 token,所以校验失败。此时需要处理 Post 请求,访问时携带后端传过来的 csrftoken。
也就是说只有 GET|HEAD|TRACE|OPTIONS 这 4 类方法会被放行,其它 Method 的 http 请求,都要验证_csrf 的 token 是否正确,而通常 post 方式调用 rest 服务时,又没有_csrf 的 token,所以校验失败。此时需要处理 Post 请求,访问时携带后端传过来的 csrftoken。