ASP.NET MVC で IIS に頼らず認証を行う属性を作る(完結編)

ASP.NET MVCIIS に頼らず認証を行う属性を作る

の続きでございます。

実装編と同様に、できるだけコードを書くだけなのは避けたいところなので、文中のコードは簡略化したものになっているものがあります。コード全体はサンプルコードをダウンロードして確認ください。

ユーザ認証を要求する承認フィルタの作成

標準の Authorize 属性の問題点を解決するため、承認失敗時の挙動を変更した AuthorizeAttribute を作成します。とはいえ、ゼロから作る必要はなくて、AuthorizeAttribute では承認失敗用の実装ポイントが用意されているので、そこだけ修正すれば十分です。

    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true, AllowMultiple = true)]
    public class HttpAuthrizeAttribute : AuthorizeAttribute
    {
        public HttpAuthrizeAttribute() { }

        /// <summary>
        /// 承認されなかった HTTP 要求を処理します。
        /// </summary>
        /// <param name="filterContext">
        /// <see cref="T:System.Web.Mvc.AuthorizeAttribute"/> を使用するための情報をカプセル化します。
        /// <paramref name="filterContext"/> オブジェクトには、コントローラー、HTTP コンテキスト、要求コンテキスト、
        /// アクション結果、ルート データなどが含まれます。
        /// </param>
        protected override void HandleUnauthorizedRequest(AuthorizationContext filterContext)
        {
            if (filterContext == null)
                throw new ArgumentNullException("filterContext");

            // 認証を要求する
            filterContext.Result = new AuthenticationRequiredResult("Basic", "test realm");
        }
    }

 /* ---- >8 ---- >8 ---- >8 ---- */

    // 使う側
    [HttpBasicAuthenticate("test realm")]
    [HttpAuthorize(Users="takaoka")]
    public ActionResult test_action()
    {
        return this.View();
    }

軽くテストするだけなら、こんなかんじの属性をつくって使うだけですが、このままだと Basic 認証用の承認フィルタになってしまいます。メソッドのドキュメントコメントにもありますが、このメソッドに渡される filterContext には様々な情報がありますので、そちらから認証用フィルタを取得して、認証要求用の ActionResult を取得することで汎用的な承認属性が作成できます。ちなみに、このドキュメントコメントは GhostDoc*1 という Visual Studio 用のアドインが、親クラスから継承したメソッドやインターフェースの実装メソッドを判断して全自動で書いてくれています。

    // コメント省略
    protected override void HandleUnauthorizedRequest(AuthorizationContext filterContext)
    {
        if (filterContext == null)
            throw new ArgumentNullException("filterContext");

        // このアクションメソッドに設定された最初の HttpAuthenticateAttribute を取得
        var authenticateFilter = filterContext.ActionDescriptor
                                              .GetFilters()
                                              .AuthorizationFilters
                                              .OfType<HttpAuthenticateAttribute>()
                                              .FirstOrDefault();
#if false
        // ASP.NET MVC3 では、こんなかんじ?
        var authenticateFilter = FilterProviders
                                    .Providers
                                    .GetFilters(filterContext, filterContext.ActionDescriptor)
                                    .Select(f => f.Instance)
                                    .OfType<HttpAuthenticateAttribute>()
                                    .FirstOrDefault();
#endif

        if (authenticateFilter != null)
            // 認証用フィルタから、認証要求用 ActionResult を取得する
            filterContext.Result = authenticateFilter.GetAuthenticationRequiredResult(filterContext);
        else
            base.HandleUnauthorizedRequest(filterContext);
    }

認証用フィルタが設定されていない時はどうしようもないので、とりあえず親クラスの実装を呼び出しています。これで Basic 認証であれば Basic 認証の要求が、Digest 認証であれば Digest 認証の要求がレスポンスとして返されるようになります。認証に使用するレルムなどのパラメータも認証用フィルタから取得するので、複数個所に同じ設定を書かないといけないようなこともありません。

アクションメソッド内からの認証要求

最後に、アクションメソッドの呼び出しが始まった以降に認証要求が必要になった場合の処理です。このパターンでは、ASP.NET MVC 標準では HttpUnauthorizedResult を使用して、

    public ActionResult test_action(string id)
    {
        if (IsAdministratorRequires(id))
        {
            // 指定されたパラメータが管理者ロールを必要とする場合のみ
            if (!this.HttpContext.User.IsInRole("admins"))
                // admins ロールが割りついていない場合は認証を要求
                return new HttpUnauthorizedResult();
        }
    }

といった感じになりますが、

    [HttpBasicAuthenticate("Test Realm")]
    public ActionResult test_action(string id)
    {
       if (...) return new HttpAuthenticate.Result("Basic", "Test Realm");
    }

みたいにしたくはないですよね。認証用属性に依存しない形で書きたいところです。アクションメソッドの結果には、結果フィルタを使用してアクセスすることができます。これを利用して、認証用フィルタに結果フィルタの機能を持たせます。

    // IResultFilter を追加する
    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true, AllowMultiple = false)]
    public abstract class HttpAuthenticateAttribute : FilterAttribute, IAuthorizationFilter, IResultFilter
    {
        /// <summary>アクション結果の実行前に呼び出されます。</summary>
        /// <param name="filterContext">フィルター コンテキスト。</param>
        void IResultFilter.OnResultExecuting(ResultExecutingContext filterContext)
        {
            // ASP.NET MVC 標準の HttpUnauthorizedResult を、この属性で認証要求を行うように置換します。
            if (filterContext.Result is HttpUnauthorizedResult)
                filterContext.Result = this.GetAuthenticationRequiredResult(filterContext);
        }

こんなかんじですね。これで、標準の ASP.NET MVC を利用しているのと同様に、

    // Basic 認証を設定し、匿名ユーザを許可する
    [HttpBasicAuthenticate("test realm", AcceptAnonymousUser = true)]
    public ActionResult test_action(string id)
    {
        // 特定の id のみ、管理者ロールを要求する
        if (IsAdministratorRequires(id))
        {
            // admins ロールが割りついてるか確認
            if (!this.HttpContext.User.IsInRole("admins"))
            {
                // admins ロールが割りついていない場合は認証を要求
                // 結果フィルタによって、Basic 認証に置換される
                return new HttpUnauthorizedResult();
            }
        }
    }

というかんじで、HttpUnauthorizedResult を返すだけで Okay になりました。これなら、先頭の HttpBasicAuthenticate を HttpDigestAuthenticate に変更するだけで、このメソッド全体をダイジェスト認証に入れ替えられます。

おわりに

以上、このようなアプリケーションレベルでユーザ認証を行う需要は少ないかもしれません。しかし、IIS やその他の Web Server 側で提供される認証機能を利用できない場合……たとえば、.NET Framework にある簡易 ASP.NET サービスや、Windows Azure や のようなプラットフォームにおいて Basic 認証や Digest 認証、NTLM による Windows 統合認証といった認証方式をサポートする手段として、アプリケーション側でユーザ認証を実施するという手段があることを知っておくことは、おそらく損ではないでしょう。

サンプルコード全体は、末尾にある dropbox.com へのリンクからをダウンロードすることができます。(ダウンロードするだけなら、Dropbox への登録等は不要です*2

C# / HttpAuthSample1.zip

*1:NuGet からもインストールできます。

*2:dropbox.com を今から利用してみようと思う人は、このリンクから新規登録すると、私の容量制限と貴方の初期容量制限が、共に 250MB 増えるらしいです^^ 2GB ぐらい余ってますが…。