WebSurfer's Home

トップ > Blog 1   |   ログイン
APMLフィルター

ASP.NET Core Identity タイムアウト判定

by WebSurfer 2020年10月31日 18:18

先の記事「ASP.NET Identity タイムアウト判定」の Core 3.1 版です。(先の記事は .NET Framework 版です。Core 版とはかなりの部分で異なります)

有効期限切れの通知

ASP.NET Identity の認証システムは、基本的に旧来のフォーム認証と同様で、ログイン後はクッキーに認証チケットを入れて要求毎にサーバーに送信するので認証状態が維持されるという仕組みになっています。

一旦認証を受けたユーザーがログオフせずブラウザを立ち上げたままタイムアウトとなって、再度どこかのページを要求したとすると、要求したページが匿名アクセスを許可してなければ、ログインページにリダイレクトされます。

その時、認証チケットが期限切れになった(認証されているか否かではありません)というのを、どのようすれば判定できるかということを書きます。

Web サーバー側では以下の条件で判定できるはずです。

  • 要求 HTTP ヘッダーに認証クッキーが含まれる。
  • 認証クッキーの中の認証チケットが期限切れ。

Web サーバーが認証クッキーを取得できれば、それを復号して認証チケットを取得し、チケットの中の有効期限の日時の情報を取得できます。それで上記の 2 つの条件を確認できます。

認証クッキーを復号する方法ですが、旧来のフォーム認証の場合とはもちろん、.NET Framework 版の ASP.NET Identity の場合ともかなり異なっています。

そのあたりはググって調べた stackoverflow の記事 How to decrypt .AspNetCore.Identity.Application cookie in ASP.NET Core 3.0?How to manually decrypt an ASP.NET Core Authentication cookie? の回答を参考にさせていただきました。

それを先の記事と同様に HTTP モジュールとして実装してみようと思いましたが、Core には HTTP モジュールも HTTP ハンドラも web.config もないとのこと。

.NET Framework の ASP.NET の HTTP モジュールに代わるものが ASP.NET Core ではミドルウエアになるらしいです。なので、以下の Microsoft のドキュメントを参考に、ミドルウェアを作ってみました。

以下がそのミドルウェアのコードです。上の画像がこのミドルウェアにより認証チケットの期限切れをチェックして Login 画面上でユーザーに通知したものです。

ただし、ユーザーへの通知を下のコードの最後の方にあるように HttpResponse.WriteAsync を使って書き込んだ場合、見かけは上の画像のようになりますが、実は <html></html> タグの外(この例では最後)に書き込まれています。また、上の記事「ASP.NET Core のミドルウェア」の警告に書いてありますが、next.Invoke の前後で応答本文に書き込むというのはそもそもやってはいけないことのようです。

今回はミドルウェアに実装してしまいましたが、ユーザーへの通知を行うならミドルウェアでなく Login ページに下のコードを実装して期限切れを判定し、所定の位置に書き込むようにする方がよさそうです。

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Authentication;
using System.Security.Claims;
using System.Security.Principal;

namespace MvcCoreApp.Middleware
{
    public class AuthExpireCheckMiddleware
    {
        private readonly RequestDelegate _next;
        private readonly CookieAuthenticationOptions _options;

        // コンストラクタの引数を以下のように設定しておくことにより
        // CookieAuthenticationOptions を DI 機能により取得できる
        public AuthExpireCheckMiddleware(
            RequestDelegate next,
            IOptions<CookieAuthenticationOptions> options)
        {
            _next = next;
            _options = options.Value;
        }

