S-JIS[2017-08-26/2017-08-28] 変更履歴

Spring Boot Form認証

Spring Bootのウェブアプリケーションのフォーム認証のサンプル。


概要

フォーム認証は、ログイン画面(form)でユーザーID・パスワードを入力してログインする認証方式。

Spring Bootでフォーム認証を行う場合は、Spring Security(spring-boot-starter-security)を使用する。

build.gradle:

〜
dependencies {
	compile('org.springframework.boot:spring-boot-starter-security')
	compile('org.springframework.boot:spring-boot-starter-thymeleaf')
	runtime('org.springframework.boot:spring-boot-devtools')
	testCompile('org.springframework.boot:spring-boot-starter-test')
	testCompile('org.springframework.security:spring-security-test')
}

Form認証の例

全ウェブページに対してForm認証を要求する例。(Spring Boot 1.5.6、Spring Security 4.2.3)

ウェブページに初めてアクセスするときにログイン画面を表示し、ログインしたらアクセスできるようにしてみる。


セキュリティー設定

まず、どのURIに対して認証を要求するかを設定するクラスを作成する。

src/main/java/com/example/demo/auth/WebSecurityFormConfig.java:

package com.example.demo.auth;

import org.springframework.context.annotation.Bean;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
/**
 * フォーム認証
 */
@EnableWebSecurity
public class WebSecurityFormConfig extends WebSecurityConfigurerAdapter {

クラスには@EnableWebSecurityアノテーションを付け、WebSecurityConfigurerAdapterを継承し、configureメソッドをオーバーライドする。


	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http.authorizeRequests() //
			.antMatchers("/login").permitAll() // ログイン画面
			.anyRequest().authenticated(); // その他の全リクエストに対して認証を要求
		http.formLogin() //
			.loginPage("/login").usernameParameter("user").passwordParameter("password") // ログイン画面
			.successForwardUrl("/") // ログイン成功時に表示するURL
			.permitAll();
		http.logout() //
			.logoutRequestMatcher(new AntPathRequestMatcher("/logout")) // logoutUrl()はPOSTに対応していない
			.logoutSuccessUrl("/login") // ログアウト成功時に表示するURL
			.deleteCookies("JSESSIONID").invalidateHttpSession(true).permitAll();
	}

configureメソッドで認証を行う対象URIとログイン画面およびログアウトのURIを定義する。

この際、ログイン画面に関するURIは認証対象から外しておく。
(でないと、認証失敗時にログインエラー画面表示(エラー画面(=ログイン画面)にフォワード)→エラー画面も認証対象→リクエスト内容は変わっていないので認証失敗→ログインエラー画面表示→…と無限ループに陥る)

formLogin(ログイン画面)の設定
設定メソッド デフォルト値 説明
loginPage   ログイン画面のパス
loginProcessingUrl loginPageと同じ ログイン画面でログインボタンを押したとき(サブミットされたとき)のパス
要するにログイン画面のformタグのth:action属性。
usernameParameter username ログイン画面で入力するユーザーIDのパラメーター名(inputタグのname)
passwordParameter password ログイン画面で入力するパスワードのパラメーター名(inputタグのname)
successForwardUrl   ログイン成功時に表示する画面のパス
これをセットしない場合、ユーザーがログイン前に要求したURLに跳ぶ。
failureForwardUrl   ログイン失敗時に表示する画面のパス
認証失敗時のユーザーIDの再表示

logout(ログアウト)の設定
設定メソッド 説明
logoutUrl ログアウト処理を行うパス
ただしGETしか対応していないらしいので、logoutRequestMatcherを使う方が良さそう。
logoutRequestMatcher ログアウト処理を行うパスのパターン(GETでもPOSTでもマッチする)
logoutSuccessUrl ログアウト後に表示する画面のパス
deleteCookies クッキーから削除するキー
invalidateHttpSession HTTPセッションを破棄するかどうか

	// パスワードのエンコーダー
	@Bean
	public PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
}

パスワードのチェックは、「入力されたパスワード」と「DB等に保存されたパスワード」が一致するするかどうかを確認するわけだが、
DBにパスワードをそのまま(平文のまま)保存するなど有り得ず、何らかの方式でエンコード(暗号化・ハッシュ化)して保存するだろう。
その方式を決めるのがPasswordEncoder。
とりあえず今はBCryptを使うのが良いらしい。
PasswordEncoderを返すメソッドを定義し、@Beanアノテーションを付けておくと、Spring Securityフレームワークの中でこれが使われる。(ユーザーから入力されたパスワードをPasswordEncoderを使ってエンコードし、「DB等にエンコードされて入っているパスワード」と比較する)

