要學就學透徹!Spring Security 中 CSRF 防護源碼解析

上篇文章鬆哥和你們聊了什麼是 CSRF 攻擊,以及 CSRF 攻擊要如何防護。主要和你們聊了 Spring Security 中處理該問題的幾種辦法。html

今天鬆哥來和你們簡單的看一下 Spring Security 中,CSRF 防護源碼。前端

本文是本系列第 19 篇,閱讀本系列前面文章有助於更好的理解本文:java

  1. 挖一個大坑,Spring Security 開搞!
  2. 鬆哥手把手帶你入門 Spring Security,別再問密碼怎麼解密了
  3. 手把手教你定製 Spring Security 中的表單登陸
  4. Spring Security 作先後端分離,咱就別作頁面跳轉了!通通 JSON 交互
  5. Spring Security 中的受權操做原來這麼簡單
  6. Spring Security 如何將用戶數據存入數據庫?
  7. Spring Security+Spring Data Jpa 強強聯手,安全管理只有更簡單!
  8. Spring Boot + Spring Security 實現自動登陸功能
  9. Spring Boot 自動登陸,安全風險要怎麼控制?
  10. 在微服務項目中,Spring Security 比 Shiro 強在哪?
  11. SpringSecurity 自定義認證邏輯的兩種方式(高級玩法)
  12. Spring Security 中如何快速查看登陸用戶 IP 地址等信息?
  13. Spring Security 自動踢掉前一個登陸用戶,一個配置搞定!
  14. Spring Boot + Vue 先後端分離項目,如何踢掉已登陸用戶?
  15. Spring Security 自帶防火牆!你都不知道本身的系統有多安全!
  16. 什麼是會話固定攻擊?Spring Boot 中要如何防護會話固定攻擊?
  17. 集羣化部署,Spring Security 要如何處理 session 共享?
  18. 鬆哥手把手教你在 SpringBoot 中防護 CSRF 攻擊!so easy!

本文主要從兩個方面來和你們講解:數據庫

  1. 返回給前端的 _csrf 參數是如何生成的。
  2. 前端傳來的 _csrf 參數是如何校驗的。

1.隨機字符串生成

咱們先來看一下 Spring Security 中的 csrf 參數是如何生成的。後端

首先,Spring Security 中提供了一個保存 csrf 參數的規範,就是 CsrfToken:安全

public interface CsrfToken extends Serializable {
    String getHeaderName();
    String getParameterName();
    String getToken();

}

這裏三個方法都好理解,前兩個是獲取 _csrf 參數的 key,第三個是獲取 _csrf 參數的 value。cookie

CsrfToken 有兩個實現類,以下:session

默認狀況下使用的是 DefaultCsrfToken,咱們來稍微看下 DefaultCsrfToken:前後端分離

public final class DefaultCsrfToken implements CsrfToken {
    private final String token;
    private final String parameterName;
    private final String headerName;
    public DefaultCsrfToken(String headerName, String parameterName, String token) {
        this.headerName = headerName;
        this.parameterName = parameterName;
        this.token = token;
    }
    public String getHeaderName() {
        return this.headerName;
    }
    public String getParameterName() {
        return this.parameterName;
    }
    public String getToken() {
        return this.token;
    }
}

這段實現很簡單,幾乎沒有添加額外的方法,就是接口方法的實現。dom

CsrfToken 至關於就是 _csrf 參數的載體。那麼參數是如何生成和保存的呢?這涉及到另一個類:

public interface CsrfTokenRepository {
    CsrfToken generateToken(HttpServletRequest request);
    void saveToken(CsrfToken token, HttpServletRequest request,
            HttpServletResponse response);
    CsrfToken loadToken(HttpServletRequest request);
}

這裏三個方法:

  1. generateToken 方法就是 CsrfToken 的生成過程。
  2. saveToken 方法就是保存 CsrfToken。
  3. loadToken 則是如何加載 CsrfToken。

CsrfTokenRepository 有四個實現類,在上篇文章中,咱們用到了其中兩個:HttpSessionCsrfTokenRepository 和 CookieCsrfTokenRepository,其中 HttpSessionCsrfTokenRepository 是默認的方案。

咱們先來看下 HttpSessionCsrfTokenRepository 的實現:

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;
    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);
        }
    }
    public CsrfToken loadToken(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        if (session == null) {
            return null;
        }
        return (CsrfToken) session.getAttribute(this.sessionAttributeName);
    }
    public CsrfToken generateToken(HttpServletRequest request) {
        return new DefaultCsrfToken(this.headerName, this.parameterName,
                createNewToken());
    }
    private String createNewToken() {
        return UUID.randomUUID().toString();
    }
}

