読者です 読者をやめる 読者になる 読者になる

BEACHSIDE BLOG

MicrosoftとかC#を好むレンジャーの個人的メモ

ASP.NET MVC でパスワードの有効期限対応をする

さてさてVisual Studio2015リリース直前な時期ですが、Visual Studio2013でのASP.NET MVCな常にログインが必要な業務系アプリ想定で、以下の実装例をメモしておきます。

  • 新規ユーザー登録(&仮パスワード発行)後、ユーザーの初回ログイン時にパスワード変更画面へ強制遷移
  • ユーザーのパスワード有効期限を設定し、過ぎたらログイン時にパスワード変更画面へ強制遷移

> Environment

今回の開発環境はこんな感じです。

> OverView

ASP.NET MVCで個人認証のテンプレート使って、DBの接続はざっくりEntityFrameworkでアプリ作るところに、パスワード要件を実装と思ってください。
AuthorizeAttributeのFilterを作って、アプリに適用させることで実現します。

手順の全体像は、

  1. ASP.NET MVC5で、個人認証つきのプロジェクト作成
  2. Attribute作成前の準備
  3. 要件を満たすためのAttribute作成
  4. アプリに適用

です。

> Implimentation 1. ASP.NET MVC5、個人認証のプロジェクト作成

これはただの下準備なので、さっくり進めます。
Visual Studioで新規プロジェクトを作成します。テンプレートの選択は、もちろん[Web]です。
f:id:beachside:20150712145712p:plain

たまに[VisualStudio2012]を選択する方がいるのですが、選択するのは、上図の通りWebです。お間違えなく。

そして、MVCで個人認証で進めます。
f:id:beachside:20150712150704p:plain

> Implimentation 2. Attribute作成前の準備

パスワード変更の最終変更日を管理するので、DBにカラムを持ちます。
ASP.NET Identity2.2.1で個人認証すると、データベースに[AspNetUsers]ってテーブルできますよね。
f:id:beachside:20150712152313p:plain
このテーブルでパスワード変更の最終変更日も管理しようと思います。

話は脱線しますが、わからんことがあって放置している点を書いておくと、
MembershipプロバイダーのLastPasswordChangedDateあたりを使うべきなのかなーとかが??です。AspNetUsersテーブルもあるのにこっちも使うのは面倒では?と何も調べず知らずに書いてます。よいプラクティスをご存じなMVPさん方々、ご教授願いますm(_ _)m...


さて、話は戻ります。
[AspNetUsers]テーブルに[LastPasswordChangedDate]列を追加します。
パスワード変更の日付は、DBファーストでもコードファーストでもどちらで書いても構わないのですが、コードファーストでいきますか(いつもはコードファーストが嫌いなのでDBファーストで書いてます...)。

[ソリューションエクスプローラ]の[Models] > [IdentityModels.cs]を開きます。
以下のように、[ApplicationUser]クラスに[LastPasswordChangedDate]プロパティを追加します。

public class ApplicationUser : IdentityUser
{
    public async Task<ClaimsIdentity> GenerateUserIdentityAsync(UserManager<ApplicationUser> manager)
    {
        // authenticationType が CookieAuthenticationOptions.AuthenticationType で定義されているものと一致している必要があります
        var userIdentity = await manager.CreateIdentityAsync(this, DefaultAuthenticationTypes.ApplicationCookie);
        // ここにカスタム ユーザー クレームを追加します
        return userIdentity;
    }

    public DateTime? LastPasswordChangedDate { get; set; }

}

あとは、Migrationすればデータベース側に反映されます。個人的には、マイグレーションで面倒なことが起きるのでDBファーストが好みです。
しばらくMigrationなんてしてないのでどうするんだっけ感がありますが、パッケージマネージャーコンソールで、

enable-migrations

を実行して、Migrationを有効にします。
次は、こんな感じで適当に...

add-migration InitialCreate

最後に、

update-database

でデータベース側も反映されます。
f:id:beachside:20150712160030p:plain

migrationについて不明の方は、ここら辺をご参考いただければと。
www.asp.net

> Implimentation 3. 要件を満たすためのAttribute作成

それでは本題のAttribute作成です。
プロジェクトに適当にフォルダを追加します。今回は以下のようにフォルダと[PasswordExpirePeriodAtteribute]クラスを追加しました。
f:id:beachside:20150712160726p:plain

コードは以下のようにしています。

