Spring Boot セキュリティ関連

Spring Boot上でSpring Securityのエラー処理を追加してみた

今回は、Spring Securityでエラーが発生した場合に、エラー画面に遷移する処理を追加してみたので、そのサンプルプログラムを共有する。

前提条件

下記記事の実装が完了していること。

Spring Boot上でリクエスト毎にCSRFトークンを変える処理を複数画面を開く場合に対応してみた以前、Spring Securityを利用して、さらにリクエスト毎にCSRFトークンの値を変更するようにしたことがあったが、このプログラ...

サンプルプログラムの作成

作成したサンプルプログラムの構成は以下の通り。
サンプルプログラムの構成
なお、上記の赤枠は、前提条件のプログラムから変更したプログラムである。

Spring Securityの設定を行うクラスの内容は以下の通りで、configure(HttpSecurity http)メソッド内に、エラー発生時にエラー画面に遷移する処理を追加している。

package com.example.demo;

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.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.csrf.CsrfFilter;
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;
import org.springframework.security.web.firewall.DefaultHttpFirewall;

@Configuration
@EnableWebSecurity
public class DemoSecurityConfig extends WebSecurityConfigurerAdapter {

    /**
     * Spring-Security用のユーザーアカウント情報を
     * 取得・設定するサービスへのアクセス
     */
    @Autowired
    private UserPassAccountService userDetailsService;

    @Override
    public void configure(WebSecurity web) {
        //org.springframework.security.web.firewall.RequestRejectedException:
        //The request was rejected because the URL contained a potentially malicious String ";"
        //というエラーログがコンソールに出力されるため、下記を追加
        DefaultHttpFirewall firewall = new DefaultHttpFirewall();
        web.httpFirewall(firewall);
        //セキュリティ設定を無視するリクエスト設定
        //静的リソースに対するアクセスの場合は、Spring Securityのフィルタを通過しないように設定する
        web.ignoring().antMatchers("/css/**");
    }

    /**
     * SpringSecurityによる認証を設定
     * @param http HttpSecurityオブジェクト
     * @throws Exception 例外
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        final HttpSessionCsrfTokenRepository repository = new HttpSessionCsrfTokenRepository();

        //初期表示画面を表示する際にログイン画面を表示する
        http.formLogin()
                //ログイン画面は常にアクセス可能とする
                .loginPage("/login").permitAll()
                //ログインに成功したら検索画面に遷移する
                .defaultSuccessUrl("/")
                .and()
                //ログイン画面のcssファイルとしても共通のdemo.cssを利用するため、
                //src/main/resources/static/cssフォルダ下は常にアクセス可能とする
                .authorizeRequests().antMatchers("/css/**").permitAll()
                .and()    //かつ
                //それ以外の画面は全て認証を有効にする
                .authorizeRequests().anyRequest().authenticated()
                .and()    //かつ
                //ログアウト時はログイン画面に遷移する
                .logout().logoutSuccessUrl("/login").permitAll()
                .and()    //かつ
                //CSRFトークンのリポジトリを設定する
                .csrf().csrfTokenRepository(repository)
                .and()    //かつ
                //エラー発生時はエラー画面に遷移する
                .exceptionHandling().accessDeniedPage("/toError")
                .and()    //かつ
                //CSRFトークンのセッションキーをリクエスト毎に更新する処理を、
                //CsrfFilterが呼ばれる前に実行するようにする
                .addFilterBefore(new UpdSessionCsrfFilter(repository), CsrfFilter.class)
                //CSRFトークンをリクエスト毎に更新する処理を、
                //CsrfFilter(CSRFトークンチェックを行うFilter)が呼ばれた後に実行するようにする
                .addFilterAfter(new ChgCsrfTokenFilter(repository), CsrfFilter.class);
    }

    /**
     * 認証するユーザー情報をデータベースからロードする処理
     * @param auth 認証マネージャー生成ツール
     * @throws Exception
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        //認証するユーザー情報をデータベースからロードする
        //その際、パスワードはBCryptで暗号化した値を利用する
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }

    /**
     * パスワードをBCryptで暗号化するクラス
     * @return パスワードをBCryptで暗号化するクラスオブジェクト
     */
    @Bean
    PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

}



また、検索画面の内容は以下の通りで、「新しい画面を開く(エラー確認用)」ボタンを追加している。

<!DOCTYPE html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <link th:href="@{/css/demo.css}" rel="stylesheet" type="text/css" />
    <title>index page</title>
