認可によって保護されたユーザー データを使って ASP.NET Core Web アプリを作成する

作成者: Rick Anderson および Joe Audette

このチュートリアルでは、認可によって保護されたユーザー データを使って ASP.NET Core Web アプリを作成する方法について説明します。 認証済み (登録済み) ユーザーが作成した連絡先の一覧が表示されます。 次の 3 つのセキュリティ グループがあります。

  • 登録済みユーザーは、承認されたすべてのデータを表示できます。また、自分のデータを編集および削除できます。
  • マネージャーは、連絡先データを承認または拒否することができます。 承認された連絡先のみがユーザーに表示されます。
  • 管理者は、すべてのデータの承認、拒否、編集、削除を行うことができます。

このドキュメントの画像は、最新のテンプレートと完全には一致していません。

次の画像では、ユーザーの Rick (rick@example.com) がサインインしています。 Rick には、承認された連絡先と、自分の連絡先の [編集]/[削除]/[新規作成] リンクのみが表示されます。 Rick が作成した最後のレコードにのみ、 [編集] および [削除] リンクが表示されます。 マネージャーまたは管理者が状態を "承認済み" に変更するまで、他のユーザーには最後のレコードが表示されません。

Rick のサインインを示すスクリーンショット

次の画像では、manager@contoso.com がサインインしており、マネージャー ロールを担っています。

manager@contoso.com のサインインを示すスクリーンショット

次の画像は、マネージャーの連絡先の詳細ビューを示しています。

マネージャーの連絡先のビュー

[承認] ボタンと [拒否] ボタンは、マネージャーと管理者にのみ表示されます。

次の画像では、admin@contoso.com がサインインしており、管理者ロールを担っています。

admin@contoso.com のサインインを示すスクリーンショット

管理者には、すべての特権があります。 すべての連絡先の読み取り、編集、削除や、連絡先の状態の変更を行うことができます。

このアプリは、次の Contact モデルをスキャフォールディングすることで作成されました。

public class Contact
{
    public int ContactId { get; set; }
    public string Name { get; set; }
    public string Address { get; set; }
    public string City { get; set; }
    public string State { get; set; }
    public string Zip { get; set; }
    [DataType(DataType.EmailAddress)]
    public string Email { get; set; }
}

このサンプルには、次の認可ハンドラーが含まれています。

  • ContactIsOwnerAuthorizationHandler: ユーザーが自分のデータのみを編集できるようにします。
  • ContactManagerAuthorizationHandler: 連絡先の承認または拒否をマネージャーに許可します。
  • ContactAdministratorsAuthorizationHandler: 連絡先の承認または拒否と、連絡先の編集と削除を管理者に許可します。

前提条件

このチュートリアルは高度です。 次のことを理解している必要があります。

スターター アプリと完成したアプリ

完成したアプリをダウンロードします。 完成したアプリをテストして、セキュリティ機能に慣れます。

スターター アプリ

スターター アプリダウンロードします。

アプリを実行し、 [ContactManager] リンクをタップし、連絡先の作成、編集、削除ができることを確認します。 スターター アプリの作成については、「スターター アプリを作成する」を参照してください。

ユーザー データをセキュリティで保護する

次のセクションでは、セキュリティで保護されたユーザー データ アプリを作成するための主要な手順について説明します。 完成したプロジェクトを参考にすることをお勧めします。

連絡先データをユーザーに結びつける

ASP.NET Identity のユーザー ID を使って、ユーザーが自分のデータを編集できるようにして、他のユーザーのデータは編集できないようにします。 Contact モデルに OwnerIDContactStatus を追加します。

public class Contact
{
    public int ContactId { get; set; }

    // user ID from AspNetUser table.
    public string? OwnerID { get; set; }

    public string? Name { get; set; }
    public string? Address { get; set; }
    public string? City { get; set; }
    public string? State { get; set; }
    public string? Zip { get; set; }
    [DataType(DataType.EmailAddress)]
    public string? Email { get; set; }

    public ContactStatus Status { get; set; }
}

public enum ContactStatus
{
    Submitted,
    Approved,
    Rejected
}

OwnerIDIdentity データベースの AspNetUser テーブルのユーザー ID です。 Status フィールドによって、連絡先が一般ユーザーから閲覧可能かどうかが決まります。

新しい移行を作成し、データベースを更新します。

dotnet ef migrations add userID_Status
dotnet ef database update

Identity にロール サービスを追加する

ロール サービスを追加するには、AddRoles を追加します。

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();

builder.Services.AddDefaultIdentity<IdentityUser>(
    options => options.SignIn.RequireConfirmedAccount = true)
    .AddRoles<IdentityRole>()
    .AddEntityFrameworkStores<ApplicationDbContext>();

認証されたユーザーを要求する

ユーザーが認証されることを要求するフォールバック認可ポリシーを設定します。

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();

builder.Services.AddDefaultIdentity<IdentityUser>(
    options => options.SignIn.RequireConfirmedAccount = true)
    .AddRoles<IdentityRole>()
    .AddEntityFrameworkStores<ApplicationDbContext>();

builder.Services.AddRazorPages();

builder.Services.AddAuthorization(options =>
{
    options.FallbackPolicy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build();
});

前の強調表示されたコードによって、フォールバック認可ポリシーが設定されます。 このフォールバック認可ポリシーにより、認可属性を持つ Razor Pages、コントローラー、またはアクション メソッドを除き、"すべての" ユーザーの認証が要求されます。 たとえば、[AllowAnonymous][Authorize(PolicyName="MyPolicy")] を使用する Razor Pages、コントローラー、またはアクション メソッドでは、フォールバック認可ポリシーではなく適用された認可属性が使用されます。

RequireAuthenticatedUser により、現在のインスタンスに DenyAnonymousAuthorizationRequirement が追加されます。これにより、現在のユーザーが認証されます。

フォールバック認可ポリシーは、次のようなものです。

  • 認可ポリシーを明示的に指定していないすべての要求に適用されます。 エンドポイント ルーティングによって処理される要求の場合、これには、認可属性を指定していないすべてのエンドポイントが含まれます。 静的ファイルなど、認可ミドルウェアの後に他のミドルウェアによって処理される要求の場合、すべての要求にこのポリシーが適用されます。

ユーザーが認証されることを要求するフォールバック認可ポリシーを設定すると、新しく追加された Razor Pages とコントローラーが保護されます。 認可が既定で必要となるようにするには、新しいコントローラーや Razor Pages に [Authorize] 属性を含めることに頼るよりも安全です。

AuthorizationOptions クラスには AuthorizationOptions.DefaultPolicy も含まれています。 DefaultPolicy は、ポリシーが指定されていない場合に [Authorize] 属性と共に使われるポリシーです。 [Authorize][Authorize(PolicyName="MyPolicy")] とは異なり、名前付きのポリシーが含まれていません。

ポリシーの詳細については、「ASP.NET Core でのポリシー ベースの認可」を参照してください。

MVC コントローラーと Razor Pages で、すべてのユーザーに認証されることを要求するもう 1 つの方法は、認可フィルターを追加することです。

using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using ContactManager.Data;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.Authorization;

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();

builder.Services.AddDefaultIdentity<IdentityUser>(
    options => options.SignIn.RequireConfirmedAccount = true)
    .AddRoles<IdentityRole>()
    .AddEntityFrameworkStores<ApplicationDbContext>();

builder.Services.AddRazorPages();

builder.Services.AddControllers(config =>
{
    var policy = new AuthorizationPolicyBuilder()
                     .RequireAuthenticatedUser()
                     .Build();
    config.Filters.Add(new AuthorizeFilter(policy));
});

var app = builder.Build();

前のコードには認可フィルターを使い、フォールバック ポリシーの設定にはエンドポイント ルーティングを使っています。 すべてのユーザーが認証されることを要求するには、フォールバック ポリシーを設定することをお勧めします。

IndexPrivacy のページに AllowAnonymous を追加し、匿名ユーザーが登録前にサイトに関する情報を得られるようにします。

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace ContactManager.Pages;

[AllowAnonymous]
public class IndexModel : PageModel
{
    private readonly ILogger<IndexModel> _logger;

    public IndexModel(ILogger<IndexModel> logger)
    {
        _logger = logger;
    }

    public void OnGet()
    {

    }
}

テスト アカウントを構成する

SeedData クラスを使って、管理者とマネージャーの 2 つのアカウントを作成します。 Secret Manager ツールを使って、これらのアカウントにパスワードを設定します。 パスワードは、プロジェクト ディレクトリ (Program.cs のあるディレクトリ) から設定します。

dotnet user-secrets set SeedUserPW <PW>

弱いパスワードが指定されている場合、SeedData.Initialize の呼び出し時に例外がスローされます。

テスト パスワードを使用するようにアプリを更新します。

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();

builder.Services.AddDefaultIdentity<IdentityUser>(
    options => options.SignIn.RequireConfirmedAccount = true)
    .AddRoles<IdentityRole>()
    .AddEntityFrameworkStores<ApplicationDbContext>();

builder.Services.AddRazorPages();

builder.Services.AddAuthorization(options =>
{
    options.FallbackPolicy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build();
});

// Authorization handlers.
builder.Services.AddScoped<IAuthorizationHandler,
                      ContactIsOwnerAuthorizationHandler>();

builder.Services.AddSingleton<IAuthorizationHandler,
                      ContactAdministratorsAuthorizationHandler>();

builder.Services.AddSingleton<IAuthorizationHandler,
                      ContactManagerAuthorizationHandler>();

var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    var services = scope.ServiceProvider;
    var context = services.GetRequiredService<ApplicationDbContext>();
    context.Database.Migrate();
    // requires using Microsoft.Extensions.Configuration;
    // Set password with the Secret Manager tool.
    // dotnet user-secrets set SeedUserPW <pw>

    var testUserPw = builder.Configuration.GetValue<string>("SeedUserPW");

   await SeedData.Initialize(services, testUserPw);
}

テスト アカウントの作成と連絡先の更新

SeedData クラスの Initialize メソッドを更新してテスト アカウントを作成します。

public static async Task Initialize(IServiceProvider serviceProvider, string testUserPw)
{
    using (var context = new ApplicationDbContext(
        serviceProvider.GetRequiredService<DbContextOptions<ApplicationDbContext>>()))
    {
        // For sample purposes seed both with the same password.
        // Password is set with the following:
        // dotnet user-secrets set SeedUserPW <pw>
        // The admin user can do anything

        var adminID = await EnsureUser(serviceProvider, testUserPw, "admin@contoso.com");
        await EnsureRole(serviceProvider, adminID, Constants.ContactAdministratorsRole);

        // allowed user can create and edit contacts that they create
        var managerID = await EnsureUser(serviceProvider, testUserPw, "manager@contoso.com");
        await EnsureRole(serviceProvider, managerID, Constants.ContactManagersRole);

        SeedDB(context, adminID);
    }
}

