かんがるーさんの日記

最近自分が興味をもったものを調べた時の手順等を書いています。今は Spring Boot をいじっています。

Spring Boot で書籍の貸出状況確認・貸出申請する Web アプリケーションを作る ( その9 )( ログイン画面の作成3 )

概要

Spring Boot で書籍の貸出状況確認・貸出申請する Web アプリケーションを作る ( その8 )( ログイン画面の作成2 ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • パスワードを5回間違えた場合には ID をロックする機能の作成

参照したサイト・書籍

  1. Spring Security でアカウントロック機構を実現する
    http://d.hatena.ne.jp/ocs/20111101/1320115388

  2. http://gotoanswer.com/?q=Use+custom+exceptions+in+Spring+Security

    • UsernameNotFoundException が BadCredentialsException に置き換わらないようにするために、AbstractUserDetailsAuthenticationProvider 抽象クラスの setHideUserNotFoundExceptions メソッドを呼び出す方法を調査していた時に参照しました。

目次

  1. はじめに
  2. 1.0.x-make-accountlock ブランチの作成
  3. ログインエラーのイベントを ApplicationListener クラスで受け取ってエラーの原因を判別できるのか?
  4. 実装内容を決める
  5. user_info テーブルの変更
  6. パスワードを5回間違えた場合には ID をロックする機能の作成
  7. 動作確認
  8. commit、Push、Pull Request、マージ
  9. メモ書き
    1. パッケージの中のクラス一覧を確認する方法
    2. Spring Security のログイン処理時のイベント一覧は Spring Security のマニュアルのどこに掲載されているのか?

手順

はじめに

パスワードを5回間違えた場合には ID をロックする機能を作成します。

UserInfoUserDetails クラスに accountNonLocked というフィールドが用意されているので、アカウントロック時はこれが false になるようにすればいいはずです。

今回は Spring Security でアカウントロック機構を実現する というやりたいことがそのままタイトルになっている記事も以前見つけていますので、Spring Boot でもそのまま実装できるのか動作確認してから進めます。

1.0.x-make-accountlock ブランチの作成

  1. IntelliJ IDEA で 1.0.x-make-accountlock ブランチを作成します。

ログインエラーのイベントを ApplicationListener クラスで受け取ってエラーの原因を判別できるのか?

  1. 以前 Spring Boot でログイン画面 + 一覧画面 + 登録画面の Webアプリケーションを作る ( その9 )( ログイン画面作成2 ) を書いていた時に見つけた Spring Security でアカウントロック機構を実現する の記事によると、認証情報が正しくない時に発生する AuthenticationFailureBadCredentialsEvent の ApplicationListener クラスを登録しておけばログインエラー時の処理を追加できるとのことでした。確認してみます。

  2. src/main/java/ksbysample/webapp/lending/security の下に AuthenticationFailureBadCredentialsEventListener.java を作成します。作成後、リンク先のその1の内容 に変更します。

  3. Gradle projects View から bootRun タスクを実行して Tomcat を起動します。

  4. ブラウザを起動して http://localhost:8080/ にアクセスし、ログイン画面を表示します。

  5. 最初はログイン成功の場合にイベントが発生しないことを確認します。ID に "tanaka.taro@sample.com"、Password に "taro" を入力して「ログイン」ボタンをクリックします。「ログイン成功!」の画面が表示され、AuthenticationFailureBadCredentialsEventListener::onApplicationEvent に入れたログも出力されませんでした。

  6. 次に存在しない ID でログインしてみます。ブラウザで http://localhost:8080/ にアクセスしてログイン画面を表示した後、ID に "xxxxxxxx"、Password に "taro" を入力して「ログイン」ボタンをクリックします。

    画面に「入力された ID あるいはパスワードが正しくありません」のエラーが表示されました。

    f:id:ksby:20150718090733p:plain

    ログを見ると BadCredentialsException のログが出力されていました。ユーザが存在しないので UsernameNotFoundException が発生すると思っていたのですが違うようです。

    f:id:ksby:20150718090921p:plain

  7. 次に ID は存在するがパスワードが間違っている場合を試してみます。再度ブラウザで http://localhost:8080/ にアクセスしてログイン画面を表示した後、ID に "tanaka.taro@sample.com"、Password に "xxxxxxxx" を入力して「ログイン」ボタンをクリックします。

    画面に「入力された ID あるいはパスワードが正しくありません」のエラーが表示されました。

    f:id:ksby:20150718093701p:plain

    ログを見ると先ほどと同じく BadCredentialsException のログが出力されていました。

    f:id:ksby:20150718093828p:plain

  8. 作成した UserDetailsService インターフェースの実装クラス UserInfoUserDetailsService では入力された ID のユーザが user_info テーブルに存在しない場合には UsernameNotFoundException を throw するように実装したのですが、もしかして UsernameNotFoundException が throw されていないのでしょうか? 確認してみます。

  9. UserInfoUserDetailsService クラスの loadUserByUsername メソッドの以下の画像の箇所にブレークポイントを設定して、存在しない ID でログインしてみます。

    f:id:ksby:20150718121716p:plain

  10. Ctrl+F2 を押して Tomcat を停止します。

  11. メイン画面右上で Debug ボタンを押して Tomcat を起動します。

    f:id:ksby:20150718122212p:plain

  12. ブラウザで http://localhost:8080/ にアクセスしてログイン画面を表示した後、ID に "xxxxxxxx"、Password に "taro" を入力して「ログイン」ボタンをクリックします。

    想定通り throw new UsernameNotFoundException(...); の処理が実行されていました。

    f:id:ksby:20150718122509p:plain

  13. UsernameNotFoundException を throw しているのになぜログに BadCredentialsException が出力されるのか調べるために、このまま Debugger の Step Over ボタンを押して処理を実行し続けてみました。以下のことが分かりました。

    • org.springframework.security.authentication.dao の AbstractUserDetailsAuthenticationProvider 抽象クラスの 136行目に if (hideUserNotFoundExceptions) { という記述があり、hideUserNotFoundExceptions が true の場合には UsernameNotFoundException ではなく BadCredentialsException に置き換わるようになっていました。
    • hideUserNotFoundExceptions はクラスの上で protected boolean hideUserNotFoundExceptions = true; と定義されていました。
    • public void setHideUserNotFoundExceptions(boolean hideUserNotFoundExceptions) というメソッドも用意されていました。setHideUserNotFoundExceptions を呼び出して true にすれば UsernameNotFoundException のまま throw されるようです。
  14. src/main/java/ksbysample/webapp/lending/config の下の WebSecurityConfig.javaリンク先の内容 に変更します。

  15. Ctrl+F2 を押して Tomcat を停止します。

  16. Gradle projects View から bootRun タスクを実行して Tomcat を起動します。

  17. 最初にログイン可能な ID, Password でログインできるか確認します。ログイン画面で ID に "tanaka.taro@sample.com"、Password に "taro" を入力して「ログイン」ボタンをクリックします。「ログイン成功!」の画面が表示されました。

  18. 次に存在しない ID でログインしてみます。ログイン画面で ID に "xxxxxxxx"、Password に "taro" を入力して「ログイン」ボタンをクリックします。

    画面に「username(xxxxxxxx) not found.」のエラーが表示されました。

    f:id:ksby:20150718134407p:plain

    ログを見ると今度は UsernameNotFoundException のログが出力されていました。

    f:id:ksby:20150718134627p:plain

  19. 次に ID は存在するがパスワードが間違っている場合を試してみます。ログイン画面で ID に "tanaka.taro@sample.com"、Password に "xxxxxxxx" を入力して「ログイン」ボタンをクリックします。こちらはログに BadCredentialsException が出力されていました。

  20. UsernameNotFoundException 発生時のエラーメッセージが BadCredentialsException 発生時と同じになるようにします。

  21. src/main/resources/messages_ja_JP.properties を リンク先の内容 に変更します。

  22. src/main/java/ksbysample/webapp/lending/security の下の UserInfoUserDetailsService.javaリンク先のその1の内容 に変更します。

  23. Ctrl+F5 を押して Tomcat を再起動します。

  24. ログイン画面で ID に "xxxxxxxx"、Password に "taro" を入力して「ログイン」ボタンをクリックします。画面に「入力された ID あるいはパスワードが正しくありません」のエラーが表示されました。

    f:id:ksby:20150718144505p:plain

  25. Ctrl+F2 を押して Tomcat を停止します。

実装内容を決める

ApplicationListener クラスでログインエラー時のイベントを受け取って処理できることが確認できましたので、以下の内容で実装を進めます。

  • user_info テーブルにログインエラー回数を保存するカラムを追加します。
  • ログインエラー回数は初期値を 0 にします。BadCredentialsException が発生した場合のみログインエラー回数をインクリメントします。
  • ログインエラー回数が 5 以上になったらアカウントをロックします。テーブルを直接操作してログインエラー回数のカラムの値を 4以下に変更しないとログインできないようにします。正しい ID、Password を入力してもログインできません。
  • ログインできたらログインエラー回数を 0 に戻します。

user_info テーブルの変更

/sql の下の create_table.sqlリンク先の内容 に変更します。

コマンドプロンプトから以下のコマンドを実行して user_info テーブルに cnt_badcredentials カラムを追加します。

C:\project-springboot\ksbysample-webapp-lending>psql -U ksbylending_user ksbylending
ユーザ ksbylending_user のパスワード:
psql (9.4.1)
"help" でヘルプを表示します.

ksbylending=> alter table user_info add column cnt_badcredentials smallint not null default 0;
ALTER TABLE
ksbylending=> \d user_info
 user_id            | bigint                 | not null default nextval('user_info_user_id_seq'::regclass)
 username           | character varying(32)  | not null
 password           | character varying(256) | not null
 mail_address       | character varying(256) | not null
 enabled            | smallint               | not null default 1
 cnt_badcredentials | smallint               | not null default 0


ksbylending=> \q

C:\project-springboot\ksbysample-webapp-lending>

IntelliJ IDEA の Database Tools の「Shynchronize」ボタンをクリックして変更内容を反映します。

f:id:ksby:20150718165203p:plain

登録済の username = 'tanaka taro' のデータを見ると cnt_badcredentials のカラムの値は 0 になっています。

f:id:ksby:20150718165354p:plain

Gradle projects View から gen タスクを実行して user_info テーブルの Entity クラスを作成し直します。

パスワードを5回間違えた場合には ID をロックする機能の作成

  1. UserInfoDao インターフェースに cnt_badcredentials 更新用のメソッドを追加します。src/main/java/ksbysample/webapp/dao の下の UserInfoDao.javaリンク先の内容 に変更します。

  2. src/main/resources/META-INF/ksbysample/webapp/lending/dao/UserInfoDao の下に incCntBadcredentialsByMailAddress.sql を作成します。作成後、リンク先の内容 に変更します。

  3. src/main/resources/META-INF/ksbysample/webapp/lending/dao/UserInfoDao の下に initCntBadcredentialsByMailAddress.sql を作成します。作成後、リンク先の内容 に変更します。

  4. src/main/java/ksbysample/webapp/lending の下に service パッケージを作成します。

  5. src/main/java/ksbysample/webapp/lending/service の下に UserInfoService.java を作成します。作成後、リンク先の内容 に変更します。

  6. ログインエラー時の処理を実装します。src/main/java/ksbysample/webapp/lending/security の下の AuthenticationFailureBadCredentialsEventListener.javaリンク先のその2の内容 に変更します。

  7. ログイン成功時の処理を実装します。src/main/java/ksbysample/webapp/lending/security の下に AuthenticationSuccessEventListener.java を作成します。作成後、リンク先の内容 に変更します。

  8. ログインエラーが5回以上になったらアカウントロックする処理を実装します。src/main/java/ksbysample/webapp/lending/security の下の UserInfoUserDetails.javaリンク先の内容 に変更します。

  9. src/main/java/ksbysample/webapp/lending/security の下の UserInfoUserDetailsService.javaリンク先のその2の内容 に変更します。

動作確認

  1. user_info テーブルの username = 'tanaka taro' のデータの cnt_badcredentials の値を 0 に変更しておきます。

    f:id:ksby:20150718204555p:plain

  2. Gradle projects View から bootRun タスクを実行して Tomcat を起動します。

  3. ブラウザを起動して http://localhost:8080/ にアクセスし、ログイン画面を表示します。

  4. ID に "tanaka.taro@sample.com"、Password に "x" を入力して「ログイン」ボタンをクリックします。

    「入力された ID あるいはパスワードが正しくありません」のメッセージが表示されます。

    f:id:ksby:20150718204902p:plain

    user_info テーブルの username = 'tanaka taro' のデータの cnt_badcredentials の値が 1 に増えています。

    f:id:ksby:20150718205208p:plain

  5. あと4回 ID に "tanaka.taro@sample.com"、Password に "x" を入力して「ログイン」ボタンをクリックする操作を繰り返します。user_info テーブルの username = 'tanaka taro' のデータの cnt_badcredentials の値が 5 になります。

    f:id:ksby:20150718210220p:plain

  6. ID に "tanaka.taro@sample.com"、Password に "x" を入力して「ログイン」ボタンをクリックします。

    「入力された ID はロックされています」のメッセージが表示されます。

    f:id:ksby:20150718210338p:plain

    user_info テーブルの username = 'tanaka taro' のデータの cnt_badcredentials の値は 5 のまま変わりません。

    f:id:ksby:20150718210523p:plain

  7. この後何回か ID に "tanaka.taro@sample.com"、Password に "x" を入力して「ログイン」ボタンをクリックしても「入力された ID はロックされています」のメッセージが表示され続けました。

  8. 今度はアカウントロックされている時に正しい ID、Password を入力してもログインできない ( アカウントロックが解除されない ) ことを確認します。ID に "tanaka.taro@sample.com"、Password に "taro" を入力して「ログイン」ボタンをクリックします。

    ログインはできず「入力された ID はロックされています」のメッセージが表示されました。

    f:id:ksby:20150718210811p:plain

    user_info テーブルの username = 'tanaka taro' のデータの cnt_badcredentials の値は 5 のままでした。

    f:id:ksby:20150718210956p:plain

  9. 今度は cnt_badcredentials の値を 4 に変更してアカウントロックの状態を解除した後に、ログインできることとログインした後 cnt_badcredentials の値が 0 に初期化されることを確認します。まず user_info テーブルの username = 'tanaka taro' のデータの cnt_badcredentials の値を 4 に変更します。

    f:id:ksby:20150718211255p:plain

  10. ID に "tanaka.taro@sample.com"、Password に "taro" を入力して「ログイン」ボタンをクリックします。

    ログインして「ログイン成功!」の画面が表示されました。

    f:id:ksby:20150718211526p:plain

    user_info テーブルの username = 'tanaka taro' のデータの cnt_badcredentials の値は 0 になりました。

    f:id:ksby:20150718211838p:plain

  11. 想定通り動作することが確認できました。Ctrl+F2 を押して Tomcat を停止します。

commit、Push、Pull Request、マージ

  1. commit します。「Code Analysis」ダイアログが出ますが、無視して「Commit」ボタンをクリックします。

  2. GitHub へ Push、1.0.x-make-accountlock -> 1.0.x へ Pull Request、1.0.x でマージ、1.0.x-make-accountlock ブランチを削除、をします。

メモ書き

パッケージの中のクラス一覧を確認する方法

今回の作業中に、package org.springframework.security.authentication.event; の中に AuthenticationFailureBadCredentialsEvent 以外のどんなイベントクラスが用意されているか確認したいと思ったので、その方法を調べてみました。

  1. 一番シンプルで速い方法は、IntelliJ IDEA のメイン画面の上部に表示されているパッケージ名をクリックすることです。そのパッケージ内のクラス一覧が表示されます。

    f:id:ksby:20150718080343p:plain

  2. Ultimate Edition であれば UML のクラス図で表示させることができます。まず package org.springframework.security.authentication.event;event の部分にカーソルを移動した後、コンテキストメニューを表示して「Diagrams」->「Show Diagram...」を選択します。

    f:id:ksby:20150718081531p:plain

  3. UML のクラス図が表示されます。下の画像ではクラス名しか表示されていませんが、左上の f や m のボタンを押して選択状態にすればフィールドやメソッドも表示されます。

    f:id:ksby:20150718081815p:plain

Spring Security のログイン処理時のイベント一覧は Spring Security のマニュアルに掲載されているのか?

Spring 関連のライブラリが用意しているイベント一覧を知るにはにはどうすればよいのか興味が湧いたので、調べてみました。

  • Spring Security Reference には記載がありませんでした。

  • パッケージの中のクラス一覧を確認する方法 に書いた通り、org.springframework.security.authentication.event にあるイベント一覧を表示させればネーミングも分かりやすいものばかりなので、何があるかは分かりそうではあります。

  • docs.spring.io の Spring Security 3.2.7.RELEASE API を表示して、"event" で検索するのが一番分かりやすそうです。

    1. Spring Security 3.2.7.RELEASE API で "event" で検索する。
    2. org.springframework.security.authentication.event のリンクをクリックして、Package org.springframework.security.authentication.event を表示する。
    3. 画面上部の Tree リンクをクリックすると Hierarchy For Package org.springframework.security.authentication.event が表示され、org.springframework.context.ApplicationEvent を継承したクラスであることが分かる。
  • おそらく Spring 関連のライブラリは docs.spring.io に同様にドキュメントが用意されていると思われるので、ここを見るのが一番調べやすそうな気がします。

  • IntelliJ IDEA に指定したパッケージ内の ApplicationEvent を継承したクラスだけをダイアグラムで表示してくれる機能があると便利なのですが。。。 と思って調べていたらそれらしいことができる機能を見つけたので番外編で書きます。

ソースコード

AuthenticationFailureBadCredentialsEventListener.java

■その1

package ksbysample.webapp.lending.security;

import org.springframework.context.ApplicationListener;
import org.springframework.security.authentication.event.AuthenticationFailureBadCredentialsEvent;
import org.springframework.stereotype.Component;

@Component
public class AuthenticationFailureBadCredentialsEventListener
        implements ApplicationListener<AuthenticationFailureBadCredentialsEvent> {

    @Override
    public void onApplicationEvent(AuthenticationFailureBadCredentialsEvent event) {
        System.out.println("★★★" + event.getException().getClass().getName());
    }

}

■その2

package ksbysample.webapp.lending.security;

import ksbysample.webapp.lending.service.UserInfoService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationListener;
import org.springframework.security.authentication.event.AuthenticationFailureBadCredentialsEvent;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;

@Component
public class AuthenticationFailureBadCredentialsEventListener
        implements ApplicationListener<AuthenticationFailureBadCredentialsEvent> {

    @Autowired
    private UserInfoService userInfoService;
    
    @Override
    public void onApplicationEvent(AuthenticationFailureBadCredentialsEvent event) {
        // UsernameNotFoundException は何もしない
        if (event.getException().getClass().equals(UsernameNotFoundException.class)) {
            return;
        }

        // 入力されたID(メールアドレス)の user_info.cnt_badcredentials を +1 する
        userInfoService.incCntBadcredentials(event.getAuthentication().getName());
    }

}

WebSecurityConfig.java

package ksbysample.webapp.lending.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.security.SecurityProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
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.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @SuppressWarnings("SpringJavaAutowiringInspection")
    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                // 認証の対象外にしたいURLがある場合には、以下のような記述を追加します
                // 複数URLがある場合はantMatchersメソッドにカンマ区切りで対象URLを複数列挙します
                // .antMatchers("/country/**").permitAll()
                .antMatchers("/fonts/**").permitAll()
                .antMatchers("/html/**").permitAll()
                .antMatchers("/encode").permitAll()
                .anyRequest().authenticated();
        http.formLogin()
                .loginPage("/")
                .loginProcessingUrl("/login")
                .defaultSuccessUrl("/loginsuccess")
                .failureUrl("/")
                .usernameParameter("id")
                .passwordParameter("password")
                .permitAll()
                .and()
                .logout()
                .logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
                .logoutSuccessUrl("/")
                .deleteCookies("JSESSIONID")
                .invalidateHttpSession(true)
                .permitAll();
    }

    @Bean
    public AuthenticationProvider daoAuhthenticationProvider() {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(userDetailsService);
        daoAuthenticationProvider.setHideUserNotFoundExceptions(false);
        daoAuthenticationProvider.setPasswordEncoder(new BCryptPasswordEncoder());
        return daoAuthenticationProvider;
    }
    
    @Autowired
    public void configAuthentication(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(daoAuhthenticationProvider());
    }

}
  • daoAuhthenticationProvider Bean を追加します。このメソッド内で UserDetailService, PasswordEncoder を設定するようにします。
  • configAuthentication メソッド内では auth.authenticationProvider を呼び出して、追加した daoAuhthenticationProvider Bean を設定するようにします。

