WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

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

by WebSurfer 6. September 2020 16:29

先の記事「ASP.NET Core Identity 独自実装(その1)」と「Register, Login, Logout 機能の実装」の続きです。

ロール管理画面

先の記事では ASP.NET Core Identity のユーザー認証に用いるデータソースとカスタムプロバイダを作成し、それを用いてユーザー認証機能を実装しました。今回、ロール機能を追加しましたので、その方法などを備忘録として書いておきます。

先の記事「ASP.NET Core Identity 独自実装(その1)」の一番上に表示した画像を見てください。前回、その画像の Data Source, Data Access Layer レイヤーおよび Identity Store レイヤーの中のカスタムプロバイダ UserStore までは作成し、期待通りの動作をすることは確認しました。

今回、ロール機能を実現するためのカスタムプロバイダ RoleStore を実装し、先に作成したカスタムプロバイダ UserStore にロールによるアクセス制限のための機能を追加します。

(1) RoleStore クラスの実装

先に骨組みだけ作っておきましたが、それに中身を実装します。作成済みの UserStore のメソッドを参考にしました。

上位レイヤーにある RoleInManager がこのクラスに定義したメソッドを使って、先に作成済みの Data Access Layer を介して SQL Server の Role テーブルにアクセスし、ロールデータの作成、取得、削除などの操作を行います。

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>
    {
        private DataContext db;

        public RoleStore(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;
                }
            }
        }

        // IQueryableRoleStore<Role> のメンバーの Roles プロパティ
        public IQueryable<Role> Roles
        {
            get { return db.Roles.Select(r => r); }
        }

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

            db.Add(role);

            int rows = await db.SaveChangesAsync(cancellationToken);

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

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

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

            db.Update(role);

            int rows = await db.SaveChangesAsync(cancellationToken);

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

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

        public async Task<IdentityResult> DeleteAsync(Role role, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (role == null) throw new ArgumentNullException(nameof(role));

            db.Remove(role);

            int rows = await db.SaveChangesAsync(cancellationToken);

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

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

        public Task<string> GetRoleIdAsync(Role role, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (role == null) throw new ArgumentNullException(nameof(role));

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

        public Task<string> GetRoleNameAsync(Role role,
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (role == null) throw new ArgumentNullException(nameof(role));

            return Task.FromResult(role.Name);
        }

        public Task SetRoleNameAsync(Role role, 
            string roleName, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (role == null) throw new ArgumentNullException(nameof(role));
            if (string.IsNullOrEmpty(roleName)) 
                throw new ArgumentException("roleName");

            role.Name = roleName;

            return Task.CompletedTask;
        }

        public Task<string> GetNormalizedRoleNameAsync(Role role, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (role == null) throw new ArgumentNullException(nameof(role));

            return Task.FromResult(role.Name);
        }

        public Task SetNormalizedRoleNameAsync(Role role, 
            string normalizedName, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (role == null) throw new ArgumentNullException(nameof(role));
            if (string.IsNullOrEmpty(normalizedName))
                throw new ArgumentException("normalizedName");

            return Task.CompletedTask;
        }

        public async Task<Role> FindByIdAsync(string roleId, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();

            if (int.TryParse(roleId, out int id))
            {
                return await db.Roles.SingleOrDefaultAsync(r => r.Id == id, 
                    cancellationToken);
            }
            else
            {
                return await Task.FromResult<Role>(null);
            }
        }

        public async Task<Role> FindByNameAsync(string normalizedRoleName, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (string.IsNullOrEmpty(normalizedRoleName))
                throw new ArgumentException("normalizedRoleName");

            Role role = await db.Roles.SingleOrDefaultAsync(
                r => r.Name.Equals(normalizedRoleName.ToLower()), 
                cancellationToken);

            return role;
        }
    }
}

(2) UserStore クラスへの追加

ロールを使えるようにするには、先に作成した UserStore クラス に機能の追加を行う必要があります。

具体的には、以下のように IUserRoleStore<User> インターフェースを継承し、AddToRoleAsunc, GetRolesAsync, IsInRoleAsync, RemoveFromRoleAsync メソッドを実装します。

using System.Collections.Generic;
using System.Linq;
using System.Reflection.Metadata.Ecma335;
using System.Threading;
using System.Threading.Tasks;