private static async Task<string> EnsureUser(IServiceProvider serviceProvider,
                                            string testUserPw, string UserName)
{
    var userManager = serviceProvider.GetService<UserManager<IdentityUser>>();

    var user = await userManager.FindByNameAsync(UserName);
    if (user == null)
    {
        user = new IdentityUser
        {
            UserName = UserName,
            EmailConfirmed = true
        };
        await userManager.CreateAsync(user, testUserPw);
    }

    if (user == null)
    {
        throw new Exception("The password is probably not strong enough!");
    }

    return user.Id;
}

private static async Task<IdentityResult> EnsureRole(IServiceProvider serviceProvider,
                                                              string uid, string role)
{
    var roleManager = serviceProvider.GetService<RoleManager<IdentityRole>>();

    if (roleManager == null)
    {
        throw new Exception("roleManager null");
    }

    IdentityResult IR;
    if (!await roleManager.RoleExistsAsync(role))
    {
        IR = await roleManager.CreateAsync(new IdentityRole(role));
    }

    var userManager = serviceProvider.GetService<UserManager<IdentityUser>>();

    //if (userManager == null)
    //{
    //    throw new Exception("userManager is null");
    //}

    var user = await userManager.FindByIdAsync(uid);

    if (user == null)
    {
        throw new Exception("The testUserPw password was probably not strong enough!");
    }

    IR = await userManager.AddToRoleAsync(user, role);

    return IR;
}

管理者のユーザー ID と ContactStatus を連絡先に追加します。 連絡先の 1 つを "送信済み" に、1 つを "拒否済み" にします。 すべての連絡先にユーザー ID と状態を追加します。 連絡先は 1 つだけ表示されます。