messages_ja_JP.properties

AbstractUserDetailsAuthenticationProvider.locked=入力された ID はロックされています
AbstractUserDetailsAuthenticationProvider.disabled=入力された ID は使用できません
AbstractUserDetailsAuthenticationProvider.expired=入力された ID の有効期限が切れています
AbstractUserDetailsAuthenticationProvider.credentialsExpired=入力された ID のパスワードの有効期限が切れています
AbstractUserDetailsAuthenticationProvider.badCredentials=入力された ID あるいはパスワードが正しくありません
UserInfoUserDetailsService.usernameNotFound=入力された ID あるいはパスワードが正しくありません

typeMismatch.java.math.BigDecimal=数値を入力して下さい。
typeMismatch.java.lang.Long=数値を入力して下さい。
  • UserInfoUserDetailsService.usernameNotFound を追加します。

UserInfoUserDetailsService.java

■その1

package ksbysample.webapp.lending.security;

import ksbysample.webapp.lending.dao.UserInfoDao;
import ksbysample.webapp.lending.dao.UserRoleDao;
import ksbysample.webapp.lending.entity.UserInfo;
import ksbysample.webapp.lending.entity.UserRole;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;

import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

@Component
public class UserInfoUserDetailsService implements UserDetailsService {

    @Autowired
    private UserInfoDao userInfoDao;

