권한 부여로 보호된 사용자 데이터를 사용하여 ASP.NET Core 웹앱 만들기

작성자: Rick AndersonJoe Audette

이 자습서에서는 권한 부여로 보호되는 사용자 데이터를 사용하여 ASP.NET Core 웹앱을 만드는 방법을 보여 줍니다. 웹앱은 인증된(등록된) 사용자가 만든 연락처 목록을 표시합니다. 세 가지 보안 그룹이 있습니다.

  • 등록된 사용자는 모든 승인된 데이터를 보고 자신의 데이터를 편집/삭제할 수 있습니다.
  • 매니저는 연락처 데이터를 승인 또는 거부할 수 있습니다. 승인된 연락처만 사용자에게 표시됩니다.
  • 관리자는 데이터를 승인/거부하고 편집/삭제할 수 있습니다.

이 문서의 이미지에는 최신 템플릿과 다른 점이 있습니다.

아래 이미지에서는 사용자 Rick(rick@example.com)이 로그인되어 있습니다. Rick은 승인된 연락처와 연락처의 편집/삭제/새로 만들기 링크만 볼 수 있습니다. Rick이 만든 마지막 레코드에만 편집 링크와 삭제 링크가 표시됩니다. 다른 사용자는 매니저나 관리자가 상태를 “승인됨”으로 변경하기 전까지 이 마지막 레코드를 볼 수 없습니다.

Screenshot showing Rick signed in

아래 이미지에서 manager@contoso.com이 매니저 역할로 로그인되어 있습니다.

Screenshot showing manager@contoso.com signed in

아래 이미지는 연락처의 매니저 세부 정보 보기를 보여 줍니다.

Manager's view of a contact

승인 단추와 거부 단추는 매니저와 관리자에게만 표시됩니다.

아래 이미지에서 admin@contoso.com이 관리자 역할로 로그인되어 있습니다.

Screenshot showing admin@contoso.com signed in

관리자는 모든 권한을 갖습니다. 관리자는 모든 연락처를 읽고 편집하고 삭제할 수 있으며 연락처의 상태를 변경할 수 있습니다.

앱은 다음과 같은 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, 컨트롤러 또는 작업 메서드는 대체 권한 부여 정책이 아닌 적용된 권한 부여 특성을 사용합니다.

RequireAuthenticatedUserDenyAnonymousAuthorizationRequirement를 현재 인스턴스에 추가하여 현재 사용자가 인증될 것을 요구합니다.

대체 권한 부여 정책:

  • 권한 부여 정책을 명시적으로 지정하지 않는 모든 요청에 적용됩니다. 엔드포인트 라우팅이 제공하는 요청의 경우 여기에는 권한 부여 특성을 지정하지 않는 모든 엔드포인트가 포함됩니다. 권한 부여 미들웨어 뒤에 오는 다른 미들웨어(예: 정적 파일)에서 처리하는 요청의 경우 이 정책이 모든 요청에 적용됩니다.

사용자 인증을 요구하도록 대체 권한 부여 정책을 설정하면 새로 추가된 Razor Pages와 컨트롤러를 보호할 수 있습니다. 기본적으로 권한 부여를 요구하도록 하는 것은 새 컨트롤러와 Razor Pages에 [Authorize] 특성을 포함하도록 하는 것보다 더 안전합니다.

AuthorizationOptions 클래스는 AuthorizationOptions.DefaultPolicy도 포함합니다. DefaultPolicy는 지정된 정책이 없는 경우 [Authorize] 특성과 함께 사용되는 정책입니다. [Authorize][Authorize(PolicyName="MyPolicy")]와 같은 명명된 정책을 포함하지 않습니다.

정책에 대한 자세한 내용은 ASP.NET Core의 정책 기반 권한 부여를 참조하세요.