public static void SeedDB(ApplicationDbContext context, string adminID)
{
    if (context.Contact.Any())
    {
        return;   // DB has been seeded
    }

    context.Contact.AddRange(
        new Contact
        {
            Name = "Debra Garcia",
            Address = "1234 Main St",
            City = "Redmond",
            State = "WA",
            Zip = "10999",
            Email = "debra@example.com",
            Status = ContactStatus.Approved,
            OwnerID = adminID
        },

所有者、マネージャー、管理者の認可ハンドラーを作成する

Authorization フォルダーに ContactIsOwnerAuthorizationHandler クラスを作成します。 ContactIsOwnerAuthorizationHandler により、リソースを操作しているユーザーがそのリソースを所有していることが確認されます。

using ContactManager.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Infrastructure;
using Microsoft.AspNetCore.Identity;
using System.Threading.Tasks;

namespace ContactManager.Authorization
{
    public class ContactIsOwnerAuthorizationHandler
                : AuthorizationHandler<OperationAuthorizationRequirement, Contact>
    {
        UserManager<IdentityUser> _userManager;

        public ContactIsOwnerAuthorizationHandler(UserManager<IdentityUser> 
            userManager)
        {
            _userManager = userManager;
        }

        protected override Task
            HandleRequirementAsync(AuthorizationHandlerContext context,
                                   OperationAuthorizationRequirement requirement,
                                   Contact resource)
        {
            if (context.User == null || resource == null)
            {
                return Task.CompletedTask;
            }

            // If not asking for CRUD permission, return.

            if (requirement.Name != Constants.CreateOperationName &&
                requirement.Name != Constants.ReadOperationName   &&
                requirement.Name != Constants.UpdateOperationName &&
                requirement.Name != Constants.DeleteOperationName )
            {
                return Task.CompletedTask;
            }

            if (resource.OwnerID == _userManager.GetUserId(context.User))
            {
                context.Succeed(requirement);
            }

            return Task.CompletedTask;
        }
    }
}

現在の認証されているユーザーが連絡先の所有者である場合、ContactIsOwnerAuthorizationHandler によって context.Succeed が呼び出されます。 認可ハンドラーの一般的な処理内容:

  • 要件が満たされている場合は context.Succeed が呼び出されます。
  • 要件が満たされていない場合は Task.CompletedTask が返されます。 先に context.Success または context.Fail が呼び出されずに Task.CompletedTask が返されることは成功でも失敗でもなく、他の認可ハンドラーの実行が許可されます。

明示的に失敗する必要がある場合は、context.Fail を呼び出します。

このアプリでは、連絡先の所有者が自分のデータを編集、削除、または作成できます。 要件パラメーターで渡された操作を ContactIsOwnerAuthorizationHandler を使って確認する必要はありません。

マネージャーの認可ハンドラーを作成する

Authorization フォルダーに ContactManagerAuthorizationHandler クラスを作成します。 ContactManagerAuthorizationHandler により、リソースを操作しているユーザーがマネージャーであることが確認されます。 内容の変更 (新規または変更) を承認または拒否できるのはマネージャーのみです。

using System.Threading.Tasks;
using ContactManager.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Infrastructure;
using Microsoft.AspNetCore.Identity;

namespace ContactManager.Authorization
{
    public class ContactManagerAuthorizationHandler :
        AuthorizationHandler<OperationAuthorizationRequirement, Contact>
    {
        protected override Task
            HandleRequirementAsync(AuthorizationHandlerContext context,
                                   OperationAuthorizationRequirement requirement,
                                   Contact resource)
        {
            if (context.User == null || resource == null)
            {
                return Task.CompletedTask;
            }

            // If not asking for approval/reject, return.
            if (requirement.Name != Constants.ApproveOperationName &&
                requirement.Name != Constants.RejectOperationName)
            {
                return Task.CompletedTask;
            }

            // Managers can approve or reject.
            if (context.User.IsInRole(Constants.ContactManagersRole))
            {
                context.Succeed(requirement);
            }

            return Task.CompletedTask;
        }
    }
}

管理者の認可ハンドラーを作成する

Authorization フォルダーに ContactAdministratorsAuthorizationHandler クラスを作成します。 ContactAdministratorsAuthorizationHandler により、リソースを操作しているユーザーが管理者であることが確認されます。 管理者はすべての操作を実行できます。

using System.Threading.Tasks;
using ContactManager.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Infrastructure;

namespace ContactManager.Authorization
{
    public class ContactAdministratorsAuthorizationHandler
                    : AuthorizationHandler<OperationAuthorizationRequirement, Contact>
    {
        protected override Task HandleRequirementAsync(
                                              AuthorizationHandlerContext context,
                                    OperationAuthorizationRequirement requirement, 
                                     Contact resource)
        {
            if (context.User == null)
            {
                return Task.CompletedTask;
            }

            // Administrators can do anything.
            if (context.User.IsInRole(Constants.ContactAdministratorsRole))
            {
                context.Succeed(requirement);
            }

            return Task.CompletedTask;
        }
    }
}

認可ハンドラーを登録する

Entity Framework Core を使うサービスは、依存関係の挿入のために AddScoped を使って登録する必要があります。 ContactIsOwnerAuthorizationHandler には、Entity Framework Core に基づいて構築された ASP.NET Core Identity が使われています。 ハンドラーをサービス コレクションに登録し、依存関係の挿入を介して ContactsController を使えるようにします。 次のコードを ConfigureServices の末尾に追加します。

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();

builder.Services.AddDefaultIdentity<IdentityUser>(
    options => options.SignIn.RequireConfirmedAccount = true)
    .AddRoles<IdentityRole>()
    .AddEntityFrameworkStores<ApplicationDbContext>();

builder.Services.AddRazorPages();

builder.Services.AddAuthorization(options =>
{
    options.FallbackPolicy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build();
});

// Authorization handlers.
builder.Services.AddScoped<IAuthorizationHandler,
                      ContactIsOwnerAuthorizationHandler>();

builder.Services.AddSingleton<IAuthorizationHandler,
                      ContactAdministratorsAuthorizationHandler>();

builder.Services.AddSingleton<IAuthorizationHandler,
                      ContactManagerAuthorizationHandler>();

var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    var services = scope.ServiceProvider;
    var context = services.GetRequiredService<ApplicationDbContext>();
    context.Database.Migrate();
    // requires using Microsoft.Extensions.Configuration;
    // Set password with the Secret Manager tool.
    // dotnet user-secrets set SeedUserPW <pw>

    var testUserPw = builder.Configuration.GetValue<string>("SeedUserPW");

   await SeedData.Initialize(services, testUserPw);
}

ContactAdministratorsAuthorizationHandlerContactManagerAuthorizationHandler はシングルトンとして追加されます。 これらがシングルトンである理由は、EF が使われておらず、必要な情報はすべて HandleRequirementAsync メソッドの Context パラメーターにあるためです。

認可のサポート

このセクションでは、Razor Pages を更新し、操作の要件クラスを追加します。

連絡先操作の要件クラスを確認する

ContactOperations クラスを確認します。 このクラスには、アプリがサポートする要件が含まれています。

using Microsoft.AspNetCore.Authorization.Infrastructure;

namespace ContactManager.Authorization
{
    public static class ContactOperations
    {
        public static OperationAuthorizationRequirement Create =   
          new OperationAuthorizationRequirement {Name=Constants.CreateOperationName};
        public static OperationAuthorizationRequirement Read = 
          new OperationAuthorizationRequirement {Name=Constants.ReadOperationName};  
        public static OperationAuthorizationRequirement Update = 
          new OperationAuthorizationRequirement {Name=Constants.UpdateOperationName}; 
        public static OperationAuthorizationRequirement Delete = 
          new OperationAuthorizationRequirement {Name=Constants.DeleteOperationName};
        public static OperationAuthorizationRequirement Approve = 
          new OperationAuthorizationRequirement {Name=Constants.ApproveOperationName};
        public static OperationAuthorizationRequirement Reject = 
          new OperationAuthorizationRequirement {Name=Constants.RejectOperationName};
    }

    public class Constants
    {
        public static readonly string CreateOperationName = "Create";
        public static readonly string ReadOperationName = "Read";
        public static readonly string UpdateOperationName = "Update";
        public static readonly string DeleteOperationName = "Delete";
        public static readonly string ApproveOperationName = "Approve";
        public static readonly string RejectOperationName = "Reject";

        public static readonly string ContactAdministratorsRole = 
                                                              "ContactAdministrators";
        public static readonly string ContactManagersRole = "ContactManagers";
    }
}

連絡先の Razor Pages の基底クラスを作成します。

連絡先の Razor Pages で使われるサービスを含む基底クラスを作成します。 基底クラスにより、初期化コードが 1 か所に配置されます。

using ContactManager.Data;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace ContactManager.Pages.Contacts
{
    public class DI_BasePageModel : PageModel
    {
        protected ApplicationDbContext Context { get; }
        protected IAuthorizationService AuthorizationService { get; }
        protected UserManager<IdentityUser> UserManager { get; }

        public DI_BasePageModel(
            ApplicationDbContext context,
            IAuthorizationService authorizationService,
            UserManager<IdentityUser> userManager) : base()
        {
            Context = context;
            UserManager = userManager;
            AuthorizationService = authorizationService;
        } 
    }
}

上記のコードでは次の操作が行われます。

  • 認可ハンドラーにアクセスする IAuthorizationService サービスを追加します。
  • IdentityUserManager サービスを追加します。
  • ApplicationDbContext を追加します。

CreateModel を更新する

Create ページのモデルを更新します。

  • DI_BasePageModel 既定クラスを使用するコンストラクター。
  • OnPostAsync メソッドを次のように更新します。
    • Contact モデルにユーザー ID を追加します。
    • 認可ハンドラーを呼び出して、ユーザーが連絡先を作成するアクセス許可を持っていることを確認します。
using ContactManager.Authorization;
using ContactManager.Data;
using ContactManager.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;

namespace ContactManager.Pages.Contacts
{
    public class CreateModel : DI_BasePageModel
    {
        public CreateModel(
            ApplicationDbContext context,
            IAuthorizationService authorizationService,
            UserManager<IdentityUser> userManager)
            : base(context, authorizationService, userManager)
        {
        }

        public IActionResult OnGet()
        {
            return Page();
        }

        [BindProperty]
        public Contact Contact { get; set; }

        public async Task<IActionResult> OnPostAsync()
        {
            if (!ModelState.IsValid)
            {
                return Page();
            }

            Contact.OwnerID = UserManager.GetUserId(User);

            var isAuthorized = await AuthorizationService.AuthorizeAsync(
                                                        User, Contact,
                                                        ContactOperations.Create);
            if (!isAuthorized.Succeeded)
            {
                return Forbid();
            }

            Context.Contact.Add(Contact);
            await Context.SaveChangesAsync();

            return RedirectToPage("./Index");
        }
    }
}

IndexModel を更新する

承認された連絡先のみを一般ユーザーに表示するように OnGetAsync メソッドを更新します。

public class IndexModel : DI_BasePageModel
{
    public IndexModel(
        ApplicationDbContext context,
        IAuthorizationService authorizationService,
        UserManager<IdentityUser> userManager)
        : base(context, authorizationService, userManager)
    {
    }

    public IList<Contact> Contact { get; set; }

    public async Task OnGetAsync()
    {
        var contacts = from c in Context.Contact
                       select c;

        var isAuthorized = User.IsInRole(Constants.ContactManagersRole) ||
                           User.IsInRole(Constants.ContactAdministratorsRole);

        var currentUserId = UserManager.GetUserId(User);

        // Only approved contacts are shown UNLESS you're authorized to see them
        // or you are the owner.
        if (!isAuthorized)
        {
            contacts = contacts.Where(c => c.Status == ContactStatus.Approved
                                        || c.OwnerID == currentUserId);
        }

        Contact = await contacts.ToListAsync();
    }
}

EditModel を更新する

ユーザーが連絡先を所有していることを確認する認可ハンドラーを追加します。 リソースの認可は検証中なので、[Authorize] 属性だけでは不十分です。 属性が評価されても、アプリからリソースにアクセスできません。 リソースベースの認可は必須です。 アプリからリソースにアクセスできるようになったら、ページ モデルにリソースを読み込むか、ハンドラー内にリソースを読み込むことでチェックを実行する必要があります。 リソースに頻繁にアクセスするには、リソース キーを渡します。

public class EditModel : DI_BasePageModel
{
    public EditModel(
        ApplicationDbContext context,
        IAuthorizationService authorizationService,
        UserManager<IdentityUser> userManager)
        : base(context, authorizationService, userManager)
    {
    }

    [BindProperty]
    public Contact Contact { get; set; }

    public async Task<IActionResult> OnGetAsync(int id)
    {
        Contact? contact = await Context.Contact.FirstOrDefaultAsync(
                                                         m => m.ContactId == id);
        if (contact == null)
        {
            return NotFound();
        }

        Contact = contact;

        var isAuthorized = await AuthorizationService.AuthorizeAsync(
                                                  User, Contact,
                                                  ContactOperations.Update);
        if (!isAuthorized.Succeeded)
        {
            return Forbid();
        }

        return Page();
    }

    public async Task<IActionResult> OnPostAsync(int id)
    {
        if (!ModelState.IsValid)
        {
            return Page();
        }

        // Fetch Contact from DB to get OwnerID.
        var contact = await Context
            .Contact.AsNoTracking()
            .FirstOrDefaultAsync(m => m.ContactId == id);

        if (contact == null)
        {
            return NotFound();
        }

        var isAuthorized = await AuthorizationService.AuthorizeAsync(
                                                 User, contact,
                                                 ContactOperations.Update);
        if (!isAuthorized.Succeeded)
        {
            return Forbid();
        }

        Contact.OwnerID = contact.OwnerID;

        Context.Attach(Contact).State = EntityState.Modified;

        if (Contact.Status == ContactStatus.Approved)
        {
            // If the contact is updated after approval, 
            // and the user cannot approve,
            // set the status back to submitted so the update can be
            // checked and approved.
            var canApprove = await AuthorizationService.AuthorizeAsync(User,
                                    Contact,
                                    ContactOperations.Approve);

            if (!canApprove.Succeeded)
            {
                Contact.Status = ContactStatus.Submitted;
            }
        }

        await Context.SaveChangesAsync();

        return RedirectToPage("./Index");
    }
}

DeleteModel を更新する

認可ハンドラーを使ってユーザーが連絡先の削除アクセス許可を持っていることを確認するように削除ページ モデルを更新します。

public class DeleteModel : DI_BasePageModel
{
    public DeleteModel(
        ApplicationDbContext context,
        IAuthorizationService authorizationService,
        UserManager<IdentityUser> userManager)
        : base(context, authorizationService, userManager)
    {
    }

    [BindProperty]
    public Contact Contact { get; set; }

    public async Task<IActionResult> OnGetAsync(int id)
    {
        Contact? _contact = await Context.Contact.FirstOrDefaultAsync(
                                             m => m.ContactId == id);

        if (_contact == null)
        {
            return NotFound();
        }
        Contact = _contact;

        var isAuthorized = await AuthorizationService.AuthorizeAsync(
                                                 User, Contact,
                                                 ContactOperations.Delete);
        if (!isAuthorized.Succeeded)
        {
            return Forbid();
        }

        return Page();
    }

    public async Task<IActionResult> OnPostAsync(int id)
    {
        var contact = await Context
            .Contact.AsNoTracking()
            .FirstOrDefaultAsync(m => m.ContactId == id);

        if (contact == null)
        {
            return NotFound();
        }

        var isAuthorized = await AuthorizationService.AuthorizeAsync(
                                                 User, contact,
                                                 ContactOperations.Delete);
        if (!isAuthorized.Succeeded)
        {
            return Forbid();
        }

        Context.Contact.Remove(contact);
        await Context.SaveChangesAsync();

        return RedirectToPage("./Index");
    }
}

ビューに認可サービスを挿入する

現在、UI には、ユーザーが変更できない連絡先の編集と削除のリンクが表示されています。

認可サービスを Pages/_ViewImports.cshtml ファイルに挿入して、すべてのビューで使えるようにします。

@using Microsoft.AspNetCore.Identity
@using ContactManager
@using ContactManager.Data
@namespace ContactManager.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@using ContactManager.Authorization;
@using Microsoft.AspNetCore.Authorization
@using ContactManager.Models
@inject IAuthorizationService AuthorizationService

前のマークアップにより、いくつかの using ステートメントが追加されます。

Pages/Contacts/Index.cshtml[Edit](編集)[Delete](削除) のリンクを更新して、適切なアクセス許可を持つユーザーにのみ表示されるようにします。

@page
@model ContactManager.Pages.Contacts.IndexModel

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

<h1>Index</h1>

<p>
    <a asp-page="Create">Create New</a>
</p>
<table class="table">
    <thead>
        <tr>
            <th>
                @Html.DisplayNameFor(model => model.Contact[0].Name)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Contact[0].Address)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Contact[0].City)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Contact[0].State)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Contact[0].Zip)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Contact[0].Email)
            </th>
             <th>
                @Html.DisplayNameFor(model => model.Contact[0].Status)
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
@foreach (var item in Model.Contact) {
        <tr>
            <td>
                @Html.DisplayFor(modelItem => item.Name)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.Address)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.City)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.State)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.Zip)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.Email)
            </td>
                           <td>
                    @Html.DisplayFor(modelItem => item.Status)
                </td>
                <td>
                    @if ((await AuthorizationService.AuthorizeAsync(
                     User, item,
                     ContactOperations.Update)).Succeeded)
                    {
                        <a asp-page="./Edit" asp-route-id="@item.ContactId">Edit</a>
                        <text> | </text>
                    }

                    <a asp-page="./Details" asp-route-id="@item.ContactId">Details</a>

                    @if ((await AuthorizationService.AuthorizeAsync(
                     User, item,
                     ContactOperations.Delete)).Succeeded)
                    {
                        <text> | </text>
                        <a asp-page="./Delete" asp-route-id="@item.ContactId">Delete</a>
                    }
                </td>
            </tr>
        }
    </tbody>
</table>

警告

データを変更するアクセス許可を持たないユーザーにリンクを非表示にしても、アプリをセキュリティで保護することにはなりません。 リンクを非表示にすると、有効なリンクのみが表示されるので、アプリが使いやすくなります。 ユーザーが生成された URL をハッキングすると、自分が所有していないデータの編集や削除の操作を呼び出すことができます。 Razor ページまたはコントローラーによって、データをセキュリティで保護するアクセス チェックを実施する必要があります。

詳細を更新する

マネージャーが連絡先を承認または拒否できるように詳細ビューを更新します。

        @*Preceding markup omitted for brevity.*@
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Contact.Email)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Contact.Email)
        </dd>
    <dt>
            @Html.DisplayNameFor(model => model.Contact.Status)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Contact.Status)
        </dd>
    </dl>
</div>

@if (Model.Contact.Status != ContactStatus.Approved)
{
    @if ((await AuthorizationService.AuthorizeAsync(
     User, Model.Contact, ContactOperations.Approve)).Succeeded)
    {
        <form style="display:inline;" method="post">
            <input type="hidden" name="id" value="@Model.Contact.ContactId" />
            <input type="hidden" name="status" value="@ContactStatus.Approved" />
            <button type="submit" class="btn btn-xs btn-success">Approve</button>
        </form>
    }
}