    @Autowired
    private UserRoleDao userRoleDao;

    @Autowired
    private MessageSource messageSource;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserInfo userInfo = userInfoDao.selectByMailAddress(username);
        if (userInfo == null) {
            throw new UsernameNotFoundException(
                    messageSource.getMessage("UserInfoUserDetailsService.usernameNotFound"
                            , new Object[]{}, LocaleContextHolder.getLocale()));
        }

        Set<SimpleGrantedAuthority> authorities = new HashSet<>();
        List<UserRole> userRoleList = userRoleDao.selectByUserId(userInfo.getUserId());
        if (userRoleList != null) {
            authorities.addAll(
                    userRoleList.stream().map(userRole -> new SimpleGrantedAuthority(userRole.getRole()))
                            .collect(Collectors.toList()));
        }

        return new UserInfoUserDetails(userInfo, authorities, true, true, true);
    }

}
  • private MessageSource messageSource; を追加します。
  • throw new UsernameNotFoundException(String.format("username(%s) not found.", username));throw new UsernameNotFoundException(messageSource.getMessage("UserInfoUserDetailsService.usernameNotFound", new Object[]{}, LocaleContextHolder.getLocale())); へ変更します。

■その2

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserInfo userInfo = userInfoDao.selectByMailAddress(username);
        if (userInfo == null) {
            throw new UsernameNotFoundException(
                    messageSource.getMessage("UserInfoUserDetailsService.usernameNotFound"
                            , new Object[]{}, LocaleContextHolder.getLocale()));
        }

        Set<SimpleGrantedAuthority> authorities = new HashSet<>();
        List<UserRole> userRoleList = userRoleDao.selectByUserId(userInfo.getUserId());
        if (userRoleList != null) {
            authorities.addAll(
                    userRoleList.stream().map(userRole -> new SimpleGrantedAuthority(userRole.getRole()))
                            .collect(Collectors.toList()));
        }

        return new UserInfoUserDetails(userInfo, authorities, true, true);
    }
  • loadUserByUsername メソッドの最後の return new UserInfoUserDetails(userInfo, authorities, true, true); から true を1つ削除します。

