WebSurfer's Home

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

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

ASP.NET Core Identity 独自実装(その1)

by WebSurfer 2020年9月4日 15:30

ASP.NET Core Identity によるユーザー認証に用いるデータソースとプロバイダを独自実装する話を書きます。認証機能の全てを独自実装するわけではありません(そんな実力は自分にはありません)。具体的には、下の画像(Microsoft のドキュメントより借用)にある Data Source, Data Access Layer, Identity Store を自力で実装するだけです。

ASP.NET Identity のアーキテクチャ

この記事のタイトルが「その1」となっているのは、まずはロール機能なしでユーザー認証を実装して「その1」として書き、ロール機能の追加を「その2」で書く予定だからです。(「その2」は途中で挫折するかもしれませんが・・・)

Data Source はユーザー名とパスワードだけを保持する SQL Server とし、Data Access Layer には Entity Framework を用い、Identity Store(プロバイダ)を独自実装して、Register, Login, Logout という基本機能を ASP.NET Core 3.1 MVC アプリで使えるようにしてみました。(アカウントロックアウト、メールを使ったアカウント確認とパスワードリセット、二要素認証などの機能は実装しません)

やってみて思ったのは、既存のデータベースにユーザー情報があってそれをどうしても使わなければならないということでなければ、独自実装を行うのに必要な時間と労力に見合うメリットはなさそうということです。(Visual Studio のテンプレートで「個別のユーザーアカウント」を選んでプロジェクトを生成すれば済むはずですので)

ということで、この記事の情報はあまり役に立たないかもしれませんが、せっかく実装して動くようになったので備忘録として書いておきます。

基本的なことは Mcrosoft のドキュメント Custom storage providers for ASP.NET Core Identity が参考になると思います。それに、"To create a custom storage provider, create the data source, the data access layer, and the store classes that interact with this data access layer (上の画像の緑色と灰色の箱). You don't need to customize the managers or your app code that interacts with them (上の画像の青色の箱)." と書いてあり、それに従いました。

クラスやメソッドのコードなど具体的な実装方法は Using your own database schema and classes with ASP.NET Core Identity and Entity Framework CoreCustomize ASP.NET Core Identity を参考にさせていただきました。

それらの記事をベースに、上の画像の緑色と灰色の箱の部分を自力で実装した手順を以下に書きます。

(1) プロジェクトの作成

プロジェクトの作成

Visual Studio 2019 で Core 3.1 ベースの ASP.NET MVC アプリを認証なしで作成します。プロジェクト名は MvcIdCustom としました。(プロジェクト名は任意です)

(2) NuGet パッケージ

NuGet パッケージ

NuGet パッケージをインストールします。画像の上から 3 つは必須のようです。一番下のパッケージは Visual Studio で Controller / View を生成する場合に必要なもので、ASP.NET Identity とは直接関係なさそうです。

(3) コンテキストクラスの実装

プロジェクトに DAL フォルダを新規に作ってそこにコンテキストクラス DataContext.cs を実装します。フォルダの名前やクラス名は任意ですが、これを使うコードで名前空間の指定などに影響しますので注意してください。

using MvcIdCustom.Models;
using Microsoft.EntityFrameworkCore;

namespace MvcIdCustom.DAL
{
    public class DataContext : DbContext
    {
        public DataContext(DbContextOptions<DataContext> options) : 
            base(options)
        {
        }

        public DbSet<Role> Roles { get; set; }

        public DbSet<User> Users { get; set; }

        public DbSet<UserRole> UserRoles { get; set; }
    }
}

(4) エンティティクラスの実装

既存の Models フォルダにエンティティクラス User, Role, UserRole を実装します。場所は Models フォルダでなくても良いですが、これを使うコードで名前空間の指定などに影響しますので注意してください。