@if (Model.Contact.Status != ContactStatus.Rejected)
{
    @if ((await AuthorizationService.AuthorizeAsync(
     User, Model.Contact, ContactOperations.Reject)).Succeeded)
    {
        <form style="display:inline;" method="post">
            <input type="hidden" name="id" value="@Model.Contact.ContactId" />
            <input type="hidden" name="status" value="@ContactStatus.Rejected" />
            <button type="submit" class="btn btn-xs btn-danger">Reject</button>
        </form>
    }
}

<div>
    @if ((await AuthorizationService.AuthorizeAsync(
         User, Model.Contact,
         ContactOperations.Update)).Succeeded)
    {
        <a asp-page="./Edit" asp-route-id="@Model.Contact.ContactId">Edit</a>
        <text> | </text>
    }
    <a asp-page="./Index">Back to List</a>
</div>

詳細ページ モデルを更新する

public class DetailsModel : DI_BasePageModel
{
    public DetailsModel(
        ApplicationDbContext context,
        IAuthorizationService authorizationService,
        UserManager<IdentityUser> userManager)
        : base(context, authorizationService, userManager)
    {
    }

    public Contact Contact { get; set; }

    public async Task<IActionResult> OnGetAsync(int id)
    {
        Contact? _contact = await Context.Contact.FirstOrDefaultAsync(m => m.ContactId == id);

        if (_contact == null)
        {
            return NotFound();
        }
        Contact = _contact;

        var isAuthorized = User.IsInRole(Constants.ContactManagersRole) ||
                           User.IsInRole(Constants.ContactAdministratorsRole);

        var currentUserId = UserManager.GetUserId(User);

        if (!isAuthorized
            && currentUserId != Contact.OwnerID
            && Contact.Status != ContactStatus.Approved)
        {
            return Forbid();
        }

        return Page();
    }

    public async Task<IActionResult> OnPostAsync(int id, ContactStatus status)
    {
        var contact = await Context.Contact.FirstOrDefaultAsync(
                                                  m => m.ContactId == id);

        if (contact == null)
        {
            return NotFound();
        }

        var contactOperation = (status == ContactStatus.Approved)
                                                   ? ContactOperations.Approve
                                                   : ContactOperations.Reject;

        var isAuthorized = await AuthorizationService.AuthorizeAsync(User, contact,
                                    contactOperation);
        if (!isAuthorized.Succeeded)
        {
            return Forbid();
        }
        contact.Status = status;
        Context.Contact.Update(contact);
        await Context.SaveChangesAsync();

        return RedirectToPage("./Index");
    }
}

ユーザーをロールに追加または削除する

以下については、この問題を参照してください。

  • ユーザーから特権を削除する。 たとえば、チャット アプリでユーザーをミュートするなどです。
  • ユーザーに特権を追加する。

チャレンジと禁止の違い

このアプリでは、認証済みユーザーを要求するように既定のポリシーが設定されています。 次のコードによって、匿名ユーザーが許可されます。 匿名ユーザーは、チャレンジと禁止の違いを示すことができます。

[AllowAnonymous]
public class Details2Model : DI_BasePageModel
{
    public Details2Model(
        ApplicationDbContext context,
        IAuthorizationService authorizationService,
        UserManager<IdentityUser> userManager)
        : base(context, authorizationService, userManager)
    {
    }

    public Contact Contact { get; set; }

    public async Task<IActionResult> OnGetAsync(int id)
    {
        Contact? _contact = await Context.Contact.FirstOrDefaultAsync(m => m.ContactId == id);

        if (_contact == null)
        {
            return NotFound();
        }
        Contact = _contact;

        if (!User.Identity!.IsAuthenticated)
        {
            return Challenge();
        }

        var isAuthorized = User.IsInRole(Constants.ContactManagersRole) ||
                           User.IsInRole(Constants.ContactAdministratorsRole);

        var currentUserId = UserManager.GetUserId(User);

        if (!isAuthorized
            && currentUserId != Contact.OwnerID
            && Contact.Status != ContactStatus.Approved)
        {
            return Forbid();
        }

        return Page();
    }
}

上のコードでは以下の操作が行われます。

  • ユーザーが認証されていないときは、ChallengeResult が返されます。 ChallengeResult が返された場合、ユーザーはサインイン ページにリダイレクトされます。
  • ユーザーが認証されていても、認可されていない場合は、ForbidResult が返されます。 ForbidResult が返された場合、ユーザーはアクセス拒否ページにリダイレクトされます。

完成したアプリをテストする

警告

この記事では、Secret Manager ツールを使用して、シード処理されたユーザー アカウントのパスワードを格納します。 Secret Manager ツールを使用すると、ローカル開発中に機密データを保存できます。 アプリをテスト環境または運用環境にデプロイするときに使用できる認証手順については、「安全な認証フロー」をご覧ください。

シードされたユーザー アカウントにまだパスワードを設定していない場合は、Secret Manager ツールを使ってパスワードを設定します。

  • 強力なパスワードを選択してください。

    • 少なくとも 12 文字で、14 文字以上がより良いです。
    • 大文字、小文字、数字、および記号の組み合わせ。
    • 辞書や人物、キャラクター、製品、または組織の名前に含まれる単語は不可。
    • 以前のパスワードと明らかに異なる
    • 覚えやすいが、他の人が推測するのは難しい。 6MonkeysRLooking^ のような覚えやすい語句を使用することを検討してください。
  • プロジェクトのフォルダーから次のコマンドを実行します。この <PW> はパスワードです。

    dotnet user-secrets set SeedUserPW <PW>
    

アプリに連絡先がある場合:

  • Contact テーブル内のすべてのレコードを削除します。
  • アプリを再起動してデータベースをシードします。

完成したアプリをテストする簡単な方法は、3 種類のブラウザー (または incognito/InPrivate セッション) を起動することです。 1 つのブラウザーで新しいユーザーを登録します (例: test@example.com)。 異なるユーザーを使って各ブラウザーにサインインします。 次の操作を確認します。

  • 登録済みユーザーは、承認されたすべての連絡先データを表示できます。
  • 登録済みユーザーは、自分のデータを編集および削除できます。
  • マネージャーは、連絡先データを承認および拒否することができます。 Details ビューには [Approve](承認)[Reject](拒否) のボタンが表示されます。
  • 管理者は、すべてのデータの承認、拒否、編集、削除を行うことができます。
User 連絡先の承認または拒否 オプション
test@example.com いいえ データを編集および削除します。
manager@contoso.com はい データを編集および削除します。
admin@contoso.com はい "すべて"のデータ尾を編集および削除します。

管理者のブラウザーで連絡先を作成します。 管理者の連絡先から、削除と編集の URL をコピーします。 これらのリンクをテスト ユーザーのブラウザーに貼り付け、テスト ユーザーがこれらの操作を行えないことを確認します。

スターター アプリを作成する

  • "ContactManager" という Razor Pages アプリを作成します。

    • [個人のユーザー アカウント] を使ってアプリを作成します。
    • 名前空間がサンプルで使われている名前空間と一致するように、"ContactManager" という名前を付けます。
    • -uld により、SQLite ではなく LocalDB が指定されています。
    dotnet new webapp -o ContactManager -au Individual -uld
    
  • Models/Contact.cs: secure-data\samples\starter6\ContactManager\Models\Contact.cs を追加します

    using System.ComponentModel.DataAnnotations;
    
    namespace ContactManager.Models
    {
        public class Contact
        {
            public int ContactId { get; set; }
            public string? Name { get; set; }
            public string? Address { get; set; }
            public string? City { get; set; }
            public string? State { get; set; }
            public string? Zip { get; set; }
            [DataType(DataType.EmailAddress)]
            public string? Email { get; set; }
        }
    }
    
  • Contact モデルをスキャフォールディングします。

  • 初期移行を作成し、データベースを更新します。

dotnet add package Microsoft.VisualStudio.Web.CodeGeneration.Design
dotnet tool install -g dotnet-aspnet-codegenerator
dotnet-aspnet-codegenerator razorpage -m Contact -udl -dc ApplicationDbContext -outDir Pages\Contacts --referenceScriptLibraries
dotnet ef database drop -f
dotnet ef migrations add initial
dotnet ef database update

注意

既定では、インストールする .NET バイナリのアーキテクチャは、現在実行中の OS アーキテクチャを表します。 別の OS アーキテクチャを指定するには、「dotnet tool install, --arch option」を参照してください。 詳細については、GitHub イシュー dotnet/AspNetCore.Docs #29262 を参照してください。

  • Pages/Shared/_Layout.cshtml ファイル内の ContactManager アンカーを更新します。

    <a class="nav-link text-dark" asp-area="" asp-page="/Contacts/Index">Contact Manager</a>
    
  • 連絡先の作成、編集、削除を実行してアプリをテストします。

データベースのシード

Data フォルダーに SeedData クラスを追加します。

using ContactManager.Models;
using Microsoft.EntityFrameworkCore;

// dotnet aspnet-codegenerator razorpage -m Contact -dc ApplicationDbContext -udl -outDir Pages\Contacts --referenceScriptLibraries

namespace ContactManager.Data
{
    public static class SeedData
    {
        public static async Task Initialize(IServiceProvider serviceProvider, string testUserPw="")
        {
            using (var context = new ApplicationDbContext(
                serviceProvider.GetRequiredService<DbContextOptions<ApplicationDbContext>>()))
            {
                SeedDB(context, testUserPw);
            }
        }

        public static void SeedDB(ApplicationDbContext context, string adminID)
        {
            if (context.Contact.Any())
            {
                return;   // DB has been seeded
            }

            context.Contact.AddRange(
                new Contact
                {
                    Name = "Debra Garcia",
                    Address = "1234 Main St",
                    City = "Redmond",
                    State = "WA",
                    Zip = "10999",
                    Email = "debra@example.com"
                },
                new Contact
                {
                    Name = "Thorsten Weinrich",
                    Address = "5678 1st Ave W",
                    City = "Redmond",
                    State = "WA",
                    Zip = "10999",
                    Email = "thorsten@example.com"
                },
                new Contact
                {
                    Name = "Yuhong Li",
                    Address = "9012 State st",
                    City = "Redmond",
                    State = "WA",
                    Zip = "10999",
                    Email = "yuhong@example.com"
                },
                new Contact
                {
                    Name = "Jon Orton",
                    Address = "3456 Maple St",
                    City = "Redmond",
                    State = "WA",
                    Zip = "10999",
                    Email = "jon@example.com"
                },
                new Contact
                {
                    Name = "Diliana Alexieva-Bosseva",
                    Address = "7890 2nd Ave E",
                    City = "Redmond",
                    State = "WA",
                    Zip = "10999",
                    Email = "diliana@example.com"
                }
             );
            context.SaveChanges();
        }

    }
}

Program.cs から SeedData.Initialize を呼び出します。

using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using ContactManager.Data;

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();

builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
    .AddEntityFrameworkStores<ApplicationDbContext>();
builder.Services.AddRazorPages();

var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    var services = scope.ServiceProvider;

    await SeedData.Initialize(services);
}

if (app.Environment.IsDevelopment())
{
    app.UseMigrationsEndPoint();
}
else
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

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

app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();

app.MapRazorPages();

app.Run();

アプリによってデータベースがシードされたことをテストします。 連絡先 DB に行がある場合は、seed メソッドは実行されません。

このチュートリアルでは、認可によって保護されたユーザー データを使って ASP.NET Core Web アプリを作成する方法について説明します。 認証済み (登録済み) ユーザーが作成した連絡先の一覧が表示されます。 次の 3 つのセキュリティ グループがあります。

  • 登録済みユーザーは、承認されたすべてのデータを表示できます。また、自分のデータを編集および削除できます。
  • マネージャーは、連絡先データを承認または拒否することができます。 承認された連絡先のみがユーザーに表示されます。
  • 管理者は、すべてのデータの承認、拒否、編集、削除を行うことができます。

このドキュメントの画像は、最新のテンプレートと完全には一致していません。

次の画像では、ユーザーの Rick (rick@example.com) がサインインしています。 Rick には、承認された連絡先と、自分の連絡先の [編集]/[削除]/[新規作成] リンクのみが表示されます。 Rick が作成した最後のレコードにのみ、 [編集] および [削除] リンクが表示されます。 マネージャーまたは管理者が状態を "承認済み" に変更するまで、他のユーザーには最後のレコードが表示されません。

Rick のサインインを示すスクリーンショット

次の画像では、manager@contoso.com がサインインしており、マネージャー ロールを担っています。

manager@contoso.com のサインインを示すスクリーンショット

次の画像は、マネージャーの連絡先の詳細ビューを示しています。

マネージャーの連絡先のビュー

[承認] ボタンと [拒否] ボタンは、マネージャーと管理者にのみ表示されます。

次の画像では、admin@contoso.com がサインインしており、管理者ロールを担っています。

admin@contoso.com のサインインを示すスクリーンショット

管理者には、すべての特権があります。 すべての連絡先の読み取り、編集、削除や、連絡先の状態の変更を行うことができます。

このアプリは、次の Contact モデルをスキャフォールディングすることで作成されました。

public class Contact
{
    public int ContactId { get; set; }
    public string Name { get; set; }
    public string Address { get; set; }
    public string City { get; set; }
    public string State { get; set; }
    public string Zip { get; set; }
    [DataType(DataType.EmailAddress)]
    public string Email { get; set; }
}

このサンプルには、次の認可ハンドラーが含まれています。

  • ContactIsOwnerAuthorizationHandler: ユーザーが自分のデータのみを編集できるようにします。
  • ContactManagerAuthorizationHandler: 連絡先の承認または拒否をマネージャーに許可します。
  • ContactAdministratorsAuthorizationHandler: 管理者は次の操作を行うことができます。
    • 連絡先の承認または拒否
    • 連絡先の編集および削除

前提条件

このチュートリアルは高度です。 次のことを理解している必要があります。

スターター アプリと完成したアプリ

完成したアプリをダウンロードします。 完成したアプリをテストして、セキュリティ機能に慣れます。

スターター アプリ

スターター アプリダウンロードします。

アプリを実行し、 [ContactManager] リンクをタップし、連絡先の作成、編集、削除ができることを確認します。 スターター アプリの作成については、「スターター アプリを作成する」を参照してください。

ユーザー データをセキュリティで保護する

次のセクションでは、セキュリティで保護されたユーザー データ アプリを作成するための主要な手順について説明します。 完成したプロジェクトを参考にすることをお勧めします。

連絡先データをユーザーに結びつける

ASP.NET Identity のユーザー ID を使って、ユーザーが自分のデータを編集できるようにして、他のユーザーのデータは編集できないようにします。 Contact モデルに OwnerIDContactStatus を追加します。

public class Contact
{
    public int ContactId { get; set; }

    // user ID from AspNetUser table.
    public string OwnerID { get; set; }

    public string Name { get; set; }
    public string Address { get; set; }
    public string City { get; set; }
    public string State { get; set; }
    public string Zip { get; set; }
    [DataType(DataType.EmailAddress)]
    public string Email { get; set; }

    public ContactStatus Status { get; set; }
}

public enum ContactStatus
{
    Submitted,
    Approved,
    Rejected
}

OwnerIDIdentity データベースの AspNetUser テーブルのユーザー ID です。 Status フィールドによって、連絡先が一般ユーザーから閲覧可能かどうかが決まります。

新しい移行を作成し、データベースを更新します。

dotnet ef migrations add userID_Status
dotnet ef database update

Identity にロール サービスを追加する

ロール サービスを追加するには、AddRoles を追加します。

