以前、Spring Securityを利用して、さらにリクエスト毎にCSRFトークンの値を変更するようにしたことがあったが、このプログラムは、同じアプリケーション内で複数画面を開く場合に対応できていなかった。
今回は、複数画面を開いた場合でも、リクエスト毎にCSRFトークンの値を変更するよう修正してみたので、そのサンプルプログラムを共有する。
前提条件
下記記事の実装が完了していること
サンプルプログラムの作成
作成したサンプルプログラムの構成は以下の通り。

なお、上記の赤枠は、前提条件のプログラムから変更したプログラムである。
今回新規で追加したFilterクラスは以下の通りで、リクエストパラメータとして受け取った「windowName」毎に、セッションキーの値を変更できるようにしている。
package com.example.demo;
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class UpdSessionCsrfFilter extends OncePerRequestFilter {
/**
* CSRFトークンキーのデフォルト値
*/
private static final String DEFAULT_NAME
= HttpSessionCsrfTokenRepository.class.getName().concat(".CSRF_TOKEN");
/**
* CSRFトークンリポジトリ
*/
private final HttpSessionCsrfTokenRepository csrfTokenRepository;
/**
* コンストラクタ
* @param csrfTokenRepository CSRFトークンリポジトリ
*/
public UpdSessionCsrfFilter(HttpSessionCsrfTokenRepository csrfTokenRepository) {
this.csrfTokenRepository = csrfTokenRepository;
}
@Override
protected void doFilterInternal(HttpServletRequest request
, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
//ウィンドウ名を取得する
String windowName = request.getParameter("windowName");
//ウィンドウ名の値に応じたセッションキーを設定する
if(windowName != null) {
this.csrfTokenRepository.setSessionAttributeName(
DEFAULT_NAME + "." + windowName);
}else {
this.csrfTokenRepository.setSessionAttributeName(DEFAULT_NAME);
}
//次のFilterを呼び出す
filterChain.doFilter(request, response);
}
}
また、Spring Securityの設定を行うクラスの内容は以下の通りで、configure(HttpSecurity http)メソッド内に、セッションキーの値を変更する処理(UpdSessionCsrfFilter.java)を追加している。
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() //かつ
//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/>
<!-- 新しい画面を開く処理 -->
<!-- 画面を開くタイミングでは、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 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=new_window1というパラメータ値を送信できるようにしている。このようにすることで、UpdSessionCsrfFilter.java内で、セッションキーの値が変更されるようになっている。
また、新しく開く画面の内容は以下の通りで、検索画面で追加した「新しい画面を開く」ボタンが押下された場合と同じように、「windowName=new_window1」をパラメータとして渡すようにしてる。
<!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="@{/toNewWindow2}" method="POST">
<input type="hidden" name="windowName" value="new_window1" />
<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>さらに、新しく開く画面から遷移する画面の内容は以下の通り。
<!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>また、コントローラクラスに追加する処理は以下の通りで、「新しい画面を開く」ボタンが押下された場合の処理と、新規オープン画面2に遷移する処理を追加している。
/**
* 新しい画面を開く
* @return 新規オープン画面1へのパス
*/
@GetMapping("/openNewWindow1")
public String openNewWindow1(){
return "new_window1";
}
/**
* 新規オープン画面2に遷移する
* @return 新規オープン画面2へのパス
*/
@PostMapping("/toNewWindow2")
public String toNewWindow2(){
return "new_window2";
}その他のソースコード内容は、以下のサイトを参照のこと。
https://github.com/purin-it/java/tree/master/spring-boot-security-reqtoken-mltwindow/demo
サンプルプログラムの実行結果
実行結果は以下の通りで、複数画面を開く場合も問題なく動作することが確認できる。
1) Spring Bootアプリケーションを起動し、「http://(サーバー名):(ポート番号)/」とアクセスすると、以下のログイン画面が表示されるので、ユーザー名・パスワードを入力し「ログイン」ボタンを押下

2) 検索画面に遷移するので、「新しい画面を開く」ボタンを押下

3) 以下のように、以前の画面とは別に「新規オープン画面1」が開くことが確認できるので、「次へ」ボタンを押下

4) 以下のように、画面遷移することが確認できるので、ここで「index page」タブを押下

6) 以下のように、一覧画面が正常に表示できることが確認できる

要点まとめ
- 複数画面を開いた場合でも、リクエスト毎にCSRFトークンの値を変更できるようにするには、CsrfFilterを実行する前に、セッションキーの値を変更する処理を追加すると共に、リクエストパラメータに、ウィンドウ毎にセッションキーを識別できる情報を渡せばよい。