using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace MvcIdCustom.Models
{
    [Table("User")]
    public class User
    {
        [Key, Required]
        public int Id { get; set; }

        [Required, MaxLength(128)]
        public string UserName { get; set; }

        [Required, MaxLength(1024)]
        public string PasswordHash { get; set; }

        public virtual ICollection<UserRole> UserRoles { get; set; }
    }

    [Table("Role")]
    public class Role
    {
        [Key, Required]
        public int Id { get; set; }

        [Required]
        public string Name { get; set; }

        public virtual ICollection<UserRole> UserRoles { get; set; }
    }

    [Table("UserRole")]
    public class UserRole
    {
        [Key, Required]
        public int Id { get; set; }

        [Required, ForeignKey(nameof(User))]
        public int UserId { get; set; }

        [Required, ForeignKey(nameof(Role))]
        public int RoleId { get; set; }

        public virtual Role Role { get; set; }
        public virtual User User { get; set; }
    }
}

User クラスは「個別のユーザーアカウント」を選んでプロジェクトを生成した場合に使われる IdentityUser クラスに該当します。

(5) service と接続文字列の追加

上のコンテキストクラスとエンティティクラスをベースに、Entity Framework Code First の機能を利用して SQL Server に DB を生成するのですが、その前に以下の設定をしておきます。

i) Startup.cs に service を追加

以下のコードを Startup.cs 内の ConfigureServices メソッドに追加します。接続文字列名は任意です。この記事では "Default" としています。

services.AddDbContext<DataContext>(options =>
    options.UseSqlServer(
        Configuration.GetConnectionString("Default")));

ii) 接続文字列を appsettings.json に追加

プロジェクトに appsettings.json ファイルがあるので、 それを開いて以下のように接続文字列を追加します。 カンマ等必要に応じて追加して全体的に有効な JSON 形式となるよう注意してください。

"ConnectionStrings": {
    "Default": "Server=(local)\\sqlexpress;Database=MvcIdCustom ..."
}

(6) Add-Migration, Update-Database

Visual Studio のパッケージマネージャーコンソールから Add-Migration CustomUserData コマンド(名前 CustomUserData は任意)を実行します。成功するとプロジェクトに Migrations フォルダと、その中に xxx_CustomUserData.cs ファイルが作られるはずです (xxx はコマンドの実行日時)。

その後 Update-Database を実行すれば、接続文字列に指定した名前(上の例では MvcIdCustom)で DB が生成され、その中にエンティティクラスの Table 属性に指定した名前のテーブルが生成されているはずです。

ここまでで、この記事の一番上の画像の Data Source と Data Access Layer の実装が終わったことになります。次はその上の Identity Store レイヤーの中の UserStore クラスの実装です。

(7) UserStore クラスの実装

参考にした記事をベースに、プロジェクトの DAL フォルダに、UserStore.cs として以下のように実装しました。この上のレイヤーにある UserManager, SignInManager がこのクラスに定義したメソッドを使って動くようです。

実装しましたとか簡単に書きましたが、上位レイヤーと調和を取って動くようにするにはどのように実装すべきかが全く分からず、実はこの実装が一番難問でした。パスワードのハッシュなど行われますが、何処でどう動いているのかさっぱり分かっていません。