MVC 컨트롤러와 Razor Pages가 모든 사용자의 인증을 요구하도록 하는 또 다른 방법은 권한 부여 필터를 추가하는 것입니다.

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 클래스는 관리자와 매니저, 이렇게 두 개의 계정을 만듭니다. 비밀 관리자 도구를 사용하여 두 계정의 암호를 설정합니다. 프로젝트 디렉터리(포함된 Program.cs디렉터리)에서 암호를 설정합니다.

dotnet user-secrets set SeedUserPW <PW>

강력한 암호를 지정하지 않을 경우 SeedData.Initialize가 호출되면 예외가 throw됩니다.

테스트 암호를 사용하도록 다음과 같이 앱을 업데이트합니다.

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를 추가합니다. 연락처 중 하나는 “Submitted”로, 다른 하나는 “Rejected”로 설정합니다. 모든 연락처에 사용자 ID와 상태를 추가합니다. 아래에는 하나의 연락처만 표시되어 있습니다.

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에서 사용되는 서비스를 포함하는 기본 클래스를 만듭니다. 기본 클래스는 초기화 코드를 한 곳에 배치합니다.

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 기본 클래스를 사용하는 생성자
  • 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 사용자에 대해서만 렌더링되도록 편집삭제 링크를 업데이트합니다.

@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>

Warning

데이터를 변경할 권한이 없는 사용자에게서 링크를 숨기는 것만으로 앱이 보호되는 것은 않습니다. 링크를 숨기는 것은 유효한 링크만 표시함으로써 앱이 사용자 친화적이 되도록 합니다. 사용자는 생성된 URL을 해킹하여 자신이 소유하지 않는 데이터에서 편집 및 삭제 작업을 호출할 수 있습니다. Razor Page 또는 컨트롤러가 액세스 검사를 적용하여 데이터를 보호해야 합니다.

업데이트 세부 정보

매니저가 연락처를 승인 또는 거부할 수 있도록 세부 정보 보기를 업데이트합니다.

        @*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가 반환되면 사용자가 액세스 거부 페이지로 리디렉션됩니다.

완성된 앱 테스트

아직 선택한 사용자 계정에 대해 암호를 설정하지 않았다면 비밀 관리자 도구를 사용하여 암호를 설정합니다.

  • 강력한 암호 선택: 8개 이상의 문자와 하나 이상의 대문자, 숫자 및 기호를 사용합니다. 예를 들어, Passw0rd!는 강력한 암호 요구 사항을 충족합니다.

  • 프로젝트의 폴더에서 다음 명령을 실행합니다. 여기서 <PW>는 암호입니다.

    dotnet user-secrets set SeedUserPW <PW>
    

앱에 연락처가 있는 경우:

  • Contact 테이블에서 모든 연락처를 삭제합니다.
  • 앱을 다시 시작하여 데이터베이스를 시드합니다.

완성된 앱을 테스트하는 간단한 방법은 세 개의 브라우저(또는 비공개 세션)를 시작하는 것입니다. 브라우저 하나에서 새 사용자를 등록합니다(예: test@example.com). 다른 사용자로 각 브라우저에 로그인합니다. 다음 작업을 확인합니다.

  • 등록된 사용자가 모든 승인된 연락처 데이터를 볼 수 있습니다.
  • 등록된 사용자가 자신의 데이터를 편집/삭제할 수 있습니다.
  • 매니저가 연락처 데이터를 승인/거부할 수 있습니다. Details 보기에 승인 단추와 거부 단추가 표시됩니다.
  • 관리자가 모든 데이터를 승인/거부하고 편집/삭제할 수 있습니다.
사용자 연락처 승인 또는 거부 옵션
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 옵션을 참조하세요. 자세한 내용은 GitHub 이슈 dotnet/AspNetCore.Docs #29262를 참조하세요.

  • 파일에서 ContactManager 앵커를 업데이트합니다 Pages/Shared/_Layout.cshtml .

    <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에 행이 있는 경우 시드 메서드가 실행되지 않습니다.