create_table.sql

create table user_info (
    user_id                 bigserial primary key
    , username              varchar(32) not null
    , password              varchar(256) not null
    , mail_address          varchar(256) not null
    , enabled               smallint not null default 1
    , cnt_badcredentials    smallint not null default 0
);
create index user_info_idx_01 on user_info(mail_address);
  • user_info テーブルに cnt_badcredentials カラムを追加します。

UserInfoDao.java

package ksbysample.webapp.lending.dao;

import ksbysample.webapp.lending.entity.UserInfo;
import ksbysample.webapp.lending.util.doma.ComponentAndAutowiredDomaConfig;
import org.seasar.doma.Dao;
import org.seasar.doma.Delete;
import org.seasar.doma.Insert;
import org.seasar.doma.Select;
import org.seasar.doma.Update;

/**
 */
@Dao
@ComponentAndAutowiredDomaConfig
public interface UserInfoDao {

    /**
     * @param userId
     * @return the UserInfo entity
     */
    @Select
    UserInfo selectById(Long userId);

    @Select
    UserInfo selectByMailAddress(String mailAddress);
    
    /**
     * @param entity
     * @return affected rows
     */
    @Insert
    int insert(UserInfo entity);

    /**
     * @param entity
     * @return affected rows
     */
    @Update
    int update(UserInfo entity);

