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

設計編(id:ladybug:20110302)の続きでございます。

文中のほとんどはコードですが、できるだけコードだけな状態を防ぐために部分的なものしか記載されていません。コード全体を確認したい方は次の記事に付属する予定のサンプルコードをダウンロードして確認してください。

クラス設計


図のように、HTTP を利用したユーザ認証を行うフィルタクラスを抽象クラスとして作成し、各認証方法にあわせた派生クラスを作成することにします。

ユーザ認証を実施する IAuthorizationFilter の実装

いきなり、ユーザ認証です。具体的な認証情報の検証は派生クラスのほうでやることになるので、抽象クラスでは

  • リクエストヘッダから受け取った認証情報を使った認証
  • 認証結果によって HttpContext.User を更新
  • 認証失敗時に、認証情報を再要求する
  • 匿名アクセスを許可しない場合、認証情報を要求する

といった実装になります。

    /// <summary>承認が必要なときに呼び出されます。</summary>
    /// <param name="filterContext">フィルター コンテキスト。</param>
    void IAuthorizationFilter.OnAuthorization(AuthorizationContext filterContext)
    {
        if (filterContext == null)
            throw new ArgumentNullException("filterContext");

        // リクエストヘッダから、認証情報を取得する
        var auth = filterContext.HttpContext.Request.Headers["Authorization"] ?? "";

        // この認証フィルタを対象としている場合のみ、認証情報を検証する
        if (auth.StartsWith(this.AuthHeader, StringComparison.InvariantCultureIgnoreCase))
        {
            // 認証に失敗した場合、401 Not Authorized を返します。
            if (!this.ValidateUser(filterContext, auth.Substring(this.AuthHeader.Length)))
                this.OnUnauthenticatedAccess(filterContext);
        }
        else //if (string.IsNullOrEmpty(auth))
        {
            // 匿名アクセスを許可しない場合、401 Not Authorized を返します。
            if (!this.AcceptAnonymousUser)
                this.OnUnauthenticatedAccess(filterContext);
        }
    }

処理の流れはこんなかんじです。複数の認証方式をサポートする場合、匿名アクセスの判定には、string.IsNullOrEmpty(auth) が必要なのですが、今回はこの判定をコメントアウトしています。これは、今回の記事では、

  • 複数の認証方式を要求する ActionResult を実装しない
  • 複数の認証方式を順番に検証するため、AcceptAnonymousUser を true にしておいて、AuthorizeAttribute で匿名ユーザを拒否するという運用が可能
  • そもそも複数の認証方式を単一メソッドに割り付けることが稀有

といった理由からです。

セキュリティプリンシパルの作成

ユーザ認証を行う ValidateUser の中身は、認証のコア部分と HttpContext.User の更新処理に分割されます。特に追加の情報を何も保持する必要がないと思いますので、.NET 側で準備されている標準ユーザーと汎用プリンシパルを利用すれば十分でしょう。

    /// <summary>ユーザ認証を実施し、Principal を設定する</summary>
    /// <param name="context">フィルター コンテキスト</param>
    /// <param name="authentication">要求された認証文字列</param>
    /// <returns>認証に成功した場合、<c>true</c></returns>
    protected virtual bool ValidateUser(AuthorizationContext context, string authentication)
    {
        string username;
        if (this.ValidateUser(authentication, out username))
        {
            // HttpContext に、標準ユーザーの汎用プリンシパルを設定する
            context.HttpContext.User = new GenericPrincipal(
                                            new GenericIdentity(username, this.AuthenticationType),
                                            this.GetRolesForUser(username));
            return true;
        }
        else
        {
            return false;
        }
    }

    /// <summary>ユーザ認証を実施する。</summary>
    /// <param name="authentication">要求された認証文字列</param>
    /// <param name="username">認証に成功したユーザ名</param>
    /// <returns>認証に成功した場合、<c>true</c></returns>
    protected abstract bool ValidateUser(string authentication, out string username);

    /// <summary>
    /// 認証されたユーザに割り当てられたロールのリストを取得します。
    /// <para>
    /// 既定では、ロール マネージャに設定されたプロバイダを使用して、ユーザーに割り当てられているロールを取得します。
    /// </para>
    /// </summary>
    /// <param name="username">ユーザ名</param>
    /// <returns>指定されたユーザーに割り当てられているすべてのロールの名前を格納している文字列配列。</returns>
    protected virtual string[] GetRolesForUser(string username)
    {
        if (Roles.Enabled)
            return Roles.GetRolesForUser(username);
        else
            return new string[0];
    }

