はじめに

Spring Boot + Spring Security使用時のSessionTimeout対応の最後に、「CSRF対策が有効の場合、POST時にSessionTimeoutしているとHTTP Status:403 Forbiddenが発生してしまう問題がある。」と記載した。

今回はこの問題の対応方法を記載し、Spring SecurityのJavaConfigの完成形を作る。

CSRF対策のせいでHTTP Status:403 Forbiddenが起こる原因

まずこの問題が起こる原因は、CSRF対策の仕組みが、リクエストパラメータで送られるCSRF TokenとSessionに保存されたCSRF Tokenを比較するというロジックであり、Sessionに依存しているから。
SessionがTimeoutによって消滅しているときにCSRF Tokenをリクエストパラメータで送っても、Sessionは既に存在していないから必ずTokenが違うということになる。

accessDeniedHandlerによる対策

対策したソースを記載する。

import java.io.IOException;

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

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.AccessDeniedException;
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.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.access.AccessDeniedHandlerImpl;
import org.springframework.security.web.csrf.MissingCsrfTokenException;

import jp.co.sample.service.UserDetailsServiceImpl;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    UserDetailsServiceImpl userDetailsService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/css/**").permitAll()
                .antMatchers("/js/**").permitAll()
                .antMatchers("/fonts/**").permitAll()
                .antMatchers("/login", "/login?**").permitAll()
                .antMatchers("/logout").permitAll()
                .anyRequest().authenticated()
        .and().formLogin()
        .and().logout()
        .and().exceptionHandling()
                // 通常のRequestとAjaxを両方対応するSessionTimeout用
                .authenticationEntryPoint(authenticationEntryPoint())
                // csrfはsessionがないと動かない。SessionTimeout時にPOSTすると403 Forbiddenを必ず返してしまうため、
                // MissingCsrfTokenExceptionの時はリダイレクトを、それ以外の時は通常の扱いとする。
                .accessDeniedHandler(accessDeniedHandler())
        ;
    }

    @Bean
    AuthenticationEntryPoint authenticationEntryPoint() {
        return new SessionExpiredDetectingLoginUrlAuthenticationEntryPoint("/login");
    }

    @Bean
    AccessDeniedHandler accessDeniedHandler() {
        return new AccessDeniedHandler() {
            @Override
            public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
                if (accessDeniedException instanceof MissingCsrfTokenException) {
                    authenticationEntryPoint().commence(request, response, null);
                } else {
                    new AccessDeniedHandlerImpl().handle(request, response, accessDeniedException);
                }
            }
        };
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService);
    }

}

前回との差分は1点。

  • exceptionHandling().accessDeniedHandler()にAccessDeniedExceptionが発生したときの処理を書いた無名クラスを設定した。

発生したExceptionが、CSRF Tokenがない場合に発生するMissingCsrfTokenExceptionだった場合、SessionTimeoutであると判断してSessionExpiredDetectingLoginUrlAuthenticationEntryPointを実行する。
SessionExpiredDetectingLoginUrlAuthenticationEntryPointではSessionが当然Invalidであると判断するので、/login?timeoutにリダイレクトしてくれる。