이 자습서에서는 권한 부여로 보호되는 사용자 데이터를 사용하여 ASP.NET Core 웹앱을 만드는 방법을 보여 줍니다. 웹앱은 인증된(등록된) 사용자가 만든 연락처 목록을 표시합니다. 세 가지 보안 그룹이 있습니다.

  • 등록된 사용자는 모든 승인된 데이터를 보고 자신의 데이터를 편집/삭제할 수 있습니다.
  • 매니저는 연락처 데이터를 승인 또는 거부할 수 있습니다. 승인된 연락처만 사용자에게 표시됩니다.
  • 관리자는 데이터를 승인/거부하고 편집/삭제할 수 있습니다.

이 문서의 이미지에는 최신 템플릿과 다른 점이 있습니다.

아래 이미지에서는 사용자 Rick(rick@example.com)이 로그인되어 있습니다. Rick은 승인된 연락처와 연락처의 편집/삭제/새로 만들기 링크만 볼 수 있습니다. Rick이 만든 마지막 레코드에만 편집 링크와 삭제 링크가 표시됩니다. 다른 사용자는 매니저나 관리자가 상태를 “승인됨”으로 변경하기 전까지 이 마지막 레코드를 볼 수 없습니다.

Screenshot showing Rick signed in

아래 이미지에서 manager@contoso.com이 매니저 역할로 로그인되어 있습니다.

Screenshot showing manager@contoso.com signed in

아래 이미지는 연락처의 매니저 세부 정보 보기를 보여 줍니다.

Manager's view of a contact

승인 단추와 거부 단추는 매니저와 관리자에게만 표시됩니다.

아래 이미지에서 admin@contoso.com이 관리자 역할로 로그인되어 있습니다.

Screenshot showing admin@contoso.com signed in

관리자는 모든 권한을 갖습니다. 관리자는 모든 연락처를 읽고 편집하고 삭제할 수 있으며 연락처의 상태를 변경할 수 있습니다.

앱은 다음과 같은 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, 컨트롤러 또는 인증 특성을 갖는 작업 메서드를 제외하고 모든 사용자에 대해 인증을 요구합니다. 예를 들어, Razor Pages, 컨트롤러, [AllowAnonymous] 또는 [Authorize(PolicyName="MyPolicy")] 특성을 갖는 작업 메서드는 대체 인증 정책이 아닌 적용된 인증 특성을 사용합니다.

RequireAuthenticatedUserDenyAnonymousAuthorizationRequirement를 현재 인스턴스에 추가하여 현재 사용자가 인증될 것을 요구합니다.

대체 인증 정책은:

  • 인증 정책을 명시적으로 지정하지 않는 모든 요청에 적용됩니다. 엔드포인트 라우팅이 제공하는 요청의 경우, 여기에는 인증 특성을 지정하지 않는 모든 엔드포인트가 포함됩니다. 권한 부여 미들웨어 뒤에 오는 다른 미들웨어가 제공하는 요청의 경우(예: 정적 파일), 정책을 모든 요청에 적용합니다.

사용자 인증을 요구하도록 대체 인증 정책을 설정하면 새로 추가된 Razor Pages와 컨트롤러가 보호됩니다. 기본적으로 인증을 요구하도록 하는 것은 새 컨트롤러와 Razor Pages가 [Authorize] 특성을 포함하도록 하는 것보다 더 안전합니다.

AuthorizationOptions 클래스는 AuthorizationOptions.DefaultPolicy도 포함합니다. DefaultPolicy는 지정된 정책이 없는 경우 [Authorize] 특성과 함께 사용되는 정책입니다. [Authorize][Authorize(PolicyName="MyPolicy")]와 같은 명명된 정책을 포함하지 않습니다.

정책에 대한 자세한 내용은 ASP.NET Core의 정책 기반 권한 부여를 참조하세요.

