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のパッケージにプログラマーがファイルを置く方法」が正しいとは思えない…。