</head>
<body>
    <p>検索条件を指定し、「検索」ボタンを押下してください。</p><br/>
    <form method="post" th:action="@{/search}" th:object="${searchForm}">
        <!-- 2行エラーがある場合は、エラーメッセージを改行して表示 -->
        <span th:if="*{#fields.hasErrors('fromBirthYear')}"
              th:errors="*{fromBirthYear}" class="errorMessage"></span>
        <span th:if="*{#fields.hasErrors('fromBirthYear') && #fields.hasErrors('toBirthYear')}">
            <br/>
        </span>
        <span th:if="*{#fields.hasErrors('toBirthYear')}"
              th:errors="*{toBirthYear}" class="errorMessage"></span>
        <table border="1" cellpadding="5">
            <tr>
                <th>名前</th>
                <td><input type="text" th:value="*{searchName}" th:field="*{searchName}" /></td>
            </tr>
            <tr>
                <th>生年月日</th>
                <td><input type="text" th:value="*{fromBirthYear}" size="4"
                           maxlength="4" th:field="*{fromBirthYear}" th:errorclass="fieldError"/>年
                    <select th:field="*{fromBirthMonth}" th:errorclass="fieldError"
                            th:classappend="${#fields.hasErrors('fromBirthYear')} ? 'fieldError'">
                        <option value=""></option>
                        <option th:each="item : *{getMonthItems()}"
                                th:value="${item.key}" th:text="${item.value}"/>
                    </select>月
                    <select th:field="*{fromBirthDay}" th:errorclass="fieldError"
                            th:classappend="${#fields.hasErrors('fromBirthYear')} ? 'fieldError'">
                        <option value=""></option>
                        <option th:each="item : *{getDayItems()}"
                                th:value="${item.key}" th:text="${item.value}"/>
                    </select>日~
                    <input type="text" th:value="*{toBirthYear}" size="4"
                           maxlength="4" th:field="*{toBirthYear}" th:errorclass="fieldError"/>年
                    <select th:field="*{toBirthMonth}" th:errorclass="fieldError"
                            th:classappend="${#fields.hasErrors('toBirthYear')} ? 'fieldError'">
                        <option value=""></option>
                        <option th:each="item : *{getMonthItems()}"
                                th:value="${item.key}" th:text="${item.value}"/>
                    </select>月
                    <select th:field="*{toBirthDay}" th:errorclass="fieldError"
                            th:classappend="${#fields.hasErrors('toBirthYear')} ? 'fieldError'">
                        <option value=""></option>
                        <option th:each="item : *{getDayItems()}"
                                th:value="${item.key}" th:text="${item.value}"/>
                    </select>日
                </td>
            </tr>
            <tr>
                <th>性別</th>
                <td>
                    <select th:field="*{searchSex}">
                        <option value=""></option>
                        <option th:each="item : *{getSexItems()}"
                                th:value="${item.key}" th:text="${item.value}"/>
                    </select>
                </td>
            </tr>
        </table>
        <br/><br/>
        <input type="submit" value="検索" />
        <input type="text" style="background-color:#FFCCCC" size="80" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
    </form>
    <br/><br/>
    <!-- 新しい画面を開く処理 -->
    <!-- 画面を開くタイミングでは、CSRFトークンチェックを行わないよう、GETメソッドを指定する -->
    <form target="newwindow" th:action="@{/openNewWindow1}" method="GET">
        <input type="hidden" name="windowName" value="new_window1" />
        <button type="submit">新しい画面を開く</button>
        <input type="text" style="background-color:#FFCCCC" size="80" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/><br/>
    </form>
    <br/><br/>
    <!-- 新しい画面を開く処理(エラー確認用) -->
    <form target="newwindow" th:action="@{/openNewWindow1Error}" method="GET">
        <input type="hidden" name="windowName" value="new_window1_error" />
        <button type="submit">新しい画面を開く(エラー確認用)</button>
        <input type="text" style="background-color:#FFCCCC" size="80" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/><br/>
    </form>
    <br/><br/>
    <form method="post" th:action="@{/logout}">
        <button type="submit">ログアウト</button>
        <input type="text" style="background-color:#FFCCCC" size="80" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
    </form>
</body>
</html>

さらに、「新しい画面を開く処理(エラー確認用)」ボタン押下時に開く画面の内容は以下の通りで、windowNameをパラメータに設定しないことで、「次へ」ボタン押下時にエラーが発生するようにしている。

<!DOCTYPE html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>新規オープン画面1</title>
</head>
<body>
   新しい画面を開きました。 こちらは「次へ」ボタンを押下するとエラーが発生します。<br/><br/>
    <form th:action="@{/toNewWindow2Error}" method="POST">
        <!-- エラーを発生させるため、下記をコメントアウト -->
        <!-- <input type="hidden" name="windowName" value="new_window1_error" /> -->
        <input type="text" style="background-color:#FFCCCC" size="80" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/><br/>
        <br/><br/>
        <button type="submit">次へ</button>
    </form>
