組み込みログイン制御 (Login Control)

LastaFluteの特徴の一つです。

概要

組み込みのログイン実装

ログインの実装が組み込まれています。TypicalLoginAssist を継承して、アプリごとにフィットさせた Assist クラスを作成します。必須ではないですが、カスタマイズも可能なのでオススメです。

ログインチェックも組み込み

Actionのスーパークラスである TypicalAction の中でログインチェックが組み込まれています。 組み込みのログインロジックを使って自動的にログインチェックがかかるようになっています。

つまり、ログイン制御の設定をしたら、(デフォルトでは)ログイン画面以外はすべてログイン必須となります。

もし、ログイン画面以外でログインしていない状態でのアクセスを許す場合は、そのActionに @AllowAnyoneAccess アノテーションを付与します。

ログインコントロールの概念マップ

LastaFlute LoginControl

ログイン画面のAction

ログイン画面の Action は、以下のような実装となります。

e.g. ログインのActionの実装、MaihamaプロジェクトのDocksideアプリにて @Java
@Resource
private DocksideLoginAssist docksideLoginAssist;

@Execute
public HtmlResponse index() {
    if (getUserBean().isPresent()) { // すでにログイン済みなら
        return redirect(MypageAction.class); // マイページへ
    }
    return asHtml(path_Signin_SigninHtml).useForm(SigninForm.class);
}

@Execute
public HtmlResponse signin(SigninForm form) { // ログインボタン押した
    validate(form, messages -> moreValidate(form, messages), () -> {
        form.clearSecurityInfo(); // ダメならパスワード消して...
        return asHtml(path_Signin_SigninHtml); // ログイン画面もう一回
    });

    // DB検索して、セッションにユーザー情報を入れて、ログイン履歴残して...
    // ログイン必須画面で拒否されて飛ばされてきてたら、そっちへログインリダイレクト
    return docksideLoginAssist.loginRedirect(createCredential(form)
            , op -> op.rememberMe(form.rememberMe) // 第三引数: 必要ならクッキー
            , () -> { // 第三引数: OKのときのリダイレクト先
        return redirect(MypageAction.class); // 通常はマイページへ
    });
}

// アノテーションによる必須チェックの後、実際にログインできるかどうかバリデーション
private void moreValidate(SigninForm form, DocksideMessages messages) {
    if (isNotEmpty(form.email) && isNotEmpty(form.password)) {
        if (!docksideLoginAssist.checkUserLoginable(form.email, form.password)) {
            messages.addErrorsLoginFailure("email");
        }
    }
}

private UserPasswordCredential createCredential(SigninForm form) {
    return new UserPasswordCredential(form.account, form.password);
}

ログインの処理は、[App]LoginAssist に集約されています。(詳しくは後述)

ログインユーザーの情報は、[App]UserBean に保持されています。(詳しくは後述)

ログインなしアクセスを許すAction

ログイン制御を有効にしたアプリでは、ログイン画面以外のActionはログインが必須になります。

Actionクラスに@AllowAnyoneAccessアノテーションを

ログイン画面以外のActionでも、ログインしていない状態でアクセスを許したい場合は、Actionクラスに @AllowAnyoneAccess を付与します。