using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using MvcIdCustom.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace MvcIdCustom.DAL
{
    public class UserStore : IUserStore<User>, 
                             IUserPasswordStore<User>, 
                             IQueryableUserStore<User>
    {
        private DataContext db;

        public UserStore(DataContext db)
        {
            this.db = db;
        }

        public void Dispose()
        {
            Dispose(true);
        }

        protected virtual void Dispose(bool disposing)
        {
            if (disposing)
            {
                if (db != null)
                {
                    db.Dispose();
                    db = null;
                }                
            }
        }

        // IQueryableUserStore<User> のメンバー
        public IQueryable<User> Users
        {
            get { return db.Users.Select(u => u); }
        }

        // IUserStore<TUser>
        public Task<string> GetUserIdAsync(User user, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (user == null) throw new ArgumentNullException(nameof(user));

            return Task.FromResult(user.Id.ToString());
        }

        // IUserStore<TUser>
        public Task<string> GetUserNameAsync(User user, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (user == null) throw new ArgumentNullException(nameof(user));

            return Task.FromResult(user.UserName);
        }

        // IUserStore<TUser>
        public Task SetUserNameAsync(User user, 
            string userName, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (user == null) throw new ArgumentNullException(nameof(user));
            if (string.IsNullOrEmpty(userName)) 
                throw new ArgumentException("userName");

            user.UserName = userName;

            return Task.CompletedTask;
        }

        // IUserStore<TUser>
        public Task<string> GetNormalizedUserNameAsync(User user, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (user == null) throw new ArgumentNullException(nameof(user));

            return Task.FromResult(user.UserName);
        }

        // IUserStore<TUser>
        public Task SetNormalizedUserNameAsync(User user, 
            string normalizedName, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (user == null) throw new ArgumentNullException(nameof(user));
            if (string.IsNullOrEmpty(normalizedName)) 
                throw new ArgumentException("normalizedName");

            return Task.CompletedTask;
        }

        // IUserStore<TUser>
        // 同じユーザー名の二重登録の防止機能は上位レイヤーに組み込まれている
        public async Task<IdentityResult> CreateAsync(User user, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (user == null) throw new ArgumentNullException(nameof(user));

            db.Add(user);

            int rows = await db.SaveChangesAsync(cancellationToken);

            if (rows > 0)
            { 
                return IdentityResult.Success;
            }

            return IdentityResult.Failed(
                new IdentityError { Description = $"{user.UserName} 登録失敗" });
        }

        // IUserStore<TUser>
        // 同じユーザー名の二重登録の防止機能は上位レイヤーに組み込まれている
        public async Task<IdentityResult> UpdateAsync(User user, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (user == null) throw new ArgumentNullException(nameof(user));

            db.Update(user);

            int rows = await db.SaveChangesAsync(cancellationToken);

            if (rows > 0)
            {
                return IdentityResult.Success;
            }

            return IdentityResult.Failed(
                new IdentityError { Description = $"{user.UserName} 更新失敗" });
        }

        // IUserStore<TUser>
        public async Task<IdentityResult> DeleteAsync(User user, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (user == null) throw new ArgumentNullException(nameof(user));

            db.Remove(user);

            int rows = await db.SaveChangesAsync(cancellationToken);

            if (rows > 0)
            {
                return IdentityResult.Success;
            }

            return IdentityResult.Failed(
                new IdentityError { Description = $"{user.UserName} 削除失敗" });
        }

        // IUserStore<TUser>
        public async Task<User> FindByIdAsync(string userId, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();

            if (int.TryParse(userId, out int id))
            {
                return await db.Users.SingleOrDefaultAsync(u => u.Id == id, 
                    cancellationToken);
            }
            else
            {
                return await Task.FromResult<User>(null);
            }
        }

        // IUserStore<TUser>
        public async Task<User> FindByNameAsync(string normalizedUserName, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (string.IsNullOrEmpty(normalizedUserName))
                throw new ArgumentException("normalizedUserName");

            User user = await db.Users.SingleOrDefaultAsync(
                u => u.UserName.Equals(normalizedUserName.ToLower()), 
                cancellationToken);

            return user;
        }

        // IUserPasswordStore<TUser>
        public Task SetPasswordHashAsync(User user, 
            string passwordHash, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (user == null) throw new ArgumentNullException(nameof(user));
            if (string.IsNullOrEmpty(passwordHash))
                throw new ArgumentException("passwordHash");

            user.PasswordHash = passwordHash;

            return Task.CompletedTask;
        }

        // IUserPasswordStore<TUser>
        public Task<string> GetPasswordHashAsync(User user, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (user == null) throw new ArgumentNullException(nameof(user));

            return Task.FromResult(user.PasswordHash);
        }

        // IUserPasswordStore<TUser>
        public Task<bool> HasPasswordAsync(User user, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (user == null) throw new ArgumentNullException(nameof(user));

            return Task.FromResult(
                !string.IsNullOrWhiteSpace(user.PasswordHash));
        }
    }
}