    @Update(sqlFile = true)
    int incCntBadcredentialsByMailAddress(String mailAddress);

    @Update(sqlFile = true)
    int initCntBadcredentialsByUsername(String username);
    
    /**
     * @param entity
     * @return affected rows
     */
    @Delete
    int delete(UserInfo entity);
}
  • int incCntBadcredentialsByMailAddress(String mailAddress); を追加します。
  • int initCntBadcredentialsByUsername(String username); を追加します。
  • どちらのメソッドも @Update アノテーションsqlFile = true を指定します。引数が Entity クラスでなくてもエラーになりません。
  • initCntBadcredentialsByUsername メソッドの引数が username なのは、この後に実装する AuthenticationSuccessEventListener クラスの onApplicationEvent メソッドの引数 AuthenticationSuccessEvent event の event.getAuthentication().getName() の戻り値が mailAddress ではなく username のためです。event.getAuthentication().getClass().getName() を出力してみたら org.springframework.security.authentication.UsernamePasswordAuthenticationToken というクラスが返ってきたので、何か別の変更が必要なようです。今回はこのまま進めます。

incCntBadcredentialsByMailAddress.sql

update
  user_info
set
  cnt_badcredentials = cnt_badcredentials + 1
where
  mail_address = /* mailAddress */'tanaka.taro@sample.com'
  and cnt_badcredentials < 5

