WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

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

by WebSurfer 5. September 2020 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 4. September 2020 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> を継承し、AddToRoleAsunc, 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

Web Forms アプリの Email Confirmation

by WebSurfer 10. August 2020 14:05

ASP.NET Web Forms アプリで登録・パスワード再取得のためのメール送信機能を利用する場合、テンプレートで実装されたコードを使うとメール本文に含まれる確認先の URL にアプリケーション名が含まれないという注意事項を書きます。(結果、メール本文に張られたリンクの URL が正しくないのでクリックしても確認できません)

アプリケーション名が含まれない

.NET Framework 版の ASP.NET Web Forms の Web アプリケーションプロジェクトを Visual Studio 2019 のテンプレートを使って、ユーザー認証に「個別のユーザーアカウント」(ASP.NET Identity) を選んで作った場合の話です。ASP.NET MVC5 ではこの問題はありません。

元の話は Teratail のスレッド「リセット画面のURLをアプリケーションルートにしたい」のものです。そのスレッドを見て初めてそういう問題があることを知りました。

メール本文に含めて送信する確認先の URL は、Models/IdentityModels.cs に含まれるヘルパーメソッド GetUserConfirmationRedirectUrl と GetResetPasswordRedirectUrl で Uri(Uri, String) コンストラクタを使って取得しています。

Uri(Uri, String) コンストラクタの第 1 引数に HttpRequest.Url プロパティで取得する Uri を、第 2 引数に "/Account/..." から始まる相対 URL の文字列を設定して Uri オブジェクトを作り、それから AbsoluteUri プロパティを使って絶対 URL を取得して確認メールの本文に張り付けています。

この記事の一番上のデバッグ画像を見てください。問題のヘルパーメソッド GetUserConfirmationRedirectUrl, GetResetPasswordRedirectUrl ではないですが、簡単に問題を再現するために Page_Load メソッドに同等のコードを書いて検証してみました。

なお、Visual Studio (IIS Express) では以下のように Request.Uri にアプリケーション名 myapp が含まれるよう[プロジェクトの URL (J)]を設定しています。

アプリケーション名 myapp を設定

上のデバッグ画像のとおり、ローカル変数の rquestUri にはアプリケーション名 myapp を含めた Uri オブジェクトを取得できています。ローカル変数 callbackUrl が確認先の URL になりますが、アプリケーション名 myapp は含まれません。これが問題です。

ちなみに、ASP.NET MVC アプリでは Url.Action を使ってアプリケーション名を含めた絶対 URL を取得していますのでこういう問題はないです。

対処方法として、ヘルパーメソッド GetUserConfirmationRedirectUrl と GetResetPasswordRedirectUrl 内にアプリケーション名をハードコーディングするのは好ましくはなさそうです。アプリケーション名の変更、アプリの移植で問題となりますので。

ルート演算子 (~) と VirtualPathUtility.ToAbsolute メソッドを利用して "~" に相当するパスを取得するのがよさそうです。具体例は以下の画像を見てください。

VirtualPathUtility.ToAbsolute メソッド利用

この記事の例のように、アプリケーション名が myapp の場合、VirtualPathUtility.ToAbsolute メソッドの引数に "~" を設定すると戻り値は "/myapp" になります。引数に "~/Account/ResetPassword" を設定すると(文字列先頭に "~" を付与している点に注意)戻り値は "/myapp/Account/ResetPassword" となります。以下の画像を見てください。

このようにすればアプリケーション名をハードコーディングしなくて済みます。


その他のパス設定の問題

テンプレートで生成されたコードのパス設定の問題は他にもあって、例えば Account/Manage.aspx ページでも以下のようにリンク先の URL にルート演算子 (~) が設定されてないです。

URL にルート演算子 (~) が設定されてない

リンクをクリックしても、普通はパスが通らないので、404 Not Found エラーとなってすぐ気が付くと思うかもしれませんが、開発環境ですと訳が分からないサーバーエラーになることがあるかもしれません。

実は、上の画像にあるように Visual Studio で[プロジェクトの URL (J)]を設定したのですが、その際 IIS Express 用の applicationHost.config 設定で application path="/" と application path="/myapp" が両方有効になって、/Account/ManagePassword.aspx に遷移できてしまったのです。

認証チケットは /mayapp/Acount/Login.aspx で取得しているのですが、/Account/ManagePassword.aspx では認証されないので User.Identity.GetUserId() でユーザー ID を取得できず訳が分からないサーバーエラーになりました。

これには半日ぐらい悩まされました。どうも Microsoft としては Web Forms アプリには力が入ってなくて、テンプレートで生成されるコードでも 100% 信頼できないような感じがします。

Tags: , ,

ASP.NET

About this blog

2010年5月にこのブログを立ち上げました。その後 ブログ2 を追加し、ここは ASP.NET 関係のトピックス、ブログ2はそれ以外のトピックスに分けました。

Calendar

<<  October 2021  >>
MoTuWeThFrSaSu
27282930123
45678910
11121314151617
18192021222324
25262728293031
1234567

View posts in large calendar