        public async Task InvokeAsync(HttpContext context)
        {
            // ここでの処理は必要ないので即後続のミドルウェアに処理を渡す。
            // 応答がクライアントに送信された後に _next.Invoke を呼び出すと
            // 例外がスローされるそうなので注意(ここで Response.WriteAsync
            // は書けない)

            await _next.Invoke(context);

            // 認証チケットの期限が切れた後、認証が必要なページにアクセス
            // して Login ページにリダイレクトされた時のみ対応。
            // "/Identity/Account/Login" は Visual Studio のテンプレートで
            // プロジェクトを作った際のデフォルト。必要あれば変更可
            if (context.Request.Path.ToString().
                                     Contains("/Identity/Account/Login"))
            {
                // 認証クッキーが送られてくる時のみ対応。クッキー名
                // .AspNetCore.Identity.Application はデフォルト。
                // 必要あれば変更可
                string cookie 
                    = context.Request.Cookies[".AspNetCore.Identity.Application"];
                if (!string.IsNullOrEmpty(cookie))
                {
                    IDataProtectionProvider provider = 
                                        _options.DataProtectionProvider;

                    // 下の「注2」参照
                    IDataProtector protector = provider.CreateProtector(
                        // CookieAuthenticationMiddleware のフルネームらしい
                        "Microsoft.AspNetCore.Authentication.Cookies." +
                        "CookieAuthenticationMiddleware",
                        // クッキー名から .AspNetCore. を削除した文字列
                        "Identity.Application",
                        // .NET Framework 版は "v1"
                        "v2");

                    // 認証クッキーから暗号化された認証チケットを復号
                    TicketDataFormat format = new TicketDataFormat(protector);
                    AuthenticationTicket authTicket = format.Unprotect(cookie);

                    // ユーザー名を取得
                    ClaimsPrincipal principal = authTicket.Principal;
                    IIdentity identity = principal.Identity;
                    string userName = identity.Name;

                    // 認証チケットの有効期限の日時を取得
                    AuthenticationProperties property = authTicket.Properties;
                    DateTimeOffset? expiersUtc = property.ExpiresUtc;

                    // ExpiresUtc と現在の時刻を比較して期限切れか否かを判定
                    if (expiersUtc.Value < DateTimeOffset.UtcNow)
                    {    
                        // ユーザーに通知
                        // 以下のコードでは <html></html> 外に書き込まれるので注意
                        await context.Response.WriteAsync("<h3><font color=red>" +
                            userName +
                            " さん、認証チケットの有効期限が切れています" +
                            "</font></h3>");
                    }
                }
            }
        }
    }
}

注1

上のコードのように DI 機能により CookieAuthenticationOptions オブジェクトを取得できます。それをベースに復号に必要な TicketDataFormat オブジェクトを取得し、認証クッキーから認証チケットを復号しています。

Visual Studio のテンプレートを使って「個別のユーザーアカウント」を認証に選んで作成したプロジェクトでは以下のコードが Startup.cs 含ま��ています。その場合、コンストラクタの引数に IOptions<CookieAuthenticationOptions> options を含めるだけで DI 機能が働きます。コントローラーのコンストラクタでも同じです。

services.AddDefaultIdentity<IdentityUser>(options => 
    options.SignIn.RequireConfirmedAccount = true)
    .AddEntityFrameworkStores<ApplicationDbContext>();

上の設定が Startup.cs に含まれない場合、上に紹介した stackoverflow の記事 How to decrypt .AspNetCore.Identity.Application cookie in ASP.NET Core 3.0? にあるようなサービスの登録が必要です、

注2

IDataProtectionProvider.CreateProtector の引数ですが、これは .NET Framework の MachineKey.Protect / MachineKey.Unprotect の第 2 引数と同様に暗号化テキストを特定の目的にロックするためのもののようです。

上の紹介した stackoverflow の記事に少し記述があります。Microsoft のドキュメントでは「ASP.NET アプリ間での認証の共有」に記載があるのを見つけました。

注3

上に書いたミドルウェアが動くようにするには、Startup.cs の Configure メソッドでの登録が必要です。その具体例は以下の通りです。場所は既存の app.UseEndpoints の直前で良いです。

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{

    // ・・・中略・・・

    // これを追記
    app.UseMiddleware<MvcCoreApp.Middleware.AuthExpireCheckMiddleware>();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllerRoute(
            name: "default",
            pattern: "{controller=Home}/{action=Index}/{id?}");

        endpoints.MapRazorPages();
    });
}

注4

上にも書きましたが、next.Invoke の前後で応答本文に書き込むというのはそもそもやってはいけないことのようですし(特に next.Invoke の前はエラーになるとのこと)、 <html></html> タグの外に書き込まれてしまうので、ユーザーへの通知を行うならミドルウェアではなく Login ページ内で行うべきと思います。

認証に「個別のユーザーアカウント」を選んでプロジェクトを作成すると Login, Register 他の認証関係の機能は Razor Class Library (RCL) として提供され、ソースコードはプロジェクトには含まれないというところが問題ですが、スキャフォールディング機能を利用して Login ページをオーバーライドすることで対応できそうです。

Tags: , , ,

