かずきのBlog@hatena

すきな言語は C# + XAML の組み合わせ。Azure Functions も好き。最近は Go 言語勉強中。日本マイクロソフトで働いていますが、ここに書いていることは個人的なメモなので会社の公式見解ではありません。

ASP.NET Identityカスタマイズに挑戦

ASP.NETの新しい認証に興味津々だったので、ちょっと遊んでみました。

デフォルトで提供されてるEntity Frameworkを使用したものやGoogleやMSアカウントなどを使ったものは無視して、自前で用意した認証情報でログインできるところまでやってみました。

プロジェクトの作成と参照の追加

空のASP.NETアプリケーションを作成して、以下の参照をNuGetから追加します。

  • Microsoft.Owin.Host.SystemWeb
    • OwinをIISで有効にするために必要みたい。(これが無くてOwinのStartupが動かない…!ってはまった)
  • Microsoft.AspNet.Identity.Owin
    • 認証に必要なライブラリをごそっとひっぱってくるために追加。

認証に必要な初期化コードの追加

空で認証もなんもなしの状態のASP.NETアプリケーションで作ったので、認証に必要な初期化コードを追加します。新規作成で、OWIN Startup クラスを追加します。名前はなんでもいいのですが、Startupという名前にしておきました。そこに、IAppBuilderのUseCookieAuthenticationメソッド(正確にはOwin.CookieAuthenticationExtensionsクラスに定義されている拡張メソッド)を使って認証の設定をします。

using Microsoft.AspNet.Identity;
using Microsoft.Owin;
using Microsoft.Owin.Security.Cookies;
using Owin;

[assembly: OwinStartup(typeof(ASPNETIdentitySample.Startup))]

namespace ASPNETIdentitySample
{
    public class Startup
    {
        public void Configuration(IAppBuilder app)
        {
            // クッキーベースの認証で、認証用のURLは/Auth/Login
            app.UseCookieAuthentication(new CookieAuthenticationOptions
            {
                AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
                LoginPath = new PathString("/Auth/Login")
            });
        }
    }
}

ここでは、認証されてない状態で認証が必要なページに飛んできたら/Auth/Loginに飛ぶようにしました。

ユーザー情報のクラスを作成

認証したユーザーの情報を保持するためのクラスを用意します。ユーザーを表すクラスを作るには、Microsoft.AspNet.Identity.IUserインターフェースを実装したクラスを作成します。こいつは、IdとUserNameを持つだけのシンプルなインターフェースなのでさくっと実装しましょう。このクラスにプロパティを追加することで、色々保持させる情報を追加できそうな気もしますが、今回はシンプルにIdとUserNameだけを実装します。Auth.csというファイルをプロジェクトの直下に作って、そこに以下のようなクラスを作成します。

using Microsoft.AspNet.Identity;

namespace ASPNETIdentitySample
{
    public class AppUser : IUser
    {
        public string Id { get; set; }

        public string UserName { get; set; }
    }
}

ユーザー情報を保持したり、パスワードを管理するクラスの作成

次に、ユーザーの情報の管理やパスワードの管理をするクラスを作成します。ユーザー管理は、Microsoft.AspNet.Identity.IUserStore<T> where T : IUserというインターフェースを実装します。パスワード管理には、 Microsoft.AspNet.Identity.IUserPasswordStore<T> where T : IUserというインターフェースを実装します。型引数には、先ほどIUserを実装したAppUserクラスを指定します。Auth.csにAppUserStoreという名前のクラスで、2つのインターフェースを実装したクラスを作ります。

public class AppUserStore : IUserStore<AppUser>, IUserPasswordStore<AppUser>
{
    private static List<AppUser> users = new List<AppUser>
    {
        new AppUser { Id = "user1-id", UserName = "user1" }
    };

    public Task CreateAsync(AppUser user)
    {
        users.Add(user);
        return Task.Delay(0);
    }

    public async Task DeleteAsync(AppUser user)
    {
        var target = await this.FindByIdAsync(user.Id);
        if (target == null)
        {
            return;
        }

        users.Remove(target);
    }

    public Task<AppUser> FindByIdAsync(string userId)
    {
        return Task.FromResult(users.FirstOrDefault(u => u.Id == userId));
    }

    public Task<AppUser> FindByNameAsync(string userName)
    {
        return Task.FromResult(users.FirstOrDefault(u => u.UserName == userName));
    }