※このエンコーディングは、アプリケーション内部でパスワードを扱う方法の話であり、クライアント(ブラウザー)とサーバー(ウェブアプリケーション)間の通信経路の暗号化の話ではない。


ログインユーザー情報取得サービス

次に、ユーザーのパスワードを管理するクラスを用意する。

src/main/java/com/example/demo/auth/LoginUserService.java:

package com.example.demo.auth;

import java.util.Collections;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class LoginUserService implements UserDetailsService {
	// 実体は@Beanの付いたpasswordEncoderメソッドから取得される
	@Autowired
	private PasswordEncoder passwordEncoder;
	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		if (username == null) {
			throw new UsernameNotFoundException("empty");
		}

		// 本来ならDBアクセスしてパスワードを取得するところだが、サンプルなのでプログラム直書き
		String password;
		switch (username) {
		case "hishidama":
			password = passwordEncoder.encode("hoge"); // パスワードは「hoge」
			break;
		default:
			throw new UsernameNotFoundException("not found");
		}

		return new User(username, password, Collections.emptySet());
	}
}

Spring Securityでは、ログインユーザーの情報はUserDetailsで管理することになっている。
UserDetailsServiceで、ユーザー毎のUserDetailsを返すよう実装する。

フィールドに@Autowiredアノテーションを付けたPasswordEncoderフィールドを用意しておくと、@Beanを付けたPasswordEncoderを返すメソッドの値が自動的にセットされる。
DBにパスワードを保存する際には、これを使ってエンコードしたパスワードを保存する。
今回の例ではDBは使っていないので、毎回エンコードしている。

なお、Userのコンストラクターの第3引数にはユーザーの権限(Role)を指定する。[2017-08-28]
権限(Role)の例


ログイン画面

ログイン画面へ遷移するControllerと、ログイン画面のhtmlを用意する。

src/main/java/com/example/demo/auth/LoginController.java:

package com.example.demo.auth;

import org.springframework.security.web.WebAttributes;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
public class LoginController {

	@RequestMapping("/login")
	public String login() {
		return "login"; // login.htmlを表示
	}
}

src/main/resources/templates/login.html:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<title>login</title>
</head>
<body>
<h1>login</h1>
<form action="#" th:action="@{/login}" method="post">
	<p>ユーザー: <input type="text" name="user" /></p>
	<p>パスワード: <input type="password" name="password" /></p>
	<p><input type="submit" value="ログイン" /></p>
</form>
<div th:if="${session['SPRING_SECURITY_LAST_EXCEPTION']} != null">
	last exception: <span th:text="${session['SPRING_SECURITY_LAST_EXCEPTION'].message}"></span>
</div>
</body>
</html>

ログインに失敗すると、内部ではBadCredentialsExceptionが発生する。
その例外はHTTPセッションのSPRING_SECURITY_LAST_EXCEPTIONに保存されるので、そこからエラーメッセージを表示できる。
(ログイン失敗時には、再度同じログイン画面を表示させる想定)


トップ画面

ログイン成功時に表示される画面へ遷移するControllerと、そのhtmlファイルを用意する。

src/main/java/com/example/demo/IndexController.java:

package com.example.demo;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
public class IndexController {

	@RequestMapping("/")
	public String index() {
		return "index";
	}
}

src/main/resources/templates/index.html:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<title>index Thymeleaf</title>
</head>
<body>
<h1>index Thymeleaf</h1>
<form action="#" th:action="@{/logout}" method="post">
	<input type="submit" value="ログアウト" />
</form>
</body>
</html>

ログアウトボタンも付けてみた。


画面遷移の解説

ブラウザーから「http://localhost:8080/hello」が要求されたとする。

このURI(パス「/」)は、WebSecurityConfigの設定により、認証が必要と判断される。
loginPageが「/login」なので、LoginControllerが呼ばれ、login.html(ログイン画面)が表示される。