namespace MvcIdCustom.DAL
{
    public class UserStore : IUserStore<User>, 
                             IUserPasswordStore<User>, 
                             IQueryableUserStore<User>, 
                             IUserRoleStore<User>       // 追加
    {

        // ・・・中略・・・

        // Visual Studio のテンプレートで「個別のユーザーアカウント」を
        // 選んで自動生成される AspNetUserRoles テーブルは UserId と
        // RoleId の 2 つのフィールドのみが含まれ、それが連結主キーにな
        // っているので UserId と RoleId の組み合わせが重複することはない。

        // 一方、このプロジェクトで作った UserRole テーブルは Id, UserId,
        // RoleId の 3 つのフィールドを持ち Id が主キーになっている。なの
        // で UserId と RoleId の組み合わせが重複できてしまう。以下のコード
        // では重複設定できないように考えた(つもり)

        public async Task AddToRoleAsync(User user, string roleName, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (user == null) throw new ArgumentNullException(nameof(user));
            if (string.IsNullOrEmpty(roleName)) 
                throw new ArgumentException("roleName");

            // roleName に該当する Role オブジェクトを取得
            var role = await db.Roles.SingleOrDefaultAsync(
                             role => role.Name.Equals(roleName), 
                             cancellationToken);

            // roleName という名前のロールは存在しないので何もせず return
            if (role == null) return;

            // UserRole テーブルの UserId と RoleId が user.Id と role.Id
            // と一致する UserRole オブジェクトを取得
            var userRole = await db.UserRoles.SingleOrDefaultAsync(
                                 userRole => userRole.UserId.Equals(user.Id) && 
                                             userRole.RoleId.Equals(role.Id),
                                 cancellationToken);

            // user は roleName という名前のロールには既にアサイン済みなので
            // 何もせず return
            if (userRole != null) return;

            userRole = new UserRole
            {
                UserId = user.Id,
                RoleId = role.Id
            };

            db.UserRoles.Add(userRole);
            await db.SaveChangesAsync(cancellationToken);
        }

        public async Task RemoveFromRoleAsync(User user, string roleName, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (user == null) throw new ArgumentNullException(nameof(user));
            if (string.IsNullOrEmpty(roleName)) 
                throw new ArgumentException("roleName");

            // roleName に該当する Role オブジェクトを取得
            var role = await db.Roles.SingleOrDefaultAsync(
                             role => role.Name.Equals(roleName), 
                             cancellationToken);

            // roleName という名前のロールは存在しないので何もせず return
            if (role == null) return;

            // UserRole テーブルの UserId と RoleId が user.Id と role.Id
            // と一致する UserRole オブジェクトを取得
            var userRole = await db.UserRoles.SingleOrDefaultAsync(
                                userRole => userRole.UserId.Equals(user.Id) &&
                                            userRole.RoleId.Equals(role.Id), 
                                cancellationToken);

            // user は roleName という名前のロールにはアサインされてないので
            // 何もせず return
            if (userRole == null) return;

            db.UserRoles.Remove(userRole);
            await db.SaveChangesAsync(cancellationToken);
        }

        public async Task<IList<string>> GetRolesAsync(User user, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (user == null) throw 
                new ArgumentNullException(nameof(user));

            // UserRole テーブルの UserId フィールドが user.Id と一致する
            // UserRole オブジェクトを取得。Include を使ってナビゲーション
            // プロパティ経由 Role を紐づけているので Select メソッドで
            // ロール名のリストを取得できる
            var roleNames = await db.UserRoles
                .Include(userRole => userRole.Role)
                .Where(userRole => userRole.UserId.Equals(user.Id))
                .Select(userRole => userRole.Role.Name).ToListAsync();

            // 一件も見つからない時は null ではなく中身が空(Count がゼロ)
            // の List<string> オブジェクトを返す
            return roleNames;
        }

        public async Task<bool> IsInRoleAsync(User user, string roleName, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (user == null) throw new ArgumentNullException(nameof(user));
            if (string.IsNullOrEmpty(roleName)) 
                throw new ArgumentException("roleName");

            // UserRole テーブルの UserId フィールドが user.Id と一致する
            // UserRole オブジェクトを取得。Include を使ってナビゲーション
            // プロパティ経由 Role を紐づけているので Select メソッドで
            // ロール名のリストを取得できる
            var roleNames = await db.UserRoles
                .Include(userRole => userRole.Role)
                .Where(userRole => userRole.UserId.Equals(user.Id))
                .Select(userRole => userRole.Role.Name).ToListAsync();

            if (roleNames != null && roleNames.Count > 0)
            {
                // 引数 roleName は大文字に変換されて渡されるので ToUpper()
                // がないと true にならない。
                bool isInRole = roleNames.Any(
                                name => name.ToUpper() == roleName);
                return isInRole;
            }

            return false;
        }

        public async Task<IList<User>> GetUsersInRoleAsync(string roleName, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (string.IsNullOrEmpty(roleName)) 
                throw new ArgumentException("roleName");

            // roleName に該当する Role オブジェクトを取得
            var role = await db.Roles.SingleOrDefaultAsync(
                             role => role.Name.Equals(roleName),
                             cancellationToken);

            if (role != null)
            {
                var users = await db.UserRoles
                    .Include(userRole => userRole.User)
                    .Where(userRole => userRole.RoleId.Equals(role.Id))
                    .Select(userRole => userRole.User).ToListAsync();

                // 一件も見つからない時は null ではなく中身が空の
                // List<User> オブジェクトを返すはず
                return users;
            }

            return new List<User>();
        }
    }
}