Authentication

ASP.NET Identity タイムアウト判定

by WebSurfer 2020年10月23日 18:22

先の記事「Forms 認証のタイムアウト判定」の ASP.NET Identity 版です。(Core ではなく .NET Framework 用の ASP.NET Identity です。Core 3.1 版は別の記事「ASP.NET Core Identity タイムアウト判定」に書きました)

有効期限切れの通知

Visual Studio 2019 などのテンプレートを使って ASP.NET Web アプリのプロジェクトを作る際、認証を「個別のユーザーアカウント」に設定すると ASP.NET Identity を利用したユーザー認証が実装されます。

基本的には旧来のフォーム認証と同様で、ログイン後はクッキーに認証チケットを入れて要求毎にサーバーに送信するので認証状態が継続されるという仕組みになっています。

そこで、一旦認証を受けたユーザーがログオフせずブラウザを立ち上げたまま長時間席を外すなどして、タイムアウトに設定した時間を超えてアクセスしなかった場合を考えてください。

ユーザーが席に戻ってきて再度アクセスした場合、アクセスしたページが匿名アクセスを許可してなければ、ログインページにリダイレクトされます。

その際、ユーザーに認証チケットが期限切れとなっていることを知らせるためにどのようなことができるかということを書きます。

User.Identity.IsAuthenticated ではダメです。認証されているか否かは判定できますが、認証チケットが期限切れになったのか、それとも最初からログインしてなかったのかはわかりませんから。

一旦認証を受けたが、認証チケットが期限切れになっているというのは、Web サーバー側では以下の条件で判定できるはずです。

  • 要求 HTTP ヘッダーに認証クッキーが含まれる。
  • 認証クッキーの中の認証チケットが期限切れ。

認証クッキーと認証チケットは違うことに注意してください。クッキーはチケットの入れ物に過ぎません。認証チケットの有効期限は認証クッキーの Value の中に入っている情報の一つです。HttpCookie.Expires ではありません。

一般的に、一旦認証クッキーの発行を受ければ、ブラウザを閉じない限り次の要求の時にブラウザはサーバーにクッキーを送ります。(注:認証クッキーを「永続化」している場合は話が違ってきます。詳しくは別の記事「Froms 認証クッキーの永続化」を見てください)

Web サーバーが認証クッキーを取得できれば、その Value を復号して認証チケットを取得し、期限切れか否かの情報を取得できます。それで上記の 2 つの条件を確認できます。

しかし、認証クッキーを復号する方法が旧来のフォーム認証の場合とは全く異なっているのが問題でした。そこは、ググって調べた記事 ASP.NET Identity 2.0 decrypt Owin cookieReading Katana Cookie Authentication Middleware’s Cookie from FormsAuthenticationModule を参考にさせていただきました。

記事のコードでは MemoryStream などを使って ClaimsIdentity などを取得していますが、前者の記事のコメント欄に書いてあるように TicketSerializer クラスの Deserialize メソッドを使って AuthenticationTicket オブジェクトを取得し、それから必要な情報を得る方が良さそうです。

と言うより、認証チケットが有効期限切れかどうかは、AuthenticationTicket.Properties で取得できる AuthenticationProperties の ExpiresUtc プロパティをチェックしないと分からないので、そうせざるを得ないようです。

コードは以下のように HTTP モジュールとして実装してみました。上の画像がこのモジュールにより認証チケットの期限切れをチェックして Login 画面上でユーザーに通知したものです。

ただし、ユーザーへの通知を下のコードの最後の方にあるように Response.Write を使って書き込んだ場合、見かけは上の画像のようになりますが、実は <html></html> タグの外(この例では先頭)に書き込まれています。

なので、ユーザーへの通知が必要なら、HTTP モジュールで通知を行うのではなく、Login ページに下のようなコードを含めて判定し、ページの所定の位置に書き込むようにする方がよさそうです。

using System;
using System.Linq;
using System.Web;
using System.Security.Claims;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.DataHandler.Serializer;

namespace Mvc5ProfileInfo.Modules
{
    public class MyHttpModule : IHttpModule
    {
        // IHttpModule に Dispose メソッドが含まれるので定義が必要
        public void Dispose()
        {
            
        }

        public void Init(HttpApplication context)
        {
            context.AuthenticateRequest += Context_AuthenticateRequest;
        }