MVC 컨트롤러와 Razor Pages가 모든 사용자의 인증을 요구하도록 하는 또 다른 방법은 권한 부여 필터를 추가하는 것입니다.

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 클래스는 관리자와 매니저, 이렇게 두 개의 계정을 만듭니다. 비밀 관리자 도구를 사용하여 두 계정의 암호를 설정합니다. 프로젝트 디렉터리(포함된 Program.cs디렉터리)에서 암호를 설정합니다.

dotnet user-secrets set SeedUserPW <PW>

강력한 암호를 지정하지 않을 경우 SeedData.Initialize가 호출되면 예외가 throw됩니다.

테스트 암호를 사용하도록 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를 추가합니다. 연락처 중 하나는 “Submitted”로, 다른 하나는 “Rejected”로 설정합니다. 모든 연락처에 사용자 ID와 상태를 추가합니다. 아래에는 하나의 연락처만 표시되어 있습니다.

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에서 사용되는 서비스를 포함하는 기본 클래스를 만듭니다. 기본 클래스는 초기화 코드를 한 곳에 배치합니다.

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 사용자에 대해서만 렌더링되도록 편집삭제 링크를 업데이트합니다.

@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>

Warning

데이터를 변경할 권한이 없는 사용자에게서 링크를 숨기는 것만으로 앱이 보호되는 것은 않습니다. 링크를 숨기는 것은 유효한 링크만 표시함으로써 앱이 사용자 친화적이 되도록 합니다. 사용자는 생성된 URL을 해킹하여 자신이 소유하지 않는 데이터에서 편집 및 삭제 작업을 호출할 수 있습니다. Razor Page 또는 컨트롤러가 액세스 검사를 적용하여 데이터를 보호해야 합니다.

업데이트 세부 정보

매니저가 연락처를 승인 또는 거부할 수 있도록 세부 정보 보기를 업데이트합니다.

        @*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가 반환되면 사용자가 액세스 거부 페이지로 리디렉션됩니다.

완성된 앱 테스트

아직 선택한 사용자 계정에 대해 암호를 설정하지 않았다면 비밀 관리자 도구를 사용하여 암호를 설정합니다.

  • 강력한 암호 선택: 8개 이상의 문자와 하나 이상의 대문자, 숫자 및 기호를 사용합니다. 예를 들어, Passw0rd!는 강력한 암호 요구 사항을 충족합니다.

  • 프로젝트의 폴더에서 다음 명령을 실행합니다. 여기서 <PW>는 암호입니다.

    dotnet user-secrets set SeedUserPW <PW>
    

앱에 연락처가 있는 경우:

  • Contact 테이블에서 모든 연락처를 삭제합니다.
  • 앱을 다시 시작하여 데이터베이스를 시드합니다.

완성된 앱을 테스트하는 간단한 방법은 세 개의 브라우저(또는 비공개 세션)를 시작하는 것입니다. 브라우저 하나에서 새 사용자를 등록합니다(예: test@example.com). 다른 사용자로 각 브라우저에 로그인합니다. 다음 작업을 확인합니다.

  • 등록된 사용자가 모든 승인된 연락처 데이터를 볼 수 있습니다.
  • 등록된 사용자가 자신의 데이터를 편집/삭제할 수 있습니다.
  • 매니저가 연락처 데이터를 승인/거부할 수 있습니다. Details 보기에 승인 단추와 거부 단추가 표시됩니다.
  • 관리자가 모든 데이터를 승인/거부하고 편집/삭제할 수 있습니다.
사용자 앱에 의해 시드됨 옵션
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 옵션을 참조하세요. 자세한 내용은 GitHub 이슈 dotnet/AspNetCore.Docs #29262를 참조하세요.

dotnet aspnet-codegenerator razorpage 명령을 사용할 때 버그가 발생하는 경우 이 GitHub 문제를 참조하세요.

  • 파일에서 ContactManager 앵커를 업데이트합니다 Pages/Shared/_Layout.cshtml .
<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에 행이 있는 경우 시드 메서드가 실행되지 않습니다.

추가 리소스