(3) ロール管理画面の実装

先の記事「ASP.NET Identity のロール管理 (CORE)」と同等な方法で、ロールの表示・追加・変更・削除およびユーザーへのロールのアサイン・解除を行う機能を実装しました。

ただし、その記事に書いた「(1) ロールサービスの追加」は不要ですので注意してください。

実装した機能を使ってロールを作成し、ユーザーをロールにアサインした結果がこの記事の一番上にある画像です。

(4) /Account/AccessDenied の実装

コントローラーやアクションメソッドに [Authorize(Roles ="アドミ")] 属性を付与すると(ロール名は日本語 OK)、「アドミ」ロールを有してないユーザーがアクセスした場合は /Account/AccessDenied にリダイレクトされます。

/Account/AccessDenied を実装してないと、リダイレクトされた際 HTTP 404 Not Found エラーとなってしまいますので実装は必要です。

リダイレクトはフレームワークが自動的に行いますので、ユーザーにアクセス権限がないことを通知するだけの簡単なページを作れば OK です。


以上で完了です。

ロールによるアクセス制限は有効に働くようになります。ユーザーの持つロールに応じて /Account/Login および /Account/AccessDenied へのリダイレクト、許可されていればページの表示が期待通り動きます。

ユーザーがアサインされているロールの削除、ロールがアサインされているユーザーの削除も、内部で階層更新が行われているのか、問題なく実行でき、結果は期待通り SQL Server の当該テーブル反映されます。

その他、同じロール名の二重登録の防止機能が組み込まれており、例えば登録済みの Xxxxx というロール名を登録しようとすると Role name 'Xxxxx' is already taken. というエラーメッセージが出て登録に失敗します。

上記は上位レイヤーで処理されているようです。上記以外にもパスワードのハッシュや認証クッキーの発行など上位レイヤーの機能は多々あり、それを自力で実装するのは非常に難しいので、カスタム化する範囲は Identity Store レイヤー以下でないと手に負えないと思いました。

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

連動ドロップダウンリスト (MVC5 版)

by WebSurfer 22. August 2020 14:53

ASP.NET MVC での連動ドロップダウンリストの実装方法を備忘録として書いておきます。

ASP.NET MVC の連動 DropDownList

ASP.NET Web Forms アプリの場合は先の記事「DetailsView 中の連動 DropDownList」に書きました。それと同様な連動機能を MVC アプリで実装してみます。

記事「DetailsView 中の連動 DropDownList」と同様に、Northwind サンプルデータベースの Categories テーブルと Products テーブルを使って例を書きます。

上の画像に示すように 2 つのドロップダウンリストを配置し、1 つめのドロップダウンリストで分類を選択すると、2 つめのドロップダウンリストにはその分類に属する製品が絞り込まれて表示されるようにします。

データベースへのアクセスやデータの取得は先の記事「スキャフォールディング機能」のステップ 1 ~ 10 で書きました Entity Data Model (EDM) を利用します。Categories テーブルと Products テーブルの EDM ダイアグラムは以下の通りです。

Categories  テーブルと Products テーブル

記事「DetailsView 中の連動 DropDownList」の場合は、分類ドロップダウンリストの選択が変更されるとポストバックがかかり、サーバー側で変更に応じて製品ドロップダウンリストの内容を書き換えてページ全体を再描画するというものでした。いちいちポストバックして再描画というのがちょっとやりすぎの感があります。

Ajax Control Toolkit の中に CascadingDropDown というものがありますが、こちらは Ajax を利用して、上位ドロップダウンリストの変更があると、サーバー側の Web サービスに下位ドロップダウンリストに表示するデータを要求し、Web サービスから戻ってきた JSON 形式のデータで下位ドロップダウンリストの内容を書き換えるというものです。