initCntBadcredentialsByMailAddress.sql

update
  user_info
set
  cnt_badcredentials = 0
where
  username = /* username */'tanaka taro'

UserInfoService.java

package ksbysample.webapp.lending.service;

import ksbysample.webapp.lending.dao.UserInfoDao;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class UserInfoService {

    @Autowired
    private UserInfoDao userInfoDao;
    
    public void incCntBadcredentials(String mailAddress) {
        userInfoDao.incCntBadcredentialsByMailAddress(mailAddress);
    }

    public void initCntBadcredentials(String mailAddress) {
        userInfoDao.initCntBadcredentialsByMailAddress(mailAddress);
    }
    
}

AuthenticationSuccessEventListener.java

package ksbysample.webapp.lending.security;

import ksbysample.webapp.lending.service.UserInfoService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationListener;
import org.springframework.security.authentication.event.AuthenticationSuccessEvent;
import org.springframework.stereotype.Component;

@Component
public class AuthenticationSuccessEventListener
        implements ApplicationListener<AuthenticationSuccessEvent> {

    @Autowired
    private UserInfoService userInfoService;

    @Override
    public void onApplicationEvent(AuthenticationSuccessEvent event) {
        // 入力されたID(メールアドレス)の user_info.cnt_badcredentials を 0 にする
        userInfoService.initCntBadcredentials(event.getAuthentication().getName());
    }

}

