C Sharpens you up

http://qiita.com/yuba に移しつつあります

ステートレスなPlay2でログイン状態を管理する

Play framework 2.x Java and 1.x Advent Calendar 2013*1の20日目(5日ぶり4回目)です。

寄稿予定表をみると、明日担当のgakuzzzzさんの内容とかぶってしまっている可能性がとても高いのですが、Play1とPlay2の違いがあるので許してもらえないものでしょうか。

さて、JavaEEにもPHPにもASP.NETにもあるのにPlay! frameworkにはないものはと問われれば。
セッションですね。アクセスしてくる閲覧者を識別して、閲覧者別にデータを保持できる容器です。Play!にはこれがありません(ドキュメントにはセッションと称する機能の記載がありますが、これは一般には一時クッキーと呼ばれるものです)。

Play!のキャッチフレーズ「ステートレス」というのがまさにセッション機能を持たないことを意味しています。機能が欠けていることが特長? そうです。

ステートレスであることの強み

ちょっと銀行の窓口を想像してみましょう。
窓口の行員さんに用事を話して、「それではこちらの用紙に必要事項を記入してお持ちください」と案内されました。
用紙を埋めたら、それを持っていく先はさっきの行員さんの窓口に決まっています。

ところがです。もし行員さんたちが究極に記憶力ゼロで、お客さんが窓口を離れた瞬間にその人のことを忘れてしまう超絶トリ頭だったとしたら。
まあそれでも銀行の仕事が回るということは行員さんは必要に応じて後ろに必要なことを聞きに行っているということなのですが、もしそういうトリ頭銀行だったら、用紙を埋め終わったあなたはどの窓口に行ったって同じということになりますね。

これは大事な意味を持ちます。
たとえば月末のお昼休み、銀行は大混雑になるわけですが、臨時で一気に窓口係を大増員してお客さんを捌くという対応が可能になります。昼休みが終わったら窓口はまた減らしてしまいましょう。「どうせどの窓口へ行ってもいい」からこそできる対応ですね。

これがWebアプリケーションだったら。
トリ頭行員がアプリケーションサーバにあたります。「後ろの人」がデータベース。アプリケーションサーバがクライアントについて何も覚えていないことが保証されていることで、負荷に応じた自由な(もしくは自動的な)サーバの増減が可能となるわけです。AWSを個人で使えるクラウド時代の必須事項といえます。

そういうわけでPlay!にはセッション機能がなく、それが特長です。

ログイン状態の管理

PHPをはじめとしたセッション状態のある処理系では、ログイン状態の管理にセッションを使います。
「(この閲覧者は)ユーザーID=いくついくつ」という情報をセッションに放り込んでおけばよろしい*2

セッションがないPlay!では?
公式ドキュメントのコード例をみると、なんとユーザーIDをそのままクッキーに突っ込んでおられます。そりゃもちろんそれで動くけど、ユーザーIDを偽装し放題だよお… 脆弱性なんてチャチなもんじゃあ断じてねえ、もっと恐ろしいものの片鱗を…末尾に訂正あり
なんとかしましょう。

キャッシュ

Play!にはキャッシュという機能、機能と言うよりはAPIが存在します。
キャッシュは、アプリケーション全体から参照できるキーバリューストアです。アプリケーション全体からどころか、背後にMemcachedとか立てればサービスを構成するマシン群全体から参照できます(この場合はMemcachedを透過的に使うAPIとなっています)。

つまりはデータベースとはまた別の「後ろの人」です。

キャッシュを使ってちゃんとしたログイン状態管理を構成してみましょう。
こうです。

  1. ログイン判定成功と同時に、ランダムなトークンが発行されます。
  2. キャッシュには、トークンをキーとしてユーザーIDが記録されます。保持期限は30分間。
  3. クッキーとしてトークンが閲覧者に返されます。保持期限は7日間。
    これでトークンが盗まれない限りログイン成功した閲覧者本人だけがキャッシュに格納されたユーザーIDを引く権利を持つことになります。
  4. 以降、アクセスのたびに閲覧者から渡されたトークンでキャッシュを引いてユーザーIDを認識します。同時にキャッシュとクッキーの保持期間を更新します。
  5. ログアウトのときはキャッシュとクッキーの両方を削除します。どちらか片方でも十分だけど。

難しいことじゃありませんね。セッション使ったログイン状態管理の中身を書き下ろしただけです。

