Spring Bootのウェブアプリケーションのフォーム認証のサンプル。
フォーム認証は、ログイン画面(form)でユーザーID・パスワードを入力してログインする認証方式。
Spring Bootでフォーム認証を行う場合は、Spring Security(spring-boot-starter-security)を使用する。
〜
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認証を要求する例。(Spring Boot 1.5.6、Spring Security 4.2.3)
ウェブページに初めてアクセスするときにログイン画面を表示し、ログインしたらアクセスできるようにしてみる。
まず、どのURIに対して認証を要求するかを設定するクラスを作成する。
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は認証対象から外しておく。
(でないと、認証失敗時にログインエラー画面表示(エラー画面(=ログイン画面)にフォワード)→エラー画面も認証対象→リクエスト内容は変わっていないので認証失敗→ログインエラー画面表示→…と無限ループに陥る)
| 設定メソッド | デフォルト値 | 説明 |
|---|---|---|
| loginPage | ログイン画面のパス | |
| loginProcessingUrl | loginPageと同じ | ログイン画面でログインボタンを押したとき(サブミットされたとき)のパス 要するにログイン画面のformタグの th:action属性。 |
| usernameParameter | username | ログイン画面で入力するユーザーIDのパラメーター名(inputタグのname) |
| passwordParameter | password | ログイン画面で入力するパスワードのパラメーター名(inputタグのname) |
| successForwardUrl | ログイン成功時に表示する画面のパス これをセットしない場合、ユーザーがログイン前に要求したURLに跳ぶ。 |
|
| failureForwardUrl | ログイン失敗時に表示する画面のパス →認証失敗時のユーザーIDの再表示 |
| 設定メソッド | 説明 |
|---|---|
| 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等にエンコードされて入っているパスワード」と比較する)
※このエンコーディングは、アプリケーション内部でパスワードを扱う方法の話であり、クライアント(ブラウザー)とサーバー(ウェブアプリケーション)間の通信経路の暗号化の話ではない。
次に、ユーザーのパスワードを管理するクラスを用意する。
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を用意する。
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を表示
}
}
<!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ファイルを用意する。
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";
}
}
<!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を再表示する為には、LoginControllerでリクエストからユーザーIDを取得し、画面表示用にModelにセットし直せばいい。
ところが、認証失敗時にログイン画面再表示の為にLoginControllerが呼び出されるときには、最初のリクエストの内容は消えてしまっており、ユーザーIDを取得することが出来ない。
そこで、WebSecurityConfigでfailureForwardUrlを指定してやると、そのパスのControllerがフォワードで呼び出されるようになるので、最初のリクエストの内容が残ってユーザーIDを取得できる。
ただしfailureForwardUrlに「/login」を指定すると呼び出しが無限ループしてしまうので、別のパスにする必要がある。
@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*」にしておけば、このパスも認証の対象外になる。
<!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セッションに例外が入ってこない為(※))
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」のメッセージを入れてやれば、それが表示される。
AbstractUserDetailsAuthenticationProvider.badCredentials=ユーザーIDまたはパスワードが違っています。
UTF-8で日本語を直接書いても大丈夫。
ところが、このmessages.propertiesが上手く反映される場合と反映されない場合があるorz
どうも、WebSecurityConfigでAuthenticationManagerBuilderのconfigureメソッドをオーバーライドすると上手くいき、オーバーライドしない場合(最初の例の場合)は上手くいかないようだ。
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を定義すると反映される。
AbstractUserDetailsAuthenticationProvider.badCredentials=\u30E6\u30FC\u30B6\u30FCID\u304B\u30D1\u30B9\u30EF\u30FC\u30C9\u304C\u9055\u3044\u307E\u3059\u3002(spring package)
(ここに書く場合はUTF-8を使う事が出来ないようで、エスケープする必要がある)
とはいえ、「springのパッケージにプログラマーがファイルを置く方法」が正しいとは思えない…。