UserInfoUserDetails.java

package ksbysample.webapp.lending.security;

import ksbysample.webapp.lending.entity.UserInfo;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.Set;

public class UserInfoUserDetails implements UserDetails {

    private UserInfo userInfo;
    private final Set<? extends GrantedAuthority> authorities;
    private final boolean accountNonExpired;
    private final boolean accountNonLocked;
    private final boolean credentialsNonExpired;
    private final boolean enabled;

    public UserInfoUserDetails(UserInfo userInfo
            , Set<? extends GrantedAuthority> authorities
            , boolean accountNonExpired
            , boolean credentialsNonExpired) {
        this.userInfo = userInfo;
        this.authorities = authorities;
        this.accountNonExpired = accountNonExpired;
        this.accountNonLocked = (userInfo.getCntBadcredentials() < 5);
        this.credentialsNonExpired = credentialsNonExpired;
        this.enabled = (userInfo.getEnabled() == 1);
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getPassword() {
        return userInfo.getPassword();
    }

    @Override
    public String getUsername() {
        return userInfo.getUsername();
    }

    public String getMailAddress() {
        return userInfo.getMailAddress();
    }

    @Override
    public boolean isAccountNonExpired() {
        return accountNonExpired;
    }

    @Override
    public boolean isAccountNonLocked() {
        return accountNonLocked;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return credentialsNonExpired;
    }

    @Override
    public boolean isEnabled() {
        return enabled;
    }
}
  • コンストラクタの以下の点を変更します。
    • 引数から boolean accountNonLocked を削除します。
    • this.accountNonLocked = accountNonLocked;this.accountNonLocked = (userInfo.getCntBadcredentials() < 5); へ変更します。

履歴

2015/07/18
初版発行。