ロール機能を使えるようにするには IUserRoleStore<User> を継承し、AddToRoleAsync, GetRolesAsync, IsInRoleAsync, RemoveFromRoleAsync メソッドを実装する必要があるかもしれませんが、それは「その2」で考えることにします。

(8) RoleStore クラスの実装

この記事「その1」ではロール機能は実装しないので必要ないのですが、「その2」で実装する予定ですので骨組みだけ作っておくことにしました。

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

namespace MvcIdCustom.DAL
{
    public class RoleStore : IRoleStore<Role>, IQueryableRoleStore<Role>
    {
        public IQueryable<Role> Roles => throw new NotImplementedException();

        public Task<IdentityResult> CreateAsync(Role role, CancellationToken cancellationToken)
        {
            throw new NotImplementedException();
        }

        public Task<IdentityResult> DeleteAsync(Role role, CancellationToken cancellationToken)
        {
            throw new NotImplementedException();
        }

        public void Dispose()
        {
            //throw new NotImplementedException();
        }

        public Task<Role> FindByIdAsync(string roleId, CancellationToken cancellationToken)
        {
            throw new NotImplementedException();
        }

        public Task<Role> FindByNameAsync(string normalizedRoleName, CancellationToken cancellationToken)
        {
            throw new NotImplementedException();
        }

        public Task<string> GetNormalizedRoleNameAsync(Role role, CancellationToken cancellationToken)
        {
            throw new NotImplementedException();
        }

        public Task<string> GetRoleIdAsync(Role role, CancellationToken cancellationToken)
        {
            throw new NotImplementedException();
        }

        public Task<string> GetRoleNameAsync(Role role, CancellationToken cancellationToken)
        {
            throw new NotImplementedException();
        }

        public Task SetNormalizedRoleNameAsync(Role role, string normalizedName, CancellationToken cancellationToken)
        {
            throw new NotImplementedException();
        }

        public Task SetRoleNameAsync(Role role, string roleName, CancellationToken cancellationToken)
        {
            throw new NotImplementedException();
        }

        public Task<IdentityResult> UpdateAsync(Role role, CancellationToken cancellationToken)
        {
            throw new NotImplementedException();
        }
    }
}

上の RoleStore クラスの中身は Visual Studio で自動生成できます。Dispose メソッドの throw new NotImplementedException(); をコメントアウトしておかないとエラーになるので注意してください。

(9) Startup.cs への追加

以下のコードを Startup.cs 内の ConfigureServices メソッドに追加します。

// Tell identity to use custom classes for users and roles
services.AddIdentity<User, Role>()
    .AddDefaultTokenProviders();

// Tell identity to use custom storage provider for users
services.AddTransient<IUserStore<User>, UserStore>();

// Tell identity to use custom storage provider for roles
services.AddTransient<IRoleStore<Role>, RoleStore>();

さらに、認証ミドルウェアを使うために app.UseAuthorization(); を Configure メソッドに追加します。順番があるので注意してください。以下の場所に追加します。

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    // ・・・中略・・・

    app.UseHttpsRedirection();
    app.UseStaticFiles();
    app.UseRouting();

    // 以下の一行を追加
    app.UseAuthentication();
            
    app.UseAuthorization();

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

以上でこの記事の一番上の画���の Data Source, Data Access Layer, Identity Store レイヤーの実装は完了です。ここまでの実装で、先の記事「ASP.NET Identity のユーザー管理 (CORE)」と同じ手段でユーザー情報の表示、追加、変更、削除が可能になることは確認しました。

次は Register, Login, Logout 機能の実装ですが、それを続けてここに書くと一つの記事としては長くなりすぎるので、別の記事「Register, Login, Logout 機能の実装」に書きました。


「その2」も書きました。こちらの記事「ASP.NET Core Identity 独自実装(その2)」です。

Tags: , , , ,

CORE

ASP.NET Core アプリの Web サーバー

by WebSurfer 2020年6月29日 15:43