ログイン画面でユーザーIDとパスワードを入力してログインボタンを押すと、formのth:action属性の設定に従い、「/login」が呼ばれる。
このURIはログイン画面表示用のものと同じだが、POSTメソッドなので(?)LoginControllerは呼ばれず、Spring Securityが処理を行う。
その結果、DaoAuthenticationProviderがHTTPリクエストからユーザーIDとパスワードを取り出し、UserDetailsServiceを呼び出して確認用のパスワードを取得し、パスワードのチェック (認証)を行う。

認証が失敗した場合、HTTPセッションのSPRING_SECURITY_LAST_EXCEPTIONにBadCredentialsExceptionが保存される。
デフォルト(failureForwardUrlが指定されていない場合)では、ログイン画面のURIに「?error」を付けた画面(/login?error)が表示される。(LoginController経由でlogin.htmlが表示される)

認証が成功した場合は、successForwardUrlで指定された画面に遷移する。(「/」なので、IndexController経由でindex.htmlが表示される)
もしsuccessForwardUrlが指定されていなかったら、最初にブラウザーから要求された画面(「/hello」)に遷移する。


index.html内のログアウトボタンを押すと、formのth:action属性の設定に従い、「/logout」が呼ばれる。
これはWebSecurityConfigのlogoutRequestMatcherに合致するので、Spring Securityによってログアウト処理が行われる。


認証失敗時のユーザーIDの再表示

上記の例の方法では、認証失敗時に再表示されたログイン画面では、入力したユーザーIDが消えてしまう。

ユーザーIDを再表示する為には、LoginControllerでリクエストからユーザーIDを取得し、画面表示用にModelにセットし直せばいい。
ところが、認証失敗時にログイン画面再表示の為にLoginControllerが呼び出されるときには、最初のリクエストの内容は消えてしまっており、ユーザーIDを取得することが出来ない。

そこで、WebSecurityConfigでfailureForwardUrlを指定してやると、そのパスのControllerがフォワードで呼び出されるようになるので、最初のリクエストの内容が残ってユーザーIDを取得できる。
ただしfailureForwardUrlに「/login」を指定すると呼び出しが無限ループしてしまうので、別のパスにする必要がある。

src/main/java/com/example/demo/auth/WebSecurityFormConfig.java:

	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http.authorizeRequests() //
			.antMatchers("/login*").permitAll() // ログイン画面
			.anyRequest().authenticated(); // その他の全リクエストに対して認証を要求
		http.formLogin() //
			.loginPage("/login").usernameParameter("user").passwordParameter("password") // ログイン画面
			.successForwardUrl("/") // ログイン成功時に表示するURL
			.failureForwardUrl("/login-fail") // ログイン失敗時に遷移するパス
			.permitAll();
		http.logout() //
			.logoutRequestMatcher(new AntPathRequestMatcher("/logout")) //
			.logoutSuccessUrl("/login") // ログアウト成功時に表示するURL
			.deleteCookies("JSESSIONID").invalidateHttpSession(true).permitAll();
	}

failureForwardUrlを追加し、ログイン失敗時に遷移するパスを「/login-fail」にした。
接頭辞が「/login」になっているので、antMatchersを「/login*」にしておけば、このパスも認証の対象外になる。


src/main/resources/templates/login.html:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<title>login</title>
</head>
<body>
<form action="#" th:action="@{/login}" method="post">
	<h1>login</h1>
	<p>ユーザー : <input type="text" name="user" th:value="${user}" /></p>
	<p>パスワード : <input type="password" name="password" /></p>
	<p><input type="submit" value="ログイン" /></p>
</form>
<div th:if="${message} != null">
	message: <span th:text="${message}"></span>
</div>
</body>
</html>

ログイン画面は、ユーザーIDを再表示するために、ユーザーID用のinputタグにth:value属性を追加した。
ControllerでModelにuserをセットしておくと、ここで表示される。

また、HTTPセッションの例外を取得する方式をやめて、メッセージ用の変数に変えた。
(WebSecurityConfigでfailureForwardUrlを指定する方式では、HTTPセッションに例外が入ってこない為())


src/main/java/com/example/demo/auth/LoginController.java:

package com.example.demo.auth;

import org.springframework.security.web.WebAttributes;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
public class LoginController {

	@RequestMapping("/login*")
	public String login(
		@ModelAttribute("user") String user,
		@RequestAttribute(name = WebAttributes.AUTHENTICATION_EXCEPTION, required = false) Exception exception,
		Model model
	) {
		model.addAttribute("user", user);

		if (exception != null) {
			model.addAttribute("message", exception.getMessage());
		}

		return "login";
	}
}

