はじめに

Spring BootにSpring Securityを入れた時のSessionTimeoutのデフォルト挙動は、ログイン画面への自動遷移になる。
一般的な要件として、ログイン画面に遷移したときに「タイムアウトしました。」などのメッセージを表示しなければいけないような時の対応方法を記載する。
※関連ページ:Spring Boot + Spring Security使用時のCSRFとSessionTimeoutの問題

検証version
・Spring Boot 1.3.3
・Spring Security 4.0.3
参考までにView側技術
 ・Thymeleaf 2.1.4
 ・Bootstrap 3.3.6

LoginUrlAuthenticationEntryPointの拡張

org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPointというExceptionが発生したときのログイン画面へのリダイレクト用クラスをSpring Securityが用意していて、これを拡張してする。

LoginUrlAuthenticationEntryPoint

Used by the ExceptionTranslationFilter to commence a form login authentication via the UsernamePasswordAuthenticationFilter.
Holds the location of the login form in the loginFormUrl property, and uses that to build a redirect URL to the login page.

拡張方法は、リダイレクトURLを決定するメソッドbuildRedirectUrlToLoginPageをOverrideする。

    @Override
    protected String buildRedirectUrlToLoginPage(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) {

        String redirectUrl = super.buildRedirectUrlToLoginPage(request, response, authException);
        if (isRequestedSessionInvalid(request)) {
            redirectUrl += redirectUrl.contains("?") ? "&" : "?";
            redirectUrl += "timeout";
        }
        return redirectUrl;
    }

    private boolean isRequestedSessionInvalid(HttpServletRequest request) {
        return request.getRequestedSessionId() != null && !request.isRequestedSessionIdValid();
    }

処理の内容は、Sessionが無効状態になっているとき、デフォルトのリダイレクトURLの後ろにリクエストパラメータ"timeout"を付与する。
これにより、/login?timeoutというURLになる。

Ajaxの場合の対処法

しかしAjaxリクエストの場合はリダイレクトが動かないため、リクエストがAjaxかどうかを判定して、Ajaxの場合はHTTP STATUS:401 Unauthorizedを返すのみにして、JavaScript側でリダイレクトするように対応する必要がある。
まずは、リクエストがAjaxかどうかを判定するために、LoginUrlAuthenticationEntryPoint#commenceをOverrideする。

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {

        if ("XMLHttpRequest".equals(request.getHeader("X-Requested-With"))) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return;
        }

        super.commence(request, response, authException);
    }

JavaScript側はjQueryを用いて書くと以下のようになる。

var ajaxUrl ="/ajax?id=1";
$.ajax({
    type : "GET",
    url : ajaxUrl,
    statusCode: {
        401: function() {
            window.location.href = /*[[@{/login?timeout}]]*/"";
        }
    }
}).done(
        function(json) {
            // 通常処理
        });

HTMLでの処理

login.htmlでは、 th:if="${param.timeout}" という条件式を設定して、リクエストパラメータにtimeoutが存在するときのみ「タイムアウトしました。」という文言を表示するようにしている。

以下はlogin.htmlのBODY部。

<body>
    <div class="container" layout:fragment="content">
        <div th:include="common/pageheader :: pageheader('ログイン画面')"></div>

        <div th:if="${param.error}" id="information" class="alert alert-danger alert-dismissible">
            <button type="button" class="close" data-dismiss="alert" aria-label="閉じる"><span aria-hidden="true">×</span></button>
            ユーザ名かパスワードに誤りがあります。
        </div>
        <div th:if="${param.logout}" id="information" class="alert alert-success alert-dismissible">
            <button type="button" class="close" data-dismiss="alert" aria-label="閉じる"><span aria-hidden="true">×</span></button>
            ログアウトしました。
        </div>
        <div th:if="${param.timeout}" id="information" class="alert alert-info alert-dismissible">
            <button type="button" class="close" data-dismiss="alert" aria-label="閉じる"><span aria-hidden="true">×</span></button>
            タイムアウトしました。
        </div>
        <form th:action="@{/login}" method="post">
            <div class="form-group col-sm-2"><label class="control-label">ユーザ名</label><input type="text" class="form-control input-sm" id="username" name="username" style="ime-mode: disabled;"/></div>
            <div class="form-group col-sm-2"><label class="control-label">パスワード</label><input type="password" class="form-control input-sm" id="password" name="password"/></div>
            <div class="form-group col-sm-1"><label></label><input type="submit" class="btn btn-primary" id="login" value="ログイン"/></div>
        </form>
    </div>

</body>

Spring Securityへの設定

あとは、拡張したクラスをSpring Securityに認識させることで、SessionTimeout時のリダイレクトとリダイレクト先での文言表示が実現できる。

まず、拡張クラスSessionExpiredDetectingLoginUrlAuthenticationEntryPointの全文を以下に掲載する。

import java.io.IOException;

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

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;

public class SessionExpiredDetectingLoginUrlAuthenticationEntryPoint extends LoginUrlAuthenticationEntryPoint {

    public SessionExpiredDetectingLoginUrlAuthenticationEntryPoint(String loginFormUrl) {
        super(loginFormUrl);
    }

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {

        if ("XMLHttpRequest".equals(request.getHeader("X-Requested-With"))) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return;
        }

        super.commence(request, response, authException);
    }

    @Override
    protected String buildRedirectUrlToLoginPage(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) {

        String redirectUrl = super.buildRedirectUrlToLoginPage(request, response, authException);
        if (isRequestedSessionInvalid(request)) {
            redirectUrl += redirectUrl.contains("?") ? "&" : "?";
            redirectUrl += "timeout";
        }
        return redirectUrl;
    }

    private boolean isRequestedSessionInvalid(HttpServletRequest request) {
        return request.getRequestedSessionId() != null && !request.isRequestedSessionIdValid();
    }
}

SessionExpiredDetectingLoginUrlAuthenticationEntryPointをSpring SecurityのJavaConfigで設定する。

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.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 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())
        ;
    }

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

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

}

WebSecurityConfigクラスでのポイントは2点。

  • exceptionHandling().authenticationEntryPoint()に拡張したSessionExpiredDetectingLoginUrlAuthenticationEntryPointを設定する
  • /login?timeoutを認証不要のURLと認識させるため、authorizeRequests().antMatchers("/login", "/login?**").permitAll()を設定する。

よくあるSpring Securityのサンプルでは、 以下のようになっていることが多いが、authorizeRequests().antMatchers("/login", "/login?**").permitAll()を設定するため、formLogin()とlogout()の箇所でpermitAll()をせず、authorizeRequests()でpermitAll()する。

.and().formLogin()
        .permitAll()
.and().logout()
        .permitAll()

以上でSpring Boot + Spring Security使用時のSessionTimeout対応は完了。 ただし、CSRF対策が有効の場合、POST時にSessionTimeoutしているとHTTP Status:403 Forbiddenが発生してしまう問題がある。 CSRFとSessionTimeout問題は別ページで対応策を記載する。 Spring Boot + Spring Security使用時のCSRFとSessionTimeoutの問題