この記事の MVC 版連動ドロップダウンリストでも、Ajax Control Toolkit の CascadingDropDown のやり方にならって、jQuery ajax を利用して、分類ドロップダウンリストの選択に応じて製品ドロップダウンリストに表示する ProductID と ProductName を JSON 形式で取得し、製品ドロップダウンリストの内容を書き換えるようにしました。

コードは以下の通りです。説明はコード内にコメントとして書きましたのでそちらを見てください。手抜きでスミマセン。

Model

using System.ComponentModel.DataAnnotations;

namespace Mvc5App.Models
{
    public class Sales
    {
        public int Id { get; set; }

        [Display(Name = "分類")]
        [Required(ErrorMessage = "{0} の選択は必須")]
        public int CategoryID { get; set; }

        [Display(Name = "製品")]
        [Required(ErrorMessage = "{0} の選択は必須")]
        public int ProductID { get; set; }

        [Display(Name = "コメント")]
        [Required(ErrorMessage = "{0} は必須")]
        public string Comment { get; set; }
    }
}

Controller / Action Method

using System;
using System.Collections.Generic;
using System.Data;
using System.Data.Entity;
using System.Linq;
using System.Threading.Tasks;
using System.Net;
using System.Web;
using System.Web.Mvc;
using Mvc5App;

namespace Mvc5App.Controllers
{
    public class ProductsController : Controller
    {
        private NORTHWINDEntities db = new NORTHWINDEntities();