ASP.NET Core 3.1 の Web アプリをホストするのに利用できる Web サーバーは何で、どのような構成になるかということを調べたので、備忘録として書いておきます。

(1) 開発環境

ASP.NET Core 3.1 の Web アプリを Visual Studio Community 2019 のテンプレートを利用して作成し、ツールバーの[デバッグ(D)]⇒[デバッグの開始(S)](または[デバッグなしで開始(H)]) で Visual Studio からアプリを実行すると IIS Express が起動されますので、デフォルトでは IIS Express が使われているのは間違いなさそうです。(注: 2021/11/9 にリリースされた Visual Studio 2022 .NET 6.0 プロジェクトではデフォルトでは Kestrel に変わっているようです)

IIS Express

Microsoft のドキュメント「ASP.NET Core での Web サーバーの実装」によると、IIS または IIS Express を使用するとインプロセス ホスティング モデルまたはアウトプロセス ホスティング モデルのどちらかで実行されるそうです。(下図参照・・・Microsoft のドキュメントから借用しました)

インプロセス ホスティング

インプロセス ホスティング モデル

アウトプロセス ホスティング

アウトプロセス ホスティング モデル

Visual Studio 2019 から実行した場合どちらで動いているかですがデフォルトでは「インプロセス ホスティング モデル」と思われます。

そのものズバリを書いた Microsoft の文書は見つからなかったので、関係する資料や Visual Studio のテンプレートで作ったアプリの内容を調べての想像が入ってますが、自信度は 90% ぐらいあります。根拠は以下の通りです。

根拠 0 (2021/5/25 追記)

後で気が付いたのですが、下記が根拠としては一番確かなようです。下の「根拠 1」~「根拠 3」はせっかく書いたので残しておきますが、見てもらわわなくても良さそうです。(笑)

Visual Studio 2019 のツールバーにあるドロップダウンはデフォルトでは以下のように IIS Express が選択されています。(注: 2021/11/9 にリリースされた Visual Studio 2022 では、デフォルトではプロジェクト名 MvcCore5App2 が選択されており、そのまま実行するとアプリは Kestrel で実行されます。選択によってどう変わるかの詳細は別の記事「開発環境で Kestrel 利用 (CORE)」を見てください)

ドロップダウンの選択

上の画像のドロップダウンで IIS Express が選択された状態で、Visual Studio 2019 のソリューションエクスプローラーからプロジェクトのプロパティを開き、[デバッグ]タブの Web サーバーの設定で[ホスティングモデル]を見るとデフォルトの選択が「(規定値) インプロセス」になっています。

Web サーバーの設定

というわけで、開発環境で Visual Studio 2019 を使ってデフォルト設定のまま ASP.NET Core アプリを実行すれば「インプロセス ホスティング モデル」で動くということに間違いなさそうです。

また、上の画像のドロップダウンで「アウトプロセス」を選択して実行すると「アウトプロセス ホスティング モデル」で動くのも間違いなさそうです。

根拠 1

Microsoft のドキュメント「IIS を使用した Windows での ASP.NET Core のホスト」には、以下のように、デフォルトでは「インプロセス ホスティング モデル」を使うと思える説明があります。

"既存のアプリではインプロセス ホスティングがオプトインされています。ASP.NET Core Web テンプレートでは、インプロセス ホスティング モデルが使用されます。"

"CreateDefaultBuilder では、UseIIS メソッドを呼び出し、CoreCLR を起動して IIS ワーカー プロセス(w3wp.exe または iisexpress.exe) 内のアプリをホストすることで、IServer インスタンスを追加します。"

"CreateHostBuilder (Program.cs) でホストを構築する場合は、CreateDefaultBuilder を呼び出して IIS 統合を有効にします。"

実際にテンプレートを使って作成したアプリの Program.cs は以下のようになっています。

namespace RazorApp
{
    public class Program
    {
        public static void Main(string[] args)
        {
            CreateHostBuilder(args).Build().Run();
        }

        public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(webBuilder =>
                {
                    webBuilder.UseStartup<Startup>();
                });
    }
}

