연습 - 정책 기반 권한 부여에서 클레임 사용

완료됨

이전 단원에서는 인증과 권한 부여의 차이점을 알아보았습니다. 또한 권한 부여를 위해 정책에서 클레임을 사용하는 방법을 알아보았습니다. 이 단원에서는 ID를 사용하여 클레임을 저장하고 조건부 액세스에 대한 정책을 적용합니다.

피자 목록 보호

피자 목록 페이지를 인증된 사용자에게만 표시해야 한다는 새로운 요구 사항을 받았습니다. 또한 관리자만 피자를 생성 및 삭제할 수 있습니다. 잠가보겠습니다.

  1. Pages/Pizza.cshtml.cs에서 다음 변경 내용을 적용합니다.

    1. PizzaModel 클래스에 [Authorize] 특성을 추가합니다.

      [Authorize]
      public class PizzaModel : PageModel
      

      이 특성은 페이지의 사용자 인증 요구 사항을 설명합니다. 이 경우 인증되는 사용자 외에는 요구 사항이 없습니다. 익명 사용자는 페이지를 볼 수 없으며 로그인 페이지로 리디렉션됩니다.

    2. 파일 맨 위에 있는 using 지시문에 다음 줄을 추가하여 Authorize에 대한 참조를 해결합니다.

      using Microsoft.AspNetCore.Authorization;
      
    3. 다음 속성을 PizzaModel 클래스에 추가합니다.

      [Authorize]
      public class PizzaModel : PageModel
      {
          public bool IsAdmin => HttpContext.User.HasClaim("IsAdmin", bool.TrueString);
      
          public List<Pizza> pizzas = new();
      

      위의 코드는 인증된 사용자에게 값이 TrueIsAdmin 클레임이 있는지를 확인합니다. 이 계산 결과는 IsAdmin이라는 읽기 전용 속성을 통해 액세스됩니다.

    4. OnPostOnPostDelete 메서드 모두if (!IsAdmin) return Forbid();을 추가합니다.

      public IActionResult OnPost()
      {
          if (!IsAdmin) return Forbid();
          if (!ModelState.IsValid)
          {
              return Page();
          }
          PizzaService.Add(NewPizza);
          return RedirectToAction("Get");
      }
      
      public IActionResult OnPostDelete(int id)
      {
          if (!IsAdmin) return Forbid();
          PizzaService.Delete(id);
          return RedirectToAction("Get");
      }
      

      다음 단계에서 관리자가 아닌 사용자에 대한 만들기/삭제 UI 요소를 숨깁니다. 그렇다고 해서 HttpRepl 또는 Postman과 같은 도구를 사용하는 악의적 사용자가 이러한 엔드포인트에 직접 액세스하는 것을 방지할 수 없습니다. 이 검사를 추가하면 이러한 시도가 있을 경우 HTTP 403 상태 코드가 반환됩니다.

  2. Pages/Pizza.cshtml에서 관리자가 아닌 사용자로부터 관리자 UI 요소를 숨기는 검사를 추가합니다.

    새 피자 양식 숨기기

    <h1>Pizza List 🍕</h1>
    @if (Model.IsAdmin)
    {
    <form method="post" class="card p-3">
        <div class="row">
            <div asp-validation-summary="All"></div>
        </div>
        <div class="form-group mb-0 align-middle">
            <label asp-for="NewPizza.Name">Name</label>
            <input type="text" asp-for="NewPizza.Name" class="mr-5">
            <label asp-for="NewPizza.Size">Size</label>
            <select asp-for="NewPizza.Size" asp-items="Html.GetEnumSelectList<PizzaSize>()" class="mr-5"></select>
            <label asp-for="NewPizza.Price"></label>
            <input asp-for="NewPizza.Price" class="mr-5" />
            <label asp-for="NewPizza.IsGlutenFree">Gluten Free</label>
            <input type="checkbox" asp-for="NewPizza.IsGlutenFree" class="mr-5">
            <button class="btn btn-primary">Add</button>
        </div>
    </form>
    }
    

    피자 삭제 단추 숨기기

    <table class="table mt-5">
        <thead>
            <tr>
                <th scope="col">Name</th>
                <th scope="col">Price</th>
                <th scope="col">Size</th>
                <th scope="col">Gluten Free</th>
                @if (Model.IsAdmin)
                {
                <th scope="col">Delete</th>
                }
            </tr>
        </thead>
        @foreach (var pizza in Model.pizzas)
        {
            <tr>
                <td>@pizza.Name</td>
                <td>@($"{pizza.Price:C}")</td>
                <td>@pizza.Size</td>
                <td>@Model.GlutenFreeText(pizza)</td>
                @if (Model.IsAdmin)
                {
                <td>
                    <form method="post" asp-page-handler="Delete" asp-route-id="@pizza.Id">
                        <button class="btn btn-danger">Delete</button>
                    </form>
                </td>
                }
            </tr>
        }
    </table>
    

    위의 변경으로 인해 인증된 사용자가 관리자인 경우에만 관리자만 액세스할 수 있어야 하는 UI 요소가 렌더링됩니다.

권한 부여 정책 적용

잠가야 할 한 가지가 더 있습니다. Pages/AdminsOnly.cshtml이라는 이름의 관리자만 액세스할 수 있는 페이지가 있습니다. IsAdmin=True 클레임을 확인하는 정책을 만들어 보겠습니다.

  1. Program.cs에서 다음을 변경합니다.

    1. 강조 표시된 다음 코드를 통합합니다.

      // Add services to the container.
      builder.Services.AddRazorPages();
      builder.Services.AddTransient<IEmailSender, EmailSender>();
      builder.Services.AddSingleton(new QRCodeService(new QRCodeGenerator()));
      builder.Services.AddAuthorization(options =>
          options.AddPolicy("Admin", policy =>
              policy.RequireAuthenticatedUser()
                  .RequireClaim("IsAdmin", bool.TrueString)));
      
      var app = builder.Build();
      

      위의 코드는 Admin이라는 권한 부여 정책을 정의합니다. 이 정책을 사용하려면 사용자가 인증되고 IsAdmin 클레임이 True로 설정되어 있어야 합니다.

    2. 다음과 같이 AddRazorPages 호출을 수정합니다.

      builder.Services.AddRazorPages(options =>
          options.Conventions.AuthorizePage("/AdminsOnly", "Admin"));
      

      AuthorizePage 메서드 호출은 Admin 정책을 적용하여 /AdminsOnly Razor 페이지 경로를 보호합니다. 정책 요구 사항을 충족하지 않는 인증된 사용자에게는 액세스 거부 메시지가 표시됩니다.

      또는 AdminsOnly.cshtml.cs를 대신 수정할 수 있습니다. 이 경우 AdminsOnlyModel 클래스에 특성으로 [Authorize(Policy = "Admin")] 추가합니다. 위에 표시된 AuthorizePage 접근 방식의 장점은 보안을 유지할 Razor Page를 수정할 필요가 없다는 것입니다. 대신, 권한 부여 측면은 Program.cs에서 관리됩니다.

  2. Pages/Shared/_Layout.cshtml에서 다음 변경 내용을 통합합니다.

    <ul class="navbar-nav flex-grow-1">
        <li class="nav-item">
            <a class="nav-link text-dark" asp-area="" asp-page="/Index">Home</a>
        </li>
        <li class="nav-item">
            <a class="nav-link text-dark" asp-area="" asp-page="/Pizza">Pizza List</a>
        </li>
        <li class="nav-item">
            <a class="nav-link text-dark" asp-area="" asp-page="/Privacy">Privacy</a>
        </li>
        @if (Context.User.HasClaim("IsAdmin", bool.TrueString))
        {
        <li class="nav-item">
            <a class="nav-link text-dark" asp-area="" asp-page="/AdminsOnly">Admins</a>
        </li>
        }
    </ul>
    

    이전 변경 내용은 사용자가 관리자가 아닌 경우 헤더의 관리자 링크를 조건부로 숨깁니다.

사용자에 IsAdmin 클레임을 추가합니다.

IsAdmin=True 클레임을 받아야 하는 사용자를 결정하기 위해 앱은 확인된 이메일 주소를 사용하여 관리자를 식별합니다.

  1. appsettings.json에서 강조 표시된 속성을 추가합니다.

    {
      "AdminEmail" : "admin@contosopizza.com",
      "Logging": {
    

    할당된 클레임을 가져오는 확인된 이메일 주소입니다.

  2. Areas/Identity/Pages/Account/ConfirmEmail.cshtml.cs에서 다음을 변경합니다.

    1. 강조 표시된 다음 코드를 통합합니다.

      public class ConfirmEmailModel : PageModel
      {
          private readonly UserManager<RazorPagesPizzaUser> _userManager;
          private readonly IConfiguration Configuration;
      
          public ConfirmEmailModel(UserManager<RazorPagesPizzaUser> userManager,
                                      IConfiguration configuration)
          {
              _userManager = userManager;
              Configuration = configuration;
          }
      
      

      위의 변경 내용은 IoC 컨테이너의 IConfiguration에서 수신하도록 생성자를 수정합니다. IConfiguration에는 appsettings.json의 값을 포함하고 이름이 Configuration인 읽기 전용 속성에 할당됩니다.

    2. 강조 표시된 변경 내용을 OnGetAsync 메서드에 적용합니다.

      public async Task<IActionResult> OnGetAsync(string userId, string code)
      {
          if (userId == null || code == null)
          {
              return RedirectToPage("/Index");
          }
      
          var user = await _userManager.FindByIdAsync(userId);
          if (user == null)
          {
              return NotFound($"Unable to load user with ID '{userId}'.");
          }
      
          code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code));
          var result = await _userManager.ConfirmEmailAsync(user, code);
          StatusMessage = result.Succeeded ? "Thank you for confirming your email." : "Error confirming your email.";
      
          var adminEmail = Configuration["AdminEmail"] ?? string.Empty;
          if(result.Succeeded)
          {
              var isAdmin = string.Compare(user.Email, adminEmail, true) == 0 ? true : false;
              await _userManager.AddClaimAsync(user, 
                  new Claim("IsAdmin", isAdmin.ToString()));
          }
      
          return Page();
      }
      

      앞의 코드에서 다음을 확인할 수 있습니다.

      • AdminEmail 문자열은 Configuration 속성에서 읽고 adminEmail에 할당됩니다.
      • Null 병합 연산자 ??appsettings.json에 해당 값이 없는 경우 adminEmailstring.Empty로 설정되도록 하는 데 사용됩니다.
      • 사용자의 메일이 성공적으로 확인되면 다음을 수행합니다.
        • 사용자의 주소는 adminEmail와(과) 비교됩니다. string.Compare()은(는) 대/소문자를 구분하지 않는 비교에 사용됩니다.
        • UserManager 클래스의 AddClaimAsync 메서드는 AspNetUserClaims 테이블에 IsAdmin 클레임을 저장하기 위해 호출됩니다.
    3. 파일 맨 위에 다음 코드를 추가합니다. 여기서는 OnGetAsync 메서드의 Claim 클래스 참조를 확인합니다.

      using System.Security.Claims;
      

관리자 클레임 테스트

마지막 테스트를 수행하여 새 관리자 기능을 확인해 보겠습니다.

  1. 모든 변경 내용을 저장했는지 확인합니다.

  2. dotnet run사용하여 앱을 실행합니다.

  3. 아직 로그인하지 않은 경우 앱으로 이동하여 기존 사용자로 로그인합니다. 머리글에서 피자 목록을 선택합니다. 사용자에게 피자를 삭제 또는 생성하기 위한 UI 요소가 제공되지 않습니다.

  4. 머리글에 관리자 링크가 없습니다. 브라우저의 주소 표시줄에서 AdminsOnly 페이지로 직접 이동합니다. URL의 /Pizza을(를) /AdminsOnly(으)로 바꿉니다.

    사용자는 해당 페이지로 이동할 수 없습니다. 액세스 거부 메시지가 표시됩니다.

  5. 로그아웃을 선택합니다.

  6. admin@contosopizza.com주소를 사용하여 새 사용자를 등록합니다.

  7. 이전과 마찬가지로 새 사용자의 이메일 주소를 확인하고 로그인합니다.

  8. 새 관리자를 사용하여 로그인한 후 헤더에서 피자 목록 링크를 선택합니다.

    관리자는 피자를 만들고 삭제할 수 있습니다.

  9. 헤더에 있는 관리자 링크를 선택합니다.

    AdminsOnly 페이지가 나타납니다.

AspNetUserClaims 테이블 검사

VS Code SQL Server 확장을 사용하여 다음 쿼리를 실행합니다.

SELECT u.Email, c.ClaimType, c.ClaimValue
FROM dbo.AspNetUserClaims AS c
    INNER JOIN dbo.AspNetUsers AS u
    ON c.UserId = u.Id

다음과 유사한 결과가 있는 탭이 나타납니다.

메일 ClaimType ClaimValue
admin@contosopizza.com IsAdmin True

IsAdmin 클레임은 AspNetUserClaims 테이블에 키-값 쌍으로 저장됩니다. AspNetUserClaims 레코드는 AspNetUsers 테이블의 사용자 레코드와 연결됩니다.

요약

이 단원에서는 클레임을 저장하고 조건부 액세스에 대한 정책을 적용하도록 앱을 수정했습니다.