這段源碼其實也很好理解:

  1. saveToken 方法將 CsrfToken 保存在 HttpSession 中,未來再從 HttpSession 中取出和前端傳來的參數作筆記。
  2. loadToken 方法固然就是從 HttpSession 中讀取 CsrfToken 出來。
  3. generateToken 是生成 CsrfToken 的過程,能夠看到,生成的默認載體就是 DefaultCsrfToken,而 CsrfToken 的值則經過 createNewToken 方法生成,是一個 UUID 字符串。
  4. 在構造 DefaultCsrfToken 是還有兩個參數 headerName 和 parameterName,這兩個參數是前端保存參數的 key。

這是默認的方案,適用於先後端不分的開發,具體用法能夠參考上篇文章

若是想在先後端分離開發中使用,那就須要 CsrfTokenRepository 的另外一個實現類 CookieCsrfTokenRepository ,代碼以下:

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;
    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(request.isSecure());
        if (this.cookiePath != null && !this.cookiePath.isEmpty()) {
                cookie.setPath(this.cookiePath);
        } else {
                cookie.setPath(this.getRequestContext(request));
        }
        if (token == null) {
            cookie.setMaxAge(0);
        }
        else {
            cookie.setMaxAge(-1);
        }
        cookie.setHttpOnly(cookieHttpOnly);
        if (this.cookieDomain != null && !this.cookieDomain.isEmpty()) {
            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);
    }
    public static CookieCsrfTokenRepository withHttpOnlyFalse() {
        CookieCsrfTokenRepository result = new CookieCsrfTokenRepository();
        result.setCookieHttpOnly(false);
        return result;
    }
    private String createNewToken() {
        return UUID.randomUUID().toString();
    }
}

和 HttpSessionCsrfTokenRepository 相比,這裏 _csrf 數據保存的時候,都保存到 cookie 中去了,固然讀取的時候,也是從 cookie 中讀取,其餘地方則和 HttpSessionCsrfTokenRepository 是同樣的。

OK,這就是咱們整個 _csrf 參數生成的過程。

總結一下,就是生成一個 CsrfToken,這個 Token,本質上就是一個 UUID 字符串,而後將這個 Token 保存到 HttpSession 中,或者保存到 Cookie 中,待請求到來時,從 HttpSession 或者 Cookie 中取出來作校驗。

2.參數校驗

那接下來就是校驗了。

校驗主要是經過 CsrfFilter 過濾器來進行,咱們來看下核心的 doFilterInternal 方法:

protected void doFilterInternal(HttpServletRequest request,
        HttpServletResponse response, FilterChain filterChain)
                throws ServletException, IOException {
    request.setAttribute(HttpServletResponse.class.getName(), response);
    CsrfToken csrfToken = this.tokenRepository.loadToken(request);
    final boolean missingToken = csrfToken == null;
    if (missingToken) {
        csrfToken = this.tokenRepository.generateToken(request);
        this.tokenRepository.saveToken(csrfToken, request, response);
    }
    request.setAttribute(CsrfToken.class.getName(), csrfToken);
    request.setAttribute(csrfToken.getParameterName(), csrfToken);
    if (!this.requireCsrfProtectionMatcher.matches(request)) {
        filterChain.doFilter(request, response);
        return;
    }
    String actualToken = request.getHeader(csrfToken.getHeaderName());
    if (actualToken == null) {
        actualToken = request.getParameter(csrfToken.getParameterName());
    }
    if (!csrfToken.getToken().equals(actualToken)) {
        if (this.logger.isDebugEnabled()) {
            this.logger.debug("Invalid CSRF token found for "
                    + UrlUtils.buildFullRequestUrl(request));
        }
        if (missingToken) {
            this.accessDeniedHandler.handle(request, response,
                    new MissingCsrfTokenException(actualToken));
        }
        else {
            this.accessDeniedHandler.handle(request, response,
                    new InvalidCsrfTokenException(csrfToken, actualToken));
        }
        return;
    }
    filterChain.doFilter(request, response);
}

這個方法我來稍微解釋下:

  1. 首先調用 tokenRepository.loadToken 方法讀取 CsrfToken 出來,這個 tokenRepository 就是你配置的 CsrfTokenRepository 實例,CsrfToken 存在 HttpSession 中,這裏就從 HttpSession 中讀取,CsrfToken 存在 Cookie 中,這裏就從 Cookie 中讀取。
  2. 若是調用 tokenRepository.loadToken 方法沒有加載到 CsrfToken,那說明這個請求多是第一次發起,則調用 tokenRepository.generateToken 方法生成 CsrfToken ,並調用 tokenRepository.saveToken 方法保存 CsrfToken。
  3. 你們注意,這裏還調用 request.setAttribute 方法存了一些值進去,這就是默認狀況下,咱們經過 jsp 或者 thymeleaf 標籤渲染 _csrf 的數據來源。
  4. requireCsrfProtectionMatcher.matches 方法則使用用來判斷哪些請求方法須要作校驗,默認狀況下,"GET", "HEAD", "TRACE", "OPTIONS" 方法是不須要校驗的。
  5. 接下來獲取請求中傳遞來的 CSRF 參數,先從請求頭中獲取,獲取不到再從請求參數中獲取。
  6. 獲取到請求傳來的 csrf 參數以後,再和一開始加載到的 csrfToken 作比較,若是不一樣的話,就拋出異常。

