WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

開発環境で Kestrel 利用

by WebSurfer 25. September 2020 10:55

先の記事「ASP.NET Core アプリの Web サーバー」に書きましたが、Visual Studio から ASP.NET Core アプリを実行するとデフォルトでは IIS Express を使用するインプロセスホスティングモデルになります。

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

それを以下のように Kestrel をエッジサーバーとして使うようにするにはどうすれば良いかということを書きます。

Kestrel

実は最近まで知らなかったのですが、Visual Studio のツールバーにあるドロップダウンの選択で容易に変更できるそうです。

ドロップダウンの選択

デフォルトでは IIS Express が選択されています。その状態で Visual Studio から[デバッグ(D)]⇒[デバッグの開始(S)](または[デバッグなしで開始(H)]) でアプリを起動するとIIS Express を使用するインプロセスホスティングモデルで動きます。

ドロップダウンの選択をプロジェクト名(上の画像の例では MvcCoreApp)に変更して Visual Studio からアプリを起動すると以下のように dotnet run コマンドによってアプリが Kestrel で実行され、

dotnet run コマンド

処理された応答が自動的にブラウザに表示されます(自動的にブラウザに表示されるのは Visual Studio を使った場合です。下の注記を見てください)。

Chrome で表示

(注: dotnet run コマンドはアプリを実行するだけです。ブラウザに表示するにはブラウザを立ち上げてアドレスバーに dotnet run コマンドで表示される Now listenung on: にある URL(上の画像では https://localhost:5001 または http://localhost:5000)を入力して Kestrel に要求をかける必要があります。Visual Studio はブラウザの立ち上げと表示まで自動的に実行してくれます。さらに、HTTPS 通信に使う開発用のサーバー証明書も自動的に発行してくれます)

ドロップダウンの選択の変更によって何がどう変わるかの詳しい説明は Microsoft のドキュメント「ASP.NET Core で複数の環境を使用する」の「開発と launchSettings.json」のセクションに書いてあります。

簡単に言うと、Visual Studio のドロップダウンの選択によって launchSettings.json の profiles を IIS Express かプロジェクト名(上のは象の例では MvcCoreApp)のどちらかに切り替えることができ、それぞれの "commandName" キーの設定に応じて IIS Express を使用するか Kestrel を使用するかを指定できるそうです。

ちなみに、"commandName" キーに設定できるのは IISExpress, IIS, Project のいずれかということです。

なお、launchSettings.json が使えるのはローカルの開発マシンだけです。運用環境にデプロイされることはありません。なので、上記は開発環境だけでの話ですので注意してください。

開発環境でアウトプロセスホスティング構成(Kestrel を Web サーバー、IIS Express をリバースプロキシとして使う)とするのはどうすればいいかは不明です。Microsoft のドキュメント「ASP.NET Core モジュール」にプロジェクトファイルで AspNetCoreHostingModel プロパティの値を OutOfProcess に設定すると書いてあるのを見つけましたが未検証・未確認です。今後の検討課題ということで・・・

Tags: ,

CORE

環境別の appsettings.json

by WebSurfer 23. September 2020 12:10

開発環境と運用環境では、通常、接続文字列やエラー表示などが異なります。.NET Framework 版の ASP.NET Web アプリではそれらの設定は web.config ファイルに含まれており、運用環境にデプロイする際は該当部分を書き換える必要があります。

Visual Studio で開発を行う場合は、運用環境にデプロイする際書き換える部分をあらかじめ Web.Release.config というファイルに作っておき、Visual Studio の発行ツールを使ってデプロイ時に書き換えるということが可能です。

Web.Release.config

上の画像は Visual Studio Community 2019 のテンプレートを使って作成した ASP.NET Web Forms アプリで自動生成される Web.Release.config です。接続文字列とエラー表示の設定を書き換えるためのひな型が含まれています。

しかし、Core 版には .NET Framework 版と同じ機能はなさそうです。では、どうするかですが、以下の Microsoft のドキュメントに説明がありました(日本語版は翻訳がダメなので意味が分からないときは英語版を見てください)。

ASP.NET Core の構成

ASP.NET Core で複数の環境を使用する

appsettings.json に加えて、必要に応じて appsettings.Development.json, appsettings.Staging.json, appsettings.Production.json を追加し、それにそれぞれの環境専用の接続文字列などの設定を書きます。(注: Visual Studio のテンプレートを使うと appsettings.json と appsettings.Development.json は自動的に作成されプロジェクトに含まれているはずです)

appsettings.json

そして、例えば接続文字列を環境によって変えたい場合、appsettings.Development.json, appsettings.Staging.json, appsettings.Production.json にそれぞれの環境の接続文字列を書けば、環境変数 ASPNETCORE_ENVIRONMENT の値 (Development, Staging, Production のいずれか)によってファイルを選んでそれから接続文字列を取得してきます。

結果は appsettings.json の設定プラス appsettings.環境.json の設定になります。appsettings.json の設定を丸ごと appsettings.環境.json の設定に置き換えるということはしませが、設定がダブった場合(同じ名前のキーがあった場合)は、上に紹介した記事「ASP.NET Core の構成」に書いてある通り、appsettings.json の方をオーバーライドします。

例えば、開発環境においては、appsettings.Development.json 構成が appsettings.json の値を上書きします。運用環境では、appsettings.Production.json 構成が appsettings.json の値を上書きします。

なお、環境変数 ASPNETCORE_ENVIRONMENT が設定されてない場合は、上に紹介した記事「ASP.NET Core で複数の環境を使用する」に書いてあるように、デフォルトで Production になることに注意してください。

では、環境変数 ASPNETCORE_ENVIRONMENT をどのように設定するかですが・・・

開発環境では Visual Studio のテンプレートを使ってプロジェクトを作ると自動的に生成される launchSettings.json ファイルに "Development" と設定されています。

launchSettings.json

(注: 上の画像で赤枠で囲った方は Visual Studio から実行した場合デフォルトになる IIS Express を使ってインプロセスホスティングで実行する場合の設定です。下の方は Kestrel で実行する場合の設定です)

"Development" を "Staging", "Production" に変更して試してみると、上に書いた通り、取得してくる先の appsettings.環境.json が異なるのが分かるはずです。

運用環境では launchSettings.json ファイルは使いません。デプロイもされません。ではどうするかですが、そもそも環境変数なので、上に紹介した記事「ASP.NET Core で複数の環境を使用する」の「環境を設定する」のセクションに書いてあるようにして設定できるそうです。

または、ASPNETCORE_ENVIRONMENT が設定されてなければデフォルトで Production なので何も設定しないということでも良いかもしれません。

上に書いた appsettings.json の設定で接続文字列は環境によって書き換えできますが、エラー表示の設定の変更はできないので注意してください。それは Startup.cs の Configure メソッドで行います(HTTP 要求を処理するパイプラインを構成するためのミドルウェアの一部として登録)。

Visual Studio のテンプレートを使ってプロジェクトを作成すると、Startup.cs ファイルに以下のような Configure メソッドが自動的に生成されるはずです(日本語のコメントは自分が追加したものです)。

Startup.cs

環境変数 ASPNETCORE_ENVIRONMENT が Development に設定されていると env.IsDevelopment() が true になって、開発用の詳細エラーページが表示されるようになります。

Tags: , , ,

CORE

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

About this blog

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

Calendar

<<  October 2020  >>
MoTuWeThFrSaSu
2829301234
567891011
12131415161718
19202122232425
2627282930311
2345678

View posts in large calendar