        private void Context_AuthenticateRequest(object sender, EventArgs e)
        {
            HttpRequest request = HttpContext.Current.Request;

            // 認証チケットの期限が切れた後、認証が必要なページにアクセス
            // して Login ページにリダイレクトされた時のみ対応します。
            // "/Account/Login" は実際に合わせて適宜変更してください
            if (request.CurrentExecutionFilePath != "/Account/Login")
            {
                return;
            }

            // 認証クッキーが送られてくる時のみ対応します。クッキー名の
            // .AspNet.ApplicationCookie はデフォルト。必要あれば変更
            HttpCookie cookie = request.Cookies.Get(".AspNet.ApplicationCookie");
            if (cookie == null) 
            { 
                return; 
            }

            // (注1)・・・下の注記参照
            string ticket = cookie.Value;
            ticket = ticket.Replace('-', '+').Replace('_', '/');

            int padding = 3 - ((ticket.Length + 3) % 4);
            if (padding != 0)
            {
                ticket = ticket + new string('=', padding);
            }

            byte[] bytes = Convert.FromBase64String(ticket);

            // (注2)・・・下の注記参照
            bytes = System.Web.Security.MachineKey.Unprotect(bytes,
                "Microsoft.Owin.Security.Cookies.CookieAuthenticationMiddleware",
                "ApplicationCookie", "v1");

            TicketSerializer serializer = new TicketSerializer();
            AuthenticationTicket authTicket = serializer.Deserialize(bytes);

            // 以下いろいろ書いてあるが必要なのは AuthenticationProperties
            // オブジェクトの ExpiresUtc プロパティで取得できる有効期限。
            // 他はその他にもいろいろ情報が取得できることを示したのみ
            ClaimsIdentity identity = authTicket.Identity;
            AuthenticationProperties property = authTicket.Properties;

            if (identity != null && property != null)
            {
                ClaimsPrincipal principal = new ClaimsPrincipal(identity);

                // クッキーが送られてくると有効期限が切れていても
                // ClaimsIdentity.IsAuthenticated は true になる
                bool isAuthenticated = identity.IsAuthenticated;
                string UserName = identity.Name;
                string authType = identity.AuthenticationType;

                // 追加したプロファイル情報 HandleName の取得
                Claim claim = identity.Claims.
                        FirstOrDefault(c => c.Type == ClaimTypes.GivenName);
                string handleName = claim.Value;

                // ExpiresUtc と現在の時刻を比較して期限切れか否かを判定
                DateTimeOffset? expiersUtc = property.ExpiresUtc;
                if (expiersUtc.Value < DateTimeOffset.UtcNow)
                {
                    // 知らせるための処置を書く
                    // 以下のコードでは <html></html> 外に書き込まれるので注意
                    HttpContext.Current.Response.Write("<h1><font color=red>" +
                      handleName +
                      " さん、認証チケットの有効期限が切れています" +
                      "</font></h1><hr>");
                }
            }
        }
    }
}

(注 1) 認証チケットのバイト列をクッキーに設定する際 Base64 でエンコードされますが、その際 URL アプリケーションのための変形が行われ '+' は '-' に、'/' は '_' に変換され、パディング '=' は除去されます。エンコードには ITextEncoder.Encode Method (Byte[]) が使われているようです。なので、そのデコードには ITextEncoder.Decode Method (String) を使えば文字列を変換するコードを書かなくてもバイト列を取得できます。具体的には以下の通りです(検証済み)。

byte[] bytes = Microsoft.Owin.Security.DataHandler.Encoder.
               TextEncodings.Base64Url.Decode(cookie.Value);

(注 2) 認証チケットのバイト列は MachineKey.Protect(Byte[], String[]) メソッドを使って暗号化されているようです。復号するには MachineKey.Unprotect(Byte[], String[]) メソッドを使いますが、その際、第 2 引数には暗号化するときに Protect メソッドで使用した値と同じものを設定する必要があります(違うと CryptographicException がスローされます)。

では、その第 2 引数は何になるかですが、自分が探した限りでは Microsoft の公式文書は見つかりませんでした。ググって見つかった記事の中で一番詳しかったのが「ASP.NET Cookie是怎么生成的」で、それによると以下のようにするのと同じになるようです(検証済み)。

string purpose1 = typeof(Microsoft.Owin.Security.Cookies.
                         CookieAuthenticationMiddleware).FullName;