根拠 2

さらに、開発環境で IIS Express が使用する applicationHost.config に以下の設定があります。(場所に注意。IIS 用とは違います。詳しくは先の記事「ApplicationHost.config の場所」を見てください)

<system.webServer>
  <!-- 中略 -->
  <handlers>
    <add name="aspNetCore" path="*" verb="*"
      modules="AspNetCoreModuleV2"
      resourceType="Unspecified" />
  </handlers>
  <aspNetCore processPath="%LAUNCHER_PATH%"
    arguments="%LAUNCHER_ARGS%" stdoutLogEnabled="false"
    hostingModel="InProcess" startupTimeLimit="3600"
    requestTimeout="23:00:00" />
  <!-- 中略 -->
</system.webServer>

aspNetCore という名前の HTTP ハンドラが追加され、全ての要求に AspNetCoreModuleV2 ハンドラを使うよう設定されています。AspNetCoreModuleV2 というのは「ASP.NET Core モジュール」らしいです。

また、aspNetCore 要素に hostingModel="InProcess" とあり、「インプロセス ホスティング モデル」が指定されています。

ちなみに上の設定は IIS 用の applicationHost.config には含まれません。

根拠 3

Microsoft のドキュメント「ASP.NET Core モジュール」によると、"アウトプロセス ホスティング用にアプリを構成するには、プロジェクトファイル ( .csproj) で、<AspNetCoreHostingModel> プロパティの値を OutOfProcess に設定します" とのことです。

テンプレートで作ったアプリのプロジェクトファイルにはそのような設定はなく、デフォルトは InPorcess とのことなので、デフォルトでは「インプロセス ホスティング モデル」になるはずです。


ただし、別の Microsoft のドキュメント「ASP.NET Core への Kestrel Web サーバーの実装」には以下の記述があるのが気になります。

"ASP.NET Core プロジェクト テンプレートは既定では Kestrel を使用します。 Program.cs では、ConfigureWebHostDefaults メソッドにより UseKestrel が呼び出されます。"

これが自信度が 90% にとどまっている理由です。(笑)

上に述べた「ASP.NET Core への Kestrel Web サーバーの実装」の記述の他にも分からない点がいろいろあります。それらは以下の通りですが今後の調査課題ということで・・・

  • ASP.NET Core モジュールというのは、チュートリアル「IIS に ASP.NET Core アプリを発行する」のリンク先「現在の .NET Core ホスティング バンドルのインストーラー (直接ダウンロード)」からダウンロードされる dotnet-hosting-3.1.x-win.exe に含まれる Microsoft .NET Core 3.1.x - Windows Server Hosting のことだと思われる。でも、それをインストールする前から Visual Studio からアプリを実行できた。Visual Studio 2019 をインストールした時点で含まれていた?
  • IIS Express と IISHttpServer の間の設定は何もしてないがそれで動くのは何故? Microsoft のドキュメント「IIS を使用した Windows での ASP.NET Core のホスト」によると、ASP.NET Core モジュールがアプリの初期化を実行(Loads the CoreCLR と Calls Program.Main)すると書いてある。それだけで十分なのか?
  • そもそも、デフォルトで IIS Express を使うように設定される理由は何?
  • 開発環境で IIS Express リバースプロキシとして使わないで Kestrel に直接アクセスするように設定することはできるか?(2020/9/28 追記: できるようです。詳しくは別の記事「開発環境で Kestrel 利用」を見てください)

(2) 運用環境

運用環境での Web serverについては、Microsoft のドキュメント「ASP.NET Core での Web サーバーの実装」がまとまっていて概要を理解するのに分かりやすいと思いました。

Linux 系の OS の場合は、上で紹介したドキュメントによると、要するに Nginx とか Apache をリバースプロキシに使って、Kestrel で ASP.NET Core アプリをホストするということのようです。Linux 系の OS は自分は触ったこともないので、残念ながらそれ以上詳しい話はできないです。