そのコードを書いたから皆さん自由に使ってくださいねというのが本日のネタなわけです。

トークンを盗まれない限りと書きましたが、それについてはクッキーにSecure属性を付ける必要がありますので、それについては以前のこの記事も参考にしてください。

認証処理クラスの作成

公式ドキュメントにもある通り、Play2でユーザー認証をかけたい場合は保護したいコントローラメソッドに

@Security.Authenticated(【認証処理クラス】.class)

というアノテーションを付加します。

この認証処理クラスを作ります。

public class MyAuthenticator extends Security.Authenticator {

    private static final String appKeyString = "PlayApp-user";

    /** 未認証状態でアクセスされたときのアクション */
    @Override
    public Result onUnauthorized(Http.Context arg0)
    {
        // ログインページへジャンプします
        return redirect(controllers.routes.AuthController.showLoginForm());
    }

    @Override
    public String getUsername(Http.Context context)
    {
        final Http.Cookie userCookie = context.request().cookie(appKeyString);
        if (userCookie == null) return null;    // ログインクッキーなし

        final String userToken = userCookie.value();
        final Object userInfo = Cache.get(userToken + ".userInfo");
        if (!(userInfo instanceof String)){
            context.response().discardCookie(appKeyString); // すでにキャッシュにないのでクッキーも破棄
            return null;
        }

        // アクセスのたびにログイン情報登録をリフレッシュする
        registerLoginSession(context, userToken, userInfo);
        
        return (String)userInfo;
    }

    public static void registerLoginSession(Http.Context context, String userToken, Object userInfo)
    {
        // アプリケーションキャッシュの有効期限を今から30分後に
        Cache.set(userToken + ".userInfo", userInfo, 60 * 30);
        // ログインクッキーの有効期限を今から7日後に
        context.response().setCookie(appKeyString, userToken, 60*60*24*7);
    }

    public static void (Http.Context context)
    {
        final Http.Cookie userCookie = context.request().cookie(appKeyString);
        if (userCookie == null) return;
        // アプリケーションキャッシュからログイン状態を削除する
        Cache.remove(userCookie.value() + ".userInfo");
        // ログインクッキーを削除させる
        context.response().discardCookie(appKeyString);
    }

getUsername が、コントローラ呼び出しに先だって呼び出されるメソッドです。これがnullを返すようだと未ログインなのでonUnauthorizedがさらに呼ばれます。ログインページへでも飛ばしてしまいましょう。

キャッシュとクッキーへのログイントークンの記録・解除をするのが今回作成したregisterLoginSession, unregisterLoginSessionメソッドです。
たとえばログインページでログイン判定成功時にはこう呼び出します。

final String userToken = UUID.randomUUID().toString(); // ランダムトークン作成
MyAuthenticator.registerLoginSession(ctx(), userToken, username);

今回はログイン状態として単にユーザー名をキャッシュに突っ込んでいますが、その他のユーザー情報(表示名だとかロールだとか)も盛り込んだデータクラスを突っ込んでもいいですね。その場合、getUsernameはとりあえずユーザー情報のうちユーザー名だけ返し、詳細なユーザー情報はコンテキストオブジェクトにセットすればコントローラメソッドからも参照できます。

とにかく、これでステートレス性と安全なログイン状態管理が両立できました。

明日は

gakuzzzzさんからもセッションセキュリティについて。Play1の場合です。

訂正 2014/01/08

Cookieに平文で情報を詰め込んでセッションとしていることについて、偽造はできないとの指摘をいただきました。Valueは平文なのですがKeyの方に署名が付加されていて、これにより真正性は確保されています。ただ、依然としてセキュリティ上の問題は抱えていますので本記事の方法は有効です。
どういったセキュリティ上の問題があるかと言いますと、

  • 発行した認証状態クッキーは発行後永遠に有効となってしまい、サーバ側からタイムアウトさせるなどのコントロールができません。共有PCでログアウトし忘れたなどでクッキー内容を盗まれるともはや不正ログインをコントロールできなくなります。
  • 平文なので格納内容が丸見えです。ユーザに見せたくない情報が格納できません。

*1:Advent Calendarとはクリスマスまでのカウントダウン日めくりのことで、それになぞらえて12/1から12/25まで日替わりで参加者がブログ記事を寄稿するイベントです。

*2:脆弱性の入り込む余地がいろいろある処理なので簡単だよねとは言わないでおきます。