string purpose2 = Microsoft.AspNet.Identity.
                  DefaultAuthenticationTypes.ApplicationCookie;
string purpose3 = "v1";
bytes = System.Web.Security.MachineKey.Unprotect(bytes, 
                                    purpose1, purpose2, purpose3);

(注 3)上に書いた HTTP モジュールを動かすには web.config での登録が必要です。その具体例は以下の通りです。

<system.webServer>
  <modules>
    <add name="MyHttpModule" type="Mvc5ProfileInfo.Modules.MyHttpModule"/>
  </modules>
</system.webServer>

Tags: , ,

Authentication

Register, Login, Logout 機能の実装 (CORE)

by WebSurfer 2020年9月5日 14:12

先の記事「ASP.NET Core Identity 独自実装(その1)」の続きです。

先の記事では ASP.NET Core Identity のユーザー認証に用いるデータソースとプロバイダを独自実装し、ユーザー情報の表示、追加、変更、削除が可能になるところまでは確認できました。この記事では Register, Login, Logout 機能を実装してみます。

Home/Index 画面

認証に「個別のユーザーアカウント」を選んでプロジェクトを作成した場合 Razor Class Library (RCL) として Login, Register 等の機能は提供され、ソースコードも入手することができます。そのコードを参考に Register, Login, Logout 用のコントローラーとビューを実装しました。

ユーザー認証の動きは、普通に ASP.NET Identity の「個別のユーザーアカウント」を選んで実装したときと同じで、以下のようになります。

初期画面では、上の画像のように、ユーザーがログインしてないときは Register, Login へのリンクを表示します。ページ右上の Login をクリックまたはアクセス制限がかかっているページを呼び出すとログイン画面に遷移します。

ログイン

有効なユーザー名とパスワードを入力して [Login] ボタンをクリックすると、以下の Fiddler の画像の通り認証クッキーが発行されます。

認証クッキー

この例では、アクションメソッドに [Authorize] を付与してアクセス制限がかかっている Home/Pricacy を要求したので、応答ヘッダの Location に /Home/Privacy が設定されています。

Location の設定により /Home/Privacy ページにリダイレクトされます。その際、認証クッキーも送られますのでユーザーは認証され、以下のように /Home/Privacy 画面が表示されます。

ログイン成功

どこでどのように実現しているのか分かりませんが、アクセス制限がかかっているページからログインページへの自動リダイレクトや認証クッキーの発行などは自力で実装しなくてもフレームワークが面倒を見てくれるようです。他には以下のことを自動的に行ってくれるのを確認しました。

  • 登録時のユーザー名とパスワードの検証は、ビューモデルのプロパティに付与したアノテーション属性に加えて、Microsoft のドキュメントの「Configure Identity services」のセクションのコードにある Password settings と User settings の設定が有効になります。
  • 同じユーザー名の二重登録の防止機能も組み込まれているようです。例えば、Register ページで既に登録済みの Surfer というユーザー名を登録しようとすると User name 'Surfer' is already taken. というエラーメッセージが出て登録に失敗します。(DB にユニーク制約は付けてないのですが・・・)
  • パスワードは自動的にハッシュされ、生のパスワードが DB に保存されることはありません。ハッシュのアルゴリズムは、参考にした記事の Customize ASP.NET Core Identity によると、PBKDF2 with HMAC-SHA256, 128-bit salt, 256-bit subkey, 10000 iterations になるそうです。既存の DB にユーザー情報があるなどで、ハッシュのアルゴリズムが上記と違う場合は問題ですが、IPasswordHasher を継承したカスタム PasswordHasher を作成してサービスとして Startup.cs に登録することで対応できるようです。
  • 永続化機能も働きます。上のログイン画面で[このアカウントを記憶する]にチェックを入れると、応答ヘッダに含まれる認証クッキーの Set-Cookie: に expires 属性が追加されます。有効期限は 2 週間先になっていました。有効期限は Startup.cs の ConfigureServices メソッドに以下のような設定を追加することで変更できます。
services.ConfigureApplicationCookie(options =>
{
    options.ExpireTimeSpan = TimeSpan.FromMinutes(5); // 5 分に設定
});

最後に、この記事で実装した Register, Login, Logout 関係のコードを以下に書いておきます。

_LoginPartial.cshtml