如此以後,就完成了整個校驗工做了。

3.LazyCsrfTokenRepository

前面咱們說了 CsrfTokenRepository 有四個實現類,除了咱們介紹的兩個以外,還有一個 LazyCsrfTokenRepository,這裏鬆哥也和你們作一個簡單介紹。

在前面的 CsrfFilter 中你們發現,對於常見的 GET 請求其實是不須要 CSRF 攻擊校驗的,可是,每當 GET 請求到來時,下面這段代碼都會執行:

if (missingToken) {
    csrfToken = this.tokenRepository.generateToken(request);
    this.tokenRepository.saveToken(csrfToken, request, response);
}

生成 CsrfToken 並保存,但實際上卻沒什麼用,由於 GET 請求不須要 CSRF 攻擊校驗。

因此,Spring Security 官方又推出了 LazyCsrfTokenRepository。

LazyCsrfTokenRepository 實際上不能算是一個真正的 CsrfTokenRepository,它是一個代理,能夠用來加強 HttpSessionCsrfTokenRepository 或者 CookieCsrfTokenRepository 的功能:

public final class LazyCsrfTokenRepository implements CsrfTokenRepository {
    @Override
    public CsrfToken generateToken(HttpServletRequest request) {
        return wrap(request, this.delegate.generateToken(request));
    }
    @Override
    public void saveToken(CsrfToken token, HttpServletRequest request,
            HttpServletResponse response) {
        if (token == null) {
            this.delegate.saveToken(token, request, response);
        }
    }
    @Override
    public CsrfToken loadToken(HttpServletRequest request) {
        return this.delegate.loadToken(request);
    }
    private CsrfToken wrap(HttpServletRequest request, CsrfToken token) {
        HttpServletResponse response = getResponse(request);
        return new SaveOnAccessCsrfToken(this.delegate, request, response, token);
    }
    private static final class SaveOnAccessCsrfToken implements CsrfToken {
        private transient CsrfTokenRepository tokenRepository;
        private transient HttpServletRequest request;
        private transient HttpServletResponse response;

        private final CsrfToken delegate;

        SaveOnAccessCsrfToken(CsrfTokenRepository tokenRepository,
                HttpServletRequest request, HttpServletResponse response,
                CsrfToken delegate) {
            this.tokenRepository = tokenRepository;
            this.request = request;
            this.response = response;
            this.delegate = delegate;
        }
        @Override
        public String getToken() {
            saveTokenIfNecessary();
            return this.delegate.getToken();
        }
        private void saveTokenIfNecessary() {
            if (this.tokenRepository == null) {
                return;
            }

            synchronized (this) {
                if (this.tokenRepository != null) {
                    this.tokenRepository.saveToken(this.delegate, this.request,
                            this.response);
                    this.tokenRepository = null;
                    this.request = null;
                    this.response = null;
                }
            }
        }

    }
}

這裏,我說三點:

  1. generateToken 方法,該方法用來生成 CsrfToken,默認 CsrfToken 的載體是 DefaultCsrfToken,如今換成了 SaveOnAccessCsrfToken。
  2. SaveOnAccessCsrfToken 和 DefaultCsrfToken 並無太大區別,主要是 getToken 方法有區別,在 SaveOnAccessCsrfToken 中,當開發者調用 getToken 想要去獲取 csrfToken 時,纔會去對 csrfToken 作保存操做(調用 HttpSessionCsrfTokenRepository 或者 CookieCsrfTokenRepository 的 saveToken 方法)。
  3. LazyCsrfTokenRepository 本身的 saveToken 則作了修改,至關於放棄了 saveToken 的功能,調用該方法並不會作保存操做。

使用了 LazyCsrfTokenRepository 以後,只有在使用 csrfToken 時纔會去存儲它,這樣就能夠節省存儲空間了。

LazyCsrfTokenRepository 的配置方式也很簡單,在咱們使用 Spring Security 時,若是對 csrf 不作任何配置,默認其實就是 LazyCsrfTokenRepository+HttpSessionCsrfTokenRepository 組合。

固然咱們也能夠本身配置,以下:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests().anyRequest().authenticated()
            .and()
            .formLogin()
            .loginPage("/login.html")
            .successHandler((req,resp,authentication)->{
                resp.getWriter().write("success");
            })
            .permitAll()
            .and()
            .csrf().csrfTokenRepository(new LazyCsrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()));
}

4.小結

今天主要和小夥伴聊了一下 Spring Security 中 csrf 防護的原理。

總體來講,就是兩個思路:

  1. 生成 csrfToken 保存在 HttpSession 或者 Cookie 中。
  2. 請求到來時,從請求中提取出來 csrfToken,和保存的 csrfToken 作比較,進而判斷出當前請求是否合法。

好啦,不知道小夥伴們有沒有 GET 到呢?若是以爲有收穫,記得點個在看鼓勵下鬆哥哦~

相關文章
相關標籤/搜索