e.g. ログイン必須ではないActionに、@AllowAnyoneAccessアノテーションを付与 @Java
@AllowAnyoneAccess
public class ProductsAction extends ShowbaseBaseAction {

※ @AAA と打って補完すると楽に打ち込めます

Executeメソッドに@AllowAnyoneAccessアノテーションを

メソッド単位で制御したい場合は、Executeメソッドの方に付与します。

e.g. ログイン必須ではないExecuteメソッドに、@AllowAnyoneAccessアノテーションを付与 @Java
@AllowAnyoneAccess
@Execute
public JsonResponse<List<ProductsRowResult>> get$index(ProductsSearchForm form) {

ログイン必須チェック

ActionHookでチェックされる

ログイン必須画面にログインしていない状態でのアクセスを防ぐ仕組みは、ActionHook の godHandPrologue() で行われています。その中から、TypicalGodHandPrologue#performPrologue() が呼ばれています。

e.g. ActionHookでのログインチェックの実装 @Java
public ActionResponse performPrologue(ActionRuntime runtime) { // fixed process
    arrangeThreadCacheContextBasicItem(runtime);
    arrangePreparedAccessContext(runtime);
    arrangeCallbackContext(runtime); // should be after access-context (using access context's info)
    checkLoginRequired(runtime); // should be after access-context (may have update)
    arrangeThreadCacheContextLoginItem(runtime);
    return ActionResponse.undefined();
}

...

/**
 * Check the login required for the requested action.
 * @param runtime The runtime meta of action execute to determine required action. (NotNull)
 * @throws LoginRequiredException When it fails to access the action for non-login.
 */
protected void checkLoginRequired(ActionRuntime runtime) throws LoginRequiredException {
    loginManager.ifPresent(nager -> {
        nager.checkLoginRequired(createLogingHandlingResource(runtime));
    });
}

すべての Action にて、loginManager の checkLoginRequired() が呼ばれます。

ここでの loginManager の実体は [App]LoginAssist のことです。LoginAssist は LoginManager インターフェースを implements しているので、フレームワークの中では、LoginManagerとして取り扱っています。

ログイン必須例外がthrowされる

ログインしていない状態で、ログイン必須のActionにアクセスした場合は、checkLoginRequired() が LoginRequiredException を throw します。

LastaFlute組み込みの例外クラスで、LaApplicationException を継承しているため業務例外として扱われます。 どのように例外ハンドリングされるのか?細かく追いたい場合は ApplicationExceptionResolver や ApiFailureHook を読むと良いでしょう。

HtmlResponseのActionであれば、自動でログイン画面に遷移します。その後、正常にログインされたら、もともとアクセスしようとした画面に遷移します。 (ログインリダイレクトと呼んでいます)

ログイン制御のON/OFF

[App]BaseActionにて指定

[App]BaseActionにて、UserBean や LoginManager を指定するとログイン制御が有効になります。

USER_TYPE定数の定義
USER_TYPE = "M" (OFFなら定義しない)
[App]LoginAssistのDI
@Resource ... [App]LoginAssist loginAssist; (OFFなら定義しない)
getUserBean()の戻り
return loginAssist.getSavedUserBean(); (OFFならempty()を戻す)
myUserType()の戻り
return OptionalThing.of(USER_TYPE); (OFFならempty()を戻す)
myLoginManager()の戻り
return OptionalThing.of(loginAssist); (OFFならempty()を戻す)

USER_TYPE定数の定義

まず、[App]BaseActionのDefinitionタグコメントのところで USER_TYPE を定義します。これ自体はログイン制御と直接は関係しないですが、ログインユーザーとして AccessContext のユーザートレースの一部として利用されますので定義しておきましょう。

e.g. [App]BaseAction の USER_TYPE の定義 @Java
// ===================================================================================
//                                                                          Definition
//                                                                          ==========
...(ここにはAPP_TYPEが定義されている)

/** The user type for Member, e.g. used by access context. */
protected static final String USER_TYPE = "M"; // #change_it_first (can delete if no login)

ユーザートレースのユーザーIDのEntity(テーブル)を識別する一文字を定義します。例えば、Memberであればユーザートレース上では M:1 となり、MemberテーブルのIDが1番を表現します。 一文字だとわかりづらい場合は、文字数を増やしても問題ありません。

そのそもログイン制御を行わないアプリ (ユーザートレースでログインユーザーを含めないアプリ) であれば、USER_TYPEは定義しなくてOKです。

#change_it_firstコメントが付いているので、(基本的には)スタートアップ直後に修正されることが想定されます。 定義したら直したことを示すために #change_it_first を #changed と修正しておきましょう。

[App]LoginAssistのDI

Attributeタグコメントのところに、[App]LoginAssistをDIしましょう。

e.g. [App]BaseAction の LoginAssit のDI定義、Docksideプロジェクトなら @Java
// ===================================================================================
//                                                                           Attribute
//                                                                           =========
...
@Resource
private DocksideLoginAssist loginAssist;

※LoginAssistの実装に関しては後述

そのそもログイン制御を行わないアプリであれば、[App]LoginAssist自体を削除してしまってOKです。

ログイン情報メソッドのOptional戻り

そして、[App]BaseAction の Login Infoタグコメントのところで、ログイン情報メソッドたちの戻りを定義します。

e.g. [App]BaseAction の Login Infoのメソッドたち、Docksideプロジェクトなら @Java
// -----------------------------------------------------
//                                            Login Info
//                                            ----------
// #app_customize return empty if login is unused
@Override
protected OptionalThing<DocksideUserBean> getUserBean() { // application may call, overriding for co-variant
    return loginAssist.getSavedUserBean();
}

@Override
protected OptionalThing<String> myUserType() { // for framework
    return OptionalThing.of(USER_TYPE);
}

@Override
protected OptionalThing<LoginManager> myLoginManager() { // for framework
    return OptionalThing.of(loginAssist);
}

#app_customizeコメントは、アプリでカスタマイズすることを想定している箇所を示します。

getUserBean()は、loginAssist の getSavedUserBean() をそのまま戻すと良いでしょう(戻り値がすでにOptionalThingになっています)。 Genericの共変戻り値を使ってアプリ側で管理している[App]UserBean型を定義すると利用するプログラムでダウンキャストが発生しません。

myUserType()は、先ほど定義した USER_TYPE を OptionalThing で包んで戻します。ユーザートレースを構築するための情報として利用されます。

myLoginManager()は、loginAssistを OptionalThing で包んで戻します。これにより、フレームワーク内で loginAssist を利用することができて、ログイン必須チェックなどが有効になります。

そのそもログイン制御を行わないアプリであれば、これらのメソッドはすべて OptionalThing.empty() を戻します。

とにかく LoginAssist

とにかく、この [App]LoginAssist がログインの処理を一手に引き受けています。

ログインのAction や ActionHook でのログイン必須チェックから呼び出されます。 こちらに、認証処理やセッションの設定、RememberMeのクッキーの設定、ログイン履歴など、ログインに必要な処理が詰め込まれています。

LoginAssistの階層構造

それぞれのWebプロジェクトに LoginAssist があり、例えば Maihama プロジェクトの Dockside Webアプリであれば、DocksideLoginAssist となります。

e.g. LoginAssistの階層構造、Maihamaプロジェクトなら @Structure
LoginManager  // ログインの操作が定義されているインターフェース
 |   |
 |   +------------------------------------------------------------+
 |                                                                |
 |                                                                |
 |-TypicalLoginAssist // フレームワーク提供の典型的なベースクラス         |
    |                                                             |
    |-MaihamaLoginAssist // プロジェクト共通のベースクラス              |
       |                // app.web.base.loginパッケージ             |
       |                                                          |
       |      +---------------------------------------------------+
       |      |
       |      |
       |     PrimaryLoginManager // 主要のLoginManager (複数あったら一つだけに)
       |      |
       |-DocksideLoginAssist // Webアプリのクラス (スマートデプロイ対象)
                                // app.web.base.loginパッケージ

PrimaryLoginManager は、ひとつのWEBアプリ内で LoginAssist が複数あったときに、主要な Assist をユニークに識別するためのインターフェースです。 そういう状況にならない限りはあまり重要なものではありませんが、しっかりと付けておきましょう。

TypicalLoginAssistの役割

LastaFluteが提供する TypicalLoginAssist に、おおよそのロジックが組み込まれていて、それぞれのプロジェクトにフィットさせる部分を、 サブクラスで実装していくような形です。

なので、DocksideLoginAssist にはあまり実装はたくさんありません。 どのテーブルにアクセスするのか?どこに履歴を残すのか?など、ピンポイントで必要な情報をスーパークラスに渡してあげるだけになります。 もちろん、とある処理だけをオーバーライドして独自の挙動にすることもできます(権限チェックなどはそのように)。

LastaFluteの組み込みログイン制御において、一番重要なクラスと言っても過言ではないので、ソースコードを読んで把握しておくと良いでしょう。

LoginAssistの現場フィット

アプリにフィットさせるための [App]LoginAssist のポイントはこちら。

PKの型
Genericで指定、メソッドの引数に影響 (Exampleテンプレートでは Integer)
ユーザー検索
ログインユーザの検索処理、doFindLoginUser()など
UserBeanの調整
[App]UserBeanの実装、LoginAssistでnewしたり
ログイン履歴の処理
saveLoginHistory() でいい感じに
アプリ固有のクラス指定
getUserBeanType() や getLoginActionType() など

ExampleのLoginAssistを参考に実装しましょう。

#change_itコメントが付いているので、(基本的には)スタートアップ直後に修正されることが想定されます。 定義したら直したことを示すために #change_it を #changed と修正しておきましょう。

LoginAssistのpublicメソッド

大きくは以下の四つ:

checkUserLoginable()
ログインできるか判定する (ログイン画面のActionのバリデーション)
login()
単純にログイン処理だけ (APIスタイルのログイン画面のAction)
loginRedirect()
ログイン処理とログインリダイレクト (HTMLスタイルのログイン画面のAction)
checkLoginRequired()
ログイン必須チェックを行う (ActionHookから呼ばれる想定)

さらには、引数が違うだけのオーバーロードのメソッドや、多種多様なログイン処理を行うメソッド、 ログイン処理やチェック処理の一部だけを行う部品的なメソッドなどがあります。

identityLogin()
ユーザーIDを指定してログイン処理 (事前にユーザーが特定できてる場合)
givenLogin()
Entityを指定してにログイン処理 (事前にユーザー情報を持っている場合)
logout()
ログアウト処理 (クッキーも削除、セッションはまるごとinvalidate)
findLoginUser()
ログインできるユーザーを探す (ログインできなければempty)

メソッドの一覧が見たいときは、LoginManager のソースを読むとよいでしょう。 詳しい処理に関しては、TypicalLoginAssist を読んでみましょう。

Typicalは提案!?

厳密には、TypicalLoginAssist を使わなければならないわけではありません。基本的に Typical と付いているものは、もし現場にフィットするなら使ってみてください、というニュアンスのものです。 ですが、多くの処理をオーバーライドなどで拡張できるようにしているので、よほど特殊でなければフィットさせることができるのではないかと想定しています。

でもって UserBean

セッション管理されるログインユーザー情報を保持するのが [App]UserBean です。

LoginAssistにて、DB検索されたユーザー情報を元に生成され、セッションにて管理されます。 それぞれのActionクラスでは getUserBean() で参照されます。

LoginAssistの階層構造

それぞれのWebプロジェクトに UserBean があり、例えば Maihama プロジェクトの Dockside Webアプリであれば、DocksideUserBean となります。

e.g. UserBeanの階層構造、Maihamaプロジェクトなら @Structure
UserBean  // フレームワークのインターフェース
 |
 |-TypicalUserBean // フレームワーク提供の典型的なベースクラス
    |
    |-MaihamaUserBean // プロジェクト共通のベースクラス
       |             // mylasta.actionパッケージ
       |
       |-DocksideUserBean // Webアプリのクラス (スマートデプロイ対象)
                            // mylasta.actionパッケージ

TypicalUserBeanの役割

LastaFluteが提供する TypicalUserBean では、5分おきにログイン可否の同期チェックを行う LastestSyncCheck が定義されています。例えばユーザーがログイン後に何かしらの理由でログインが許されない状態に変わったとき、5分以内に強制的にログアウトさせる仕組みです。 TypicalLoginAssistと自動的に連動するように作られています。

また、TypicalUserBean では、ユーザーLocale/TimeZoneのプロパティが定義されています。 これはアプリで保持する必要がある場合は、LoginAssistでUserBeanを生成するときなどにセットすると良いでしょう。

そんなにたいそうな実装はありませんので、サッとソースコードを読んで把握しておくと良いでしょう。

UserBeanの現場フィット

アプリにフィットさせるための [App]UserBean のポイントはこちら。

PKの型
Genericで指定、メソッドの引数に影響 (Exampleテンプレートでは Integer)
コンストラクタ
ユーザー情報はコンストラクタで受け取ってImmutableにすると良い
getUserId()
フレームワークがユーザーを識別するために使う
持たせる情報は?
セッションの中に入ることを想定してアプリで必要な情報だけを持たせる

ExampleのLoginAssistを参考に実装しましょう。

#change_itコメントが付いているので、(基本的には)スタートアップ直後に修正されることが想定されます。 定義したら直したことを示すために #change_it を #changed と修正しておきましょう。

権限チェックの実装

拡張ポイントが用意されている

権限チェックの仕組み自体は、かなり業務に特化する部分なので特に組み込みでの用意はありませんが、 ログインロジックのプロセスの一環として組み込める拡張ポイントが用意されています。

checkPermission()

ログインチェックの処理の中で、ログイン状態のときに必ず呼ばれるメソッドがあります。 そのメソッドの中で権限チェックをすることで、ログインしていてもその画面で必要な権限がないアクセスを弾くことができます。

[App]LoginAssist が継承している TypicalLoginAssist にて、checkPermission() というメソッドが定義されていて、デフォルトでは中身は空っぽです。

e.g. checkPermission()のそのままのコード @Java
/**
 * Check the permission of the login user. (called in login status) <br>
 * If the request cannot access the action for permission denied, throw LoginRequiredException.
 * @param resource The resource of login handling to determine. (NotNull)
 * @throws LoginRequiredException When it fails to access the action for permission denied.
 */
protected void checkPermission(LoginHandlingResource resource) {
    // no check as default, you can override
}

checkPermission() は、既にログイン済み、もしくは、いま RememberMe したばかり のときに呼び出されます。

オーバーライドして権限チェック

それぞれの [App]LoginAssist にて、 この checkPermission() をオーバーライドして、権限チェックをするとよいでしょう。 権限エラーの場合は、LoginRequiredException を throw します。

e.g. checkPermission()で権限チェックして、ダメなら例外 @Java
@Override
protected void checkPermission(LoginHandlingResource resource) {
    ... = resource.getExecuteMethod(); // (いい感じに使って判定)
    if (...) { // 権限なければ
        throw new LoginRequiredException("デバッグ用メッセージ、何かいい感じに");
    }
}

どの画面がどんな権限?

ここは様々な方法がありますが、一例として、アノテーションに付けるやり方があります。

e.g. Executeメソッドにアノテーションでロールを紐付ける @Java
@Execute()
@Permission({CDef.Role.営業, CDef.Role.企画}) // 営業と企画の役割の人が実行できる
public HtmlResponse index() {
    ...
}
e.g. Executeメソッドに付ける権限のアノテーション @Java
// (アノテーションに付けるアノテーションはいい感じに)
public @interface Permission {

    CDef.Role[] value();
}

ロールはテーブル区分値として定義しておきます。自動生成された CDef をアノテーションで指定できるようにして、checkPermission() の中でそのアノテーションの値を取得して、ログインユーザーのロールと照らし合わせてアクセス可能かどうかを判定します。

もちろん、必ずこのやり方である必要はありません。例えば、データベースの中で、画面(Executeメソッド)とロールを紐づけてもよいでしょう。 データベースの中で権限設定を閉じこめることができて、動的にも修正できるのがメリットです。 ただ、画面が増えたときにそのテーブルのデータもメンテナンスしてあげないといけないので、インクリメンタル開発の現場ではちょっと運用が煩雑になります。 アノテーション方式であればプログラム作成時に自然と権限が付与されるので、定義し忘れのトラブルは少ないでしょう。

アクセス権限まとめブログ

ユーザーの方が、アクセス権限に関してブログでまとめて頂いています。 そちらもぜひ参考にすると良いでしょう。

RDB以外のもので認証するなら?

LoginAssistクラスにて、RDBに検索している箇所を別の認証サービスを使ったプログラムに差し替えます。 Assistクラスを使う側(Actionなど)では、RDBなのか別の認証サービスなのかは意識しないようにするのがオススメです。

具体的には、LoginAssistクラスの checkCredential() や resolveCredential() や doFindLoginUser() で、RDBにアクセスしてユーザーを認証している処理を、別のものに差し替えます。

例えば、RemoteAPi を使って認証することも想定されます。

ユーザーの方のブログをご紹介

実際に業務で LastaFlute を利用されていた方が素敵なブログを書いてくださっているのでご紹介します。 ありがとうございます!(リンクの承諾も頂いています)

アクセス権限以外の話題も書いてくださっています。