public void ConfigureServices(IServiceCollection services)
{
    services.AddDbContext<ApplicationDbContext>(options =>
        options.UseSqlServer(
            Configuration.GetConnectionString("DefaultConnection")));
    services.AddDefaultIdentity<IdentityUser>(
        options => options.SignIn.RequireConfirmedAccount = true)
        .AddRoles<IdentityRole>()
        .AddEntityFrameworkStores<ApplicationDbContext>();

認証されたユーザーを要求する

ユーザーが認証されることを要求するフォールバック認証ポリシーを設定します。

public void ConfigureServices(IServiceCollection services)
{
    services.AddDbContext<ApplicationDbContext>(options =>
        options.UseSqlServer(
            Configuration.GetConnectionString("DefaultConnection")));
    services.AddDefaultIdentity<IdentityUser>(
        options => options.SignIn.RequireConfirmedAccount = true)
        .AddRoles<IdentityRole>()
        .AddEntityFrameworkStores<ApplicationDbContext>();

    services.AddRazorPages();

    services.AddAuthorization(options =>
    {
        options.FallbackPolicy = new AuthorizationPolicyBuilder()
            .RequireAuthenticatedUser()
            .Build();
    });

前の強調表示されたコードでは、フォールバック認証ポリシーを設定しています。 このフォールバック認証ポリシーにより、認証属性を持つ Razor Pages、コントローラー、またはアクション メソッドを除き、"すべての" ユーザーに認証が要求されます。 たとえば、[AllowAnonymous][Authorize(PolicyName="MyPolicy")] を使用する Razor Pages、コントローラー、またはアクション メソッドでは、フォールバック認証ポリシーではなく適用された認証属性が使用されます。

RequireAuthenticatedUser により、現在のインスタンスに DenyAnonymousAuthorizationRequirement が追加されます。これにより、現在のユーザーが認証されます。

フォールバック認証ポリシー:

  • 認証ポリシーを明示的に指定していないすべての要求に適用されます。 エンドポイント ルーティングによって処理される要求の場合、認可属性を指定していないすべてのエンドポイントが含まれます。 静的ファイルなど、認可ミドルウェアの後に他のミドルウェアによって処理される要求の場合、すべての要求にこのポリシーが適用されます。

ユーザーが認証されることを要求するフォールバック認証ポリシーを設定すると、新しく追加された Razor Pages とコントローラーが保護されます。 既定で認証を要求することは、新しいコントローラーや Razor Pages に [Authorize] 属性を含めることに頼るよりも安全です。

AuthorizationOptions クラスには AuthorizationOptions.DefaultPolicy も含まれています。 DefaultPolicy は、ポリシーが指定されていない場合に [Authorize] 属性と共に使われるポリシーです。 [Authorize][Authorize(PolicyName="MyPolicy")] とは異なり、名前付きのポリシーが含まれていません。

ポリシーの詳細については、「ASP.NET Core でのポリシー ベースの認可」を参照してください。

MVC コントローラーと Razor Pages で、すべてのユーザーに認証されることを要求するもう 1 つの方法は、認可フィルターを追加することです。

public void ConfigureServices(IServiceCollection services)
{

    services.AddDbContext<ApplicationDbContext>(options =>
        options.UseSqlServer(
            Configuration.GetConnectionString("DefaultConnection")));
    services.AddDefaultIdentity<IdentityUser>(
        options => options.SignIn.RequireConfirmedAccount = true)
        .AddRoles<IdentityRole>()
        .AddEntityFrameworkStores<ApplicationDbContext>();

    services.AddRazorPages();

    services.AddControllers(config =>
    {
        // using Microsoft.AspNetCore.Mvc.Authorization;
        // using Microsoft.AspNetCore.Authorization;
        var policy = new AuthorizationPolicyBuilder()
                         .RequireAuthenticatedUser()
                         .Build();
        config.Filters.Add(new AuthorizeFilter(policy));
    });

前のコードには認可フィルターを使い、フォールバック ポリシーの設定にはエンドポイント ルーティングを使っています。 すべてのユーザーが認証されることを要求するには、フォールバック ポリシーを設定することをお勧めします。

IndexPrivacy のページに AllowAnonymous を追加し、匿名ユーザーが登録前にサイトに関する情報を得られるようにします。

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;

namespace ContactManager.Pages
{
    [AllowAnonymous]
    public class IndexModel : PageModel
    {
        private readonly ILogger<IndexModel> _logger;

        public IndexModel(ILogger<IndexModel> logger)
        {
            _logger = logger;
        }

        public void OnGet()
        {

        }
    }
}

テスト アカウントを構成する

SeedData クラスを使って、管理者とマネージャーの 2 つのアカウントを作成します。 Secret Manager ツールを使って、これらのアカウントにパスワードを設定します。 パスワードは、プロジェクト ディレクトリ (Program.cs のあるディレクトリ) から設定します。

dotnet user-secrets set SeedUserPW <PW>

強力なパスワードが指定されていない場合、SeedData.Initialize の呼び出し時に例外がスローされます。

テスト パスワードを使うように Main を更新します。

public class Program
{
    public static void Main(string[] args)
    {
        var host = CreateHostBuilder(args).Build();

        using (var scope = host.Services.CreateScope())
        {
            var services = scope.ServiceProvider;

            try
            {
                var context = services.GetRequiredService<ApplicationDbContext>();
                context.Database.Migrate();

                // requires using Microsoft.Extensions.Configuration;
                var config = host.Services.GetRequiredService<IConfiguration>();
                // Set password with the Secret Manager tool.
                // dotnet user-secrets set SeedUserPW <pw>

                var testUserPw = config["SeedUserPW"];

                SeedData.Initialize(services, testUserPw).Wait();
            }
            catch (Exception ex)
            {
                var logger = services.GetRequiredService<ILogger<Program>>();
                logger.LogError(ex, "An error occurred seeding the DB.");
            }
        }

        host.Run();
    }

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

テスト アカウントの作成と連絡先の更新

SeedData クラスの Initialize メソッドを更新してテスト アカウントを作成します。

public static async Task Initialize(IServiceProvider serviceProvider, string testUserPw)
{
    using (var context = new ApplicationDbContext(
        serviceProvider.GetRequiredService<DbContextOptions<ApplicationDbContext>>()))
    {
        // For sample purposes seed both with the same password.
        // Password is set with the following:
        // dotnet user-secrets set SeedUserPW <pw>
        // The admin user can do anything

        var adminID = await EnsureUser(serviceProvider, testUserPw, "admin@contoso.com");
        await EnsureRole(serviceProvider, adminID, Constants.ContactAdministratorsRole);

        // allowed user can create and edit contacts that they create
        var managerID = await EnsureUser(serviceProvider, testUserPw, "manager@contoso.com");
        await EnsureRole(serviceProvider, managerID, Constants.ContactManagersRole);

        SeedDB(context, adminID);
    }
}

private static async Task<string> EnsureUser(IServiceProvider serviceProvider,
                                            string testUserPw, string UserName)
{
    var userManager = serviceProvider.GetService<UserManager<IdentityUser>>();

    var user = await userManager.FindByNameAsync(UserName);
    if (user == null)
    {
        user = new IdentityUser
        {
            UserName = UserName,
            EmailConfirmed = true
        };
        await userManager.CreateAsync(user, testUserPw);
    }

    if (user == null)
    {
        throw new Exception("The password is probably not strong enough!");
    }

    return user.Id;
}

private static async Task<IdentityResult> EnsureRole(IServiceProvider serviceProvider,
                                                              string uid, string role)
{
    var roleManager = serviceProvider.GetService<RoleManager<IdentityRole>>();

    if (roleManager == null)
    {
        throw new Exception("roleManager null");
    }

    IdentityResult IR;
    if (!await roleManager.RoleExistsAsync(role))
    {
        IR = await roleManager.CreateAsync(new IdentityRole(role));
    }

    var userManager = serviceProvider.GetService<UserManager<IdentityUser>>();

    //if (userManager == null)
    //{
    //    throw new Exception("userManager is null");
    //}

    var user = await userManager.FindByIdAsync(uid);

    if (user == null)
    {
        throw new Exception("The testUserPw password was probably not strong enough!");
    }

    IR = await userManager.AddToRoleAsync(user, role);

    return IR;
}

管理者のユーザー ID と ContactStatus を連絡先に追加します。 連絡先の 1 つを "送信済み" に、1 つを "拒否済み" にします。 すべての連絡先にユーザー ID と状態を追加します。 連絡先は 1 つだけ表示されます。

public static void SeedDB(ApplicationDbContext context, string adminID)
{
    if (context.Contact.Any())
    {
        return;   // DB has been seeded
    }

    context.Contact.AddRange(
        new Contact
        {
            Name = "Debra Garcia",
            Address = "1234 Main St",
            City = "Redmond",
            State = "WA",
            Zip = "10999",
            Email = "debra@example.com",
            Status = ContactStatus.Approved,
            OwnerID = adminID
        },

所有者、マネージャー、管理者の認可ハンドラーを作成する

Authorization フォルダーに ContactIsOwnerAuthorizationHandler クラスを作成します。 ContactIsOwnerAuthorizationHandler により、リソースを操作しているユーザーがそのリソースを所有していることが確認されます。

using ContactManager.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Infrastructure;
using Microsoft.AspNetCore.Identity;
using System.Threading.Tasks;

namespace ContactManager.Authorization
{
    public class ContactIsOwnerAuthorizationHandler
                : AuthorizationHandler<OperationAuthorizationRequirement, Contact>
    {
        UserManager<IdentityUser> _userManager;

        public ContactIsOwnerAuthorizationHandler(UserManager<IdentityUser> 
            userManager)
        {
            _userManager = userManager;
        }

        protected override Task
            HandleRequirementAsync(AuthorizationHandlerContext context,
                                   OperationAuthorizationRequirement requirement,
                                   Contact resource)
        {
            if (context.User == null || resource == null)
            {
                return Task.CompletedTask;
            }

            // If not asking for CRUD permission, return.

            if (requirement.Name != Constants.CreateOperationName &&
                requirement.Name != Constants.ReadOperationName   &&
                requirement.Name != Constants.UpdateOperationName &&
                requirement.Name != Constants.DeleteOperationName )
            {
                return Task.CompletedTask;
            }

            if (resource.OwnerID == _userManager.GetUserId(context.User))
            {
                context.Succeed(requirement);
            }

            return Task.CompletedTask;
        }
    }
}

現在の認証されているユーザーが連絡先の所有者である場合、ContactIsOwnerAuthorizationHandler によって context.Succeed が呼び出されます。 認可ハンドラーの一般的な処理内容:

  • 要件が満たされている場合は context.Succeed が呼び出されます。
  • 要件が満たされていない場合は Task.CompletedTask が返されます。 先に context.Success または context.Fail が呼び出されずに Task.CompletedTask が返されることは成功でも失敗でもなく、他の認可ハンドラーの実行が許可されます。

明示的に失敗する必要がある場合は、context.Fail を呼び出します。

このアプリでは、連絡先の所有者が自分のデータを編集、削除、または作成できます。 要件パラメーターで渡された操作を ContactIsOwnerAuthorizationHandler を使って確認する必要はありません。

マネージャーの認可ハンドラーを作成する

Authorization フォルダーに ContactManagerAuthorizationHandler クラスを作成します。 ContactManagerAuthorizationHandler により、リソースを操作しているユーザーがマネージャーであることが確認されます。 内容の変更 (新規または変更) を承認または拒否できるのはマネージャーのみです。

using System.Threading.Tasks;
using ContactManager.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Infrastructure;
using Microsoft.AspNetCore.Identity;

namespace ContactManager.Authorization
{
    public class ContactManagerAuthorizationHandler :
        AuthorizationHandler<OperationAuthorizationRequirement, Contact>
    {
        protected override Task
            HandleRequirementAsync(AuthorizationHandlerContext context,
                                   OperationAuthorizationRequirement requirement,
                                   Contact resource)
        {
            if (context.User == null || resource == null)
            {
                return Task.CompletedTask;
            }

            // If not asking for approval/reject, return.
            if (requirement.Name != Constants.ApproveOperationName &&
                requirement.Name != Constants.RejectOperationName)
            {
                return Task.CompletedTask;
            }

            // Managers can approve or reject.
            if (context.User.IsInRole(Constants.ContactManagersRole))
            {
                context.Succeed(requirement);
            }

            return Task.CompletedTask;
        }
    }
}

管理者の認可ハンドラーを作成する

Authorization フォルダーに ContactAdministratorsAuthorizationHandler クラスを作成します。 ContactAdministratorsAuthorizationHandler により、リソースを操作しているユーザーが管理者であることが確認されます。 管理者はすべての操作を実行できます。

using System.Threading.Tasks;
using ContactManager.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Infrastructure;

namespace ContactManager.Authorization
{
    public class ContactAdministratorsAuthorizationHandler
                    : AuthorizationHandler<OperationAuthorizationRequirement, Contact>
    {
        protected override Task HandleRequirementAsync(
                                              AuthorizationHandlerContext context,
                                    OperationAuthorizationRequirement requirement, 
                                     Contact resource)
        {
            if (context.User == null)
            {
                return Task.CompletedTask;
            }

            // Administrators can do anything.
            if (context.User.IsInRole(Constants.ContactAdministratorsRole))
            {
                context.Succeed(requirement);
            }

            return Task.CompletedTask;
        }
    }
}

認可ハンドラーを登録する

Entity Framework Core を使うサービスは、依存関係の挿入のために AddScoped を使って登録する必要があります。 ContactIsOwnerAuthorizationHandler には、Entity Framework Core に基づいて構築された ASP.NET Core Identity が使われています。 ハンドラーをサービス コレクションに登録し、依存関係の挿入を介して ContactsController を使えるようにします。 次のコードを ConfigureServices の末尾に追加します。

public void ConfigureServices(IServiceCollection services)
{
    services.AddDbContext<ApplicationDbContext>(options =>
        options.UseSqlServer(
            Configuration.GetConnectionString("DefaultConnection")));
    services.AddDefaultIdentity<IdentityUser>(
        options => options.SignIn.RequireConfirmedAccount = true)
        .AddRoles<IdentityRole>()
        .AddEntityFrameworkStores<ApplicationDbContext>();

    services.AddRazorPages();

    services.AddAuthorization(options =>
    {
        options.FallbackPolicy = new AuthorizationPolicyBuilder()
            .RequireAuthenticatedUser()
            .Build();
    });

    // Authorization handlers.
    services.AddScoped<IAuthorizationHandler,
                          ContactIsOwnerAuthorizationHandler>();

    services.AddSingleton<IAuthorizationHandler,
                          ContactAdministratorsAuthorizationHandler>();

    services.AddSingleton<IAuthorizationHandler,
                          ContactManagerAuthorizationHandler>();
}

ContactAdministratorsAuthorizationHandlerContactManagerAuthorizationHandler はシングルトンとして追加されます。 これらがシングルトンである理由は、EF が使われておらず、必要な情報はすべて HandleRequirementAsync メソッドの Context パラメーターにあるためです。

認可のサポート

このセクションでは、Razor Pages を更新し、操作の要件クラスを追加します。

連絡先操作の要件クラスを確認する

ContactOperations クラスを確認します。 このクラスには、アプリがサポートする要件が含まれています。

using Microsoft.AspNetCore.Authorization.Infrastructure;

namespace ContactManager.Authorization
{
    public static class ContactOperations
    {
        public static OperationAuthorizationRequirement Create =   
          new OperationAuthorizationRequirement {Name=Constants.CreateOperationName};
        public static OperationAuthorizationRequirement Read = 
          new OperationAuthorizationRequirement {Name=Constants.ReadOperationName};  
        public static OperationAuthorizationRequirement Update = 
          new OperationAuthorizationRequirement {Name=Constants.UpdateOperationName}; 
        public static OperationAuthorizationRequirement Delete = 
          new OperationAuthorizationRequirement {Name=Constants.DeleteOperationName};
        public static OperationAuthorizationRequirement Approve = 
          new OperationAuthorizationRequirement {Name=Constants.ApproveOperationName};
        public static OperationAuthorizationRequirement Reject = 
          new OperationAuthorizationRequirement {Name=Constants.RejectOperationName};
    }

    public class Constants
    {
        public static readonly string CreateOperationName = "Create";
        public static readonly string ReadOperationName = "Read";
        public static readonly string UpdateOperationName = "Update";
        public static readonly string DeleteOperationName = "Delete";
        public static readonly string ApproveOperationName = "Approve";
        public static readonly string RejectOperationName = "Reject";

        public static readonly string ContactAdministratorsRole = 
                                                              "ContactAdministrators";
        public static readonly string ContactManagersRole = "ContactManagers";
    }
}

連絡先の Razor Pages の基底クラスを作成します。

連絡先の Razor Pages で使われるサービスを含む基底クラスを作成します。 基底クラスにより、初期化コードが 1 か所に配置されます。

using ContactManager.Data;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace ContactManager.Pages.Contacts
{
    public class DI_BasePageModel : PageModel
    {
        protected ApplicationDbContext Context { get; }
        protected IAuthorizationService AuthorizationService { get; }
        protected UserManager<IdentityUser> UserManager { get; }

        public DI_BasePageModel(
            ApplicationDbContext context,
            IAuthorizationService authorizationService,
            UserManager<IdentityUser> userManager) : base()
        {
            Context = context;
            UserManager = userManager;
            AuthorizationService = authorizationService;
        } 
    }
}

上記のコードでは次の操作が行われます。

  • 認可ハンドラーにアクセスする IAuthorizationService サービスを追加します。
  • IdentityUserManager サービスを追加します。
  • ApplicationDbContext を追加します。

CreateModel を更新する

DI_BasePageModel 基底クラスを使うように作成ページ モデル コンストラクターを更新します。

public class CreateModel : DI_BasePageModel
{
    public CreateModel(
        ApplicationDbContext context,
        IAuthorizationService authorizationService,
        UserManager<IdentityUser> userManager)
        : base(context, authorizationService, userManager)
    {
    }

CreateModel.OnPostAsync メソッドを次のように更新します。

  • Contact モデルにユーザー ID を追加します。
  • 認可ハンドラーを呼び出して、ユーザーが連絡先を作成するアクセス許可を持っていることを確認します。
public async Task<IActionResult> OnPostAsync()
{
    if (!ModelState.IsValid)
    {
        return Page();
    }

    Contact.OwnerID = UserManager.GetUserId(User);

    // requires using ContactManager.Authorization;
    var isAuthorized = await AuthorizationService.AuthorizeAsync(
                                                User, Contact,
                                                ContactOperations.Create);
    if (!isAuthorized.Succeeded)
    {
        return Forbid();
    }

    Context.Contact.Add(Contact);
    await Context.SaveChangesAsync();

    return RedirectToPage("./Index");
}

IndexModel を更新する

承認された連絡先のみを一般ユーザーに表示するように OnGetAsync メソッドを更新します。

public class IndexModel : DI_BasePageModel
{
    public IndexModel(
        ApplicationDbContext context,
        IAuthorizationService authorizationService,
        UserManager<IdentityUser> userManager)
        : base(context, authorizationService, userManager)
    {
    }

    public IList<Contact> Contact { get; set; }

    public async Task OnGetAsync()
    {
        var contacts = from c in Context.Contact
                       select c;

        var isAuthorized = User.IsInRole(Constants.ContactManagersRole) ||
                           User.IsInRole(Constants.ContactAdministratorsRole);

        var currentUserId = UserManager.GetUserId(User);

        // Only approved contacts are shown UNLESS you're authorized to see them
        // or you are the owner.
        if (!isAuthorized)
        {
            contacts = contacts.Where(c => c.Status == ContactStatus.Approved
                                        || c.OwnerID == currentUserId);
        }

        Contact = await contacts.ToListAsync();
    }
}

EditModel を更新する

ユーザーが連絡先を所有していることを確認する認可ハンドラーを追加します。 リソースの認可は検証中なので、[Authorize] 属性だけでは不十分です。 属性が評価されても、アプリからリソースにアクセスできません。 リソースベースの認可は必須です。 アプリからリソースにアクセスできるようになったら、ページ モデルにリソースを読み込むか、ハンドラー内にリソースを読み込むことでチェックを実行する必要があります。 リソースに頻繁にアクセスするには、リソース キーを渡します。

public class EditModel : DI_BasePageModel
{
    public EditModel(
        ApplicationDbContext context,
        IAuthorizationService authorizationService,
        UserManager<IdentityUser> userManager)
        : base(context, authorizationService, userManager)
    {
    }

    [BindProperty]
    public Contact Contact { get; set; }

    public async Task<IActionResult> OnGetAsync(int id)
    {
        Contact = await Context.Contact.FirstOrDefaultAsync(
                                             m => m.ContactId == id);

        if (Contact == null)
        {
            return NotFound();
        }

        var isAuthorized = await AuthorizationService.AuthorizeAsync(
                                                  User, Contact,
                                                  ContactOperations.Update);
        if (!isAuthorized.Succeeded)
        {
            return Forbid();
        }

        return Page();
    }

    public async Task<IActionResult> OnPostAsync(int id)
    {
        if (!ModelState.IsValid)
        {
            return Page();
        }

        // Fetch Contact from DB to get OwnerID.
        var contact = await Context
            .Contact.AsNoTracking()
            .FirstOrDefaultAsync(m => m.ContactId == id);

        if (contact == null)
        {
            return NotFound();
        }

        var isAuthorized = await AuthorizationService.AuthorizeAsync(
                                                 User, contact,
                                                 ContactOperations.Update);
        if (!isAuthorized.Succeeded)
        {
            return Forbid();
        }

        Contact.OwnerID = contact.OwnerID;

        Context.Attach(Contact).State = EntityState.Modified;

        if (Contact.Status == ContactStatus.Approved)
        {
            // If the contact is updated after approval, 
            // and the user cannot approve,
            // set the status back to submitted so the update can be
            // checked and approved.
            var canApprove = await AuthorizationService.AuthorizeAsync(User,
                                    Contact,
                                    ContactOperations.Approve);

            if (!canApprove.Succeeded)
            {
                Contact.Status = ContactStatus.Submitted;
            }
        }

        await Context.SaveChangesAsync();

        return RedirectToPage("./Index");
    }
}

DeleteModel を更新する

認可ハンドラーを使ってユーザーが連絡先の削除アクセス許可を持っていることを確認するように削除ページ モデルを更新します。

public class DeleteModel : DI_BasePageModel
{
    public DeleteModel(
        ApplicationDbContext context,
        IAuthorizationService authorizationService,
        UserManager<IdentityUser> userManager)
        : base(context, authorizationService, userManager)
    {
    }

    [BindProperty]
    public Contact Contact { get; set; }

    public async Task<IActionResult> OnGetAsync(int id)
    {
        Contact = await Context.Contact.FirstOrDefaultAsync(
                                             m => m.ContactId == id);

        if (Contact == null)
        {
            return NotFound();
        }

        var isAuthorized = await AuthorizationService.AuthorizeAsync(
                                                 User, Contact,
                                                 ContactOperations.Delete);
        if (!isAuthorized.Succeeded)
        {
            return Forbid();
        }

        return Page();
    }

    public async Task<IActionResult> OnPostAsync(int id)
    {
        var contact = await Context
            .Contact.AsNoTracking()
            .FirstOrDefaultAsync(m => m.ContactId == id);

        if (contact == null)
        {
            return NotFound();
        }

        var isAuthorized = await AuthorizationService.AuthorizeAsync(
                                                 User, contact,
                                                 ContactOperations.Delete);
        if (!isAuthorized.Succeeded)
        {
            return Forbid();
        }

        Context.Contact.Remove(contact);
        await Context.SaveChangesAsync();

        return RedirectToPage("./Index");
    }
}

ビューに認可サービスを挿入する

現在、UI には、ユーザーが変更できない連絡先の編集と削除のリンクが表示されています。

認可サービスを Pages/_ViewImports.cshtml ファイルに挿入して、すべてのビューで使えるようにします。

@using Microsoft.AspNetCore.Identity
@using ContactManager
@using ContactManager.Data
@namespace ContactManager.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@using ContactManager.Authorization;
@using Microsoft.AspNetCore.Authorization
@using ContactManager.Models
@inject IAuthorizationService AuthorizationService

前のマークアップにより、いくつかの using ステートメントが追加されます。

Pages/Contacts/Index.cshtml[Edit](編集)[Delete](削除) のリンクを更新して、適切なアクセス許可を持つユーザーにのみ表示されるようにします。

@page
@model ContactManager.Pages.Contacts.IndexModel

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

<h2>Index</h2>

<p>
    <a asp-page="Create">Create New</a>
</p>
<table class="table">
    <thead>
        <tr>
            <th>
                @Html.DisplayNameFor(model => model.Contact[0].Name)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Contact[0].Address)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Contact[0].City)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Contact[0].State)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Contact[0].Zip)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Contact[0].Email)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Contact[0].Status)
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model.Contact)
        {
            <tr>
                <td>
                    @Html.DisplayFor(modelItem => item.Name)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Address)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.City)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.State)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Zip)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Email)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Status)
                </td>
                <td>
                    @if ((await AuthorizationService.AuthorizeAsync(
                     User, item,
                     ContactOperations.Update)).Succeeded)
                    {
                        <a asp-page="./Edit" asp-route-id="@item.ContactId">Edit</a>
                        <text> | </text>
                    }

                    <a asp-page="./Details" asp-route-id="@item.ContactId">Details</a>

                    @if ((await AuthorizationService.AuthorizeAsync(
                     User, item,
                     ContactOperations.Delete)).Succeeded)
                    {
                        <text> | </text>
                        <a asp-page="./Delete" asp-route-id="@item.ContactId">Delete</a>
                    }
                </td>
            </tr>
        }
    </tbody>
