Spring Security は 5.4 以降、設定の書き方に大幅な変更が入っています。
詳しくは @suke_masa さんの Spring Security 5.7でセキュリティ設定の書き方が大幅に変わる件 - Qiita を参照してください。
基礎・仕組み的な話
認証・認可の話
Remember-Me の話
セッション管理の話
レスポンスヘッダーの話
メソッドセキュリティの話
CORS の話
Run-As の話
ACL の話
テストの話
MVC, Boot との連携の話
番外編
Spring Security にできること・できないこと
CSRF とは Cross-site Request Forgery の略。
Forgery は「偽造」という意味らしい。
この攻撃が何なのかとか、対策方法については IPA のサイト とか Wikipedia とかを参照のこと。
Spring Security での CSRF 対策
Spring Security で namespace や Java Configuration を使用した場合は、デフォルトで CSRF 対策が有効になる。
CSRF 対策が有効になった場合、 GET
, HEAD
, TRACE
, OPTIONS
以外 の HTTP メソッド(POST
, PUT
, DELETE
, PATCH
など)でリクエストが来た場合にトークンのチェックが行われるようになる。
対象外となっているメソッドは、HTTP の仕様ではサーバーの状態を変更すべきでないということになっている。
なので、ちゃんと HTTP の仕様に沿って Web サービスを作っていれば、 GET
や HEAD
などを CSRF の保護対象にする必要はないことになる(必要があるとすれば、それは Web サービスのほうがなんか変)。
トークンの連携方法
デフォルトだと、トークンは _csrf
という名前でリクエストスコープに保存されている。
このトークンは CsrfToken
オブジェクトで、次のようなメソッドを持つ。
package org.springframework.security.web.csrf;
import java.io.Serializable;
public interface CsrfToken extends Serializable {
String getHeaderName();
String getParameterName();
String getToken();
}
getParameterName()
でリクエストパラメータで渡すときのパラメータ名を取得でき、 getToken()
で実際のトークンの値が取得できる。
よって、 EL式などを使えば次のようにしてトークンをパラメータとして埋め込むことができるようになる。
<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@page contentType="text/html" pageEncoding="UTF-8"%>
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Hello Spring Security!!</title>
</head>
<body>
<h1>Hello Spring Security!!</h1>
<c:url var="logoutUrl" value="/logout" />
<form action="${logoutUrl}" method="post">
<input type="submit" value="logout" />
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" /> ★ここでトークンを埋め込んでいる
</form>
</body>
</html>
HTTP ヘッダーでトークンを送信する
Ajax などでボディは JSON にする場合、上記のような方法でトークンを渡すことができない。
その場合は HTTP ヘッダーでトークンを渡す。
ヘッダー名は ${_csrf.headerName}
で取得できる。
あとは、ヘッダー名とトークンの値を HTTP 内に埋め込んで置き、 JavaScript で取り出して Ajax リクエストのヘッダーにセットすればいい。
トークンの値
トークンの値は、 UUID.randomUUID()
で生成されている。
最初これを見たとき「あれ、 SecureRandom
を使わなくてええんか?」と思った。
しかし、 UUID.randomUUID()
の Javadoc を見てみると次のように説明されている。
このUUIDは、暗号強度の高い擬似乱数ジェネレータを使って生成されます。
実際ソースを見てみると、 SecureRandom
を使っている。
なので、別に UUID.randomUUID()
でも問題ないっぽい。
トークンが生成されるタイミング
前述の IPA の解説ページ などを見てみると、トークンを生成するタイミングがいくつか紹介されている。
照合情報には、他者が推測困難な値を用いる。そのような値として下記の 3つが挙げられる。
・セッションIDそのもの もしくは セッションIDから導かれるハッシュ値 等
・セッション開始時に一度生成され各ページで使われる、セッションIDとは無関係の値
・ページごとに生成される、毎回異なる値(ページトークン)
自分は最初、トークンは3つ目の「ページごと」に生成するものだと思っていた。
しかし、実際に Spring Security の CSRF 対策を動かしてみると、トークンは一度ログインしたあとはログアウトするまで同じ値が使いまわされていることに気付いた。
つまり、 Spring Security は2つ目の方法を採用していることになる。
調べてみると、ページごとにトークンを生成するのは、使いやすさの面で問題になるらしい。
こちらの stackoverflow によると、ユーザーがブラウザの「戻るボタン」を使ったときを考慮しだすと話が複雑になるらしい。
ブラウザの「戻るボタン」を使って前の画面に戻ると、クライアント側のページに埋め込まれているトークンが古い状態になってしまう。
そのため次にアクションを起こすと、アプリケーションは CSRF 攻撃が行われたと誤認してしまうことになる。
ページごとにトークンを生成するとなると、トークンの管理が大変になるっぽい。
エラーハンドリング
不正なトークンが渡ってきた場合、デフォルトだとサーバーのエラーページが表示される。
このままだといろいろアレなので、エラーハンドリングを実装する。
エラーページの指定
CSRF トークンのチェックは CsrfFilter
によって行われる。
そして、もしトークンに問題がある場合は AccessDeniedHandlerImpl
にエラー処理が委譲される。
AccessDeniedHandlerImpl
には errorPage
というプロパティがあり、エラー時のフォワード先を指定できる。
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>不正リクエスト</title>
</head>
<body>
<h1>不正なリクエストを検知しました</h1>
</body>
</html>
namespace
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:sec="http://www.springframework.org/schema/security"
...>
<sec:http>
...
<sec:access-denied-handler error-page="/access-denied.html" />
</sec:http>
...
</beans>
-
<access-denied-handler>
タグを追加し、error-page
属性で指定する。
Java Configuration
...
@EnableWebSecurity
public class MySpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
...
.and()
.exceptionHandling()
.accessDeniedPage("/access-denied.html");
}
...
}
-
exceptionHandling().accessDeniedPage(String)
で指定する
CSRF トークンを不正な値にしてエラーを発生させると、指定されたページにフォワードされた。
※AccessDeniedHandlerImpl
は通常の権限チェックのエラーハンドリングでも利用されるので、どちらのエラーでも不自然じゃないメッセージにしておかないといけない点に注意
どうしても2つのエラーハンドリングの実装クラスを分けたい場合は、 CsrfFilter
と ExceptionTranslationFilter
のそれぞれの AccessDeniedHandler
を直接上書きするように BeanPostProcessor を実装するみたいなことをすればいいと思う。
実装を指定する
package sample.spring.security.handler;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
public class MyAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
if (response.isCommitted()) {
return;
}
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
try (PrintWriter writer = response.getWriter()) {
writer.println("Access Denied!!");
}
}
}
-
AccessDeniedHandler
を実装したクラスを用意する -
handle()
メソッドの第三引数には発生した例外オブジェクトが渡される- CSRF トークンのエラーの場合は
MissingCsrfTokenException
かInvalidCsrfTokenException
になる -
MissingCsrfTokenException
はサーバーで保存しているはずのトークンが見つからなかった場合に渡ってくる(主にセッション切れ) -
InvalidCsrfTokenException
はクライアントから来たトークンとサーバーが保存しているトークンに差異があった場合に渡ってくる - どちらのクラスも
CsrfException
を継承している
- CSRF トークンのエラーの場合は
namespace
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:sec="http://www.springframework.org/schema/security"
...>
<bean id="accessDeniedHandler" class="sample.spring.security.handler.MyAccessDeniedHandler" />
<sec:http>
...
<sec:access-denied-handler ref="accessDeniedHandler" />
</sec:http>
...
</beans>
-
<access-denied-handler>
タグのref
属性で Bean を指定する
Java Configuration
...
import sample.spring.security.handler.MyAccessDeniedHandler;
@EnableWebSecurity
public class MySpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
...
.and()
.exceptionHandling()
.accessDeniedHandler(new MyAccessDeniedHandler());
}
...
}
-
accessDeniedHandler(AccessDeniedHandler)
で指定する
MyAccessDeniedHandler
によって生成したレスポンスが出力された。
セッションタイムアウト問題
デフォルトだと、 CSRF 対策用のトークンはセッションに保存される。
よって、セッションタイムアウトが発生するとトークンが取得できずに、 CSRF のトークンチェックのエラーが発生する。
この場合は、セッション切れなので単純にログインページに遷移させたい。
しかし、 AccessDeniedHanlderImpl
の errorPage
を指定する方法だとセッション切れと不正トークンを区別できないので、両者を区別してハンドリングしたい場合は必然的に AccessDeniedHandler
を自作することになりそう。
package sample.spring.security.handler;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.csrf.MissingCsrfTokenException;
import javax.servlet.RequestDispatcher;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class MyAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
if (response.isCommitted()) {
return;
}
if (accessDeniedException instanceof MissingCsrfTokenException) {
DefaultRedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
redirectStrategy.sendRedirect(request, response, "/login");
} else {
RequestDispatcher dispatcher = request.getRequestDispatcher("/access-denied.html");
dispatcher.forward(request, response);
}
}
}
例外が MissingCsrfTokenException
の場合だけログインページにリダイレクトさせて、それ以外は専用のエラーページに遷移させる。
MyAccessDeniedHandler
の設定方法はエラーの説明のところを参照。
不正なトークンを受け取った場合
セッション切れの場合
ログインのときもセッションタイムアウトの可能性がある
もしログインのときに CSRF のチェックをしていない場合、知らない間に攻撃者が用意したアカウントでログインさせられるかもしれない。
そして、そうと気付かずログイン後に個人情報に関わる入力をしたとする。
攻撃者は後で同じアカウントでログインし、被害者が知らずに入力した個人情報を履歴の照会機能などを利用して参照できてしまうかもしれない。
といった攻撃があるかもしれないので、 CSRF のチェックはログインのときにも行ったほうが良いとされているらしい。
しかし、前述したとおりデフォルトだと CSRF トークンはセッションに保存される。
そのため、ログイン画面を開いた後にセッションが切れるまで放置してからログインを行うと、 CSRF トークンがセッションから取得できずエラーになってしまう。
この場合、ログイン画面に戻したりエラー画面に遷移させるというのはちょっと使い勝手が悪い。
Spring Security のリファレンスでは、「Submit 前に JavaScript でトークンを取ってくるのが一般的だよ!」みたいなことが書かれている。
A common technique to protect the log in form is by using a JavaScript function to obtain a valid CSRF token before the form submission.
(訳)
ログインフォームを守る一般的なテクニックは、フォームを Submit する前に JavaScript の関数で有効な CSRF トークンを取得するという方法です。
しかし、 JavaScript 側の具体的なソースが記載されていない。
仕方ないので想像で書いてみた。
`-src/main/
|-java/
| `-sample/spring/security/servlet/
| `-CsrfTokenServlet.java
|
`-webapp/
|-js/
| |-jquery.min.js
| `-login.js
|
|-WEB-INF/
| `-applicationContext.xml
|
`-login.jsp
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:sec="http://www.springframework.org/schema/security"
...>
...
<sec:http>
<sec:intercept-url pattern="/login.jsp" access="permitAll" />
<sec:intercept-url pattern="/js/**" access="permitAll" />
<sec:intercept-url pattern="/api/csrf-token" access="permitAll" />
<sec:intercept-url pattern="/**" access="isAuthenticated()" />
<sec:form-login login-page="/login.jsp" />
...
</sec:http>
...
</beans>
- 未ログインの状態でもアクセスできるように、
/js/**
や/api/csrf-token
(CSRF トークンを取得するためのエンドポイント)をpermitAll
で定義している。 - また、ログイン画面を自作のもの(
login.jsp
)に置き換えるので<form-login>
のlogin-page
属性で指定している。
<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@page contentType="text/html" pageEncoding="UTF-8"%>
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Login</title>
</head>
<body>
<h1>Login</h1>
<c:url var="loginUrl" value="/login" />
<form id="form" action="${loginUrl}" method="post">
<div>
Username : <input type="text" name="username" />
</div>
<div>
Password : <input type="password" name="password" />
</div>
<input type="button" id="loginButton" value="Login" />
<input type="hidden" id="csrfToken" name="${_csrf.parameterName}" />
</form>
<c:url var="jsDirUrl" value="/js" />
<script src="${jsDirUrl}/jquery.min.js"></script>
<script src="${jsDirUrl}/login.js"></script>
</body>
</html>
- CSRF トークンをセットする hidden は
value
を空にしておき、 Submit するときに設定する。
$(function() {
$('#loginButton').on('click', function() {
$.ajax({
url: 'api/csrf-token',
type: 'GET'
})
.done(function(token) {
$('#csrfToken').val(token);
$('#form').submit();
});
});
});
-
loginButton
がクリックされたら Ajax で CSRF トークンを取得し、 hidden に設定してからフォームを submit している。
package sample.spring.security.servlet;
import org.springframework.security.web.csrf.CsrfToken;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
@WebServlet("/api/csrf-token")
public class CsrfTokenServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
CsrfToken token = (CsrfToken) req.getAttribute(CsrfToken.class.getName());
try (PrintWriter writer = resp.getWriter()) {
writer.print(token.getToken());
}
}
}
-
CsrfToken
はリクエストスコープに保存されているので、それを取得してgetToken()
で生のトークン値を取得、レスポンスとして返している - なお、リファレンスでは
CsrfTokenArgumentResolver
を使って Spring MVC で実装する例が紹介されている。- 普通はこっちの実装になると思う。
これで、ログイン直前でトークンを取得するようになるのでセッションが切れた後でもスムーズにログインできるようになる。
実際に使う場合は実装の細かいところを修正する必要があるだろうが、基本的な流れはこれで合っているのだろうか?
セッションを使わず Cookie を使う
そもそもトークンの情報をセッションに保存しているために、タイムアウトという現象が発生している。
Spring Security には CSRF 対策用トークンを Cookie に保存する方法も用意されている。
なので、こちらを利用すればセッションタイムアウトについて気にする必要はなくなる。
ではなぜデフォルトで Cookie を使っていないのかというと、セッションよりも若干セキュリティのレベルが下がるかららしい。
若干というのは、
- Flash を使った特殊な攻撃が実行されたときに、 Cookie を使っていると CSRF 対策が有効に働かなくなることがあった
- 仮にトークンが盗まれた場合でも、セッションを使っていれば最悪セッションを無効にすれば即座にトークンを無効にすることができる
というぐらい。
Flash を使った攻撃方法については こちら に解説が載っている。
結構前(2011年2月)の話だが、 Ruby on Rails とかもこの問題に対して対策を講じたりしていたらしい。
2017年現在どうなっているのか調べようとしたが、調べ方がよくないのか明確な情報は見つけられなかった。
Flash Player のリリースノート とかも見てみたが、 10.2 付近にそれっぽいアップデートは見当たらなかった。。。
ただ、 Spring Security のリファレンスには Cookie を使うことについて次のように説明している。
As previously mentioned, this is not as secure as using a session, but in many cases can be good enough.
(訳)
前述のとおり、これ(Cookie を使うこと)はセッションほど安全ではありません。しかし、多くの場合でそれは十分に良いものです。
さて、これをどう判断すべきか。。。
Cookie を使う場合は、次のように実装する。
namespace
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:sec="http://www.springframework.org/schema/security"
...>
...
<bean id="tokenRepository"
class="org.springframework.security.web.csrf.CookieCsrfTokenRepository" /> ★
<sec:http>
...
<sec:csrf token-repository-ref="tokenRepository" /> ★
</sec:http>
...
</beans>
CookieCsrfTokenRepository
を Bean として定義し、 <csrf>
タグの token-repository-ref
属性で指定する。
Java Configuration
package sample.spring.security;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.ComponentScan;
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.CookieCsrfTokenRepository;
import java.util.Collections;
@EnableWebSecurity
@ComponentScan
public class MySpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/login").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.csrf().csrfTokenRepository(new CookieCsrfTokenRepository()); ★
}
...
}
.csrf().csrfTokenRepository()
に CookieCsrfTokenRepository
のインスタンスを渡す。
Cookie に XSRF-TOKEN
が追加され、そこにトークンの値が保存されている。
ちなみに、デフォルトだと httpOnly が true になるので JavaScript からこの Cookie にアクセスすることはできないようになっている。
JavaScript のフレームワークとかを使っていてどうしても JavaScript から Cookie を触れないと困るような場合は、この設定を
false にしてあげる必要がある(リファレンスでは AngularJS を例に挙げているっぽい)。
namespace
<bean id="tokenRepository"
class="org.springframework.security.web.csrf.CookieCsrfTokenRepository">
<property name="cookieHttpOnly" value="false" /> ★
</bean>
CookieCsrfTokenRepository
の cookieHttpOnly
プロパティを false
にする。
Java Configuration
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
...
.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()); ★
}
CookieCsrfTokenRepository.withHttpOnlyFalse()
でインスタンスを生成する。
secure 属性の指定
通信が HTTPS だと勝手に secure 属性が有効になる模様。
CookieCsrfTokenRepository
の実装を見ると次のようになっている。
@Override
public void saveToken(CsrfToken token, HttpServletRequest request,
HttpServletResponse response) {
...
Cookie cookie = new Cookie(this.cookieName, tokenValue);
cookie.setSecure(request.isSecure()); ★ここ
...
}