ログイン画面を表示する為のLoginControllerでは、メソッドの引数にuserとExceptionとModelを追加した。

引数 説明
@ModelAttribute("user") String user HTTPリクエストに入っているユーザーID
@RequestAttribute(WebAttributes.AUTHENTICATION_EXCEPTION) Exception exception HTTPリクエストに入っているSPRING_SECURITY_LAST_EXCEPTIONの例外(
Model model 値をhtmlに渡す為のModel

RequestMappingを「/login*」とすることにより、パスが「/login」の場合も「/login-fail」の場合もこのメソッドに来る。
(いずれも表示したい画面はlogin.htmlなので、まとめて処理する方が分かりやすい)
初回のログイン画面表示時にはuesrやexceptionはnullになっている。
認証失敗時(/login-fail)の場合はuserにはログイン画面で入力されていたユーザーID、exceptionにはBadCredentialsException(認証例外)が入ってくる。

userやExceptionのメッセージをModelに入れてやれば、htmlからその値を取得することが出来る。

WebSecurityConfigでfailureForwardUrlを指定しない場合、認証エラーはSimpleUrlAuthenticationFailureHandlerで処理され、BadCredentialsExceptionは(デフォルトでは)HTTPセッションに保存される。
 しかし、failureForwardUrlを指定した場合は、認証エラーはForwardAuthenticationFailureHandlerで処理され、BadCredentialsExceptionはHTTPリクエスト(のattribute)に保存される。
 したがって、failureForwardUrlを指定した場合は、HTTPセッションのSPRING_SECURITY_LAST_EXCEPTIONから例外を取得することは出来ない。


認証失敗時のエラーメッセージ

認証失敗時のエラーメッセージを表示してみると、「Bad credentials」と表示される。これを日本語に変えるには、messages.propertiesを用意する。

参考: nononoteさんのSpringSecutiryで例外メッセージを変更するのーと


認証失敗時はDaoAuthenticationProviderでBadCredentialsExceptionが発生する。

			throw new BadCredentialsException(messages.getMessage(
				"AbstractUserDetailsAuthenticationProvider.badCredentials",
				"Bad credentials"));

したがって、messages.propertiesを作ってキー「AbstractUserDetailsAuthenticationProvider.badCredentials」のメッセージを入れてやれば、それが表示される。

src/main/resources/messages.properties:

AbstractUserDetailsAuthenticationProvider.badCredentials=ユーザーIDまたはパスワードが違っています。

UTF-8で日本語を直接書いても大丈夫。


ところが、このmessages.propertiesが上手く反映される場合と反映されない場合があるorz

どうも、WebSecurityConfigでAuthenticationManagerBuilderのconfigureメソッドをオーバーライドすると上手くいき、オーバーライドしない場合(最初の例の場合)は上手くいかないようだ。

src/main/java/com/example/demo/auth/WebSecurityFormConfig.java:

import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.core.userdetails.UserDetailsService;
@EnableWebSecurity
public class WebSecurityFormConfig extends WebSecurityConfigurerAdapter {
	@Autowired
	private UserDetailsService userDetailsService;
〜
	// これを追加すると上手くいく
	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		auth.userDetailsService(this.userDetailsService).passwordEncoder(passwordEncoder());
	}
〜
}

どうやら、このconfigureメソッドの有無によってメッセージ用のResourceBundleの初期化方法が違っている模様。ぶっちゃけバグじゃね?


このconfigureメソッドが無い場合は、以下の場所にmessages_ja_JP.propertiesを定義すると反映される。

src/main/resources/org/springframework/security/messages_ja_JP.properties:

AbstractUserDetailsAuthenticationProvider.badCredentials=\u30E6\u30FC\u30B6\u30FCID\u304B\u30D1\u30B9\u30EF\u30FC\u30C9\u304C\u9055\u3044\u307E\u3059\u3002(spring package)

(ここに書く場合はUTF-8を使う事が出来ないようで、エスケープする必要がある)

とはいえ、「springのパッケージにプログラマーがファイルを置く方法」が正しいとは思えない…。


Spring Bootへ戻る / Spring Frameworkへ戻る / 技術メモへ戻る
メールの送信先:ひしだま