</table>

警告

データを変更するアクセス許可を持たないユーザーにリンクを非表示にしても、アプリをセキュリティで保護することにはなりません。 リンクを非表示にすると、有効なリンクのみが表示されるので、アプリが使いやすくなります。 ユーザーが生成された URL をハッキングすると、自分が所有していないデータの編集や削除の操作を呼び出すことができます。 Razor ページまたはコントローラーによって、データをセキュリティで保護するアクセス チェックを実施する必要があります。

詳細を更新する

マネージャーが連絡先を承認または拒否できるように詳細ビューを更新します。

        @*Precedng markup omitted for brevity.*@
        <dt>
            @Html.DisplayNameFor(model => model.Contact.Email)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Contact.Email)
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Contact.Status)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Contact.Status)
        </dd>
    </dl>
</div>

@if (Model.Contact.Status != ContactStatus.Approved)
{
    @if ((await AuthorizationService.AuthorizeAsync(
     User, Model.Contact, ContactOperations.Approve)).Succeeded)
    {
        <form style="display:inline;" method="post">
            <input type="hidden" name="id" value="@Model.Contact.ContactId" />
            <input type="hidden" name="status" value="@ContactStatus.Approved" />
            <button type="submit" class="btn btn-xs btn-success">Approve</button>
        </form>
    }
}

