Java
JavaScript
Thymeleaf
spring-boot

Spring BootでSpring SecurityのCSRF対策を使う

More than 1 year has passed since last update.

ちょっと前にいじってたんですが、諸々忘れかけているので復習を兼ねてまとめてみます。

環境

Spring Boot 1.3.3.RELEASE

設定

依存関係を追加

pom.xml
...中略
  <dependencies>
...中略
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
  </dependencies>

...中略

設定クラス追加

org.springframework.security.config.annotation.web.WebWebSecurityConfigurerAdapter
を継承した設定クラスを作成し、@EnableWebSecurityを付与しておきます。

Security設定クラス例
package jp.gr.java_conf.nenokido2000.sample;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
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;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    /*
     * { @inheritDoc }
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth)
            throws Exception {
        auth.inMemoryAuthentication().withUser("user").password("user")
                .roles("USER");
    }

    /*
     * { @inheritDoc }
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().anyRequest().authenticated();

        http.formLogin().loginPage("/login").usernameParameter("username")
                .passwordParameter("password")
                .loginProcessingUrl("/login/auth").failureUrl("/login/error")
                .permitAll();

        http.logout().logoutUrl("/logout").permitAll()
                .logoutSuccessUrl("/login")
                .deleteCookies("JSESSIONID").invalidateHttpSession(true);

    }

サンプルなので、inMemoryAuthenticationを用いた簡易的な認証と、ログイン/ログアウトの設定、全リクエストにログイン済の認可を適用するだけにしてあります。

CSRF対策の実装

Thymeleafを用いた画面遷移ベースの処理の場合

前段の設定がされたアプリケーションで、こんな感じでThymeleafにform書いておけば

フォームの例
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
  <body>
    <div id="container">
      <form id="loginForm" method="post" th:action="@{/login/auth}">
        <label for="username">username</label>
        <input id="username" name="username" type="text" />
        <label for="password">password</label>
        <input id="password" name="password" type="password" />
        <button type="submit">login</button>
      </form>
    </div>
  </body>
</html>

CSRF対策用のtokenが「_csrf」として自動的に埋め込まれます。

フォームの例_HTML変換後
<!DOCTYPE HTML>
<html>
  <body>
    <div id="container">
      <form id="loginForm" method="post" action="/login/auth">
        <label for="username">username</label>
        <input id="username" name="username" type="text" />
        <label for="password">password</label>
        <input id="password" name="password" type="password" />
        <button type="submit">login</button>
      <input type="hidden" name="_csrf" value="5e5f0472-c675-43ad-be0a-d673a0325db4" /></form>
    </div>
  </body>
</html>

Thymeleaf + @EnableWebSecurityを用いた設定を行うと、CSRFのチェックが必要なmethodを用いたformに自動的にtokenが埋め込まれる設定になるそうです。

参考:http://docs.spring.io/spring-security/site/docs/current/reference/html/csrf.html

このtokenを用いて、org.springframework.security.web.csrf.CsrfFilterでチェックが行われます。

画面を用いない処理の場合

例えばSPAっぽい作りの処理があって、APIに対してPOST/PUTなどのリクエストを行う、といった場合も、それらのリクエストはCSRFチェックの対象となります。
画面遷移ならhiddenでtoken渡せばいいけど、そうじゃない場合はどうするのがベターなんだろうかと調べると、以下のエントリがありました。

https://spring.io/guides/tutorials/spring-security-and-angular-js/

これはAngularを使ってますが、特にFWに限定されたことではない部分として、以下の内容を読み取りました。

  • Spring Securityはリクエストヘッダ「X-CSRF-TOKEN」がある場合には、その値をCSRFチェックのtokenとして使用するよ。
  • クライアントサイドへのtokenの受け渡しは、Cookieを使うのがいいんじゃない。
  • tokenをCookieに設定するのは、Filter作ってそこで実装するよ。
  • 作ったFilterは、Spring SecurityのFilter Chainに追加してね。

では、これらを実装してみます。

tokenをCookieに設定するfilterの作成

Filterの実装サンプル
package jp.gr.java_conf.nenokido2000.sample.filter;

import java.io.IOException;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.WebUtils;

/**
 * <pre>
 * API通信に対してCSRFトークンをCookieで設定するためのFilter
 * </pre>
 * 
 * @author naoki.enokido
 *
 */