ロールについては、ASP.NET の標準的なロール マネージャを利用して取得できることを想定した実装にしています。

ユーザ認証の要求を行う

ユーザ認証の要求は、401 Not Authorized のレスポンスヘッダに WWW-Authenticate を含めることで行います。

    /// <summary>認証を要求するレスポンスを生成する ActionResult を作成します。</summary>
    /// <returns>認証を要求する ActionResult</returns>
    public ActionResult GetAuthenticationRequiredResult()
    {
        return new AuthenticationRequiredResult(this.AuthenticationType, this.Realm);
    }

    private class AuthenticationRequiredResult : ActionResult
    {
        public AuthenticationRequiredResult(string authenticationType, string realm)
        {
            this.HeaderValue = string.Format("{0} realm=\"{1}\"", authenticationType, HttpUtility.UrlEncode(realm));
        }

        private string HeaderValue;

        public override void ExecuteResult(ControllerContext context)
        {
            if (context == null)
                throw new ArgumentNullException("context");

            context.HttpContext.Response.StatusCode = 401;
            context.HttpContext.Response.AddHeader("WWW-Authenticate", this.HeaderValue);
        }
    }

AuthenticationRequiredResult が内部クラスだったり private なのは趣味ですので。

Basic 認証を行う具体的な実装を作成する

出来上がった HttpAuthenticateAttribute を利用して、Basic 認証の実装を行います。実装しなければならないメソッドは ValidateUser() だけです。

    /// <summary>ユーザ認証を実施する。</summary>
    /// <param name="authentication">要求された認証文字列</param>
    /// <param name="username">認証に成功したユーザ名</param>
    /// <returns>認証に成功した場合、<c>true</c></returns>
    protected sealed override bool ValidateUser(string authentication, out string username)
    {
        var bytes = Convert.FromBase64String(authentication);
        var ascii = Encoding.ASCII.GetString(bytes);
        var index = ascii.IndexOf(':');

        username = ascii.Substring(0, index);
        return this.ValidateUser(username, ascii.Substring(index + 1));
    }

    /// <summary>
    /// ユーザのパスワード認証を実施する。
    /// <para>
    /// 既定では、メンバーシップに設定された既定のプロバイダを使用して、ユーザーの検証を行います。。
    /// </para>
    /// </summary>
    /// <param name="username">検証対象のユーザー名。</param>
    /// <param name="password">提示されたパスワード</param>
    /// <returns>指定されたユーザー名とパスワードが有効な場合は<c>true</c>。それ以外の場合は<c>false</c></returns>
    protected virtual bool ValidateUser(string username, string password)
    {
        return Membership.ValidateUser(username, password);
    }

Basic 認証では、BASE64エンコードされただけの生のユーザ名とパスワードが送られてきますので、それをデコードしてパスワード認証を行います。パスワード認証はロールの取得と同様に ASP.NET の仕組みを使うように実装しておきます。

できた属性をアクションメソッドに適用して試してみましょう。

    [HttpBasicAuthenticate("Basci test", AcceptAnonymousUser = false)]
    //[Authorize(Users="takaoka")]
    public ActionResult test_action()
    {
        return this.View();
    }

前回の記事にもありますが、標準の AuthorizeAttribute はソレ単体で認証要求を行わないため、匿名アクセスを拒否することで BasicAuthenticateAttribute で認証要求を発行しています。

続く

次の記事では、そのあたりの対応方法とサンプルソース全体を掲載して締めたいと思います。