@if (Model.Contact.Status != ContactStatus.Rejected)
{
    @if ((await AuthorizationService.AuthorizeAsync(
     User, Model.Contact, ContactOperations.Reject)).Succeeded)
    {
        <form style="display:inline;" method="post">
            <input type="hidden" name="id" value="@Model.Contact.ContactId" />
            <input type="hidden" name="status" value="@ContactStatus.Rejected" />
            <button type="submit" class="btn btn-xs btn-danger">Reject</button>
        </form>
    }
}

<div>
    @if ((await AuthorizationService.AuthorizeAsync(
         User, Model.Contact,
         ContactOperations.Update)).Succeeded)
    {
        <a asp-page="./Edit" asp-route-id="@Model.Contact.ContactId">Edit</a>
        <text> | </text>
    }
    <a asp-page="./Index">Back to List</a>
</div>

詳細ページ モデルを更新します。

public class DetailsModel : DI_BasePageModel
{
    public DetailsModel(
        ApplicationDbContext context,
        IAuthorizationService authorizationService,
        UserManager<IdentityUser> userManager)
        : base(context, authorizationService, userManager)
    {
    }

    public Contact Contact { get; set; }

    public async Task<IActionResult> OnGetAsync(int id)
    {
        Contact = await Context.Contact.FirstOrDefaultAsync(m => m.ContactId == id);

        if (Contact == null)
        {
            return NotFound();
        }

        var isAuthorized = User.IsInRole(Constants.ContactManagersRole) ||
                           User.IsInRole(Constants.ContactAdministratorsRole);

        var currentUserId = UserManager.GetUserId(User);

        if (!isAuthorized
            && currentUserId != Contact.OwnerID
            && Contact.Status != ContactStatus.Approved)
        {
            return Forbid();
        }

        return Page();
    }

    public async Task<IActionResult> OnPostAsync(int id, ContactStatus status)
    {
        var contact = await Context.Contact.FirstOrDefaultAsync(
                                                  m => m.ContactId == id);

        if (contact == null)
        {
            return NotFound();
        }

        var contactOperation = (status == ContactStatus.Approved)
                                                   ? ContactOperations.Approve
                                                   : ContactOperations.Reject;

        var isAuthorized = await AuthorizationService.AuthorizeAsync(User, contact,
                                    contactOperation);
        if (!isAuthorized.Succeeded)
        {
            return Forbid();
        }
        contact.Status = status;
        Context.Contact.Update(contact);
        await Context.SaveChangesAsync();

        return RedirectToPage("./Index");
    }
}

ユーザーをロールに追加または削除する

以下については、この問題を参照してください。

  • ユーザーから特権を削除する。 たとえば、チャット アプリでユーザーをミュートするなどです。
  • ユーザーに特権を追加する。

チャレンジと禁止の違い

このアプリでは、認証済みユーザーを要求するように既定のポリシーが設定されています。 次のコードによって、匿名ユーザーが許可されます。 匿名ユーザーは、チャレンジと禁止の違いを示すことができます。

[AllowAnonymous]
public class Details2Model : DI_BasePageModel
{
    public Details2Model(
        ApplicationDbContext context,
        IAuthorizationService authorizationService,
        UserManager<IdentityUser> userManager)
        : base(context, authorizationService, userManager)
    {
    }

    public Contact Contact { get; set; }

    public async Task<IActionResult> OnGetAsync(int id)
    {
        Contact = await Context.Contact.FirstOrDefaultAsync(m => m.ContactId == id);

        if (Contact == null)
        {
            return NotFound();
        }

        if (!User.Identity.IsAuthenticated)
        {
            return Challenge();
        }

        var isAuthorized = User.IsInRole(Constants.ContactManagersRole) ||
                           User.IsInRole(Constants.ContactAdministratorsRole);

        var currentUserId = UserManager.GetUserId(User);

        if (!isAuthorized
            && currentUserId != Contact.OwnerID
            && Contact.Status != ContactStatus.Approved)
        {
            return Forbid();
        }

        return Page();
    }
}

上のコードでは以下の操作が行われます。

  • ユーザーが認証されていないときは、ChallengeResult が返されます。 ChallengeResult が返された場合、ユーザーはサインイン ページにリダイレクトされます。
  • ユーザーが認証されていても、認可されていない場合は、ForbidResult が返されます。 ForbidResult が返された場合、ユーザーはアクセス拒否ページにリダイレクトされます。

完成したアプリをテストする

シードされたユーザー アカウントにまだパスワードを設定していない場合は、Secret Manager ツールを使ってパスワードを設定します。

  • 強力なパスワードを選びます。8 文字以上で、少なくとも 1 つの大文字、数字、および記号を使います。 たとえば、Passw0rd! は強力なパスワードの要件を満たしています。

  • プロジェクトのフォルダーから次のコマンドを実行します。この <PW> はパスワードです。

    dotnet user-secrets set SeedUserPW <PW>
    

アプリに連絡先がある場合:

  • Contact テーブル内のすべてのレコードを削除します。
  • アプリを再起動してデータベースをシードします。

完成したアプリをテストする簡単な方法は、3 種類のブラウザー (または incognito/InPrivate セッション) を起動することです。 1 つのブラウザーで新しいユーザーを登録します (例: test@example.com)。 異なるユーザーを使って各ブラウザーにサインインします。 次の操作を確認します。

  • 登録済みユーザーは、承認されたすべての連絡先データを表示できます。
  • 登録済みユーザーは、自分のデータを編集および削除できます。
  • マネージャーは、連絡先データを承認および拒否することができます。 Details ビューには [Approve](承認)[Reject](拒否) のボタンが表示されます。
  • 管理者は、すべてのデータの承認、拒否、編集、削除を行うことができます。
User アプリによるシード オプション
test@example.com いいえ 自分のデータを編集および削除します。
manager@contoso.com はい 自分のデータの承認および拒否と、編集および削除を行います。
admin@contoso.com はい すべてのデータの承認および拒否と、編集および削除を行います。

管理者のブラウザーで連絡先を作成します。 管理者の連絡先から、削除と編集の URL をコピーします。 これらのリンクをテスト ユーザーのブラウザーに貼り付け、テスト ユーザーがこれらの操作を行えないことを確認します。

スターター アプリを作成する

  • "ContactManager" という Razor Pages アプリを作成します。

    • [個人のユーザー アカウント] を使ってアプリを作成します。
    • 名前空間がサンプルで使われている名前空間と一致するように、"ContactManager" という名前を付けます。
    • -uld により、SQLite ではなく LocalDB が指定されています。
    dotnet new webapp -o ContactManager -au Individual -uld
    
  • Models/Contact.cs を追加します。

    public class Contact
    {
        public int ContactId { get; set; }
        public string Name { get; set; }
        public string Address { get; set; }
        public string City { get; set; }
        public string State { get; set; }
        public string Zip { get; set; }
        [DataType(DataType.EmailAddress)]
        public string Email { get; set; }
    }
    
  • Contact モデルをスキャフォールディングします。

  • 初期移行を作成し、データベースを更新します。

dotnet add package Microsoft.VisualStudio.Web.CodeGeneration.Design
dotnet tool install -g dotnet-aspnet-codegenerator
dotnet aspnet-codegenerator razorpage -m Contact -udl -dc ApplicationDbContext -outDir Pages\Contacts --referenceScriptLibraries
dotnet ef database drop -f
dotnet ef migrations add initial
dotnet ef database update

注意

既定では、インストールする .NET バイナリのアーキテクチャは、現在実行中の OS アーキテクチャを表します。 別の OS アーキテクチャを指定するには、「dotnet tool install, --arch option」を参照してください。 詳細については、GitHub イシュー dotnet/AspNetCore.Docs #29262 を参照してください。

dotnet aspnet-codegenerator razorpage コマンドを使ってバグが発生した場合は、この GitHub の問題を参照してください。

  • Pages/Shared/_Layout.cshtml ファイル内の ContactManager アンカーを更新します。
<a class="navbar-brand" asp-area="" asp-page="/Contacts/Index">ContactManager</a>
  • 連絡先の作成、編集、削除を実行してアプリをテストします。

データベースのシード

Data フォルダーに SeedData クラスを追加します。

using ContactManager.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Linq;
using System.Threading.Tasks;

// dotnet aspnet-codegenerator razorpage -m Contact -dc ApplicationDbContext -udl -outDir Pages\Contacts --referenceScriptLibraries

namespace ContactManager.Data
{
    public static class SeedData
    {
        public static async Task Initialize(IServiceProvider serviceProvider, string testUserPw)
        {
            using (var context = new ApplicationDbContext(
                serviceProvider.GetRequiredService<DbContextOptions<ApplicationDbContext>>()))
            {              
                SeedDB(context, "0");
            }
        }        

        public static void SeedDB(ApplicationDbContext context, string adminID)
        {
            if (context.Contact.Any())
            {
                return;   // DB has been seeded
            }

            context.Contact.AddRange(
                new Contact
                {
                    Name = "Debra Garcia",
                    Address = "1234 Main St",
                    City = "Redmond",
                    State = "WA",
                    Zip = "10999",
                    Email = "debra@example.com"
                },
                new Contact
                {
                    Name = "Thorsten Weinrich",
                    Address = "5678 1st Ave W",
                    City = "Redmond",
                    State = "WA",
                    Zip = "10999",
                    Email = "thorsten@example.com"
                },
                new Contact
                {
                    Name = "Yuhong Li",
                    Address = "9012 State st",
                    City = "Redmond",
                    State = "WA",
                    Zip = "10999",
                    Email = "yuhong@example.com"
                },
                new Contact
                {
                    Name = "Jon Orton",
                    Address = "3456 Maple St",
                    City = "Redmond",
                    State = "WA",
                    Zip = "10999",
                    Email = "jon@example.com"
                },
                new Contact
                {
                    Name = "Diliana Alexieva-Bosseva",
                    Address = "7890 2nd Ave E",
                    City = "Redmond",
                    State = "WA",
                    Zip = "10999",
                    Email = "diliana@example.com"
                }
             );
            context.SaveChanges();
        }

    }
}

Main から SeedData.Initialize を呼び出します。

using ContactManager.Data;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System;

namespace ContactManager
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var host = CreateHostBuilder(args).Build();

            using (var scope = host.Services.CreateScope())
            {
                var services = scope.ServiceProvider;

                try
                {
                    var context = services.GetRequiredService<ApplicationDbContext>();
                    context.Database.Migrate();
                    SeedData.Initialize(services, "not used");
                }
                catch (Exception ex)
                {
                    var logger = services.GetRequiredService<ILogger<Program>>();
                    logger.LogError(ex, "An error occurred seeding the DB.");
                }
            }

            host.Run();
        }

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

アプリによってデータベースがシードされたことをテストします。 連絡先 DB に行がある場合は、seed メソッドは実行されません。

その他の技術情報