        public ActionResult CascadingDropDown()
        {
            ViewBag.CategoryID = 
                new SelectList(db.Categories, "CategoryID", "CategoryName");
            return View();
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public ActionResult CascadingDropDown(
            [Bind(Include = "ProductID,CategoryID,Comment")] 
            Mvc5App.Models.Sales sales)
        {
            if (ModelState.IsValid)
            {
                return RedirectToAction("Index");
            }

            // 検証結果 NG で再描画する際は以下のように再度 ViewBag
            // に SelectList を設定しないと分類ドロップダウンの中身
            // が表示されないので注意
            ViewBag.CategoryID = 
                new SelectList(db.Categories, "CategoryID", "CategoryName");
            return View(sales);
        }

        // 製品ドロップダウンに表示する ProductID と ProductName を
        // JSON 形式で取得するアクションメソッド。引数 id が分類ドロ
        // ップダウンで選択された CategoryID
        public async Task<ActionResult> GetProducts(int id)
        {
            var products = from p in db.Products
                           where p.CategoryID == id
                           select new
                           {
                               ProductID = p.ProductID,
                               ProductName = p.ProductName
                           };
            return Json(await products.ToListAsync(), 
                        JsonRequestBehavior.AllowGet);
        }
    }
}

View

@model Mvc5App.Models.Sales

@{
    ViewBag.Title = "CascadingDropDown";
}

<h2>CascadingDropDown</h2>


@using (Html.BeginForm())
{
    @Html.AntiForgeryToken()

    <div class="form-horizontal">
        <h4>Sales</h4>
        <hr />
        @Html.ValidationSummary(true, "", new { @class = "text-danger" })
        <div class="form-group">
            @Html.LabelFor(model => model.CategoryID, 
                htmlAttributes: new { @class = "control-label col-md-2" })
            <div class="col-md-10">
                @Html.DropDownList("CategoryID", null, 
                    "▼分類を選択してください▼", 
                    htmlAttributes: new { @class = "form-control" })
                @Html.ValidationMessageFor(model => model.CategoryID, 
                    "", new { @class = "text-danger" })
            </div>
        </div>

        <div class="form-group">
            @Html.LabelFor(model => model.ProductID, 
                htmlAttributes: new { @class = "control-label col-md-2" })
            <div class="col-md-10">
                <select class="form-control" id="ProductID" name="ProductID"></select>
                @Html.ValidationMessageFor(model => model.ProductID, 
                    "", new { @class = "text-danger" })
            </div>
        </div>

        <div class="form-group">
            @Html.LabelFor(model => model.Comment, 
                htmlAttributes: new { @class = "control-label col-md-2" })
            <div class="col-md-10">
                @Html.EditorFor(model => model.Comment, 
                    new { htmlAttributes = new { @class = "form-control" } })
                @Html.ValidationMessageFor(model => model.Comment, 
                    "", new { @class = "text-danger" })
            </div>
        </div>

        <div class="form-group">
            <div class="col-md-offset-2 col-md-10">
                <input type="submit" value="Create" class="btn btn-default" />
            </div>
        </div>
    </div>
}

<div>
    @Html.ActionLink("Back to List", "Index")
</div>

@section Scripts {
    @Scripts.Render("~/bundles/jqueryval")

    <script type="text/javascript">
        //<![CDATA[

        // 長くなるので JavaScript / jQuery のコードは下に別掲

        //]]>
    </script>
}

JavaScript / jQuery

実際は以下のコードは View の @section Scripts { ... } ブロックの中にインラインで書いています。View のコードが長くなって見難くなるので別掲にしました。

$(function () {
    // 分類ドロップダウンの jQuery オブジェクトを取得。
    // html コードの id は html ヘルパの Id メソッドで取得
    var categoryDDL = $('#@Html.Id("CategoryID")');

    // 製品ドロップダウンの jQuery オブジェクトを取得。
    // こちらは <select id="ProductID" とハードコーディン
    // グされているので以下で OK (Id メソッドは不要)
    var productDDL = $("#ProductID");

    // DropDownList の第 3 引数 "▼分類を選択してください▼"
    // は以下の option 要素になる (必ず value="" になると書
    // いた Microsoft の文書が見つからないのが不安要素):
    // <option value="">▼分類を選択してください▼</option>

    // 以下は初期画面および検証結果 NG での再描画の際必要。
    // 初期画面では categoryDDL.val() は上の option 要素の
    // value="" を取得するので if 文の条件は false となる。
    // 検証結果 NG で再描画される際は分類ドロップダウンの
    // 選択に応じて製品ドロップダウンの内容を書き換える
    if (categoryDDL.val() != "") {
        productDDL.children().remove();
        productDDL.append(
            '<option value="">▼製品を選択してください▼</option>');
        productDDL.removeAttr('disabled');

        // 分類ドロップダウンの選択結果 (CategoryID) を
        // jQuery ajax でサーバー側の GetProducts アクション
        // メソッドに送信。アクションメソッドは Products テ
        // ーブルに SELECT クエリを発行し、CategoryID に属す
        // 製品の ProductID, ProductName を取得して JSON 文
        // 字列として返す。その JSON 文字列から option 要素
        // の文字列を組み立てて製品ドロップダウンに追加する
        $.ajax({
            url: '/Products/GetProducts/' + categoryDDL.val(),
            method: 'get',
        }).done(function (data) {
            $.each(data, function (key, val) {
                productDDL.append('<option value=' +
                    val.ProductID + '>' + val.ProductName +
                    '</option>');
            });
        }).fail(function (jqXHR, textStatus, errorThrown) {
            alert('Error getting products!');
        });
    } else {
        productDDL.children().remove();
        productDDL.attr('disabled', 'disabled');
    }

    // 分類ドロップダウンの選択が変更されると change イベント
    // が発生 するのでそのリスナで製品ドロップダウンの内容を
    // 分類に応じて書き換え。リスナの中身は上と同じコード
    categoryDDL.on("change", function () {
        if (categoryDDL.val() != "") {
            productDDL.children().remove();
            productDDL.append(
                '<option value="">▼製品を選択してください▼</option>');
            productDDL.removeAttr('disabled');

            $.ajax({
                url: '/Products/GetProducts/' + categoryDDL.val(),
                method: 'get',
            }).done(function (data) {
                $.each(data, function (key, val) {
                    productDDL.append('<option value=' +
                        val.ProductID + '>' + val.ProductName +
                        '</option>');
                });
            }).fail(function (jqXHR, textStatus, errorThrown) {
                alert('Error getting products!');
            });
        } else {
            productDDL.children().remove();
            productDDL.attr('disabled', 'disabled');
        }
    });
});

このコードで、この記事の上の画像のとおり表示されます。

分類ドロップダウンリストの選択変更に連動して製品ドロップダウンリストの内容が変わります。送信ボタンクリックの際のドロップダウンリストの検証は、クライアント側ではかかりませんが、サーバー側では検証されてエラーメッセージは期待通り表示されます。

サーバー側での検証結果が NG の場合、同じ画面が再描画されユーザーに選択の修正を促すようにしていますが、その際のドロップダウンリストの連動も OK なことを確認しています。

Tags: , ,

MVC

About this blog

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

Calendar

<<  September 2020  >>
MoTuWeThFrSaSu
31123456
78910111213
14151617181920
21222324252627
2829301234
567891011

View posts in large calendar