    public async Task UpdateAsync(AppUser user)
    {
        var target = await this.FindByIdAsync(user.Id);
        if (target == null)
        {
            return;
        }

        target.UserName = user.UserName;
    }

    public void Dispose()
    {
    }

    public Task<string> GetPasswordHashAsync(AppUser user)
    {
        return Task.FromResult(new PasswordHasher().HashPassword(user.UserName));
    }

    public Task<bool> HasPasswordAsync(AppUser user)
    {
        return Task.FromResult(true);
    }

    public Task SetPasswordHashAsync(AppUser user, string passwordHash)
    {
        return Task.Delay(0);
    }
}

基本的にCRUDの処理なので悩むことはないと思います。今回は簡単にするためにパスワードをAppUserクラスのUserNameと同じものだとOKとなるようにしています。そのときに、ちょっと悩んだのがパスワードを取得するためのGetPasswordHashAsyncメソッドで返す値です。どうも、このIUserPasswordStoreインターフェースは、ハッシュ化されたパスワードを前提としているようで、ハッシュ化されたパスワードを返す必要があります。普通は、SetPasswordHashAsyncでユーザーを作る時に、ハッシュ化されたパスワードが渡されてくるので、ユーザーとパスワードの紐づけを行っておけば、それをそのまま返すだけでいいのですが、今回は手抜きのためにUserNameをハッシュ化した値を返さないといけませんでした。んで、ASP.NET Identifyのクラスの中にはIPasswordHasherというインターフェースがいて、ハッシュ化の方法をカスタマイズできるようになっています。デフォルトの実装としてPasswordHasherというクラスがいるので、こいつを使ってUserNameをハッシュ化して返しました。

ASP.NET MVCの追加

HomeControllerを追加します。Controllersフォルダをプロジェクトの下に作ってHomeControllerを作成します。HomeControllerを追加すると、今まで綺麗だったプロジェクトにASP.NET MVC用のクラス達がガシガシと追加されて若干穢された気持ちになりますが、気にしないで行きます。

Indexアクションに対応するViewも追加して、Index.cshtmlの中身を以下のようにします。

@{
    ViewBag.Title = "Index";
}

<h2>Index</h2>

Hello @(User.Identity.Name).

そして、この手順で作成したApp_StartのRouteConfigにはデフォルトのコントローラが設定されてないので、HomeControllerをデフォルトに設定するようにコードを変えます。routes.MapRouteのdefaultsにcontrollerを追加します。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Web.Routing;

namespace ASPNETIdentitySample
{
    public class RouteConfig
    {
        public static void RegisterRoutes(RouteCollection routes)
        {
            routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

            routes.MapRoute(
                name: "Default",
                url: "{controller}/{action}/{id}",
                defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
            );
        }
    }
}

この状態で実行して、以下のようにユーザー名が出ない状態のページが表示されることを確認します。

f:id:okazuki:20131110180709p:plain

デフォルトで認証が必要なように構成

次に、基本全ページに認証がいるようにしようと思うのでFilterConfig.csにAuthorizeAttributeを追加します。

using System.Web;
using System.Web.Mvc;

namespace ASPNETIdentitySample
{
    public class FilterConfig
    {
        public static void RegisterGlobalFilters(GlobalFilterCollection filters)
        {
            filters.Add(new HandleErrorAttribute());
            // デフォで認証が必要
            filters.Add(new AuthorizeAttribute());
        }
    }
}

この状態で実行して、Auth/Loginへリダイレクトされることを確認します。

f:id:okazuki:20131110180949p:plain

ReturnUrlというパラメータで、戻るべきページがわたってきてることも確認できます。

認証用コントローラの作成

ついに認証処理です。AuthControllerを作成してLoginというアクションを追加します。returnUrlをパラメータから受け取りViewBagにつめてページにわたすのと、LoginViewModelというユーザー名とパスワードを保持するクラスをViewに渡します。あと、このコントローラは認証いらないので、AllowAnonymous属性を追加しておきます。

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Web;
using System.Web.Mvc;

namespace ASPNETIdentitySample.Controllers
{
    // このコントローラは認証いらない
    [AllowAnonymous]
    public class AuthController : Controller
    {
        //
        // GET: /Auth/Login
        public ActionResult Login(string returnUrl)
        {
            ViewBag.ReturnUrl = returnUrl;
            return View(new LoginViewModel());
        }
    }