public class CsrfCookieFilter extends OncePerRequestFilter {

    /** CookieにCSRFを設定する際の名称 */
    private static final String CSRF_COOKIE_NAME = "_ctkn";

    /** CookieにCSRFを設定する際の有効範囲 */
    private static final String CSRF_COOKIE_PATH = "/";

    /*
     * { @inheritDoc }
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request,
            HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        final CsrfToken csrf = (CsrfToken) request.getAttribute(CsrfToken.class
                .getName());

        if (csrf != null) {
            final String token = csrf.getToken();
            Cookie cookie = WebUtils.getCookie(request, CSRF_COOKIE_NAME);
            if (cookie == null || token != null
                    && !token.equals(cookie.getValue())) {
                cookie = new Cookie(CSRF_COOKIE_NAME, token);
                cookie.setPath(CSRF_COOKIE_PATH);
                response.addCookie(cookie);
            }
        }

        filterChain.doFilter(request, response);
    }
}

参考にしたエントリにあったコードを参考に作成しています。
前段の処理(org.springframework.security.web.csrf.CsrfFilter)でtokenが設定されている前提で、それを取得して前回取得していたtokenと差異があれば再度Cookieを設定し直す、という処理になります。

作成したFilterをSpring SecurityのFilter Chainに追加する。

設定クラスに記述追加します。

Security設定クラス例
package jp.gr.java_conf.nenokido2000.sample;

import jp.gr.java_conf.nenokido2000.sample.filter.CsrfCookieFilter;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
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;
import org.springframework.security.web.csrf.CsrfFilter;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

...中略

    /*
     * { @inheritDoc }
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {

...中略

        http.addFilterAfter(new CsrfCookieFilter(), CsrfFilter.class);
    }
}

org.springframework.security.config.annotation.web.builders.HttpSecurity#addFilterAfter
を呼び出して追加します。

Spring Securityは

org.springframework.security.web.FilterChainProxy$VirtualFilterChain

を用いて独自のFilter Chainを持っており、この中に追加することになります。
追加する位置としてはtokenが設定された後であれば大丈夫そうですが、設定された直後、即ち

org.springframework.security.web.csrf.CsrfFilter

の直後に追加するのが望ましいようです。

クライアントサイド

超雑ですが、React + superagent + cookieでサンプル書いてみました。

クライアントサイド例
import React from 'react';
import request from 'superagent';
import cookie from 'cookie';

export default class App extends React.Component {

    constructor(props) {
        super(props);
    }

    handleGet() {
        request
            .get('/api')
            .end((err, res) => {
                if (res.ok) {
                    alert(res.body.message);
                } else if (res.forbidden) {
                    alert('forbidden');
                } else {
                    alert(`error:${res.status}`);
                }
            });
    }

    handlePut() {
        const cookies = cookie.parse(document.cookie);
        const csrf = cookies._ctkn;
        if (csrf) {
            request
                .put('/api')
                .set('X-CSRF-TOKEN', csrf)
                .end((err, res) => {
                    if (res.ok) {
                        alert(res.body.message);
                    } else if (res.forbidden) {
                        alert('forbidden');
                    } else {
                        alert(`error:${res.status}`);
                    }
                });
        } else {
            alert('エラー:不正な遷移を検知したため処理を続行できません');
        }
    }

    render() {
        return (
            <div>
                <h2>Spring Boot CSRF Sample</h2>
                <button type="button" onClick={this.handleGet.bind(this)} >GET</button>
                <button type="button" onClick={this.handlePut.bind(this)} >PUT</button>
            </div>
        );
    }
}

GETの場合は特に何もしてないですが、PUTの場合にはCookieに付与されたtokenを「X-CSRF-TOKEN」ヘッダとして送信しています。
これにより、サーバ側でCSRFのチェックが行われ、tokenの値が妥当であればチェックOKとなります。

サンプル

作成したサンプルソース群を以下に置いてあります。

https://github.com/nenokido2000/spring-boot-csrf-sample

参考ページ

以下を参考にさせていただきました。
ありがとうございました。

http://docs.spring.io/spring-security/site/docs/current/reference/html/csrf.html
https://spring.io/guides/tutorials/spring-security-and-angular-js/