ページの右上にログイン状態を表示するためのパーシャルビュー _LoginPartial.cshtml を作成し、それを _Layout.schtml に配置します。コードは以下の通りです。「個別のユーザーアカウント」を選んで作ったプロジェクトのものをコピーして、それに手を加えました。

ログインすると表示されるユーザー名のリンク先は、「個別のユーザーアカウント」を選んで作ったプロジェクトの場合は管理用のページ Manage になるのですが、この記事では単にユーザー一覧が表示されるだけの Account/Index アクションメソッドにしています。

@using Microsoft.AspNetCore.Identity

@inject SignInManager<User> SignInManager
@inject UserManager<User> UserManager

<ul class="navbar-nav">
    @if (SignInManager.IsSignedIn(User))
    {
        <li class="nav-item">            
            <a id="manage" class="nav-link text-dark" asp-action="Index" 
                asp-controller="Account" title="Manage">
                Hello @UserManager.GetUserName(User) !
            </a>
        </li>
        <li class="nav-item">
            <form id="logoutForm" class="form-inline" asp-action="Logout" 
                  asp-controller="Account" 
                  asp-route-returnUrl="@Url.Action("Index", "Home", new { area = "" })">
                <button id="logout" type="submit" class="nav-link btn btn-link text-dark">
                    Logout
                </button>
            </form>
        </li>
    }
    else
    {
        <li class="nav-item">
            <a class="nav-link text-dark" id="register" asp-action="Register" 
               asp-controller="Account">Register</a>
        </li>
        <li class="nav-item">
            <a class="nav-link text-dark" id="login" asp-action="Login" 
               asp-controller="Account">Login</a>
        </li>
    }
</ul>

ビューモデル

Login と Register 用のビューモデルを作成します。MVC で言う Model で、コントローラとビューの間のデータのやり取りに使います。

using System.ComponentModel.DataAnnotations;

namespace MvcIdCustom.Models
{
    // AccountController の Login 用
    public class LoginViewModel
    {
        [Required(ErrorMessage = "{0}は必須です。")]
        [Display(Name = "ユーザー名")]
        [StringLength(100, ErrorMessage =
            "{0}は{2}から{1}文字の範囲で設定してください。",
            MinimumLength = 6)]
        public string UserName { get; set; }

        [Required(ErrorMessage = "{0}は必須です。")]
        [StringLength(100, ErrorMessage =
            "{0}は{2}から{1}文字の範囲で設定してください。",
            MinimumLength = 6)]
        [DataType(DataType.Password)]
        [Display(Name = "パスワード")]
        public string Password { get; set; }

        [Display(Name = "このアカウントを記憶する")]
        public bool RememberMe { get; set; }
    }

    // AccountController の Register 用
    public class RegisterViewModel
    {
        [Required(ErrorMessage = "{0}は必須です。")]
        [Display(Name = "ユーザー名")]
        [StringLength(100, ErrorMessage =
            "{0}は{2}から{1}文字の範囲で設定してください。",
            MinimumLength = 6)]
        public string UserName { get; set; }

        [Required(ErrorMessage = "{0}は必須です。")]
        [StringLength(100, ErrorMessage =
            "{0}は{2}から{1}文字の範囲で設定してください。",
            MinimumLength = 6)]
        [DataType(DataType.Password)]
        [Display(Name = "パスワード")]
        public string Password { get; set; }

        [DataType(DataType.Password)]
        [Display(Name = "パスワード確認")]
        [Compare("Password", 
            ErrorMessage = "確認パスワードが一致しません。")]
        public string ConfirmPassword { get; set; }
    }
}

コントローラ / アクションメソッド

アクションメソッド Index へはログインするとページ右上に表示されるユーザー名からリンクが張られています。アクションメソッド Register, Login, Logout を、上に述べた _LoginPartial.cshtml のリンクから呼び出すことができます。

なお、Login ページについては、手動でリンクをクリックしなくとも、アクセス制限されているページを匿名ユーザーが要求した場合は自動的にリダイレクトされます。

using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Identity;
using MvcIdCustom.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authorization;

namespace MvcIdCustom.Controllers
{
    public class AccountController : Controller
    {
        private readonly UserManager<User> _userManager;
        private readonly SignInManager<User> _signInManager;

        public AccountController(UserManager<User> userManager, 
                                 SignInManager<User> signInManager)
        {
            _userManager = userManager;
            _signInManager = signInManager;
        }