    public class LoginViewModel
    {
        [Required(ErrorMessage = "ユーザー名を入れてください")]
        [Display(Name = "ユーザー名")]
        public string UserName { get; set; }

        [Required(ErrorMessage = "パスワードを入れてください")]
        [Display(Name = "パスワード")]
        public string Password { get; set; }
    }
}

LoginViewModelクラスをmodelにした空のViewを作成して、以下のようにログイン用のフォームを作ります。

@model ASPNETIdentitySample.Controllers.LoginViewModel

@{
    ViewBag.Title = "Login";
}

<h2>Login</h2>
@Html.ValidationSummary(true)

@* ReturnUrlをパラメータに渡すようにしたフォームを作る *@
@using (Html.BeginForm(new { ReturnUrl = ViewBag.ReturnUrl }))
{
    <div class="form-group">
        @Html.LabelFor(m => m.UserName)
        @Html.TextBoxFor(m => m.UserName)
        @Html.ValidationMessageFor(m => m.UserName)
    </div>
    <div class="form-group">
        @Html.LabelFor(m => m.Password)
        @Html.PasswordFor(m => m.Password)
        @Html.ValidationMessageFor(m => m.Password)
    </div>
    
    <input type="submit" value="ログイン" class="btn-default" />
}

UserManager<T>クラスを使って認証処理を行います。型引数にはIUserの実装クラス(今回の場合はAppUserクラス)を渡して、コンストラクタの引数には、IUserStore<T>の実装クラス(今回の場合はAppUserStoreクラス)を渡すことでインスタンス化できます。こいつのFindAsyncメソッドにユーザ名とパスワードを渡すことで、対象のユーザーが取得できます。nullの場合はログインに失敗したときの処理をすればOKです。

ユーザー情報がとれたら、認証情報をクッキーに保持するための処理をします。UserManagerクラスのCreateIdentityAsyncでクレームベースのIDが作れるみたいです(よくわかってない)。あとは、HttpContextからOwinContextを取得して、そこからAuthenticationプロパティを使ってIAuthenticationManagerのインスタンスを取得します。こいつのSignInメソッドに先ほど作成したクレームベースのIDを渡してやることで、無事認証情報された状態になります。

あとは、ReturnUrlで渡されてきたページへリダイレクトすればOKです。(正しいURLなのかどうかとかはチェックしたほうがいいと思うけど…)

//
// POST: /Auth/Login
[HttpPost]
public async Task<ActionResult> Login(LoginViewModel model, string returnUrl)
{
    if (!this.ModelState.IsValid)
    {
        ViewBag.ReturnUrl = returnUrl;
        return View(model);
    }

    // 認証
    var userManager = new UserManager<AppUser>(new AppUserStore());
    var user = await userManager.FindAsync(model.UserName, model.Password);
    if (user == null)
    {
        // 認証失敗したらエラーメッセージを設定してログイン画面を表示する
        this.ModelState.AddModelError("", "ユーザ名かパスワードが違います");
        ViewBag.ReturnUrl = returnUrl;
        return View(model);
    }

    // クレームベースのIDを作って
    var identify = await userManager.CreateIdentityAsync(
        user, 
        DefaultAuthenticationTypes.ApplicationCookie);

    // 認証情報を設定
    var authentication = this.HttpContext.GetOwinContext().Authentication;
    authentication.SignIn(identify);

    // 元のページへリダイレクト
    return Redirect(returnUrl);
}

動作確認

アプリケーションを起動すると、Home/Indexを表示しようと試みますが、Auth/Loginページへリダイレクトされてログインページが表示されます。

f:id:okazuki:20131110185218p:plain

そして、ユーザー名にuser1、パスワードにuser1と入力してログインを押すとHome/Indexへリダイレクトされて以下のようにユーザー名が表示されます。

f:id:okazuki:20131110185337p:plain

ユーザー名やパスワードを間違えるともちろんログインできません。

f:id:okazuki:20131110185440p:plain

まとめと感想

情報があんまりなかったので手探りでしたが、結局はIUserインターフェースとIUserStoreインターフェースとIUserPasswordStoreインターフェースを実装するだけで、どうやってユーザーを認証するかという方法をカスタマイズできることがわかりました。MembershipProviderみたいにカオスな感じじゃなくて、実装するのもさほど苦じゃないので、とっととこっちを使うように世の中がなればいいなと思いました。

ロールとか、そっち方面も調べないとな…。

プロジェクトのダウンロード

このプロジェクトファイルは以下からダウンロードできます。