Windows OS の場合は、ASP.NET Core に付属している以下のサーバーを利用できるそうです。

  • Kestrel
  • IISHttpServer
  • HTTP.sys (旧称 WebListener)

運用環境で IIS によってホストする場合の説明は、Microsoft のドキュメント「IIS を使用した Windows での ASP.NET Core のホスト」が詳しいので、それを見てください。

上にも書きましたが、「インプロセス ホスティング」と「アウトプロセス ホスティング」という 2 つのモデルがあって、前者には IISHttpServer が、後者には Kestrel が使用されるそうです。

他に、HTTP.sys(IIS の HTTP Protocol Stack HTTP.sys とは違うもののようです)を使うというオプションもあるそうです。自分は勉強不足で多くは語れませんので、Microsoft のドキュメント「ASP.NET Core での HTTP.sys Web サーバーの実装」を見てください。

OS が Windows 10 Pro 64-bit の PC のローカル IIS に ASP.NET Core 3.1 アプリを発行するのは実際にやってみました。

その手順は Microsoft のチュートリアル「IIS に ASP.NET Core アプリを発行する」の通りですが、概略を以下に書いておきます。結果「インプロセス ホスティング モデル」になっていると思います。

  1. C:\WebSites2019 というフォルダ下に AspNetCoreWebSite という名前のフォルダを作成。
  2. IIS Manager を起動してそのフォルダをサイトに設定。
  3. サイトバインド設定は、種類: http, IP アドレス: 未使用の IP アドレスすべて, ポート: 80, ホスト名: www.aspnetcorewebsite.com とした。
  4. hosts ファイルに 127.0.0.1 www.aspnetcorewebsite.com と設定。
  5. VS2019 のテンプレートで自動生成される Startup.cs に HTTPS 通信を実行させるミドルウェアを適用するコード app.UseHsts(); と app.UseHttpsRedirection(); が含まれている。自分の PC のローカル IIS では HTTPS で通信できないので、そのあたりの設定を変更する必要があるかと思ったが、実際試すとそうでもなかった。 Microsoft のドキュメント Enforce HTTPS in ASP.NET Core にポートを設定しないとリダイレクトされないというようなことが書いてある。そのため?
  6. Microsoft のドキュメント「IIS を使用した Windows での ASP.NET Core のホスト」に "IIS サーバーのオプションを構成するには、IISServerOptions 用のサービス構成を ConfigureServices に含めます" と書いてあるが、テンプレートで作ったアプリにはその設定はない。設定しなければデフォルトが使われるので問題なさそう。
  7. チュートリアルの「アプリを発行および配置する」セクションに従って、上のステップで作成した C:\WebSites2019\AspNetCoreWebSite フォルダにプロジェクトを発行。IIS Manager で見た結果は下の画像のようになる。

IIS に発行された Razor ページ

  1. 以上の設定でブラウザから http://www.aspnetcorewebsite.com/ を要求すると Razor ページが表示される。 ワーカープロセスに C:\WebSites2019\AspNetCoreWebSite フォルダへの権限は何も与える必要はなかった。
  2. web.config は元のプロジェクトには含まれないが、自動生成されて配置される。aspNetCore という名前の HTTP ハンドラが追加され、全ての要求に AspNetCoreModuleV2 ハンドラを使うよう設定されている。AspNetCoreModuleV2 というのは「ASP.NET Core モジュール」らしい。
<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <location path="." inheritInChildApplications="false">
    <system.webServer>
      <handlers>
        <add name="aspNetCore" path="*" verb="*" 
          modules="AspNetCoreModuleV2" 
          resourceType="Unspecified" />
      </handlers>
      <aspNetCore processPath="dotnet" 
        arguments=".\RazorApp.dll" stdoutLogEnabled="false" 
        stdoutLogFile=".\logs\stdout" 
        hostingModel="inprocess" />
    </system.webServer>
  </location>
</configuration>
<!--ProjectGuid: 8db7ea97-35b2-4168-81d5-7c68974ce862-->

Tags: , , ,

CORE

About this blog

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

Calendar

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

View posts in large calendar