        public async Task<IActionResult> Index()
        {
            // using Microsoft.EntityFrameworkCore;
            var users = await _userManager.Users.
                              OrderBy(user => user.UserName).ToListAsync();
            return View(users);
        }

        // GET: Account/Login/ReturnUrl
        // Model は Models/UserViewModel.cs の LoginViewModel
        [AllowAnonymous]
        public IActionResult Login(string returnUrl)
        {
            returnUrl = returnUrl ?? Url.Content("~/");
            ViewBag.ReturnUrl = returnUrl;
            return View();
        }

        // POST: Account/Login/ReturnUrl
        [AllowAnonymous]
        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Login(string returnUrl, 
            [Bind("UserName,Password,RememberMe")] LoginViewModel model)
        {
            returnUrl = returnUrl ?? Url.Content("~/");

            if (ModelState.IsValid)
            {                
                var result = await _signInManager.
                    PasswordSignInAsync(model.UserName, 
                                        model.Password, 
                                        model.RememberMe, 
                                        lockoutOnFailure: false);
                if (result.Succeeded)
                {                    
                    return LocalRedirect(returnUrl);
                }
                else
                {
                    ModelState.AddModelError(string.Empty, "無効なログイン");
                    return View(model);
                }
            }

            return View(model);
        }

        // POST: Account/Logout
        [AllowAnonymous]
        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Logout(string returnUrl)
        {
            await _signInManager.SignOutAsync();

            // _LayoutPartial.cshtml の form 要素の
            // asp-route-returnUrl="@Url.Action("Index", "Home", new { area = "" })
            // により returnUrl は "/" になる。
            if (returnUrl != null)
            {
                return LocalRedirect(returnUrl);
            }
            else
            {
                return RedirectToAction("Index", "Home");
            }
        }

        // GET: Account/Register
        // Model は Models/UserViewModel.cs の RegisterViewModel
        [AllowAnonymous]
        public IActionResult Register(string returnUrl)
        {
            returnUrl = returnUrl ?? Url.Content("~/");
            ViewBag.ReturnUrl = returnUrl;
            return View();
        }

        // POST: Account/Register
        [AllowAnonymous]
        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Register(string returnUrl, 
            [Bind("UserName,Password,ConfirmPassword")] RegisterViewModel model)
        {
            returnUrl = returnUrl ?? Url.Content("~/");

            if (ModelState.IsValid)
            {
                var user = new User { UserName = model.UserName };
                var result = await _userManager.CreateAsync(user, model.Password);
                if (result.Succeeded)
                {                    
                    await _signInManager.SignInAsync(user, isPersistent: false);
                    return LocalRedirect(returnUrl);                    
                }
                foreach (var error in result.Errors)
                {
                    ModelState.AddModelError(string.Empty, error.Description);
                }
            }

            return View(model);
        }
    }
}

ビュー (Login.cshtml)

Login アクションメソッド用のビューのコードのみ載せておきます。他はスキャフォールディング機能で生成したものをほぼそのまま使えますので省略します。

タグヘルパー form の asp-route-returnUrl 属性に "@ViewBag.ReturnUrl" を設定しているところに注目してください。アクセス制限がかかっているページにアクセスすると、そのページの URL が設定され、ログイン後 URL に設定されたページが表示されるようになっています。

@model MvcIdCustom.Models.LoginViewModel

@{
    ViewData["Title"] = "Login";
}

<h1>Login</h1>

<h4>LoginViewModel</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form asp-action="Login" asp-controller="Account" 
              asp-route-returnUrl="@ViewBag.ReturnUrl">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <div class="form-group">
                <label asp-for="UserName" class="control-label"></label>
                <input asp-for="UserName" class="form-control" />
                <span asp-validation-for="UserName" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Password" class="control-label"></label>
                <input asp-for="Password" class="form-control" />
                <span asp-validation-for="Password" class="text-danger"></span>
            </div>
            <div class="form-group form-check">
                <label class="form-check-label">
                    <input class="form-check-input" asp-for="RememberMe" /> 
                    @Html.DisplayNameFor(model => model.RememberMe)
                </label>
            </div>
            <div class="form-group">
                <input type="submit" value="Login" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>

@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

Tags: , , , ,

CORE

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。

Calendar

<<  2024年4月  >>
31123456
78910111213
14151617181920
21222324252627
2829301234
567891011

View posts in large calendar