public class PasswordExpirePeriodAttribute : AuthorizeAttribute
{
    private static readonly int PasswordExpirePeriod = 90;

    public override void OnAuthorization(AuthorizationContext filterContext)
    {

        if (filterContext.ActionDescriptor.ControllerDescriptor.ControllerName.ToLower() == "manage") return;

        var user = HttpContext.Current.User;
        if (user != null && user.Identity.IsAuthenticated)
        {
            using (var context = new ApplicationDbContext())
            {
                var passwordChangedDate = context.AspNetUsers.Find(user.Identity.GetUserId()).LastPasswordChangedDate;
                // NULLの場合=初回ログイン
                if (passwordChangedDate == null)
                {
                    filterContext.Controller.TempData["ExpirePeriodMessage"] = "初期パスワードの変更をしてください。";
                    filterContext.Result = new RedirectResult("~/manage/changepassword/");
                    return;
                }

                //期限切れの場合
                if (DateTime.Today >= passwordChangedDate.Value.AddDays(PasswordExpirePeriod))
                {
                    filterContext.Controller.TempData["ExpirePeriodMessage"] = "パスワードの有効期限が切れています。変更するまでここから逃げることはできませんよ?";
                    filterContext.Result = new RedirectResult("~/manage/changepassword/");
                }

            }
        }
    }
}


まず、[PasswordExpirePeriodAttribute]クラスは、[AuthorizeAttribute]を継承したクラスと定義し、フィルターとしての動作をさせます。
パスワードの有効期限は、staticな変数を持たせて90日を設定しました。多少真面目に書く場合は、webconfigとかにもたせて読み込む感じでしょうか。

動作のトリガーとして、[OnAuthorization]メソッドをoverrideします。
ここら辺が不明の場合は、"authorization filter asp.net mvc 5"あたりでググってみるとよいと思います。

次に、manageのコントローラーに来た場合は、このフィルターを抜けるようにしています。

 if (filterContext.ActionDescriptor.ControllerDescriptor.ControllerName.ToLower() == "manage") return;

今回作っているASP.NET MVCのプロジェクトのパスワード変更が、[ManageController]の[ChangePassword]メソッド2つにリダイレクトするようにしています。
そのため、ManageControllerでも同様にフィルターを書けてしまうと無限リダイレクトループしてしまうからです。

次の処理は、DBにアクセスして[LastPasswordChangedDate]がnullだったら初期パスワード発行状態と判断し、パスワード変更画面へリダイレクトします。
そのため、ユーザーの登録時には、[LastPasswordChangedDate]を登録せずNULLにしておくという勝手なルールがあることはお察しくださいませ。
[LastPasswordChangedDate]の型がNullableな理由です。

ここで一点注意していただきたいのは、以下のコード部分です。

 using (var context = new ApplicationDbContext())
{
       var passwordChangedDate = context.AspNetUsers.Find(user.Identity.GetUserId()).LastPasswordChangedDate;
      ...

[ApplicationDbContext]は、私は勝手にカスタマイズしているので、デフォルトで同様のコードを書いても[AspNetUsers]のテーブルにアクセスできません!DbContextをちゃんと作って、そのcontextを呼ぶ必要があります。このEntityframeworkの入門としてweb上に溢れていると思うので省略しちゃいます。

それ以降のコードでは、パスワードの最終更新から90日過ぎたかをみて、リダイレクトを判断します。

> Implimentation 4. アプリに適用

さて、最後にアプリに適用です。
ものがものなので、今回の例ではGlobalFiltersに登録しちゃいます。
ソリューションエクスプローラーの[App_Start]フォルダ > [FilterConfig.cs]を開きます。
[RegisterGlobalFilters]メソッドに、以下のようにフィルターを追加します。

filters.Add(new PasswordExpirePeriodAttribute());

GlobalFiltersに登録するということは、アプリ全体に適用されるので、それが不適合な場合は、必要なクラスのみのフィルターをつけます。

これで動作しますが、パスワードを変更した際にLastPasswordChangedDateを更新させてあげる処理を追加する必要はありますのでお忘れなく。
あと、パスワード変更のView(~\Views\Manage\ChangePassword.cshtml)では、TempData["ExpirePeriodMessage"]に値があれば表示するみたいなことしてあげれば、メッセージを表示できます。


なんか泥臭いコードなのであまり満足度低めなのですが、取り急ぎメモということで...

さて焼肉食べに行こう...