6.10. 代表的なセキュリティ要件の実装例¶
Caution
本バージョンの内容は既に古くなっています。最新のガイドラインはこちらからご参照ください。
目次
6.10.1. はじめに¶
6.10.1.1. この章で説明すること¶
- TERASOLUNA Server Framework for Java (5.x)を利用して代表的なセキュリティ要件を満たすための実装方法の例
- アプリケーションの説明 に示すサンプルアプリケーションを題材として、実装方法とソースコードの説明を行う
Warning
- この章で説明している実装方法はあくまでも一例であり、実際の開発においては個別の要件を考慮して実装する必要がある
- セキュリティ対策の網羅的な実施を保証するものではないため、必要に応じて追加の対策を検討すること
6.10.1.2. 対象読者¶
- TERASOLUNA Server Framework for Java (5.x)によるアプリケーション開発 の内容を理解していること
- Spring Security概要, 認証, 認可 の内容を理解していること
- Spring Securityチュートリアル を実施済みのこと
6.10.2. アプリケーションの説明¶
6.10.2.1. セキュリティ要件¶
本アプリケーションが満たすセキュリティ要件の一覧を以下に示す。各分類ごとに、実装方法とコード解説 にて実装例の解説を行う。
項番 | 分類 | 要件 | 概説 |
---|---|---|---|
(1)
|
パスワード変更の強制・促進 | 初期パスワード使用時のパスワード変更の強制 | 初期パスワードを使用して認証成功した際に、パスワードの変更を強制する |
(2)
|
期限切れパスワードの変更の強制 | 一定期間パスワードを変更していないユーザに対して、認証成功時にパスワードの変更を強制する
本アプリケーションでは、管理ユーザのみを対象とする
|
|
(3)
|
パスワード変更を促すメッセージの表示 | 一定期間パスワードを変更していないユーザに対して、認証成功時にパスワードの変更を促すメッセージを表示する | |
(4)
|
パスワードの品質チェック | パスワードの最小文字数指定 | パスワードとして設定できる文字数の最小値を指定する |
(5)
|
パスワードの文字種別指定 | パスワード中に含めなければならない文字種別(英大文字、英小文字、数字、記号)を指定する | |
(6)
|
ユーザ名を含むパスワードの禁止 | パスワード中にアカウントのユーザ名を含めることを禁止する | |
(7)
|
管理ユーザパスワードの再使用禁止 | 管理ユーザが、以前使用したパスワードを短期間のうちに再使用することを禁止する | |
(8)
|
アカウントのロックアウト | アカウントロックアウト | あるアカウントが短期間の間に一定回数以上認証に失敗した場合、そのアカウントを認証不能な状態(ロックアウト状態)にする |
(9)
|
アカウントロックアウト期間の指定 | アカウントのロックアウト状態の継続時間を指定する | |
(10)
|
管理ユーザによるロックアウトの解除 | 管理ユーザは任意のアカウントのロックアウト状態を解除できる | |
(11)
|
最終ログイン日時の表示 | 前回ログイン日時の表示 | あるアカウントで認証成功した後、トップ画面にそのアカウントが前回認証に成功した日時を表示する |
(12)
|
パスワード再発行のための認証情報の生成 | パスワード再発行用URLへのランダム文字列の付与 | 不正なアクセスを防ぐため、パスワード再発行画面にアクセスするためのURLに十分に推測困難な文字列を付与する |
(13)
|
パスワード再発行用秘密情報の発行 | パスワード再発行時のユーザ確認に用いるために、事前に十分に推測困難な秘密情報(ランダム文字列)を生成する | |
(14)
|
パスワード再発行のための認証情報の配布 | パスワード再発行画面URLのメール送付 | パスワード再発行ページにアクセスするためのURLは、アカウントの登録済みメールアドレスへ送付する |
(15)
|
パスワード再発行画面のURLと秘密情報の別配布 | パスワード再発行画面のURLの漏えいに備え、秘密情報はメール以外の方法でユーザに配布する | |
(16)
|
パスワード再発行実行時の検査 | パスワード再発行用の認証情報の有効期限の設定 | パスワード再発行画面のURLと秘密情報に有効期限を設定し、有効期限が切れた場合はパスワード再発行画面のURLと秘密情報を使用不能にする |
(17)
|
パスワード再発行の失敗上限回数の設定 | パスワード再発行の失敗上限回数の設定 | パスワード再発行時の認証に一定回数失敗した場合、パスワード再発行画面のURLと秘密情報を使用不能にする |
6.10.2.2. 機能¶
本アプリケーションは、Spring Securityチュートリアル で作成したアプリケーションに加え、以下の機能を持つ。
機能名 | 説明 |
---|---|
パスワード変更機能 | ログイン済みのユーザが、自分のアカウントのパスワードを変更する機能 |
アカウントロックアウト機能 | 短期間に一定回数以上認証に失敗したアカウントを認証不能な状態にする機能 |
ロックアウト解除機能 | アカウントロックアウト機能により認証不能な状態になったアカウントを再び認証可能な状態に戻す機能 |
パスワード再発行機能 | ユーザがパスワードを忘れてしまった場合に、ユーザ確認を行った後、新しいパスワードを設定できる機能 |
Note
本アプリケーションはセキュリティ対策に関するサンプルであるため、本来は当然必要となる ユーザ登録の機能やパスワード以外の登録情報の更新機能を作成していない。
6.10.2.3. 認証・認可に関する仕様¶
本アプリケーションにおける、認証・認可に関する仕様についてそれぞれ以下に示す。
6.10.2.3.1. 認証¶
- 認証に使用するための初期パスワードはアプリケーション側から払い出されるものとする
6.10.2.3.2. 認可¶
- ログイン画面とパスワード再発行に使用する画面以外の画面へのアクセスには、認証が必要
- 「一般ユーザ」と「管理ユーザ」の二種類のロールが存在する
- 一つのアカウントが複数のロールを持つことができる
- アカウントロックアウト解除機能は、管理ユーザの権限を持つアカウントのみが使用できる
6.10.2.3.3. パスワード再発行時の認証¶
- パスワード再発行の認証にはアプリケーションが生成する次の二つの情報を用いる
- パスワード再発行画面のURL
- 認証用の秘密情報
- アプリケーションが生成するパスワード再発行画面のURLは以下の形式である
- {baseUrl}/reissue/resetpassword?form&token={token}
- {baseUrl} : アプリケーションのベースURL
- {token} : UUID version4形式の文字列(ハイフン込みで36文字、128bit)
- パスワード再発行画面のURLには30分の有効期限を設け、有効期限内のみ認証可能
6.10.2.4. 設計情報¶
6.10.2.4.1. 画面遷移¶
画面遷移図を以下に示す。エラー時の画面遷移は省略している。
項番
|
画面名
|
アクセスコントロール
|
---|---|---|
(1)
|
ログイン画面
|
-
|
(2)
|
トップ画面
|
認証済みユーザのみ
|
(3)
|
アカウント情報表示画面
|
認証済みユーザのみ
|
(4)
|
パスワード変更画面
|
認証済みユーザのみ
|
(5)
|
パスワード変更完了画面
|
認証済みユーザのみ
|
(6)
|
ロックアウト解除画面
|
管理ユーザのみ
|
(7)
|
ロックアウト解除完了画面
|
管理ユーザのみ
|
(8)
|
パスワード再発行のための認証情報生成画面
|
-
|
(9)
|
パスワード再発行のための認証情報生成完了画面
|
-
|
(10)
|
パスワード再発行画面
|
-
|
(11)
|
パスワード再発行完了画面
|
-
|
6.10.2.4.2. URL一覧¶
URL一覧を以下に示す。
項番 | プロセス名 | HTTPメソッド | URL | 説明 |
---|---|---|---|---|
1 | ログイン画面表示 | GET | /login | ログイン画面を表示する |
2 | ログイン | POST | /login | ログイン画面から入力されたユーザー名、パスワードを使って認証する(Spring Securityが行う) |
3 | ログアウト | POST | /logout | ログアウトする(Spring Securityが行う) |
4 | トップ画面表示 | GET | / | トップ画面を表示する |
5 | アカウント情報表示 | GET | /account | ログインユーザーのアカウント情報を表示する |
6 | パスワード変更画面表示 | GET | /password?form | パスワード変更画面を表示する |
7 | パスワード変更 | POST | /password | パスワード変更画面で入力された情報を使用して、アカウントのパスワードを変更する |
8 | パスワード変更完了画面表示 | GET | /password?complete | パスワード変更完了画面を表示する |
9 | ロックアウト解除画面表示 | GET | /unlock?form | ロックアウト解除画面を表示する |
10 | ロックアウト解除 | POST | /unlock | ロック解除画面に入力された情報を使用してアカウントのロックアウトを解除する |
11 | ロックアウト解除完了画面表示 | GET | /unlock?complete | ロックアウト解除完了画面を表示する |
12 | パスワード再発行のための認証情報生成画面表示 | GET | /reissue/create?form | パスワード再発行のための認証情報生成画面を表示する |
13 | パスワード再発行のための認証情報生成 | POST | /reissue/create | パスワード再発行のための認証情報を生成する |
14 | パスワード再発行のための認証情報生成完了画面表示 | GET | /reissue/create?complete | パスワード再発行のための認証情報生成完了画面を表示する |
15 | パスワード再発行画面表示 | GET | /reissue/resetpassword?form&token={token} | 二つのリクエストパラメータを使用して、ユーザ専用のパスワード再発行画面表示を表示する |
16 | パスワード再発行 | POST | /reissue/resetpassword | パスワード再発行画面に入力された情報を使用してパスワードを再発行する |
17 | パスワード再発行完了画面表示 | GET | /reissue/resetpassword?complete | パスワード再発行完了画面を表示する |
6.10.2.4.3. ER図¶
本アプリケーションにおけるER図を以下に示す。
項番 | エンティティ名 | 説明 | 属性 |
---|---|---|---|
(1)
|
アカウント
|
ユーザの登録済みアカウント情報
|
username : ユーザ名
password : パスワード(ハッシュ化済み)
firstName : 名
lastName : 姓
email : E-mailアドレス
roles : ロール(複数可)
|
(2)
|
ロール
|
認可に使用する権限
|
roleValue : ロールの識別子
roleLabel : ロールの表示名
|
(3)
|
認証成功イベント
|
アカウントの最終ログイン日時を取得するために、認証成功時に残す情報
|
username : ユーザ名
authenticationTimestamp : 認証成功日時
|
(4)
|
認証失敗イベント
|
アカウントのロックアウト機能で用いるために、認証失敗時に残す情報
|
username : ユーザ名
authenticationTimestamp : 認証失敗日時
|
(5)
|
パスワード変更履歴
|
パスワードの有効期限の判定等に用いるために、パスワード変更時に残す情報
|
username : ユーザ名
useFrom : 変更後のパスワードの使用開始日時
password : 変更後のパスワード
|
(6)
|
パスワード再発行用の認証情報
|
パスワード再発行時に、ユーザの確認に用いる情報
|
token : パスワード再発行画面のURLを一意かつ推測不能にするために用いる文字列
username : ユーザ名
secret : ユーザの確認に用いる文字列
experyDate : パスワード再発行用の認証情報の有効期限
|
(7)
|
パスワード再発行失敗イベント
|
パスワード再発行用の試行回数を制限するために、パスワード再発行失敗に残す情報
|
token : パスワード再発行に失敗した際に使用したtoken
attemptDate : パスワード再発行を試行した日時
|
Tip
初期パスワードやパスワード有効期限切れの判定を行うために、アカウントエンティティにフィールドを追加してパスワードの最終変更日時等の情報を持たせるといった設計も可能である。 そのような方法で実装を行う場合、アカウントのテーブルに様々な状態を判定するためのカラムが追加され、エントリが頻繁に更新されるという状況に繋がりがちである。
本アプリケーションでは、テーブルをシンプルな状態に保ち、エントリの不要な更新を避けて単純に挿入と削除を使用することで要件を実現するために、認証成功イベントエンティティ等のイベントエンティティを用いた設計を採用している。
6.10.3. 実装方法とコード解説¶
Note
本アプリケーションでは、ボイラープレートコードの排除のために、Lombokを使用している。Lombokについては、ボイラープレートコードの排除(Lombok) を参照。
6.10.3.1. パスワード変更の強制・促進¶
6.10.3.1.3. 実装方法¶
パスワード変更履歴エンティティの保存
パスワードを変更した際に、以下の情報を持ったパスワード変更履歴エンティティをデータベースに登録する。
- パスワードを変更したアカウントのユーザ名
- 変更後のパスワードの使用開始日時
初期パスワード、パスワード有効期限切れの判定
認証後、認証されたアカウントのパスワード変更履歴エンティティをデータベースから検索し、一件も見つからなければ初期パスワードを使用していると判断する。そうでない場合には、最新のパスワード変更履歴エンティティを取得し、現在日時とパスワードの使用開始日時の差分を計算して、パスワードの有効期限が切れているかどうかの判定を行う。パスワード変更画面への強制リダイレクト
パスワードの変更を強制するために、以下のいずれかに該当する場合には、パスワード変更画面以外へのリクエストが要求された際に、パスワード変更画面へリダイレクトさせる。
- 認証済みのユーザが初回パスワードを使用している場合
- 認証済みのユーザが管理ユーザであり、かつパスワードの有効期限が切れている場合
org.springframework.web.servlet.handler.HandlerInterceptor
を利用して、Controllerのハンドラメソッド実行前に上記の条件に該当するかどうかの判定を行う。Tip
認証後にパスワード変更画面へリダイレクトさせる方法は他にもあるが、方法によってはリダイレクト後にURLを直打ちすることでパスワード変更を避けて別画面にアクセスできてしまう可能性がある。
HandlerInterceptor
を使用する方法ではハンドラメソッド実行前に処理を行うため、URLを直打ちするなどの方法で回避することはできない。Tip
HandlerInterceptor
の代わりにServlet Filterを用いることもできる。両者の説明については Controllerの呼び出し前後で行う共通処理の実装 を参照すること。 ここでは、アプリケーションが許可したリクエストのみに対して処理を行うために、HandlerInterceptor
を用いている。パスワード変更を促すメッセージの表示
Controllerの中で前述のパスワード有効期限切れ判定処理を呼び出す。判定結果をViewに渡し、Viewでメッセージの表示・非表示を切り替える。
6.10.3.1.4. コード解説¶
上記の実装方法に従って実装されたコードについて順に解説する。
パスワード変更履歴エンティティの保存
パスワード変更時にパスワード変更履歴エンティティをデータベースに登録するための一連の実装を示す。
Entityの実装
パスワード変更履歴エンティティの実装は以下の通り。
package org.terasoluna.securelogin.domain.model; // omitted @Data public class PasswordHistory { private String username; // (1) private String password; // (2) private DateTime useFrom; // (3) }
項番 説明 (1)パスワードを変更したアカウントのユーザ名(2)変更後のパスワード(3)変更後のパスワードの使用開始日時Repositoryの実装
データベースに対するパスワード変更履歴エンティティの登録、検索を行うためのRepositoryを以下に示す。
package org.terasoluna.securelogin.domain.repository.passwordhistory; // omitted public interface PasswordHistoryRepository { int create(PasswordHistory history); // (1) List<PasswordHistory> findByUseFrom(@Param("username") String username, @Param("useFrom") LocalDateTime useFrom); // (2) List<PasswordHistory> findLatest( @Param("username") String username, @Param("limit") int limit); // (3) }
項番 説明 (1)引数として与えられたPasswordHistory
オブジェクトをデータベースのレコードとして登録するメソッド(2)引数として与えられたユーザ名をキーとして、パスワードの使用開始日時が指定された日付よりも新しいPasswordHistory
オブジェクトを降順(新しい順)に取得するメソッド(3)引数として与えられたユーザ名をキーとして、指定された個数のPasswordHistory
オブジェクトを新しい順に取得するメソッドマッピングファイルは以下の通り。
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="org.terasoluna.securelogin.domain.repository.passwordhistory.PasswordHistoryRepository"> <resultMap id="PasswordHistoryResultMap" type="PasswordHistory"> <id property="username" column="username" /> <id property="password" column="password" /> <id property="useFrom" column="use_from" /> </resultMap> <select id="findByUseFrom" resultMap="PasswordHistoryResultMap"> <![CDATA[ SELECT username, password, use_from FROM password_history WHERE username = #{username} AND use_from >= #{useFrom} ORDER BY use_from DESC ]]> </select> <select id="findLatest" resultMap="PasswordHistoryResultMap"> <![CDATA[ SELECT username, password, use_from FROM password_history WHERE username = #{username} ORDER BY use_from DESC LIMIT #{limit} ]]> </select> <insert id="create" parameterType="PasswordHistory"> <![CDATA[ INSERT INTO password_history ( username, password, use_from ) VALUES ( #{username}, #{password}, #{useFrom} ) ]]> </insert> </mapper>
Serviceの実装
パスワード変更履歴エンティティの操作は パスワードの品質チェック においても使用する。 そのため、以下のようにSharedServiceからRepositoryのメソッドを呼び出す。
package org.terasoluna.securelogin.domain.service.passwordhistory; // omitted @Service @Transactional public class PasswordHistorySharedServiceImpl implements PasswordHistorySharedService { @Inject PasswordHistoryRepository passwordHistoryRepository; @Transactional(propagation = Propagation.REQUIRES_NEW) public int insert(PasswordHistory history) { return passwordHistoryRepository.create(history); } @Transactional(readOnly = true) public List<PasswordHistory> findHistoriesByUseFrom(String username, LocalDateTime useFrom) { return passwordHistoryRepository.findByUseFrom(username, useFrom); } @Override @Transactional(readOnly = true) public List<PasswordHistory> findLatest(String username, int limit) { return passwordHistoryRepository.findLatest(username, limit); } }
パスワード変更時にパスワード変更履歴エンティティをデータベースに保存する処理の実装を以下に示す。
package org.terasoluna.securelogin.domain.service.account; // omitted @Service @Transactional public class AccountSharedServiceImpl implements AccountSharedService { @Inject ClassicDateFactory dateFactory; @Inject PasswordHistorySharedService passwordHistorySharedService; @Inject AccountRepository accountRepository; @Inject PasswordEncoder passwordEncoder; // omitted public boolean updatePassword(String username, String rawPassword) { // (1) String password = passwordEncoder.encode(rawPassword); boolean result = accountRepository.updatePassword(username, password); // (2) LocalDateTime passwordChangeDate = dateFactory.newTimestamp().toLocalDateTime(); PasswordHistory passwordHistory = new PasswordHistory(); // (3) passwordHistory.setUsername(username); passwordHistory.setPassword(password); passwordHistory.setUseFrom(passwordChangeDate); passwordHistorySharedService.insert(passwordHistory); // (4) return result; } // omitted }
項番 説明 (1)パスワードを変更する際に呼び出されるメソッド(2)データベース上のパスワードを更新する処理を呼び出す。(3)パスワード変更履歴エンティティを作成し、ユーザ名、変更後のパスワード、変更後のパスワードの使用開始日時を設定する。(4)作成したパスワード変更履歴エンティティをデータベースに登録する処理を呼び出す。
初期パスワード、パスワード有効期限切れの判定
データベースに登録されたパスワード変更履歴エンティティを用いて、初期パスワードを使用しているかどうかの判定と、パスワードの有効期限が切れているかどうかを判定する処理の実装を以下に示す。
package org.terasoluna.securelogin.domain.service.account; // omitted @Service @Transactional public class AccountSharedServiceImpl implements AccountSharedService { @Inject ClassicDateFactory dateFactory; @Inject PasswordHistorySharedService passwordHistorySharedService; @Value("${security.passwordLifeTimeSeconds}") // (1) int passwordLifeTimeSeconds; // omitted @Transactional(readOnly = true) @Override @Cacheable("isInitialPassword") public boolean isInitialPassword(String username) { // (2) List<PasswordHistory> passwordHistories = passwordHistorySharedService .findLatest(username, 1); // (3) return passwordHistories.isEmpty(); // (4) } @Transactional(readOnly = true) @Override @Cacheable("isCurrentPasswordExpired") public boolean isCurrentPasswordExpired(String username) { // (5) List<PasswordHistory> passwordHistories = passwordHistorySharedService .findLatest(username, 1); // (6) if (passwordHistories.isEmpty()) { // (7) return true; } if (passwordHistories .get(0) .getUseFrom() .isBefore( dateFactory.newTimestamp().toLocalDateTime() .minusSeconds(passwordLifeTimeSeconds))) { // (8) return true; } return false; } }
項番 説明 (1)プロパティファイルからパスワードが有効である期間の長さ(秒単位)を取得し、設定する。(2)初期パスワードを使用しているかどうかを判定し、使用している場合はtrue、そうでなければfalseを返すメソッド(3)データベースから最新のパスワード変更履歴エンティティを一件取得する処理を呼び出す。(4)データベースからパスワード変更履歴エンティティが取得できなかった場合に、初期パスワードを使用していると判定し、trueを返す。そうでなければfalseを返す。(5)現在使用中のパスワードの有効期限が切れているかどうかを判定し、切れている場合はtrue、そうでなければfalseを返すメソッド(6)データベースから最新のパスワード変更履歴エンティティを一件取得する処理を呼び出す。(7)データベースからパスワード変更履歴エンティティが取得できなかった場合には、パスワードの有効期限が切れていると判定し、trueを返す。(8)パスワード変更履歴エンティティから取得したパスワードの使用開始日時と現在日時の差分が、(1)で設定したパスワード有効期間よりも大きい場合、パスワードの有効期限が切れていると判定し、trueを返す。(9)(7), (8)のいずれの条件にも該当しない場合、パスワード有効期限内であると判定し、falseを返す。Tip
isInitialPassword および isCurrentPasswordExpired に付与されている
@Cacheable
は Spring の Cache Abstraction 機能を使用するためのアノテーションである。@Cacheable
アノテーションを付与することで、メソッドの引数に対する結果をキャッシュすることができる。 ここでは、キャッシュの使用により初期パスワード判定、パスワード期限切れ判定のたびにデータベースへのアクセスが発生することを防止し、パフォーマンスの低下を防いでいる。 Cache Abstraction については 公式ドキュメント を参照すること。尚、キャッシュを使用する際には、必要なタイミングでキャッシュをクリアする必要があることに注意すること。 本アプリケーションではパスワード変更時や、ログアウト時には再度初期パスワード判定、パスワード期限切れ判定を行うためにキャッシュをクリアする。
また、必要に応じてキャッシュのTTL(生存時間)を設定すること。TTLは使用するキャッシュの実装によっては設定不能であることに注意。
パスワード変更画面への強制リダイレクト
パスワードの変更を強制するために、パスワード変更画面へリダイレクトさせる処理の実装を以下に示す。
package org.terasoluna.securelogin.app.common.interceptor; // omitted public class PasswordExpirationCheckInterceptor extends HandlerInterceptorAdapter { // (1) @Inject AccountSharedService accountSharedService; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException { // (2) Authentication authentication = (Authentication) request .getUserPrincipal(); if (authentication != null) { Object principal = authentication.getPrincipal(); if (principal instanceof UserDetails) { // (3) LoggedInUser userDetails = (LoggedInUser) principal; // (4) if ((userDetails.getAccount().getRoles().contains(Role.ADMIN) && accountSharedService .isCurrentPasswordExpired(userDetails.getUsername())) // (5) || accountSharedService.isInitialPassword(userDetails .getUsername())) { // (6) response.sendRedirect(request.getContextPath() + "/password?form"); // (7) return false; // (8) } } } return true; } }
項番 説明 (1)Controllerのハンドラメソッド実行前に処理を挟み込むために、org.springframework.web.servlet.handler.HandlerInterceptorAdapter
を継承する。(2)Controllerのハンドラメソッド実行前に実行されるメソッド(3)取得したユーザ情報がorg.springframework.security.core.userdetails.UserDetails
のオブジェクトであるかどうかを確認する。(4)UserDetails
のオブジェクトを取得する。本アプリケーションでは、UserDetails
の実装としてLoggedInUser
というクラスを作成して用いている。(5)UserDetails
オブジェクトからロールを取得してユーザが管理ユーザであるかどうかを判定する。その後、パスワード有効期限が切れているかどうかを判定する処理を呼び出す。二つの判定結果の論理積(And)をとる。(6)初回パスワードを使用しているかどうかを判定する処理を呼び出す。(7)(5)または(6)のいずれかが真である場合、javax.servlet.http.HttpServletResponse
のsendRedirect
メソッドを使用して、パスワード変更画面へリダイレクトさせる。(8)続けてControllerのハンドラメソッドが実行されることを防ぐために、falseを返す。上記のリダイレクト処理を有効にするための設定は以下の通り。
spring-mvc.xml
<!-- omitted --> <mvc:interceptors> <!-- omitted --> <mvc:interceptor> <mvc:mapping path="/**" /> <!-- (1) --> <mvc:exclude-mapping path="/password/**" /> <!-- (2) --> <mvc:exclude-mapping path="/reissue/**" /> <!-- (3) --> <mvc:exclude-mapping path="/resources/**" /> <mvc:exclude-mapping path="/**/*.html" /> <bean class="org.terasoluna.securelogin.app.common.interceptor.PasswordExpirationCheckInterceptor" /> <!-- (4) --> </mvc:interceptor> <!-- omitted --> </mvc:interceptors> <!-- omitted -->
項番 説明 (1)“/”以下のすべてのパスに対するアクセスにHandlerInterceptor
を適用する。(2)パスワード変更画面からパスワード変更画面へのリダイレクトを防ぐため、 “/password” 以下のパスは適用対象外とする。(3)パスワード再発行時にはパスワード有効期限のチェックを行う必要はないため、 “/reissue” 以下のパスは適用対象外とする。(4)HandlerInterceptor
のクラスを指定する。パスワード変更を促すメッセージの表示
トップ画面にパスワード変更を促すメッセージを表示するための、Controllerの実装を以下に示す。
package org.terasoluna.securelogin.app.welcome; // omitted @Controller public class HomeController { @Inject AccountSharedService accountSharedService; @RequestMapping(value = "/", method = { RequestMethod.GET, RequestMethod.POST }) public String home(@AuthenticationPrincipal LoggedInUser userDetails, // (1) Model model) { Account account = userDetails.getAccount(); // (2) model.addAttribute("account", account); if(accountSharedService.isCurrentPasswordExpired(account.getUsername())){ // (3) ResultMessages messages = ResultMessages.warning().add("w.sl.pe.0001"); model.addAttribute(messages); } // omitted return "welcome/home"; } }
項番 説明 (1)AuthenticationPrincipal
アノテーションを指定して、UserDetails
を実装したLoggedInUser
のオブジェクトを取得する。(2)LoggedInUser
が保持しているアカウント情報を取得する。(3)アカウント情報から取得したユーザ名を引数として、パスワードの有効期限切れ判定処理を呼び出す。判定結果がtrueの場合、プロパティファイルからメッセージを取得してModelに設定し、Viewに渡す。Viewの実装は以下の通り。
トップ画面(home.jsp)
<!-- omitted --> <body> <div id="wrapper"> <span id="expiredMessage"> <t:messagesPanel /> <!-- (1) --> </span> <!-- omitted --> </div> </body> <!-- omitted -->
項番 説明 (1)messagesPanelタグを用いて、Controllerから渡されたパスワード有効期限切れメッセージを表示する。
6.10.3.2. パスワードの品質チェック¶
6.10.3.2.3. 実装方法¶
Passayの検証規則の作成
要件の実現に用いるために、以下の検証規則を作成する。
- パスワード長の最小値を設定した検証規則
- パスワードに含めなければならない文字種別を設定した検証規則
- パスワードがユーザ名を含まないことをチェックするための検証規則
- 同一のパスワードを過去に使用していないことをチェックするための検証規則
Passayの検証器の作成
上記で作成した検証規則を設定した、Passayの検証器を作成する。
Bean Validationのアノテーションの作成
Passayの検証器を使用してパスワードの入力チェックを行うためのアノテーションを作成する。 一つのアノテーションですべての検証規則を検査することもできるが、多種の規則の検査を行うことで処理が複雑になり視認性が下がることを避けるため、以下の二つに分けて実装する。
パスワード自体の性質を検証するアノテーション
「パスワードが最小文字列長よりも長いこと」、「指定した文字種別の文字を含むこと」、「ユーザ名を含まないこと」の三つの検証規則をチェックする
過去のパスワードとの比較を行うアノテーション
管理ユーザが、以前使用したパスワードを短期間のうちに再使用していないことをチェックする
いずれのアノテーションも、ユーザ名と新しいパスワードを用いる相関入力チェックルールとなる。 両方のルールに違反した入力を行った場合、それぞれのエラーメッセージが表示される。
パスワードの入力チェック
作成したBean Validationアノテーションを用いて、パスワードの入力チェックを行う。
6.10.3.2.4. コード解説¶
上記の実装方法に従って実装されたコードについて順に解説する。Passayを用いたパスワード入力チェックについては パスワード入力チェック にて説明する。
Passayの検証規則の作成
本アプリケーションで使用するほとんどの検証規則は、Passayにデフォルトで用意されたクラスを利用することで定義できる。しかしながら、Passayが提供するクラスでは、org.springframework.security.crypto.password.PasswordEncoder
でハッシュ化された過去のパスワードと比較する検証規則を定義することができない。そのため、Passayが提供するクラスを拡張し、独自の検証規則のクラスを以下のように作成する必要がある。package org.terasoluna.securelogin.app.common.validation.rule; // omitted public class EncodedPasswordHistoryRule extends HistoryRule { // (1) PasswordEncoder passwordEncoder; // (2) public EncodedPasswordHistoryRule(PasswordEncoder passwordEncoder) { this.passwordEncoder = passwordEncoder; } @Override protected boolean matches(final String clearText, final PasswordData.Reference reference) { // (3) return passwordEncoder.matches(clearText, reference.getPassword()); // (4) } }
項番 説明 (1)パスワードが過去に使用したパスワードに含まれないをチェックするためのorg.passay.HistoryRule
を拡張する。(2)パスワードのハッシュ化に用いているPasswordEncoder
をインジェクションする。(3)過去のパスワードとの比較を行うメソッドをオーバーライドする。(4)PasswordEncoder
のmatches
メソッドを使用してハッシュ化されたパスワードとの比較を行う。Passayの検証規則を以下に示す通りBean定義する。
applicationContext.xml
<bean id="lengthRule" class="org.passay.LengthRule"> <!-- (1) --> <property name="minimumLength" value="${security.passwordMinimumLength}" /> </bean> <bean id="upperCaseRule" class="org.passay.CharacterRule"> <!-- (2) --> <constructor-arg name="data"> <util:constant static-field="org.passay.EnglishCharacterData.UpperCase" /> </constructor-arg> <constructor-arg name="num" value="1" /> </bean> <bean id="lowerCaseRule" class="org.passay.CharacterRule"> <!-- (3) --> <constructor-arg name="data"> <util:constant static-field="org.passay.EnglishCharacterData.LowerCase" /> </constructor-arg> <constructor-arg name="num" value="1" /> </bean> <bean id="digitRule" class="org.passay.CharacterRule"> <!-- (4) --> <constructor-arg name="data"> <util:constant static-field="org.passay.EnglishCharacterData.Digit" /> </constructor-arg> <constructor-arg name="num" value="1" /> </bean> <bean id="specialCharacterRule" class="org.passay.CharacterRule"> <!-- (5) --> <constructor-arg name="data"> <util:constant static-field="org.passay.EnglishCharacterData.Special" /> </constructor-arg> <constructor-arg name="num" value="1" /> </bean> <bean id="characterCharacteristicsRule" class="org.passay.CharacterCharacteristicsRule"> <!-- (6) --> <property name="rules"> <list> <ref bean="upperCaseRule" /> <ref bean="lowerCaseRule" /> <ref bean="digitRule" /> <ref bean="specialCharacterRule" /> </list> </property> <property name="numberOfCharacteristics" value="3" /> </bean> <bean id="usernameRule" class="org.passay.UsernameRule" /> <!-- (7) --> <bean id="encodedPasswordHistoryRule" class="org.terasoluna.securelogin.app.common.validation.rule.EncodedPasswordHistoryRule"> <!-- (8) --> <constructor-arg name="passwordEncoder" ref="passwordEncoder" /> </bean>
項番 説明 (1)パスワードの長さをチェックするためのorg.passay.LengthRule
のプロパティに、プロパティファイルから取得したパスワードの最短長を設定する。(2)半角英大文字を一文字以上含むことをチェックする検証規則。パスワードに含まれる文字種別に関するチェックを行うためのorg.passay.CharacterRule
のコンストラクタに、org.passay.EnglishCharacterData.UpperCase
と数値の1を設定する。(3)半角英小文字を一文字以上含むことをチェックする検証規則。パスワードに含まれる文字種別に関するチェックを行うためのorg.passay.CharacterRule
のコンストラクタに、org.passay.EnglishCharacterData.LowerCase
と数値の1を設定する。(4)半角数字を一文字以上含むことをチェックする検証規則。パスワードに含まれる文字種別に関するチェックを行うためのorg.passay.CharacterRule
のコンストラクタに、org.passay.EnglishCharacterData.Digit
と数値の1を設定する。(5)半角記号を一文字以上含むことをチェックする検証規則。パスワードに含まれる文字種別に関するチェックを行うためのorg.passay.CharacterRule
のコンストラクタに、org.passay.EnglishCharacterData.Special
と数値の1を設定する。(6)(2)-(5)の4つの検証規則のうち、3つを満たすことをチェックするための検証規則。org.passay.CharacterCharacteristicsRule
のプロパティに、(2)-(5)で定義したBeanのリストと、数値の3を設定する。(7)パスワードにユーザ名が含まれていないことをチェックするための検証規則(8)パスワードが過去に使用したものの中に含まれていないことをチェックするための検証規則Passayの検証器の作成
前述したPassayの検証規則を用いて、実際に検証を行う検証器のBean定義を以下に示す。
applicationContext.xml
<bean id="characteristicPasswordValidator" class="org.passay.PasswordValidator"> <!-- (1) --> <constructor-arg name="rules"> <list> <ref bean="lengthRule" /> <ref bean="characterCharacteristicsRule" /> <ref bean="usernameRule" /> </list> </constructor-arg> </bean> <bean id="encodedPasswordHistoryValidator" class="org.passay.PasswordValidator"> <!-- (2) --> <constructor-arg name="rules"> <list> <ref bean="encodedPasswordHistoryRule" /> </list> </constructor-arg> </bean>
項番 説明 (1)パスワード自体の性質を検証するための検証器。プロパティとして、LengthRule
,CharacterCharacteristicsRule
,UsernameRule
のBeanを設定する。(2)過去に使用したパスワードの履歴を使用したチェックを行うための検証器。プロパティとしてEncodedPasswordHistoryRule
のBeanを設定する。Bean Validationのアノテーションの作成
要件を実現するために、前述した検証器を使用する2つのアノテーションを作成する。
パスワード自体の性質を検証するアノテーション
パスワードが最小文字列長よりも長いこと、指定した文字種別の文字を含むこと、ユーザ名を含まないことという三つの検証規則をチェックするアノテーションの実装を以下に示す。
package org.terasoluna.securelogin.app.common.validation; // omitted @Documented @Constraint(validatedBy = { StrongPasswordValidator.class }) // (1) @Target({ TYPE, ANNOTATION_TYPE }) @Retention(RUNTIME) public @interface StrongPassword { String message() default "{org.terasoluna.securelogin.app.common.validation.StrongPassword.message}"; Class<?>[] groups() default {}; String usernamePropertyName(); // (2) String newPasswordPropertyName(); // (3) @Target({ TYPE, ANNOTATION_TYPE }) @Retention(RUNTIME) @Documented public @interface List { StrongPassword[] value(); } Class<? extends Payload>[] payload() default {}; }
項番 説明 (1)アノテーション付与時に使用するConstraintValidator
を指定する。(2)ユーザ名のプロパティ名を指定するためのプロパティ。(3)パスワードのプロパティ名を指定するためのプロパティ。package org.terasoluna.securelogin.app.common.validation; // omitted public class StrongPasswordValidator implements ConstraintValidator<StrongPassword, Object> { @Inject @Named("characteristicPasswordValidator") // (1) PasswordValidator characteristicPasswordValidator; private String usernamePropertyName; private String newPasswordPropertyName; @Override public void initialize(StrongPassword constraintAnnotation) { usernamePropertyName = constraintAnnotation.usernamePropertyName(); newPasswordPropertyName = constraintAnnotation.newPasswordPropertyName(); } @Override public boolean isValid(Object value, ConstraintValidatorContext context) { BeanWrapper beanWrapper = new BeanWrapperImpl(value); String username = (String) beanWrapper.getPropertyValue(usernamePropertyName); String newPassword = (String) beanWrapper .getPropertyValue(newPasswordPropertyName); RuleResult result = characteristicPasswordValidator .validate(PasswordData.newInstance(newPassword, username, null)); // (2) if (result.isValid()) { // (3) return true; } else { context.disableDefaultConstraintViolation(); for (String message : characteristicPasswordValidator .getMessages(result)) { // (4) context.buildConstraintViolationWithTemplate(message) .addPropertyNode(newPasswordPropertyName) .addConstraintViolation(); } return false; } } }
項番 説明 (1)Passayの検証器をインジェクションする。(2)パスワードとユーザ名を指定したorg.passay.PasswordData
のインスタンスを作成し、検証器で入力チェックを行う。(3)チェックの結果を確認し、OKならばtrueを返し、そうでなければfalseを返す。(4)パスワード入力チェックエラーメッセージをすべて取得し、設定する。過去のパスワードとの比較を行うアノテーション
管理ユーザが、以前使用したパスワードを短期間のうちに再使用していないことをチェックするアノテーションの実装を以下に示す。過去に使用したパスワードを取得するために、パスワード変更履歴エンティティを用いる。パスワード変更履歴エンティティについては パスワード変更の強制・促進 を参照。Note
「いくつ前までのパスワードの再使用を禁止するか」のみの設定では、短時間の間にパスワード変更を繰り返すことでパスワードを再使用することが可能となってしまう。 これを防ぐために、本アプリケーションでは「いつ以降使用したパスワードの再使用を禁止するか」を設定して検査を行う。
package org.terasoluna.securelogin.app.common.validation; @Documented @Constraint(validatedBy = { NotReusedPasswordValidator.class }) // (1) @Target({ TYPE, ANNOTATION_TYPE }) @Retention(RUNTIME) public @interface NotReusedPassword { String message() default "{org.terasoluna.securelogin.app.common.validation.NotReusedPassword.message}"; Class<?>[] groups() default {}; String usernamePropertyName(); // (2) String newPasswordPropertyName(); // (3) @Target({ TYPE, ANNOTATION_TYPE }) @Retention(RUNTIME) @Documented public @interface List { NotReusedPassword[] value(); } Class<? extends Payload>[] payload() default {}; }
項番 説明 (1)アノテーション付与時に使用するConstraintValidator
を指定する。(2)ユーザ名のプロパティ名を指定するためのプロパティ。データベースから過去に使用したパスワードを検索するために必要となる。(3)パスワードのプロパティ名を指定するためのプロパティ。package org.terasoluna.securelogin.app.common.validation; // omitted public class NotReusedPasswordValidator implements ConstraintValidator<NotReusedPassword, Object> { @Inject ClassicDateFactory dateFactory; @Inject AccountSharedService accountSharedService; @Inject PasswordHistorySharedService passwordHistorySharedService; @Inject PasswordEncoder passwordEncoder; @Inject @Named("encodedPasswordHistoryValidator") // (1) PasswordValidator encodedPasswordHistoryValidator; @Value("${security.passwordHistoricalCheckingCount}") // (2) int passwordHistoricalCheckingCount; @Value("${security.passwordHistoricalCheckingPeriod}") // (3) int passwordHistoricalCheckingPeriod; private String usernamePropertyName; private String newPasswordPropertyName; private String message; @Override public void initialize(NotReusedPassword constraintAnnotation) { usernamePropertyName = constraintAnnotation.usernamePropertyName(); newPasswordPropertyName = constraintAnnotation.newPasswordPropertyName(); message = constraintAnnotation.message(); } @Override public boolean isValid(Object value, ConstraintValidatorContext context) { BeanWrapper beanWrapper = new BeanWrapperImpl(value); String username = (String) beanWrapper.getPropertyValue(usernamePropertyName); String newPassword = (String) beanWrapper .getPropertyValue(newPasswordPropertyName); Account account = accountSharedService.findOne(username); String currentPassword = account.getPassword(); boolean result = checkNewPasswordDifferentFromCurrentPassword( newPassword, currentPassword, context); // (4) if (result && account.getRoles().contains(Role.ADMIN)) { // (5) result = checkHistoricalPassword(username, newPassword, context); } return result; } private boolean checkNewPasswordDifferentFromCurrentPassword( String newPassword, String currentPassword, ConstraintValidatorContext context) { if (!passwordEncoder.matches(newPassword, currentPassword)) { return true; } else { context.disableDefaultConstraintViolation(); context.buildConstraintViolationWithTemplate(message) .addPropertyNode(newPasswordPropertyName).addConstraintViolation(); return false; } } private boolean checkHistoricalPassword(String username, String newPassword, ConstraintValidatorContext context) { LocalDateTime useFrom = dateFactory.newTimestamp().toLocalDateTime() .minusMinutes(passwordHistoricalCheckingPeriod); List<PasswordHistory> historyByTime = passwordHistorySharedService .findHistoriesByUseFrom(username, useFrom); List<PasswordHistory> historyByCount = passwordHistorySharedService .findLatest(username, passwordHistoricalCheckingCount); List<PasswordHistory> history = historyByCount.size() > historyByTime .size() ? historyByCount : historyByTime; // (6) List<PasswordData.Reference> historyData = new ArrayList<>(); for (PasswordHistory h : history) { historyData.add(new PasswordData.HistoricalReference(h .getPassword())); // (7) } PasswordData passwordData = PasswordData.newInstance(newPassword, username, historyData); // (8) RuleResult result = encodedPasswordHistoryValidator .validate(passwordData); // (9) if (result.isValid()) { // (10) return true; } else { context.disableDefaultConstraintViolation(); context.buildConstraintViolationWithTemplate( encodedPasswordHistoryValidator.getMessages(result).get(0)) // (11) .addPropertyNode(newPasswordPropertyName).addConstraintViolation(); return false; } } }
項番 説明 (1)Passayの検証器をインジェクションする。(2)いくつ前までのパスワードの再使用を禁止するかの閾値をプロパティファイルから取得し、インジェクションする。(3)いつ以降使用したパスワードの再使用を禁止するかの閾値(秒数)をプロパティファイルから取得し、インジェクションする。(4)新しいパスワードが現在使用しているものと異なるかどうかをチェックする処理を呼び出す。このチェックは一般ユーザ・管理ユーザにかかわらず行う。(5)管理ユーザの場合は、新しいパスワードが過去に使用したパスワードに含まれていないかをチェックする処理を呼び出す。(6)(2)で指定した個数分のパスワード変更履歴エンティティと、(3)で指定した期間分のパスワード変更履歴エンティティを取得し、どちらか数の多い方を以降のチェックに用いる。(7)Passayの検証器で過去のパスワードとの比較を行うために、パスワード変更履歴エンティティからパスワードを取得し、org.passay.PasswordData.HistoricalReference
のリストを作成する。(8)パスワード、ユーザ名、過去のパスワードのリストを指定したorg.passay.PasswordData
のインスタンスを作成する。(9)検証器で入力チェックを行う。(10)チェック結果を確認し、OKならばtrueを返し、そうでなければfalseを返す。(11)パスワード入力チェックエラーメッセージを取得する。
パスワードの入力チェック
Bean Validationアノテーションを使用してアプリケーション層で、パスワード入力チェックを行う。 Formクラスに付与されたアノテーションによってNullチェック以外の入力チェックが網羅されていることから、単項目チェックとしては
@NotNull
のみを付与している。package org.terasoluna.securelogin.app.passwordchange; // omitted import lombok.Data; @Data @Compare(source = "newPasssword", destination = "confirmNewPassword", operator = Compare.Operator.EQUAL) // (1) @StrongPassword(usernamePropertyName = "username", newPasswordPropertyName = "newPassword") // (2) @NotReusedPassword(usernamePropertyName = "username", newPasswordPropertyName = "newPassword") // (3) @ConfirmOldPassword(usernamePropertyName = "username", oldPasswordPropertyName = "oldPassword") // (4) public class PasswordChangeForm implements Serializable{ private static final long serialVersionUID = 1L; @NotNull private String username; @NotNull private String oldPassword; @NotNull private String newPassword; @NotNull private String confirmNewPassword; }
項番 説明 (1)新しいパスワードの二回の入力が一致しているかをチェックするためのアノテーション。詳細は terasoluna-gfw-commonのチェックルール を参照すること。(2)上述した、パスワード自体の性質を検証するアノテーション(3)過去のパスワードとの比較を行うアノテーション(4)入力された現在のパスワードが正しいことをチェックするアノテーション。定義は割愛する。package org.terasoluna.securelogin.app.passwordchange; // omitted @Controller @RequestMapping("password") public class PasswordChangeController { @Inject PasswordChangeService passwordService; // omitted @RequestMapping(method = RequestMethod.POST) public String change(@AuthenticationPrincipal LoggedInUser userDetails, @Validated PasswordChangeForm form, BindingResult bindingResult, // (1) Model model) { Account account = userDetails.getAccount(); if (bindingResult.hasErrors() || !account.getUsername().equals(form.getUsername())) { // (2) model.addAttribute(account); return "passwordchange/changeForm"; } passwordService.updatePassword(form.getUsername(), form.getNewPassword()); return "redirect:/password?complete"; } // omitted }
項番 説明 (1)パスワード変更時に呼び出されるハンドラメソッド。パラメータ中のFormに@Validated
アノテーションを付与して、入力チェックを行う。(2)パスワード変更対象のユーザ名がログイン中のアカウントのユーザ名と一致していることを確認する。両者が異なる場合には、再度パスワード変更画面へ遷移させる。Note
本アプリケーションではBean Valiidationでユーザ名を用いたパスワード入力チェックを行うために、ユーザ名をFormから取得している。 Viewでは
Model
に設定したユーザ名をhiddenで保持することを想定しているが、改ざんされる恐れがあるため、パスワード変更前にFormから取得したユーザ名の確認を行っている。
6.10.3.3. アカウントのロックアウト¶
6.10.3.3.2. 動作イメージ¶
- アカウントロックアウト
- ロックアウト解除
管理権限を持つユーザでログインした場合にのみ、ロックアウト解除機能を使用することができる。 ロックアウト状態を解消したいユーザ名を入力してロックアウト解除を実行すると、そのユーザのアカウントは再び認証可能な状態に戻る。
6.10.3.3.3. 実装方法¶
org.springframework.security.core.userdetails.UserDetails
に対してアカウントのロックアウト状態を設定することができる。org.springframework.security.authentication.LockedException
をthrowする。UserDetails
に設定する処理のみを実装すれば、ロックアウト機能が実現できる。認証失敗イベントエンティティの保存
不正な認証情報の入力によって認証に失敗した際に、Spring Securityが発生させるイベントをハンドリングし、認証に使用したユーザ名と認証を試みた日時を認証失敗イベントエンティティとしてデータベースに登録する。
ロックアウト状態の判定
あるアカウントについて、現在時刻から一定以上新しい認証失敗イベントエンティティが一定個数以上存在する場合、該当アカウントはロックアウト状態であると判定する。 認証時にこの判定処理を呼び出し、判定結果を
UserDetails
の実装クラスに設定する。認証失敗イベントエンティティの削除
あるアカウントについて、認証失敗イベントエンティティをすべて削除する。ロックアウトの対象となるのは連続して認証に失敗した場合のみであるため、認証に成功した際には認証失敗イベントエンティティを削除する。また、アカウントのロックアウト状態は認証失敗イベントエンティティを用いて判定されるため、認証失敗イベントエンティティを消去することでロックアウト解除機能が実現できる。 アカウントのロックアウトは認可機能を用いて、管理ユーザ以外実行できないようにする。
Warning
認証失敗イベントエンティティはロックアウトの判定のみを目的としているため、不要になったタイミングで消去する。 認証ログが必要な場合は必ず別途ログを保存しておくこと。
認証失敗イベントエンティティを用いたロックアウト機能の動作例を以下の図を用いて説明する。 例として3回の認証失敗でロックアウトされるものとし、ロックアウト継続時間は10分とする。
項番 | 説明 |
---|---|
(1)
|
過去10分以内に、誤ったパスワードでの認証が3回試行されており、データベースには3回分の認証失敗イベントエンティティが保存されている。
そのため、アカウントはロックアウト状態であると判定される。
|
(2)
|
データベースには3回分の認証失敗イベントエンティティが保存されている。
しかしながら、過去10分以内の認証失敗イベントエンティティは2回分のみであるため、ロックアウト状態ではないと判定される。
|
同様に、ロックアウトを解除する場合の動作例を以下の図で説明する。
項番 | 説明 |
---|---|
(1)
|
過去10分以内に、誤ったパスワードでの認証が3回試行されている。
その後、認証失敗イベントエンティティが消去されているため、データベースには認証失敗イベントエンティティが保存されておらず、ロックアウト状態ではないと判定される。
|
6.10.3.3.4. コード解説¶
共通部分
本アプリケーションにおいて、アカウントのロックアウトに関する機能を実現するためには、データベースに対する認証失敗イベントエンティティの登録、検索、削除が共通的に必要となる。 そのため、まずは認証失敗イベントエンティティに関するドメイン層・インフラストラクチャ層の実装を示す。
Entityの実装
ユーザ名と認証試行日時を持つ認証失敗イベントエンティティの実装を以下に示す。
package org.terasoluna.securelogin.domain.model; // omitted @Data public class FailedAuthentication implements Serializable { private static final long serialVersionUID = 1L; private String username; // (1) private LocalDateTime authenticationTimestamp; // (2) }
項番 説明 (1)認証に使用したユーザ名(2)認証を試行した日時Repositoryの実装
認証失敗イベントエンティティの検索、登録、削除のためのRepositoryを以下に示す。
package org.terasoluna.securelogin.domain.repository.authenticationevent; // omitted public interface FailedAuthenticationRepository { int create(FailedAuthentication event); // (1) List<FailedAuthentication> findLatest( @Param("username") String username, @Param("count") long count); // (2) int deleteByUsername(@Param("username") String username); // (3) }
項番 説明 (1)引数として与えられたFailedAuthentication
オブジェクトをデータベースのレコードとして登録するメソッド(2)引数として与えられたユーザ名をキーとして、指定された個数のFailedAuthentication
オブジェクトを新しい順に取得するメソッド(3)引数として与えられたユーザ名をキーとして、認証失敗イベントエンティティのレコードを一括削除するメソッドマッピングファイルは以下の通り。
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="org.terasoluna.securelogin.domain.repository.authenticationevent.FailedAuthenticationRepository"> <resultMap id="failedAuthenticationResultMap" type="FailedAuthentication"> <id property="username" column="username" /> <id property="authenticationTimestamp" column="authentication_timestamp" /> </resultMap> <insert id="create" parameterType="FailedAuthentication"> <![CDATA[ INSERT INTO failed_authentication ( username, authentication_timestamp ) VALUES ( #{username}, #{authenticationTimestamp} ) ]]> </insert> <select id="findLatest" resultMap="failedAuthenticationResultMap"> <![CDATA[ SELECT username, authentication_timestamp FROM failed_authentication WHERE username = #{username} ORDER BY authentication_timestamp DESC LIMIT #{count} ]]> </select> <delete id="deleteByUsername"> <![CDATA[ DELETE FROM failed_authentication WHERE username = #{username} ]]> </delete> </mapper>
Serviceの実装
作成したRepositoryのメソッドを呼び出すServiceを以下の通り定義する。
package org.terasoluna.securelogin.domain.service.authenticationevent; // omitted @Service @Transactional public class AuthenticationEventSharedServiceImpl implements AuthenticationEventSharedService { // omitted @Inject ClassicDateFactory dateFactory; @Inject FailedAuthenticationRepository failedAuthenticationRepository; @Inject AccountSharedService accountSharedService; @Transactional(readOnly = true) @Override public List<FailedAuthentication> findLatestFailureEvents( String username, int count) { return failedAuthenticationRepository.findLatestEvents(username, count); } @Transactional(propagation = Propagation.REQUIRES_NEW) @Override public void authenticationFailure(String username) { // (1) if (accountSharedService.exists(username)){ FailedAuthentication failureEvents = new FailedAuthentication(); failureEvents.setUsername(username); failureEvents.setAuthenticationTimestamp(dateFactory.newTimestamp() .toLocalDateTime()); failedAuthenticationRepository.create(failureEvents); } } @Override public int deleteFailureEventByUsername(String username) { return failedAuthenticationRepository.deleteByUsername(username); } // omitted }
項番 説明 (1)認証失敗イベントエンティティを作成してデータベースに登録するメソッド。引数として受け取ったユーザ名のアカウントが存在しない場合、データベースの外部キー制約に違反するため、データベースへの登録処理をスキップする。本メソッド実行後の例外により認証失敗イベントエンティティが登録されない可能性を考慮し、トランザクションの伝搬方法にREQUIRES_NEW
を指定している。
以下、実装方法に従って実装されたコードについて順に解説する。
認証失敗イベントエンティティの保存
認証失敗時に発生するイベントをハンドリングして処理を行うために、
@EventListener
アノテーションを使用する。@EventListener
アノテーションによるイベントのハンドリングについては 認証イベントのハンドリング を参照すること。package org.terasoluna.securelogin.domain.service.account; // omitted @Component public class AccountAuthenticationFailureBadCredentialsEventListener{ @Inject AuthenticationEventSharedService authenticationEventSharedService; @EventListener // (1) public void onApplicationEvent( AuthenticationFailureBadCredentialsEvent event) { String username = (String) event.getAuthentication().getPrincipal(); // (2) authenticationEventSharedService.authenticationFailure(username); // (3) } }
項番 説明 (1)@EventListener
アノテーションを付与することで、誤ったパスワード等の不正な認証情報によって認証が失敗した際に、onApplicationEvent
メソッドが実行される。(2)AuthenticationFailureBadCredentialsEvent
オブジェクトから、認証に使用したユーザ名を取得する。(3)認証失敗イベントエンティティを作成してデータベースに登録する処理を呼び出す。ロックアウト状態の判定
認証失敗イベントエンティティを用いてアカウントのロックアウト状態を判定する処理を記述する。
package org.terasoluna.securelogin.domain.service.account; // omitted @Service @Transactional public class AccountSharedServiceImpl implements AccountSharedService { // omitted @Inject ClassicDateFactory dateFactory; @Inject AuthenticationEventSharedService authenticationEventSharedService; @Value("${security.lockingDurationSeconds}") // (1) int lockingDurationSeconds; @Value("${security.lockingThreshold}") // (2) int lockingThreshold; @Transactional(readOnly = true) @Override public boolean isLocked(String username) { List<FailedAuthentication> failureEvents = authenticationEventSharedService .findLatestFailureEvents(username, lockingThreshold); // (3) if (failureEvents.size() < lockingThreshold) { // (4) return false; } if (failureEvents .get(lockingThreshold - 1) // (5) .getAuthenticationTimestamp() .isBefore( dateFactory.newTimestamp().toLocalDateTime() .minusSeconds(lockingDurationSeconds))) { return false; } return true; } // omitted }
項番 説明 (1)ロックアウトの継続時間を秒単位で指定する。プロパティファイルに定義された値をインジェクションしている。(2)ロックアウトの閾値を指定する。ここで指定した回数だけ認証に失敗すると、アカウントがロックアウトされる。プロパティファイルに定義された値をインジェクションしている。(3)認証失敗イベントエンティティを、ロックアウトの閾値と同じ数だけ新しい順に取得する。(4)取得した認証失敗イベントエンティティの個数がロックアウトの閾値より小さい場合、ロックアウト状態ではないと判定する。(5)取得した認証失敗イベントエンティティのうち最も古い認証失敗時刻と現在時刻の差分が、ロックアウト継続時間よりも大きい場合には、ロックアウト状態ではないと判定する。UserDetails
の実装クラスであるorg.springframework.security.core.userdetails.User
では、コンストラクタにロックアウト状態を渡すことができる。本アプリケーションでは以下のようにUser
を継承したクラスと、org.springframework.security.core.userdetails.UserDetailsService
を実装したクラスを用いる。package org.terasoluna.securelogin.domain.service.userdetails; // omitted public class LoggedInUser extends User { // omitted private final Account account; public LoggedInUser(Account account, boolean isLocked, LocalDateTime lastLoginDate, List<SimpleGrantedAuthority> authorities) { super(account.getUsername(), account.getPassword(), true, true, true, !isLocked, authorities); // (1) this.account = account; // omitted } public Account getAccount() { return account; } // omitted }
項番 説明 (1)親クラスであるUser
のコンストラクタに ロックアウト状態でないかどうか を真理値で渡す。ロックアウト状態でない場合にtrueを渡す必要があることに注意する。package org.terasoluna.securelogin.domain.service.userdetails; // omitted @Service public class LoggedInUserDetailsService implements UserDetailsService { @Inject AccountSharedService accountSharedService; @Transactional(readOnly = true) @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { try { Account account = accountSharedService.findOne(username); List<SimpleGrantedAuthority> authorities = new ArrayList<>(); for (Role role : account.getRoles()) { authorities.add(new SimpleGrantedAuthority("ROLE_" + role.getRoleValue())); } return new LoggedInUser(account, accountSharedService.isLocked(username), // (1) accountSharedService.getLastLoginDate(username), authorities); } catch (ResourceNotFoundException e) { throw new UsernameNotFoundException("user not found", e); } } }
項番 説明 (1)LoggedInUser
のコンストラクタに、isLocked
メソッドによるロックアウト状態の判定結果を渡す。作成した
UserDetailsService
を使用するための設定は以下の通り。spring-security.xml
<!-- omitted --> <sec:authentication-manager> <sec:authentication-provider user-service-ref="loggedInUserDetailsService"> <!-- (1) --> <sec:password-encoder ref="passwordEncoder" /> </sec:authentication-provider> </sec:authentication-manager> <!-- omitted -->
項番 説明 (1)UserDetailsService
のBeanのidを指定する。認証失敗イベントエンティティの削除
認証成功時の認証失敗イベントエンティティの削除
連続した認証失敗のみをロックアウトの判定に使用するため、認証に成功した際にはアカウントの認証失敗イベントエンティティを削除する。 共通部分として作成したServiceに、認証成功時に実行するメソッドを作成する。
package org.terasoluna.securelogin.domain.service.authenticationevent; // omitted @Service @Transactional public class AuthenticationEventSharedServiceImpl implements AuthenticationEventSharedService { // omitted @Transactional(propagation = Propagation.REQUIRES_NEW) @Override public void authenticationSuccess(String username) { // omitted deleteFailureEventByUsername(username); // (1) } // omitted }
項番 説明 (1)引数として渡されたユーザ名のアカウントに関する認証失敗イベントエンティティを削除する。認証成功時に発生するイベントをハンドリングして処理を行うために、
@EventListener
アノテーションを使用する。package org.terasoluna.securelogin.domain.service.account; // omitted @Component public class AccountAuthenticationSuccessEventListener{ @Inject AuthenticationEventSharedService authenticationEventSharedService; @EventListener // (1) public void onApplicationEvent( AuthenticationSuccessEvent event) { LoggedInUser details = (LoggedInUser) event.getAuthentication() .getPrincipal(); authenticationEventSharedService.authenticationSuccess(details.getUsername()); // (2) } }
項番 説明 (1)@EventListener
アノテーションを付与することで、認証が成功した際にonApplicationEvent
メソッドが実行される。(2)AuthenticationSuccessEvent
からユーザ名を取得し、認証失敗イベントエンティティを削除する処理を呼び出す。ロックアウト状態の解除
ロックアウト状態の判定に認証失敗イベントエンティティを使用しているため、認証失敗イベントエンティティを削除することでロックアウト状態を解除することができる。 ロックアウト解除機能の使用を管理権限を持つユーザに限定するための認可の設定と、ドメイン層・アプリケーション層の実装を行う。
認可の設定
ロックアウトの解除を行うことができるユーザの権限を以下の通りに設定する。
spring-security.xml
<!-- omitted --> <sec:http pattern="/resources/**" security="none" /> <sec:http> <!-- omitted --> <sec:intercept-url pattern="/unlock/**" access="hasRole('ADMIN')" /> <!-- (1) --> <!-- omitted --> </sec:http> <!-- omitted -->
項番 説明 (1)/unlock 以下のURLへのアクセス権限を管理ユーザに限定する。Serviceの実装
package org.terasoluna.securelogin.domain.service.unlock; // omitted @Transactional @Service public class UnlockServiceImpl implements UnlockService { @Inject AccountSharedService accountSharedService; @Inject AuthenticationEventSharedService authenticationEventSharedService; @Override public void unlock(String username) { authenticationEventSharedService .deleteFailureEventByUsername(username); // (1) } }
項番 説明 (1)認証失敗イベントエンティティを消去することによりロックアウト状態を解除する。Formの実装
package org.terasoluna.securelogin.app.unlock; @Data public class UnlockForm implements Serializable { private static final long serialVersionUID = 1L; @NotEmpty private String username; }
Viewの実装
トップ画面(home.jsp)
<!-- omitted --> <body> <div id="wrapper"> <!-- omitted --> <sec:authorize url="/unlock"> <!-- (1) --> <div> <a id="unlock" href="${f:h(pageContext.request.contextPath)}/unlock?form"> Unlock Account </a> </div> </sec:authorize> <!-- omitted --> </div> </body> <!-- omitted -->
項番 説明 (1)/unlock 以下のアクセス権限を持つユーザに対してのみ表示する。ロックアウト解除フォーム(unlokcForm.jsp)
<!-- omitted --> <body> <div id="wrapper"> <h1>Unlock Account</h1> <t:messagesPanel /> <form:form action="${f:h(pageContext.request.contextPath)}/unlock" method="POST" modelAttribute="unlockForm"> <table> <tr> <th><form:label path="username" cssErrorClass="error-label">Username</form:label> </th> <td><form:input path="username" cssErrorClass="error-input" /></td> <td><form:errors path="username" cssClass="error-messages" /></td> </tr> </table> <input id="submit" type="submit" value="Unlock" /> </form:form> <a href="${f:h(pageContext.request.contextPath)}/">go to Top</a> </div> </body> <!-- omitted -->
ロックアウト解除完了画面(unlockComplete.jsp)
<!-- omitted --> <body> <div id="wrapper"> <h1>${f:h(username)}'s account was successfully unlocked.</h1> <a href="${f:h(pageContext.request.contextPath)}/">go to Top</a> </div> </body> <!-- omitted -->
Controllerの実装
package org.terasoluna.securelogin.app.unlock; // omitted @Controller @RequestMapping("/unlock") // (1) public class UnlockController { @Inject UnlockService unlockService; @RequestMapping(params = "form") public String showForm(UnlockForm form) { return "unlock/unlockForm"; } @RequestMapping(method = RequestMethod.POST) public String unlock(@Validated UnlockForm form, BindingResult bindingResult, Model model, RedirectAttributes attributes) { if (bindingResult.hasErrors()) { return showForm(form); } try { unlockService.unlock(form.getUsername()); // (2) attributes.addFlashAttribute("username", form.getUsername()); return "redirect:/unlock?complete"; } catch (BusinessException e) { model.addAttribute(e.getResultMessages()); return showForm(form); } } @RequestMapping(method = RequestMethod.GET, params = "complete") public String unlockComplete() { return "unlock/unlockComplete"; } }
項番 説明 (1)/unlock 以下のURLにマッピングする。認可の設定によって、管理ユーザのみがアクセス可能となる。(2)Formから取得したユーザ名を引数として、アカウントのロックアウトを解除する処理を呼び出す。
6.10.3.4. 最終ログイン日時の表示¶
6.10.3.4.3. 実装方法¶
認証成功イベントエンティティの保存
認証に成功した際にSpring Securityが発生させるイベントをハンドリングし、認証に使用したユーザ名と認証に成功した日時を認証成功イベントエンティティとしてデータベースに登録する。
前回ログイン日時の取得と表示
認証時に、アカウントにおける最新の認証成功イベントエンティティをデータベースから取得し、イベントエンティティから認証成功日時を取得して
org.springframework.security.core.userdetails.UserDetails
に設定する。 jspにUserDetails
が保持している認証成功日時をフォーマットして渡し、表示する。
6.10.3.4.4. コード解説¶
共通部分
本アプリケーションにおいて、前回ログイン日時を表示するためには、データベースに対する認証成功イベントエンティティの登録、検索が必要となる。 そのため、まずは認証成功イベントエンティティに関するドメイン層・インフラストラクチャ層の実装から解説を行う。
Entityの実装
ユーザ名と認証成功日時を持つ認証成功イベントエンティティの実装は以下の通り。
package org.terasoluna.securelogin.domain.model; // omitted @Data public class SuccessfulAuthentication implements Serializable { private static final long serialVersionUID = 1L; private String username; // (1) private LocalDateTime authenticationTimestamp; // (2) }
項番 説明 (1)認証に使用したユーザ名(2)認証を試行した日時Repositoryの実装
認証成功イベントエンティティの検索、登録を行うためのRepositoryを以下に示す。
package org.terasoluna.securelogin.domain.repository.authenticationevent; // omitted public interface SuccessfulAuthenticationRepository { int create(SuccessfulAuthentication event); // (1) List<SuccessfulAuthentication> findLatestEvents( @Param("username") String username, @Param("count") long count); // (2) }
項番 説明 (1)引数として与えられたSuccessfulAuthentication
オブジェクトをデータベースのレコードとして登録するメソッド(2)引数として与えられたユーザ名をキーとして、指定された個数のSuccessfulAuthentication
オブジェクトを新しい順に取得するメソッドマッピングファイルは以下の通り。
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="org.terasoluna.securelogin.domain.repository.authenticationevent.SuccessfulAuthenticationRepository"> <resultMap id="successfulAuthenticationResultMap" type="SuccessfulAuthentication"> <id property="username" column="username" /> <id property="authenticationTimestamp" column="authentication_timestamp" /> </resultMap> <insert id="create" parameterType="SuccessfulAuthentication"> <![CDATA[ INSERT INTO successful_authentication ( username, authentication_timestamp ) VALUES ( #{username}, #{authenticationTimestamp} ) ]]> </insert> <select id="findLatestEvents" resultMap="successfulAuthenticationResultMap"> <![CDATA[ SELECT username, authentication_timestamp FROM successful_authentication WHERE username = #{username} ORDER BY authentication_timestamp DESC LIMIT #{count} ]]> </select> </mapper>
Serviceの実装
作成したRepositoryのメソッドを呼び出すServiceを以下に示す。
package org.terasoluna.securelogin.domain.service.authenticationevent; // omitted @Service @Transactional public class AuthenticationEventSharedServiceImpl implements AuthenticationEventSharedService { // omitted @Inject ClassicDateFactory dateFactory; @Inject SuccessfulAuthenticationRepository successAuthenticationRepository; @Transactional(readOnly = true) @Override public List<SuccessfulAuthentication> findLatestSuccessEvents( String username, int count) { return successAuthenticationRepository.findLatestEvents(username, count); } @Transactional(propagation = Propagation.REQUIRES_NEW) @Override public void authenticationSuccess(String username) { SuccessfulAuthentication successEvent = new SuccessfulAuthentication(); successEvent.setUsername(username); successEvent.setAuthenticationTimestamp(dateFactory.newTimestamp().toLocalDateTime()); successAuthenticationRepository.create(successEvent); deleteFailureEventByUsername(username); } }
以下、実装方法に従って実装されたコードについて順に解説する。
認証成功イベントエンティティの保存
認証成功時に発生するイベントをハンドリングして処理を行うために、
@EventListener
アノテーションを使用する。package org.terasoluna.securelogin.domain.service.account; // omitted @Component public class AccountAuthenticationSuccessEventListener{ @Inject AuthenticationEventSharedService authenticationEventSharedService; @EventListener // (1) public void onApplicationEvent(AuthenticationSuccessEvent event) { LoggedInUser details = (LoggedInUser) event.getAuthentication() .getPrincipal(); // (2) authenticationEventSharedService.authenticationSuccess(details.getUsername()); // (3) } }
項番 説明 (1)@EventListener
アノテーションを付与することで、認証が成功した際に、onApplicationEvent
メソッドが実行される。(2)AuthenticationSuccessEvent
オブジェクトから、UserDetails
の実装クラスを取得する。このクラスについては以降で説明する。(3)認証成功イベントエンティティを作成し、データベースに登録する処理を呼び出す。前回ログイン日時の取得と表示
認証成功イベントエンティティから前回ログイン日時を取得するためのServiceを以下に示す。
package org.terasoluna.securelogin.domain.service.account; // omitted @Service @Transactional public class AccountSharedServiceImpl implements AccountSharedService { // omitted @Inject AuthenticationEventSharedService authenticationEventSharedService; @Transactional(readOnly = true) @Override public LocalDateTime getLastLoginDate(String username) { List<SuccessfulAuthentication> events = authenticationEventSharedService .findLatestSuccessEvents(username, 1); // (1) if (events.isEmpty()) { return null; // (2) } else { return events.get(0).getAuthenticationTimestamp(); // (3) } } // omitted }
項番 説明 (1)引数として与えられたユーザ名をキーとして、最新の認証成功イベントエンティティを一件取得する。(2)初回ログイン時等、認証成功イベントエンティティが一件も取得できない場合にはnullを返す。(3)認証成功イベントエンティティから、認証日時を取得して返す。ログイン時に前回ログイン日時を取得して
UserDetails
に保持させるために、以下のようにUser
を継承したクラスと、UserDetailsService
を実装したクラスを作成する。package org.terasoluna.securelogin.domain.service.userdetails; // omitted public class LoggedInUser extends User { private final Account account; private final LocalDateTime lastLoginDate; // (1) public LoggedInUser(Account account, boolean isLocked, LocalDateTime lastLoginDate, List<SimpleGrantedAuthority> authorities) { super(account.getUsername(), account.getPassword(), true, true, true, !isLocked, authorities); this.account = account; this.lastLoginDate = lastLoginDate; // (2) } // omitted public LocalDateTime getLastLoginDate() { // (3) return lastLoginDate; } }
項番 説明 (1)前回ログイン日時を保持するためのフィールドを宣言する。(2)引数として与えられた前回ログイン日時をフィールドに設定する。(3)保持している前回ログイン日時を返すメソッドpackage org.terasoluna.securelogin.domain.service.userdetails; // omitted @Service public class LoggedInUserDetailsService implements UserDetailsService { @Inject AccountSharedService accountSharedService; @Transactional(readOnly = true) @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { try { Account account = accountSharedService.findOne(username); List<SimpleGrantedAuthority> authorities = new ArrayList<>(); for (Role role : account.getRoles()) { authorities.add(new SimpleGrantedAuthority("ROLE_" + role.getRoleValue())); } return new LoggedInUser(account, accountSharedService.isLocked(username), accountSharedService.getLastLoginDate(username), // (1) authorities); } catch (ResourceNotFoundException e) { throw new UsernameNotFoundException("user not found", e); } } }
項番 説明 (1)Serviceのメソッドを呼び出して前回ログイン日時を取得し、LoggedInUser
のコンストラクタに渡す。トップ画面に前回ログイン日時を表示するためのアプリケーション層の実装を行う。
package org.terasoluna.securelogin.app.welcome; // omitted @Controller public class HomeController { @Inject AccountSharedService accountSharedService; @RequestMapping(value = "/", method = { RequestMethod.GET, RequestMethod.POST }) public String home(@AuthenticationPrincipal LoggedInUser userDetails, // (1) Model model) { // omitted LocalDateTime lastLoginDate = userDetails.getLastLoginDate(); // (2) if (lastLoginDate != null) { model.addAttribute("lastLoginDate", lastLoginDate .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); // (3) } return "welcome/home"; } }
項番 説明 (1)@AuthenticationPrincipal
を使用してUserDetailsオブジェクトを取得する。(2)LoggedInUserDetails
から最終ログイン日時を取得する。(3)最終ログイン日時をフォーマットしてModelに設定し、Viewに渡す。トップ画面(home.jsp)
<body> <div id="wrapper"> <!-- omitted --> <c:if test="${!empty lastLoginDate}"> <!-- (1) --> <p id="lastLogin"> Last login date is ${f:h(lastLoginDate)}. <!-- (2) --> </p> </c:if> <!-- omitted --> </div> </body>
項番 説明 (1)前回ログイン日時がnullの場合は表示しない。(2)Controllerから渡された前回ログイン日時を表示する。
6.10.3.5. パスワード再発行のための認証情報の生成¶
6.10.3.5.2. 動作イメージ¶
パスワード再発行のための認証情報生成画面で、パスワードを再発行するユーザ名を入力する。このとき、パスワード再発行時の認証に使用する秘密情報と、トークンが生成される。 秘密情報は画面に表示され、トークンを含んだパスワード再発行画面のURLはユーザの登録済みメールアドレスに送付される。
メール送付されたURLには有効期限があり、有効期限内にアクセスして秘密情報と新しいパスワードを入力することで、パスワードを変更することができる。 有効期限が切れた後にメール送付されたURLにアクセスした場合、エラー画面に遷移する。
ここでは、上記の流れのうち、秘密情報とトークンの生成について説明を行う。
6.10.3.5.3. 実装方法¶
パスワード再発行のための認証情報の生成と保存
以下の4つの情報を、パスワード再発行のための認証情報としてデータベースに保存する。
- ユーザ名:パスワードを再発行するアカウントのユーザ名
- トークン:パスワード再発行画面のURLを、一意かつ推測不能にするために生成するランダムな文字列
- 秘密情報:パスワード再発行時にユーザに入力させるために生成するランダムな文字列
- 有効期限:パスワード再発行のための認証情報の有効期限
トークンの生成には
java.util.UUID
クラスのrandomUUID
メソッドを用い、秘密情報の生成にはPassayのパスワード生成機能を用いる。秘密情報については、パスワードと同様にハッシュ化してデータベースへ保存する。 有効期限の設定と確認処理については、パスワード再発行実行時の検査 に記す。 パスワード再発行のための認証情報をユーザに配布する方法については、パスワード再発行のための認証情報の配布 を参照。
6.10.3.5.4. コード解説¶
共通部分
上記の実装方法に従って実装を進める上で、パスワード再発行のための認証情報をデータベースに登録、検索する処理が共通的に必要となる。 そのため、まずはパスワード再発行のための認証情報に関連するEntityとRepositoryの実装から解説する。
Entityの作成
パスワード再発行のための認証情報のEntityを作成する。
package org.terasoluna.securelogin.domain.model; // omitted @Data public class PasswordReissueInfo { private String username; // (1) private String token; // (2) private String secret; // (3) private LocalDateTime expiryDate; // (4) }
項番 説明 (1)パスワード再発行対象のユーザ名(2)パスワード再発行用URLに含めるために生成される文字列(トークン)(3)パスワード再発行時にユーザを確認するための文字列(秘密情報)(2)パスワード再発行のための認証情報の有効期限Repositoryの実装
パスワード再発行のための認証情報の検索、登録、削除を行うためのRepositoryを以下に示す。
package org.terasoluna.securelogin.domain.repository.passwordreissue; // omitted public interface PasswordReissueInfoRepository { void create(PasswordReissueInfo info); // (1) PasswordReissueInfo findOne(@Param("token") String token); // (2) int delete(@Param("token") String token); // (3) // omitted }
項番 説明 (1)引数として与えられたPasswordReissueInfo
オブジェクトをデータベースのレコードとして登録するメソッド(2)引数として与えられたトークンをキーとして、PasswordReissueInfo
オブジェクトを検索し、取得するメソッド(3)引数として与えられたトークンをキーとして、PasswordReissueInfo
オブジェクトを削除するメソッドマッピングファイルは以下の通り。
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="org.terasoluna.securelogin.domain.repository.passwordreissue.PasswordReissueInfoRepository"> <resultMap id="PasswordReissueInfoResultMap" type="PasswordReissueInfo"> <id property="username" column="username" /> <id property="token" column="token" /> <id property="secret" column="secret" /> <id property="expiryDate" column="expiry_date" /> </resultMap> <select id="findOne" resultMap="PasswordReissueInfoResultMap"> <![CDATA[ SELECT username, token, secret, expiry_date FROM password_reissue_info WHERE token = #{token} ]]> </select> <insert id="create" parameterType="PasswordReissueInfo"> <![CDATA[ INSERT INTO password_reissue_info ( username, token, secret, expiry_date ) VALUES ( #{username}, #{token}, #{secret}, #{expiryDate} ) ]]> </insert> <delete id="delete"> <![CDATA[ DELETE FROM password_reissue_info WHERE token = #{token} ]]> </delete> <!-- omitted --> </mapper>
以下、実装方法に従って実装されたコードについて順に解説する。
パスワード再発行のための認証情報の生成と保存
パスワード生成器の定義
Passayのパスワード生成機能を使用するための、パスワード生成器と生成規則の定義を以下に示す。 パスワード生成器や生成規則に関しては パスワード生成 を参照。
項番 説明 (1)Passayのパスワード生成機能で用いるパスワード生成器のBean定義(2)Passayのパスワード生成機能で用いるパスワード生成規則のBean定義。 パスワードの品質チェック で使用した検証規則を使用し、半角英大文字、半角英小文字、半角数字をそれぞれ一文字以上含むパスワードの生成規則を定義する。applicationContext.xml
<bean id="passwordGenerator" class="org.passay.PasswordGenerator" /> <!-- (1) --> <util:list id="passwordGenerationRules"> <ref bean="upperCaseRule" /> <ref bean="lowerCaseRule" /> <ref bean="digitRule" /> </util:list>
Serviceの実装
パスワード再発行のための認証情報を作成し、データベースへ保存するための処理の実装を以下に示す。この処理中で生成した認証情報をメール送信する。メール送信については後述するため、ここでは省略する。
package org.terasoluna.securelogin.domain.service.passwordreissue; // omitted @Service @Transactional public class PasswordReissueServiceImpl implements PasswordReissueService { @Inject ClassicDateFactory dateFactory; @Inject PasswordReissueInfoRepository passwordReissueInfoRepository; @Inject AccountSharedService accountSharedService; @Inject PasswordEncoder passwordEncoder; @Inject PasswordGenerator passwordGenerator; // (1) @Resource(name = "passwordGenerationRules") List<CharacterRule> passwordGenerationRules; //(2) @Value("${security.tokenLifeTimeSeconds}") int tokenLifeTimeSeconds; // (3) // omitted @Override public String createAndSendReissueInfo(String username) { String rowSecret = passwordGenerator.generatePassword(10, passwordGenerationRules); // (4) if(!accountSharedService.exists(username)){ // (5) return rowSecret; } Account account= accountSharedService.findOne(username); // (6) String token = UUID.randomUUID().toString(); // (7) LocalDateTime expiryDate = dateFactory.newTimestamp().toLocalDateTime() .plusSeconds(tokenLifeTimeSeconds); // (8) PasswordReissueInfo info = new PasswordReissueInfo(); // (9) info.setUsername(username); info.setToken(token); info.setSecret(passwordEncoder.encode(rowSecret)); // (10) info.setExpiryDate(expiryDate); passwordReissueInfoRepository.create(info); // (11) // omitted (Send E-Mail) return rowSecret; // (12) } // omitted }
項番 説明 (1)Passayのパスワード生成機能で用いるパスワード生成器をインジェクションする。(2)Passayのパスワード生成機能で用いるパスワード生成ルールをインジェクションする。(3)パスワード再発行用の認証情報が有効である期間の長さを秒単位で指定する。プロパティファイルに定義された値をインジェクションしている。(4)秘密情報として用いるために、Passayのパスワード生成機能を用いて、パスワード生成規則に従った、長さ10のランダムな文字列を生成する。(5)引数として渡されてきたユーザ名のアカウントが存在するかどうか確認する。存在しなかった場合、ユーザが存在しないことを知られないためにダミーの秘密情報を返す。(6)パスワード再発行用の認証情報に含まれるユーザ名のアカウント情報を取得する。(7)トークンとして用いるために、java.util.UUID
クラスのrandomUUID
メソッドを用いてランダムな文字列を生成する。(8)現在時刻に(3)の値を加えることにより、パスワード再発行用の認証情報の有効期限を計算する。(9)パスワード再発行用の認証情報を作成し、ユーザ名、トークン、秘密情報、有効期限を設定する。(10)秘密情報はハッシュ化を行ってからPasswordReissueInfo
に設定する。(11)パスワード再発行用の認証情報をデータベースに登録する。(12)生成した秘密情報を返す。Formの実装
package org.terasoluna.securelogin.app.passwordreissue; // omitted @Data public class CreateReissueInfoForm implements Serializable { private static final long serialVersionUID = 1L; @NotEmpty private String username; }
Viewの実装
パスワード再発行のための認証情報生成画面(createReissueInfoForm.xml)
<!-- omitted --> <body> <div id="wrapper"> <h1>Reissue password</h1> <t:messagesPanel /> <form:form action="${f:h(pageContext.request.contextPath)}/reissue/create" method="POST" modelAttribute="createReissueInfoForm"> <table> <tr> <th><form:label path="username" cssErrorClass="error-label">Username</form:label> </th> <td><form:input path="username" cssErrorClass="error-input" /></td> <td><form:errors path="username" cssClass="error-messages" /></td> </tr> </table> <input id="submit" type="submit" value="Reissue password" /> </form:form> </div> </body> <!-- omitted -->
Controllerの実装
package org.terasoluna.securelogin.app.passwordreissue; // omitted @Controller @RequestMapping("/reissue") public class PasswordReissueController { @Inject PasswordReissueService passwordReissueService; @RequestMapping(value = "create", params = "form") public String showCreateReissueInfoForm(CreateReissueInfoForm form) { return "passwordreissue/createReissueInfoForm"; } @RequestMapping(value = "create", method = RequestMethod.POST) public String createReissueInfo(@Validated CreateReissueInfoForm form, BindingResult bindingResult, Model model, RedirectAttributes attributes) { if (bindingResult.hasErrors()) { return showCreateReissueInfoForm(form); } String rawSecret = passwordReissueService.createAndSendReissueInfo(form.getUsername()); // (1) attributes.addFlashAttribute("secret", rawSecret); return "redirect:/reissue/create?complete"; } @RequestMapping(value = "create", params = "complete", method = RequestMethod.GET) public String createReissueInfoComplete() { return "passwordreissue/createReissueInfoComplete"; } // omitted }
項番 説明 (1)Formから取得したユーザ名から、パスワード再発行のための認証情報を生成し、データベースに登録する処理を呼び出す。
6.10.3.6. パスワード再発行のための認証情報の配布¶
6.10.3.6.2. 動作イメージ¶
パスワード再発行のための認証情報の生成 では、パスワード再発行のための認証情報の生成について説明した。 ここでは、生成した認証情報の配布について説明する。
パスワード再発行のための認証は、パスワード再発行画面のURLと秘密情報を用いて行う。 この二つの情報が一度に漏れることを防ぐため、それぞれ別の方法でユーザに配布する。 本アプリケーションでは、パスワード再発行画面のURLはユーザの登録済みメールアドレスへ送付し、秘密情報は画面に表示する。
6.10.3.6.3. 実装方法¶
秘密情報の画面表示
パスワード再発行のための認証情報の生成 で生成したハッシュ化前の秘密情報を、画面に表示させることでユーザに配布する。
パスワード再発行画面のURLのメール送付
パスワード再発行のための認証情報の生成 で生成したトークンを含むパスワード再発行画面のURLを、Spring FrameworkのMail連携用コンポーネントを用いて、メールで送付する。
6.10.3.6.4. コード解説¶
上記の実装方法に従って実装されたコードについて順に解説する。
秘密情報の画面表示
Controllerから秘密情報の生成処理を呼び出し、Viewに表示するための一連の実装を以下に示す。
package org.terasoluna.securelogin.app.passwordreissue; // omitted @Controller @RequestMapping("/reissue") public class PasswordReissueController { @Inject PasswordReissueService passwordReissueService; // omitted @RequestMapping(value = "create", method = RequestMethod.POST) public String createReissueInfo(@Validated CreateReissueInfoForm form, BindingResult bindingResult, Model model, RedirectAttributes attributes) { if (bindingResult.hasErrors()) { return showCreateReissueInfoForm(form); } String rawSecret = passwordReissueService.createAndSendReissueInfo(form.getUsername()); // (1) attributes.addFlashAttribute("secret", rawSecret); // (2) return "redirect:/reissue/create?complete"; // (3) } @RequestMapping(value = "create", params = "complete", method = RequestMethod.GET) public String createReissueInfoComplete() { return "passwordreissue/createReissueInfoComplete"; } // omitted }
項番 説明 (1)秘密情報を生成する処理を呼び出す。(2)RedirectAttributesを利用して、リダイレクト先に秘密情報を渡す。(3)パスワード再発行用の認証情報完了画面にリダイレクトする。パスワード再発行用の認証情報生成完了画面(createReissueInfoComplete.jsp)
<!-- omitted --> <body> <div id="wrapper"> <h1>Your Password Reissue URL was successfully generated.</h1> The URL was sent to your registered E-mail address.<br /> Please access the URL and enter the secret shown below. <h3>Secret : <span id=secret>${f:h(secret)}</span></h3> <!-- (1) --> </div> </body> <!-- omitted -->
項番 説明 (1)秘密情報を画面に表示する。パスワード再発行画面のURLのメール送付
パスワード再発行用の認証情報からパスワード再発行画面のURLを作成し、メール送付する処理の実装を以下に示す。 依存ライブラリの追加方法やメールセッションの取得方法等の詳細については、E-mail送信(SMTP) を参照。
package org.terasoluna.securelogin.domain.service.mail; // omitted @Service public class PasswordReissueMailSharedServiceImpl implements PasswordReissueMailSharedService { @Inject JavaMailSender mailSender; // (1) @Inject @Named("passwordReissueMessage") SimpleMailMessage templateMessage; // (2) // omitted @Override public void send(String to, String text) { SimpleMailMessage message = new SimpleMailMessage(templateMessage); // (3) message.setTo(to); message.setText(text); mailSender.send(message); } }
項番 説明 (1)org.springframework.mail.javamail.JavaMailSender
のBeanをインジェクションする。(2)送信元のメールアドレスとメールタイトルが設定された、org.springframework.mail.SimpleMailMessage
のBeanをインジェクションする。本アプリケーションではSimpleMailMessage
のBeanは一つしか定義されていないが、一般にはメールのテンプレートとして複数のBeanが定義されるため、@Named
でBean名を指定している。(3)テンプレートからSimpleMailMessage
のインスタンスを生成し、引数として与えられた宛先メールアドレスと本文を設定して送信する。package org.terasoluna.securelogin.domain.service.passwordreissue; // omitted @Service @Transactional public class PasswordReissueServiceImpl implements PasswordReissueService { @Inject ClassicDateFactory dateFactory; @Inject PasswordReissueMailSharedService mailSharedService; @Inject AccountSharedService accountSharedService; @Inject PasswordEncoder passwordEncoder; @Value("${security.tokenLifeTimeSeconds}") int tokenLifeTimeSeconds; @Value("${app.applicationBaseUrl}") // (1) String baseUrl; @Value("${app.passwordReissueProtocol}") String protocol; // omitted @Override public String createAndSendReissueInfo(String username) { String rowSecret = passwordGenerator.generatePassword(10, passwordGenerationRules); if(!accountSharedService.exists(username)){ return rowSecret; } Account account= accountSharedService.findOne(username); String token = UUID.randomUUID().toString(); LocalDateTime expiryDate = dateFactory.newTimestamp().toLocalDateTime() .plusSeconds(tokenLifeTimeSeconds); PasswordReissueInfo info = new PasswordReissueInfo(); info.setUsername(username); info.setToken(token); info.setSecret(passwordEncoder.encode(rowSecret)); info.setExpiryDate(expiryDate); passwordReissueInfoRepository.create(info); UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(baseUrl); uriBuilder.pathSegment("reissue").pathSegment("resetpassword") .queryParam("form").queryParam("token", info.getToken()); // (2) String passwordResetUrl = uriBuilder.build().encode().toUriString(); mailSharedService.send(account.getEmail(), passwordResetUrl); // (3) return rowSecret; } // omitted }
項番 説明 (1)パスワード再発行画面のURLに使用するベースURLをプロパティファイルから取得する。(2)(1)で取得した値と、生成したパスワード再発行用の認証情報に含まれるトークンを使用して、ユーザに配布するパスワード再発行画面のURLを作成する。URLの作成にはorg.springframework.web.util.UriComponentsBuilder
を利用する。UriComponentsBuilder
については、ハイパーメディアリンクの実装 の中で説明されている。(3)ユーザの登録メールアドレス宛てに、パスワード再発行画面のURLを本文に記したメールを送付する。
6.10.3.7. パスワード再発行実行時の検査¶
6.10.3.7.2. 動作イメージ¶
パスワード再発行のための認証情報の配布 では、パスワード再発行のための認証情報の配布について説明した。 ここでは、配布された認証情報を使用する際の処理について説明する。
パスワード再発行時の認証として、パスワード再発行のための認証情報の配布 でそれぞれ別配布したパスワード再発行画面のURLと秘密情報を照合する。 URLに含まれるトークンと秘密情報の組が正しい場合にのみ、パスワードが再発行される。
また、一般的にはパスワードの再発行は認証情報の生成から間を置かずに行われるため、不必要に長期間有効となることが無いように、認証情報に有効期限を設定する。 パスワード再発行画面のURLにアクセスした際に、認証情報が有効期限内であればパスワード再発行画面を表示し、有効期限が切れていればエラー画面に遷移する。
6.10.3.7.3. 実装方法¶
パスワード再発行用の認証情報の有効期限の設定
パスワード再発行のための認証情報の生成 で説明した処理の中で、生成した認証情報に有効期限を設定する。
パスワード再発行のための認証情報の有効期限の検査
パスワード再発行画面にアクセスされた際に、リクエストパラメータに含まれるトークンを取得し、トークンをキーとしてデータベースに保存されているパスワード再発行のための認証情報を検索する。 認証情報に含まれる有効期限と現在時刻を比較し、有効期限が切れていればエラー画面に遷移させる。
パスワード再発行のための認証情報を用いたユーザの確認
パスワードの再発行を行う際に、ユーザ名、トークンとユーザが入力した秘密情報の組み合わせがデータベース内の認証情報と一致しているかどうかを確認する。 一致する場合にはパスワードを再発行し、不一致の場合にはエラーメッセージを表示する。
6.10.3.7.4. コード解説¶
パスワード再発行用の認証情報の有効期限の設定
パスワード再発行用の認証情報への有効期限の設定自体は、 パスワード再発行のための認証情報の生成 で説明した処理に含まれている。ここでは、関連する実装箇所のみ再掲する。
Serviceの実装
package org.terasoluna.securelogin.domain.service.passwordreissue; // omitted @Service @Transactional public class PasswordReissueServiceImpl implements PasswordReissueService { @Inject ClassicDateFactory dateFactory; @Inject PasswordReissueInfoRepository passwordReissueInfoRepository; @Value("${security.tokenLifeTimeSeconds}") int tokenLifeTimeSeconds; // (1) // omitted @Override public String createAndSendReissueInfo(String username) { // omitted LocalDateTime expiryDate = dateFactory.newTimestamp().toLocalDateTime() .plusSeconds(tokenLifeTimeSeconds); // (2) PasswordReissueInfo info = new PasswordReissueInfo(); // (3) info.setUsername(username); info.setToken(token); info.setSecret(passwordEncoder.encode(rowSecret)); info.setExpiryDate(expiryDate); passwordReissueInfoRepository.create(info); // (4) // omitted (Send E-Mail) } // omitted }
項番 説明 (1)パスワード再発行用の認証情報が有効である期間の長さを秒単位で指定する。プロパティファイルに定義された値をインジェクションしている。(2)現在時刻に(1)の値を加えることにより、パスワード再発行用の認証情報の有効期限を計算する。(3)パスワード再発行用の認証情報を作成し、ユーザ名、トークン、秘密情報、有効期限を設定する。(4)パスワード再発行用の認証情報をデータベースに登録する。
パスワード再発行のための認証情報の有効期限の検査
パスワード再発行画面にアクセスされた際に、リクエストパラメータとしてURLに含まれるトークンからパスワード再発行のための認証情報を取得し、有効期限内であるかどうかを検査する処理の実装を以下に示す。 この処理中ではパスワード再発行の失敗上限を超過しているかどうかの検査も行うが、後述するため、ここでは省略する。
Serviceの実装
package org.terasoluna.securelogin.domain.service.passwordreissue; // omitted @Service @Transactional public class PasswordReissueServiceImpl implements PasswordReissueService { @Inject ClassicDateFactory dateFactory; @Inject PasswordReissueInfoRepository passwordReissueInfoRepository; // omitted @Override @Transactional(readOnly = true) public PasswordReissueInfo findOne(String token) { PasswordReissueInfo info = passwordReissueInfoRepository.findOne(token); // (1) if (info == null) { throw new ResourceNotFoundException(ResultMessages.error().add( MessageKeys.E_SL_PR_5002, token)); } if (dateFactory.newTimestamp().toLocalDateTime().isAfter(info.getExpiryDate())) { // (2) throw new BusinessException(ResultMessages.error().add( MessageKeys.E_SL_PR_2001)); } // omitted (attempts exceeded upper bounds) return info; } // omitted }
項番 説明 (1)引数として与えられたトークンをキーとして、パスワード再発行のための認証情報をデータベースから取得する。(2)有効期限が切れている場合は、org.terasoluna.gfw.common.exception.BusinessException
をthrowする。Controllerの実装
package org.terasoluna.securelogin.app.passwordreissue; // omitted @Controller @RequestMapping("/reissue") public class PasswordReissueController { @Inject PasswordReissueService passwordReissueService; // omitted public String showPasswordResetForm(PasswordResetForm form, Model model, @RequestParam("token") String token) { // (1) PasswordReissueInfo info = passwordReissueService.findOne(token); // (3) form.setUsername(info.getUsername()); form.setToken(token); model.addAttribute("passwordResetForm", form); return "passwordreissue/passwordResetForm"; } // omitted }
項番 説明 (1)パスワード再発行画面のURLにリクエストパラメータとして含まれるトークンを取得する。(2)Serviceのメソッドにトークンを渡して呼び出す。データベースから認証情報が取得され、有効期限が検査される。
パスワード再発行のための認証情報を用いたユーザの確認
パスワード再発行画面においてユーザが入力した秘密情報と、パスワード再発行画面のURLに含まれるトークンの組が正しいかどうかを確認する処理の実装を以下に示す。 この確認処理はパスワード再発行固有のロジックであり、かつデータベースの内容によって結果が異なるチェックであることから、Bean ValidationやSpring Validatorを用いず、Serviceに実装している。
Serviceの実装
package org.terasoluna.securelogin.domain.service.passwordreissue; // omitted public interface PasswordReissueService { // omitted boolean resetPassword(String username, String token, String secret, // (1) String rawPassword); // omitted }
項番 説明 (1)引数として与えられたユーザ名、トークン、秘密情報を用いてユーザの確認を行った後、新しいパスワードを設定するメソッドpackage org.terasoluna.securelogin.domain.service.passwordreissue; // omitted @Service @Transactional public class PasswordReissueServiceImpl implements PasswordReissueService { @Inject PasswordReissueFailureSharedService passwordReissueFailureSharedService; @Inject PasswordReissueInfoRepository passwordReissueInfoRepository; @Inject AccountSharedService accountSharedService; @Inject PasswordEncoder passwordEncoder; // omitted @Override public boolean resetPassword(String username, String token, String secret, String rawPassword) { PasswordReissueInfo info = this.findOne(token); // (1) if (!passwordEncoder.matches(secret, info.getSecret())) { // (2) passwordReissueFailureSharedService.resetFailure(username, token); throw new BusinessException(ResultMessages.error().add( MessageKeys.E_SL_PR_5003)); } failedPasswordReissueRepository.deleteByToken(token); passwordReissueInfoRepository.delete(token); // (3) return accountSharedService.updatePassword(username, rawPassword); // (4) } // omitted }
項番 説明 (1)引数として与えられたトークンを用いて、データベースからパスワード再発行用の認証情報を取得する。このとき、有効期限が改めて検査される。(2)パスワード再発行用の認証情報に含まれるハッシュ化された秘密情報と、引数として与えられた秘密情報を比較する。異なる場合にはBusinessException
をthrowする。この場合、パスワードの再発行は失敗となる。(3)使用された認証情報を再使用不能にするために、データベースから消去する。(4)引数として渡されたユーザ名を持つアカウントのパスワードを、指定された新しいパスワードに更新する。Formの実装
クラスに付与されたアノテーションによってNullチェック以外の入力チェックが網羅されていることから、単項目チェックとしては
@NotNull
のみを付与している。package org.terasoluna.securelogin.app.passwordreissue; // omitted @Data @Compare(source = "newPasssword", destination = "confirmNewPassword", operator = Compare.Operator.EQUAL) @StrongPassword(usernamePropertyName = "username", newPasswordPropertyName = "newPassword") // (1) @NotReusedPassword(usernamePropertyName = "username", newPasswordPropertyName = "newPassword") // (2) public class PasswordResetForm implements Serializable{ private static final long serialVersionUID = 1L; @NotNull private String username; @NotNull private String token; @NotNull private String secret; @NotNull private String newPassword; @NotNull private String confirmNewPassword; }
項番 説明 (1)パスワードの強度を検査するためのアノテーション。詳細は パスワードの品質チェック を参照。(2)パスワードの再利用を検査するためのアノテーション。詳細は パスワードの品質チェック を参照。Viewの実装
パスワード再発行画面(passwordResetForm.jsp)
<body> <div id="wrapper"> <h1>Reset Password</h1> <t:messagesPanel /> <form:form action="${f:h(pageContext.request.contextPath)}/reissue/resetpassword" method="POST" modelAttribute="passwordResetForm"> <table> <tr> <th><form:label path="username">Username</form:label></th> <td>${f:h(passwordResetForm.username)} <form:hidden path="username" value="${f:h(passwordResetForm.username)}" /> <!-- (1) --> </td> <td></td> </tr> <form:hidden path="token" value="${f:h(passwordResetForm.token)}" /> <!-- (2) --> <tr> <th><form:label path="secret" cssErrorClass="error-label">Secret</form:label> </th> <td><form:password path="secret" cssErrorClass="error-input" /></td> <!-- (3) --> <td><form:errors path="secret" cssClass="error-messages" /></td> </tr> <tr> <th><form:label path="newPassword" cssErrorClass="error-label">New password</form:label> </th> <td><form:password path="newPassword" cssErrorClass="error-input" /></td> <td><form:errors path="newPassword" cssClass="error-messages" htmlEscape="false" /></td> </tr> <tr> <th><form:label path="confirmNewPassword" cssErrorClass="error-label">New password(Confirm)</form:label></th> <td><form:password path="confirmNewPassword" cssErrorClass="error-input" /></td> <td><form:errors path="confirmNewPassword" cssClass="error-messages" /></td> </tr> </table> <input id="submit" type="submit" value="Reset password" /> </form:form> </div> </body>
項番 説明 (1)ユーザ名をhidden項目として保持する。(2)トークンをhidden項目として保持する。(3)ユーザの確認のために、秘密情報を入力させる。パスワード再発行画面(passwordResetComplete.jsp)
<body> <div id="wrapper"> <h1>Your password was successfully reset.</h1> <a href="${f:h(pageContext.request.contextPath)}/">go to Top</a> </div> </body>
Controllerの実装
package org.terasoluna.securelogin.app.passwordreissue; // omitted @Controller @RequestMapping("/reissue") public class PasswordReissueController { @Inject PasswordReissueService passwordReissueService; // omitted @RequestMapping(value = "resetpassword", method = RequestMethod.POST) public String resetPassword(@Validated PasswordResetForm form, BindingResult bindingResult, Model model) { if (bindingResult.hasErrors()) { return showPasswordResetForm(form, model, form.getUsername(), form.getToken()); } try { passwordReissueService.resetPassword(form.getUsername(), form.getToken(), form.getSecret(), form.getNewPassword()); // (1) return "redirect:/reissue/resetpassword?complete"; } catch (BusinessException e) { model.addAttribute(e.getResultMessages()); return showPasswordResetForm(form, model, form.getUsername(), form.getToken()); } } @RequestMapping(value = "resetpassword", params = "complete", method = RequestMethod.GET) public String resetPasswordComplete() { return "passwordreissue/passwordResetComplete"; } // omitted }
項番 説明 (1)Serviceのメソッドにユーザ名、トークン、秘密情報、新しいパスワードを渡す。ユーザ名、トークン、秘密情報の組み合わせが正しい場合、新しいパスワードに更新される。
6.10.3.8. パスワード再発行の失敗上限回数の設定¶
6.10.3.8.2. 動作イメージ¶
パスワード再発行画面のURLが何らかの原因で漏えいした場合であっても、秘密情報が漏えいしていなければパスワードが不正に再発行されることはない。 秘密情報には十分に推測困難なランダム値を用いているため簡単に破られる可能性は低いが、ブルートフォース攻撃を阻止する目的で認証失敗の回数に上限値を設定する。 上限値を超えてパスワード再発行のための認証に失敗した場合、そのURL(トークン)でのパスワード再発行が行えないようにする。
6.10.3.8.3. 実装方法¶
パスワード再発行失敗イベントエンティティの保存
パスワード再発行実行時の検査 における、「パスワード再発行のための認証情報を用いたユーザの確認」処理の中で、ユーザの確認に失敗した場合に、使用したトークンと失敗日時の組をパスワード再発行失敗イベントエンティティとしてデータベースに登録する。
パスワード再発行時の例外のスロー
パスワード再発行のために認証情報をデータベースから取得した際に、パスワード再発行失敗イベントエンティティの数をカウントし、上限値以上であれば例外をスローする。
Warning
パスワード再発行失敗イベントエンティティはパスワード再発行の失敗回数のカウントのみを目的としているため、不要になったタイミングで消去する。 パスワード再発行の失敗時のログが必要な場合は必ず別途ログを保存しておくこと。
6.10.3.8.4. コード解説¶
共通部分
前提として、 パスワード再発行実行時の検査 に記した各処理が実装されているものとする。 その他に共通的に必要な、データベースに対するパスワード再発行失敗イベントエンティティの登録、検索、削除に関する実装を以下に示す。
Entityの実装
パスワード再発行失敗イベントエンティティの実装の実装は以下の通り。
package org.terasoluna.securelogin.domain.model; // omitted @Data public class FailedPasswordReissue { private String token; // (1) private LocalDateTime attemptDate; // (2) }
項番 説明 (1)パスワード再発行に使用したトークン(2)パスワード再発行を試行した日時Repositoryの実装
Entityの検索、登録、削除を行うためのRepositoryを以下に示す。
package org.terasoluna.securelogin.domain.repository.passwordreissue; // omitted public interface FailedPasswordReissueRepository { int countByToken(@Param("token") String token); // (1) int create(FailedPasswordReissue event); // (2) int deleteByToken(@Param("token") String token); // (3) // omitted }
項番 説明 (1)引数として与えられたトークンをキーとしてFailedPasswordReissue
オブジェクトの個数を取得するメソッド(2)引数として与えられたFailedPasswordReissue
オブジェクトをデータベースのレコードとして登録するメソッド(3)引数として与えられたトークンをキーとしてFailedPasswordReissue
オブジェクトを削除するメソッドマッピングファイルは以下の通り。
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="org.terasoluna.securelogin.domain.repository.passwordreissue.FailedPasswordReissueRepository"> <select id="countByToken" resultType="_int"> <![CDATA[ SELECT COUNT(*) FROM failed_password_reissue WHERE token = #{token} ]]> </select> <insert id="create" parameterType="FailedPasswordReissue"> <![CDATA[ INSERT INTO failed_password_reissue ( token, attempt_date ) VALUES ( #{token}, #{attemptDate} ) ]]> </insert> <delete id="deleteByToken"> <![CDATA[ DELETE FROM failed_password_reissue WHERE token = #{token} ]]> </delete> </mapper>
以下、実装方法に従って実装されたコードについて順に解説する。
パスワード再発行失敗イベントエンティティの保存
パスワード再発行失敗時に行う処理を実装したクラスを以下に示す。
package org.terasoluna.securelogin.domain.service.passwordreissue; public interface PasswordReissueFailureSharedService { void resetFailure(String username, String token); }
package org.terasoluna.securelogin.domain.service.passwordreissue; // omitted @Service @Transactional public class PasswordReissueFailureSharedServiceImpl implements PasswordReissueFailureSharedService { @Inject ClassicDateFactory dateFactory; @Inject FailedPasswordReissueRepository failedPasswordReissueRepository; // omitted @Transactional(propagation = Propagation.REQUIRES_NEW) // (1) @Override public void resetFailure(String username, String token) { FailedPasswordReissue event = new FailedPasswordReissue(); // (2) event.setToken(token); event.setAttemptDate(dateFactory.newTimestamp().toLocalDateTime()); failedPasswordReissueRepository.create(event); // (3) } }
項番 説明 (1)パスワード再発行に失敗した際に呼び出されるメソッドであり、呼び出し元で実行時例外を発生させる設計としている。そのため、呼び出し元のServiceとは別にトランザクション管理を行うために、伝搬方法を「REQUIRES_NEW」に指定する。(2)パスワード再発行失敗イベントエンティティを作成し、トークンと失敗日時を設定する。(3)(2)で作成したパスワード再発行失敗イベントエンティティをデータベースに登録する。パスワード再発行実行時の検査 の「パスワード再発行のための認証情報を用いたユーザの確認」処理の中から、パスワード再発行失敗時の処理を呼び出す。
package org.terasoluna.securelogin.domain.service.passwordreissue; // omitted @Service @Transactional public class PasswordReissueServiceImpl implements PasswordReissueService { @Inject PasswordReissueFailureSharedService passwordReissueFailureSharedService; @Inject PasswordReissueInfoRepository passwordReissueInfoRepository; @Inject AccountSharedService accountSharedService; @Inject PasswordEncoder passwordEncoder; // omitted @Override public boolean resetPassword(String username, String token, String secret, String rawPassword) { PasswordReissueInfo info = this.findOne(token); // (1) if (!passwordEncoder.matches(secret, info.getSecret())) { // (2) passwordReissueFailureSharedService.resetFailure(username, token); // (3) throw new BusinessException(ResultMessages.error().add( // (4) MessageKeys.E_SL_PR_5003)); } //omitted } // omitted }
項番 説明 (1)引数として与えられたトークンを用いて、データベースからパスワード再発行用の認証情報を取得する。(2)パスワード再発行用の認証情報に含まれるハッシュ化された秘密情報と、引数として与えられた秘密情報を比較する。(3)パスワード再発行失敗時の処理を行うSharedServiceのメソッド呼び出す。(4)実行時例外をthrowするが、パスワード再発行失敗時の処理は別のトランザクションで実行されるため、影響を与えることはない。パスワード再発行時の例外のスロー
パスワード再発行の失敗回数の取得と、失敗回数が上限に達した際の処理の実装を以下に示す。
package org.terasoluna.securelogin.domain.service.passwordreissue; // omitted @Service @Transactional public class PasswordReissueServiceImpl implements PasswordReissueService { @Inject FailedPasswordReissueRepository failedPasswordReissueRepository; @Inject PasswordReissueInfoRepository passwordReissueInfoRepository; @Value("${security.tokenValidityThreshold}") int tokenValidityThreshold; // (1) // omitted @Override @Transactional(readOnly = true) public PasswordReissueInfo findOne(String token) { // omitted int count = failedPasswordReissueRepository // (2) .countByToken(token); if (count >= tokenValidityThreshold) { // (3) throw new BusinessException(ResultMessages.error().add( MessageKeys.E_SL_PR_5004)); } return info; } // omitted }
項番 説明 (1)パスワード再発行の失敗回数の上限値をプロパティファイルから取得して設定する。(2)引数として与えられたトークンをキーとして、データベースからパスワード再発行失敗イベントエンティティの数を取得。(3)取得したパスワード再発行の失敗イベントエンティティの数と失敗回数の上限値を比較し、上限値以上ならば例外をスローする。
6.10.4. おわりに¶
6.10.5. Appendix¶
6.10.5.1. Passay¶
Passayはパスワード入力チェック機能とパスワード生成機能を提供するライブラリである。 PassayのAPIは以下の三つの主要コンポーネントで構成される。
検証規則
パスワードが満たすべき条件の定義。パスワードの長さや含まれる文字種別等の一般的によく利用される規則についてはライブラリが提供するクラスを使用して容易に作成することができる。その他、必要な規則を自分で定義することもできる。
検証器
検証規則に基づいて実際にパスワードのチェックを行うコンポーネント。複数の検証規則を一つの検証器に設定することができる。
生成器
与えられた文字種別に関する検証規則に適合するパスワードを生成するコンポーネント。
Passayの機能を使用する場合は、pom.xmlに以下の定義を追加すること。
<dependencies>
<dependency>
<groupId>org.passay</groupId>
<artifactId>passay</artifactId>
<version>1.1.0</version>
</dependency>
<dependencies>
6.10.5.1.1. パスワード入力チェック¶
6.10.5.1.1.1. Overview¶
項番 | 説明 |
---|---|
(1)
|
org.passay.PasswordData のインスタンスを作成し、入力チェック対象のパスワードに関する情報を設定する。PasswordData は、パスワード、ユーザ名に加え、過去に使用したパスワードのリスト等をプロパティとして持つことができる。過去に使用したパスワード等は
org.passay.PasswordData.Reference のインスタンスとして保持する。 |
(2)
|
検証規則に従い、検証器を用いて
PasswordData に対する入力チェックを行う。検証規則は
org.passay.Rule の実装クラスのインスタンスとして作成する。検証器はorg.passay.PasswordValidator のインスタンスであり、複数の検証規則をプロパティとして持つことができる。 |
(3)
|
検証器による入力チェックの結果として
org.passay.RuleResult のインスタンスが作成される。 |
(4)
|
RuleResult からパスワード入力チェックの結果をboolean として得ることができる。また、検証器を使ってRuleResult からエラーメッセージが取得できる。 |
Passayが提供している検証規則のクラスの一部を以下の表に示す。
クラス名 | 説明 | 主なプロパティ |
---|---|---|
LengthRule |
パスワード長の最小値、最大値を規定するための検証規則のクラス
|
minimuxLength : パスワード長の最小値(int )。コンストラクタまたはsetterで設定。maximumLength : パスワード長の最大値(int )。コンストラクタまたはsetterで設定。 |
CharacterRule |
パスワードに含まれるべき文字種別と、その文字種別の最低文字数を規定するための検証規則のクラス
|
characterData : 文字種別(org.passay.CharacterData )。コンストラクタで設定。numberOfCharacters : 最低文字数(int )。コンストラクタまたはsetterで設定。 |
CharacterCharacteristicsRule |
複数の
CharacterRule のうち、いくつ以上の規則を満たす必要があるかを規定するための検証規則のクラス |
rules : 文字種別に関する検証規則のリスト(List<CharacterRule> )。setterで設定。numberOfCharacteristics : 満たすべき検証規則の数の最小値(int )。setterで設定。 |
HistoryRule |
パスワードが以前に使用したパスワードと一致していないことをチェックするための検証規則のクラス
|
なし
|
UsernameRule |
パスワードがユーザ名を含まないことをチェックするための検証規則のクラス
|
matchBackwards : ユーザ名を逆にした文字列もチェックする(boolean )。コンストラクタまたはsetterで設定。ignoreCase : 大文字、小文字を区別しない(boolean )。コンストラクタまたはsetterで設定。 |
この他にも、特定の文字を含む/含まないことのチェックや、正規表現によるチェックを行うための検証規則のクラス等が提供されている。 詳細は http://www.passay.org/ を参照。
6.10.5.1.1.2. How to use¶
PasswordValidator
のコンストラクタにorg.passay.Rule
のインスタンスのリストを渡すことによって、検証器を作成することができる。
検証規則を設定した検証器を以下のようにBeanとして定義しておくことでDIが可能となる。
尚、複数の検証規則をBean定義する場合、@Inject
と@Named
を併用することでBean名によるDIを行うこと。
<!-- Password Rules. -->
<bean id="upperCaseRule" class="org.passay.CharacterRule"> <!-- (1) -->
<constructor-arg name="data">
<util:constant static-field="org.passay.EnglishCharacterData.UpperCase" /> <!-- (2) -->
</constructor-arg>
<constructor-arg name="num" value="1" /> <!-- (3) -->
</bean>
<bean id="lowerCaseRule" class="org.passay.CharacterRule"> <!-- (4) -->
<constructor-arg name="data">
<util:constant static-field="org.passay.EnglishCharacterData.LowerCase" />
</constructor-arg>
<constructor-arg name="num" value="1" />
</bean>
<bean id="digitRule" class="org.passay.CharacterRule"> <!-- (5) -->
<constructor-arg name="data">
<util:constant static-field="org.passay.EnglishCharacterData.Digit" />
</constructor-arg>
<constructor-arg name="num" value="1" />
</bean>
<!-- Password Validator. -->
<bean id="characterPasswordValidator" class="org.passay.PasswordValidator"> <!-- (6) -->
<constructor-arg name="rules">
<list>
<ref bean="upperCaseRule" />
<ref bean="lowerCaseRule" />
<ref bean="digitRule" />
</list>
</constructor-arg>
</bean>
項番 | 説明 |
---|---|
(1)
|
パスワードに含まれるべき文字種別と、その文字種別の最低文字数を規定するための検証規則のBean定義
|
(2)
|
文字種別を指定する。ここでは、
org.passay.EnglishCharacterData.UpperCase を渡しているため、半角英大文字に関する検証規則となる。 |
(3)
|
文字数を指定する。ここでは”1”を渡しているため、半角英大文字を一文字以上含むことをチェックする検証規則となる。
|
(4)
|
(1)-(3)と同様だが、文字種別として
org.passay.EnglishCharacterData.UpperCase を渡しているため、半角英小文字を一文字以上含むことをチェックする検証規則のBean定義となる。 |
(5)
|
(1)-(3)と同様だが、文字種別として
org.passay.EnglishCharacterData.Digit を渡しているため、半角数字を一文字以上含むことをチェックする検証規則のBean定義となる。 |
(6)
|
検証器のBean定義。コンストラクタに検証規則のリストを渡す。
|
作成した検証器を使用してパスワード入力チェックを行う。
@Inject
PasswordValidator characterPasswordValidator;
// omitted
public void validatePassword(String password){
PasswordData pd = new PasswordData(password); // (1)
RuleResult result = characterPasswordValidator.validate(pd); // (2)
if (result.isValid()) { // (3)
logger.info("Password is valid");
} else {
logger.error("Invalid password:");
for (String msg : characterPasswordValidator.getMessages(result)) { // (4)
logger.error(msg);
}
}
}
項番 | 説明 |
---|---|
(1)
|
検証対象のパスワードを
PasswordData のコンストラクタに渡し、インスタンスを作成する。 |
(2)
|
PasswordValidator の validate メソッドに PasswordData を引数として渡し、パスワード入力チェックを実行する。 |
(3)
|
RuleResult の isValid メソッドを使用して、パスワード入力チェックの結果を真理値で取得する。 |
(4)
|
PasswordValidator の getMessages メソッドに RuleResult を引数として渡し、エラーメッセージを取得する。 |
6.10.5.1.2. パスワード生成¶
6.10.5.1.2.1. Overview¶
org.passay.PasswordGenerator
のインスタンスであり、生成規則は文字種別に関する検証規則(org.passay.CharacterRule
)のリストである。6.10.5.1.2.2. How to use¶
生成規則に含まれる、文字種別に関する検証規則の作成方法は、パスワード入力チェック と同様である。 生成規則と生成器を以下のようにBeanとして定義しておくことでDIが可能となる。
<!-- Password Rules. -->
<bean id="upperCaseRule" class="org.passay.CharacterRule"> <!-- (1) -->
<constructor-arg name="data">
<util:constant static-field="org.passay.EnglishCharacterData.UpperCase" /> <!-- (2) -->
</constructor-arg>
<constructor-arg name="num" value="1" /> <!-- (3) -->
</bean>
<bean id="lowerCaseRule" class="org.passay.CharacterRule"> <!-- (4) -->
<constructor-arg name="data">
<util:constant static-field="org.passay.EnglishCharacterData.LowerCase" />
</constructor-arg>
<constructor-arg name="num" value="1" />
</bean>
<bean id="digitRule" class="org.passay.CharacterRule"> <!-- (5) -->
<constructor-arg name="data">
<util:constant static-field="org.passay.EnglishCharacterData.Digit" />
</constructor-arg>
<constructor-arg name="num" value="1" />
</bean>
<!-- Password Generator. -->
<bean id="passwordGenerator" class="org.passay.PasswordGenerator" /> <!-- (6) -->
<util:list id="passwordGenerationRules"> <!-- (7) -->
<ref bean="upperCaseRule" />
<ref bean="lowerCaseRule" />
<ref bean="digitRule" />
</util:list>
項番 | 説明 |
---|---|
(1)
|
パスワードに含まれるべき文字種別と、その文字種別の最低文字数を規定するための検証規則のBean定義
|
(2)
|
文字種別を指定する。ここでは、
org.passay.EnglishCharacterData.UpperCase を渡しているため、半角英大文字に関する検証規則となる。 |
(3)
|
文字数を指定する。ここでは”1”を渡しているため、半角英大文字を一文字以上含むことをチェックする検証規則となる。
|
(4)
|
(1)-(3)と同様だが、文字種別として
org.passay.EnglishCharacterData.UpperCase を渡しているため、半角英小文字を一文字以上含むことをチェックする検証規則のBean定義となる。 |
(5)
|
(1)-(3)と同様だが、文字種別として
org.passay.EnglishCharacterData.Digit を渡しているため、半角数字を一文字以上含むことをチェックする検証規則のBean定義となる。 |
(6)
|
生成器のBean定義
|
(7)
|
生成規則のBean定義。(1)-(5)で定義した、文字種別に関する検証規則のリストとして定義する。
|
作成した生成器と生成規則を使用してパスワード生成を行う。
@Inject
PasswordGenerator passwordGenerator;
@Resource(name = "passwordGenerationRules")
List<CharacterRule> passwordGenerationRules;
// omitted
public void generatePassword(){
String password = passwordGenerator.generatePassword(10, passwordGenerationRules); // (1)
}
項番 | 説明 |
---|---|
(1)
|
PasswordGenerator のgeneratePassword メソッドに、生成するパスワードの長さと生成規則を引数として渡すと、生成規則を満たしたパスワードが生成される。 |
Tip
Bean定義したコレクションをDIする際には、@Inject
+ @Named
では期待した動作をしない。
そのため、代わりに@Resource
を使用してBean名でDIする。