</body>
</html>



また、「new_window1_error.html」で「次へ」ボタン押下時にエラーが発生しない場合の、遷移先画面の内容は以下の通り。

<!DOCTYPE html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>新規オープン画面2</title>
</head>
<body>
   新規オープン画面から画面遷移しました。 <br/><br/>
    <input type="text" style="background-color:#FFCCCC" size="80" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/><br/><br/>
    <input type="button" value="閉じる" onclick="window.close();" />
</body>
</html>

さらに、エラー画面の内容は以下の通り。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>エラー画面</title>
</head>
<body>
   エラーが発生しました。処理を終了します。<br/><br/>
    <input type="button" value="閉じる" onclick="window.close();" />
</body>
</html>



また、コントローラクラスに追加する内容は以下の通りで、新しく追加した画面の遷移処理とエラー画面遷移処理を追加している。

/**
 * 新しい画面を開く(エラー確認用)
 * @return 新規オープン画面1(エラー確認用)へのパス
 */
@GetMapping("/openNewWindow1Error")
public String openNewWindow1Error(){
    return "new_window1_error";
}

/**
 * 新規オープン画面2(エラー確認用)に遷移する
 * @return 新規オープン画面2(エラー確認用)へのパス
 */
@PostMapping("/toNewWindow2Error")
public String toNewWindow2Error(){
    return "new_window2_error";
}

/**
 * エラー画面に遷移する
 * @return エラー画面へのパス
 */
@RequestMapping("/toError")
public String toError(){
    return "error";
}

その他のソースコード内容は、以下のサイトを参照のこと。
https://github.com/purin-it/java/tree/master/spring-boot-security-error-handling/demo

サンプルプログラムの実行結果

Spring Securityの設定を行うクラスでエラーハンドリングを行う前の動作は以下の通りで、エラー時はSpring Bootデフォルトのホワイトラベルエラーページが表示される。

1) Spring Bootアプリケーションを起動し、「http://(サーバー名):(ポート番号)/」とアクセスすると、以下のログイン画面が表示されるので、ユーザー名・パスワードを入力し「ログイン」ボタンを押下
サンプルプログラムの実行結果_1_1

2) 検索画面に遷移するので、「新しい画面を開く」ボタンを押下
サンプルプログラムの実行結果_1_2

3) 以下のように、以前の画面とは別に「新規オープン画面1」が開くことが確認できるので、「次へ」ボタンを押下
サンプルプログラムの実行結果_1_3

4) 以下のように、新規オープン画面2に遷移するので、「閉じる」ボタンを押下
サンプルプログラムの実行結果_1_4

5) 以下のように、確認ダイアログが表示されるので、「はい」ボタンを押下
サンプルプログラムの実行結果_1_5

6) 検索画面で、「新しい画面を開く(エラー確認用)」ボタンを押下
サンプルプログラムの実行結果_1_6

7) 「新規オープン画面1」が開くことが確認できるので、「次へ」ボタンを押下
サンプルプログラムの実行結果_1_7

8) エラーハンドリングを行っていないので、以下のように、Spring Bootデフォルトのホワイトラベルエラーページが表示される
サンプルプログラムの実行結果_1_8

また、Spring Securityの設定を行うクラスでエラーハンドリングを行った後の動作は以下の通りで、エラー時はエラー画面に遷移する。

1) Spring Bootアプリケーションを起動し、「http://(サーバー名):(ポート番号)/」とアクセスすると、以下のログイン画面が表示されるので、ユーザー名・パスワードを入力し「ログイン」ボタンを押下
サンプルプログラムの実行結果_2_1

2) 検索画面に遷移するので、「新しい画面を開く(エラー確認用)」ボタンを押下
サンプルプログラムの実行結果_2_2

3) 「新規オープン画面1」が開くことが確認できるので、「次へ」ボタンを押下
サンプルプログラムの実行結果_2_3

4) エラーハンドリングによって、以下のエラー画面に遷移することが確認できる
サンプルプログラムの実行結果_2_4

要点まとめ

  • Spring Securityでエラーが発生した場合に、エラー画面に遷移する処理を追加するには、Spring Securityの設定を行うクラスの内のconfigure(HttpSecurity http)メソッド内で、「exceptionHandling().accessDeniedPage(“/エラー画面へのパス”)」を追加すればよい。