.NET/ASP.NET

ASP.NET Core - 20. ASP.NET Core Identity

클리엘 2023. 4. 4. 10:45
728x90

ASP.NET Core Identity는 ASP.NET Core application에서 사용자를 관리하기 위한 API이며 인증과 권한을 요청 pipeline으로 통합할 수 있도록 지원합니다.

 

또한 application에서 필요한 인증과 권한기능을 생성하는 도구이기도 한데 two-factor authentication, federation, single sign-on, account self-service와 같은 기능의 통합을 위한 것과 대규모 조직환경 또는 cloud hosting 사용자 관리를 사용할 경우에 유용한 많은 option들을 제공하고 있습니다.

 

ASP.NET Core Identity는 자체 framework로 발전하였고 이것에 대한 전체를 다루기에는 상당히 방대한 양이 검토되어야 하므로 Entity Framework Core에서 했던 것만큼 web application 개발과 관련된 Identity API부분에만 초점을 맞추어 필요한 것으로 알아보고자 합니다. 따라서 이제 어떻게 ASP.NET Core Identity를 project에 추가하고 기본적인 사용자와 역할관리를 수행하기 위한 도구를 생성할 수 있을지와 이와 관련된 ASP.NET Core Identity API를 어떻게 사용할 수 있을지를 알아보도록 하겠습니다.

 

1. Project 준비

 

예제를 위해 사용할 project는 이전의 것을 그대로 사용할 것입니다. project를 실행하고 다음과 같은 응답이 생성되는지 확인합니다.

2. ASP.NET Core Identity를 위한 Project 준비

 

ASP.NET Core Identity를 설정하려면 project에 필요한 package를 추가하고 application을 설정한 뒤 여기에 맞는 database를 준비해야 합니다. 이를 시작하기 위해 MyBlazorApp project에  NuGet Package Manager를 실행하고 Microsoft.AspNetCore.Identity.EntityFrameworkCore와 Microsoft.EntityFrameworkCore.Design package를 project에 추가합니다.

(1) ASP.NET Core Identity Database 준비

 

ASP.NET Identity는 Entity Framework Core를 통해 관리되는 database를 필요로 합니다. Identity data로의 접근을 제공할 Entity Framework Core context class를 생성하기 위해 Models folder에 IdentityContext.cs file을 아래와 같이 추가합니다.

using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace MyBlazorApp.Models
{
	public class IdentityContext : IdentityDbContext<IdentityUser>
	{
		public IdentityContext(DbContextOptions<IdentityContext> options)
		: base(options) { }
	}
}

ASP.NET Core Identity package는 Entity Framework Core context class를 생성하기 위해 사용되는 IdentityDbContext<T> class를 포함하고 있습니다. 여기서 generic type 인수 T는 database에서 user를 나타낼 class를 지정하는 데 사용됩니다. 물론 사용자 정의 class를 생성할 수 있지만 핵심적인 Identity 기능을 제공하는 IdentityUser라는 기본적인 class를 사용하는 데는 부족함이 없을 것입니다.

 

만약 상기 예제의 class가 무엇을 말하고 있는지 명확해 보이지 않더라도 걱정할 필요는 없습니다. Entity Framework Core에 친숙하지 않더라도 있는 그대로 두면 됩니다. ASP.NET Core Identity 설정을 위해 한번 code를 완성해 두면 수정해야 할 일은 잘 생기지 않으며 file을 복사하여 다른 project에서 namespace와 같은 것만 약간 변경하면 그대로 사용할 수도 있습니다.

 

● Database 연결문자열 설정

 

ASP.NET Core Identity에서 필요한 data를 저장하도록 하기 위해 database에 대한 연결문자열을 지정해야 합니다. 아래 예제는 MyBlazorApp project의 appsettings.json file을 변경한 것으로 application의 data에 사용된 것과 함께 연결문자열을 추가한 것입니다.

"ConnectionStrings": {
    "ProductConnection": "Server=192.168.20.10;Database=BlazorTDB;Trust Server Certificate=true;User ID=sa;Password=1234;MultipleActiveResultSets=True",
    "IdentityConnection": "Server=192.168.20.10;Database=Identity;Trust Server Certificate=true;User ID=sa;Password=1234;MultipleActiveResultSets=True"
}

예제에서 Identity연결 문자열은 Identity DB를 지정하고 있습니다.

원격지 DB가 아닌 Local에 DB를 생성하고자 한다면 'Server=(localdb)\\MSSQLLocalDB;Database=Identity;MultipleActiveR esultSets=True'처럼 연결문자열을 설정할 수 있습니다.

(2) Application 설정

 

다음에는 ASP.NET Core를 설정하는 것으로 아래 MyBlazorApp project의 Program.cs와 같이 Identity database context를 service로 설정해야 합니다.

builder.Services.AddSingleton<MyBlazorApp.Services.ToggleService>();

builder.Services.AddDbContext<IdentityContext>(opts => opts.UseSqlServer(builder.Configuration["ConnectionStrings:IdentityConnection"]));
builder.Services.AddIdentity<IdentityUser, IdentityRole>().AddEntityFrameworkStores<IdentityContext>();

(3) Identity Database Migration 생성 및 적용

 

마지막으로 Entity Framework Core database migration을 생성하고 database생성에 적용해야 합니다.(위에서 Server연결을 설정하였는데 해당 Database Server에 Identity이름의 DB를 먼저 만들어 놓아야 합니다.) Visual Studio를 종료하고 PowerShell을 사용해 MyBlazorApp project (csproj file이 있는 곳)로 이동한 뒤 아래 명령을 내려 줍니다.

dotnet ef migrations add --context IdentityContext Initial
dotnet ef database update --context IdentityContext

만약 위 명령 실행 시 오류가 발생한다면 아래 명령을 통해 'dotnet-ef'를 먼저 설치합니다.

dotnet tool install --global dotnet-ef

Entity Framework Core는 database schema에 대한 변경사항을 migrations라고 하는 기능을 통해 관리합니다. project에는 2개의 database context class가 존재하고 Entity Framework Core tools는 어떤 context class를 사용할지를 결정하기 위해  위 명령에서와 같이 --context인수를 사용합니다. 최종적으로 이러한 명령은 ASP.NET Core Identity schema를 포함하는 migration을 생성하게 되고 이것을 database에 적용하게 됩니다.

ASP.NET Core Identity Database초기화
만약 database에 대한 초기화가 필요하다면 해당 project folder(csproj가 있는)에서 'dotnet ef database drop --force --context IdentityContext명령을 내린 다음 다시 dotnet ef database update --context IdentityContext 명령을 내려주면 됩니다. 이러한 명령은 기존에 존재하던 database를 삭제하고 새로운 database를 생성하게 됩니다.(다만 기존 data는 모두 삭제됩니다.)

 

3. 사용자 관리도구 생성

 

이제 ASP.NET Core Identity를 통해 사용자를 관리할 도구를 만들어볼 것입니다. 해당 기능은 UserManager< T> class를 통해 관리되며 여기서 T는 database에서 사용자를 나타내기 위해 사용되는 class입니다. 예제에서는 Entity Framework Core context class를 만들 때 이에 대한 class로 IdentityUser class를 지정하였습니다. 이 것은 ASP.NET Core Identity에서 제공하는 내장 class이며 대부분의 application에서 필요한 핵심적인 기능을 제공합니다. 아래 표에서는 IdentityUser에서 가장 유용하게 사용될 수 있는 속성을 나열하였습니다.(물론 그 이상이 추가적인 속성도 존재하지만 대부분의 application에서 필요한 것은 많지 않습니다.)

Identity 관리 도구 Scaffolding
Microsoft는 사용자 관리를 위한 일련의 Razor Page를 생성하는 도구를 제공하고 있습니다. 도구는 template으로부터 scaffolding으로 알려진 조정가능한 일반적인 content를 project에 추가합니다. Microsoft Identity template는 잘 만들어졌지만 관리자의 개입이 없이 사용자를 생성하고 비밀번호를 변경하는 등등의 자체 관리성에 초점이 맞추어졌으며 template이 가진 한계성 때문에 사용성이 어느 정도 제한되어 있기도 합니다. 물론 사용자가 수행할 수 있는 작업이 범위를 제한하기 위해 template을 조정할 수 있지만 원하는 만큼의 자유도로 구현하기는 쉽지 않습니다.
만약 사용자가 자신의 자격증명을 관리하는 유형의 application을 만들어야 한다면 scaffolding은 꽤 가치 있는 기능일 수 있습니다. scaffolding에 관한 자세한 사항은 아래 homepage를 참조하시기 바랍니다.

Scaffold Identity in ASP.NET Core projects | Microsoft Learn

 

Scaffold Identity in ASP.NET Core projects

Learn how to scaffold Identity in an ASP.NET Core project.

learn.microsoft.com

그 외 다른 모든 접근법의 경우에는 ASP.NET Core Identity에서 제공하는 user management API를 사용해야 합니다.
Id 해당 속성은 사용자의 ID를 나타냅니다.
UserName 해당 속성은 사용자의 이름을 나타냅니다.
Email 해당 속성은 사용자의 Email을 나타냅니다.

아래 표는 사용자를 관리하기 위해 사용할 UserManagement<T>의 member를 나열하고 있습니다.

Users 해당 속성은 database에 저장된 사용자의 목록을 반환합니다.
FindByIdAsync(id) 해당 method는 ID를 지정하여 database로 사용자의 개체를 질의합니다.
CreateAsync(user, password) 해당 method는 지정된 password를 사용하여 database에 새로운 사용자를 추가합니다.
UpdateAsync(user) 해당 method는 기존에 database에 존재하는 사용자를 변경합니다.
DeleteAsync(user) 해당 method는 database에서 지정한 사용자를 삭제합니다.

(1) User Management Tools 준비하기

 

관리도구 생성을 준비하기 위해 MyBlazorApp project의 Pages folder에 있는 _ViewImports.cshtml file에 아래 표현식을 추가합니다.

@using Microsoft.EntityFrameworkCore

@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Identity
@using MyBlazorApp.Pages

다음으로 Pages에 Users folder를 생성하고 _Layout.cshtml이름의 Razor Layout file을 아래와 같이 추가합니다.

<!DOCTYPE html>
<html>
<head>
	<title>Identity</title>
	<link href="/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet" />
</head>
<body>
	<div class="m-2">
		<h5 class="bg-info text-white text-center p-2">User Administration</h5>
		@RenderBody()
	</div>
</body>
</html>

그리고 Pages folder에 AminPageModel.cs class file을 아래와 같이 추가합니다.

using Microsoft.AspNetCore.Mvc.RazorPages;

namespace MyBlazorApp.Pages
{
	public class AdminPageModel : PageModel
	{
	}
}

해당 class는 page model class의 기반 class가 될 것입니다. 이전에도 봤었던 것과 같이 공통 base class는 application을 전체적으로 보호하는데 유용하게 사용될 수 있습니다.

 

(2) 사용자 계정목록 나열하기

 

현재 database에는 아무것도 존재하지 않지만 일단은 사용자 목록을 전체적으로 살펴볼 수 있는 Razor Page를 생성해 보고자 합니다. List.cshtml이름의 Razor Page를 Pages/Users folder에 아래와 같이 추가합니다.

@page
@model MyBlazorApp.Pages.Users.ListModel

<table class="table table-sm table-bordered">
	<tr>
		<th>ID</th>
		<th>Name</th>
		<th>Email</th>
		<th></th>
	</tr>

	@if (Model.Users.Count() == 0)
	{
		<tr><td colspan="4" class="text-center">No User Accounts</td></tr>
	}
	else
	{
		foreach (IdentityUser user in Model.Users)
		{
			<tr>
				<td>@user.Id</td>
				<td>@user.UserName</td>
				<td>@user.Email</td>
				<td class="text-center">
					<form asp-page="List" method="post">
						<input type="hidden" name="Id" value="@user.Id" />
						<a class="btn btn-sm btn-warning" asp-page="Editor" asp-route-id="@user.Id" asp-route-mode="edit">Edit</a>
						<button type="submit" class="btn btn-sm btn-danger">
							Delete
						</button>
					</form>
				</td>
			</tr>
		}
	}
</table>

<a class="btn btn-primary" asp-page="create">Create</a>
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace MyBlazorApp.Pages.Users
{
    public class ListModel : AdminPageModel
    {
		public UserManager<IdentityUser> UserManager;

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

		public IEnumerable<IdentityUser> Users { get; set; } = Enumerable.Empty<IdentityUser>();

		public void OnGet()
		{
			Users = UserManager.Users;
		}
	}
}

UserManager<IdentityUser> class는 service로 설정되었으므로 의존성주입을 통해 사용될 수 있습니다. 또한 Users속성은  사용자 계정을 열거하는 데 사용될 수 있는 IdentityUser collection을 반환하도록 하고 있습니다. 비록 현재는 아무런 사용자 개체도 표시할 것이 없는 경우 placeholder message가 표시될 테지만 예제에서의 Razor Page는 각 사용자를 수정하거나 삭제할 수 있는 button과 함께 table에서 현재의 사용자를 표시하도록 하고 있습니다. 또한 잠시 후 정의할 Create이름의 Razor page를 탐색할 수 있는 button도 마련하였습니다.

 

project를 실행하고 /users/list URL을 요청하면 아래와 같은 응답(현재까지는 비어있음)을 확인할 수 있습니다.

(3) 사용자 생성

 

MyBlazorApp project의 Pages/Users foler에 Create.cshtml이라는 이름의 Razor Page를 아래와 같이 추가합니다.

@page
@model MyBlazorApp.Pages.Users.CreateModel

<h5 class="bg-primary text-white text-center p-2">Create User</h5>
<form method="post">
	<div asp-validation-summary="All" class="text-danger"></div>
	<div class="form-group">
		<label>User Name</label>
		<input name="UserName" class="form-control" value="@Model.UserName" />
	</div>
	<div class="form-group">
		<label>Email</label>
		<input name="Email" class="form-control" value="@Model.Email" />
	</div>
	<div class="form-group">
		<label>Password</label>
		<input name="Password" class="form-control" value="@Model.Password" />
	</div>
	<div class="py-2">
		<button type="submit" class="btn btn-primary">Submit</button>
		<a class="btn btn-secondary" asp-page="list">Back</a>
	</div>
</form>
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.ComponentModel.DataAnnotations;

namespace MyBlazorApp.Pages.Users
{
    public class CreateModel : AdminPageModel
	{
		public UserManager<IdentityUser> UserManager;

		public CreateModel(UserManager<IdentityUser> usrManager)
		{
			UserManager = usrManager;
		}

		[BindProperty]
		public string UserName { get; set; } = string.Empty;

		[BindProperty]
		[EmailAddress]
		public string Email { get; set; } = string.Empty;

		[BindProperty]
		public string Password { get; set; } = string.Empty;

		public async Task<IActionResult> OnPostAsync()
		{
			if (ModelState.IsValid)
			{
				IdentityUser user = new IdentityUser { UserName = UserName, Email = Email };
				IdentityResult result = await UserManager.CreateAsync(user, Password);

				if (result.Succeeded)
					return RedirectToPage("List");

				foreach (IdentityError err in result.Errors)
					ModelState.AddModelError("", err.Description);
			}

			return Page();
		}
	}
}

ASP.NET Core Identity data가 비록 Entity Framework Core를 사용해 저장되기는 하지만 database context class를 통해 이러한 작업을 직접적으로 구현할 필요는 없으며 data자체도 UserManager<T> class에서 제공하는 method를 통해 관리됩니다. 따라서 새로운 사용자는 IdentityUser개체와 비밀번호 문자열을 인수로서 받는 CreateAsync method를 사용해 생성합니다.

 

해당 Razor Page는 model binding을 대상으로 하는 3개의 속성을 정의하고 있는데 UserName과 Email 속성은 CreaeAsync method를 호출하기 위해 Password속성으로 bound 되는 값으로 결합된 IdentityUser 개체를 구성하는 데 사용됩니다. 이들 속성은 validation attribute를 통해 설정되며 속성의 type이 non-nullable이므로 반드시 값이 지정되어야 합니다.

 

CreateAsync method의 결과로는 Task<IdentityResult>개체가 반환되는데 아래 표의 속성을 사용해 생성작업의 결과를 확인할 수 있습니다.

Succeeded 해당 속성은 생성작업이 성공했다면 true를 반환합니다.
Errors 생성작업을 시도하는 과정에서 발생한 error를 설명하는 일련의 IdentityError개체를 반환합니다. 각 IdentityError개체는 문제점의 요약한 Description속성을 제공합니다.

예제에서는 Succeeded속성을 사용해 새로운 사용자가 database에 생성되었는지를 확인하고 있습니다. Succeeded속성이 true라면 List page로 redirect 되어 새롭게 추가된 사용자가 반영된 목록을 확인할 수 있게 됩니다.

 

하지만 Succeeded속성이 false를 반환한다면 Errors속성에서 제공되는 일련의 IdentityError개체가 열거됩니다. 해당 IdentityError개체는 ModelState.AddModelError method를 사용해 model수준 validation error를 생성하는 데 사용되는 Description속성을 가지고 있습니다.

 

사용자 생성기능을 확인해 보기 위해 project를 실행하고 /users/list로 URL을 요청합니다. 그리고 Create button을 click 하여 사용자생성 page로 이동한 뒤 아래와 같이 각 입력값을 부여합니다. 모든 값을 입력하고 나면 submit button을 click 합니다. ASP.NET Core Identity는 database에 해당 사용자를 추가하고 browser는 redirect 되어 아래와 같은 응답을 생성할 것입니다. (ID값은 각 사용자에 대해서 고유함과 동시에 random 하게 생성되므로 다르게 보일 수 있습니다.)

예제에서는 password를 입력하는 입력 field로 일반적인 input box를 사용하였습니다. 하지만 실제 project라면 input요소의 type attribute를 password로 설정하여 입력된 값이 실제 보이지 않도록 하는 것이 좋습니다.

Create button을 다시 click 하여 이전과 동일한 값을 부여한뒤 사용자생성을 시도합니다. 다만 이번에는 model validation summary를 통해 나타난 error를 보게 될 것입니다. 해당 error는 CreateAsync method에서 생성된 IdentityResult개체를 통해 반환된 것입니다.

● Password 유효성 검증

 

application 사용자의 보안을 위해 특히 가장 필요한 것 중 하나는 password정책을 정하는 것입니다. 위에서 이미 사용자의 비밀번호를 입력할 때 'Cliel123!'라는 비밀번호를 입력했는데 이것으로 대략적으로나마 기본 password의 정책을 확인해 볼 수 있었습니다.

 

비밀번호를 입력하고 submit button을 누르면 ASP.NET Core Identity는 사용할 password를 확인하고 password정책과 일치하지 않는다면 다음과 같이 오류를 생성하게 됩니다.

이러한 password validation 규칙은 option pattern을 사용해 project의 Program.cs file에서 아래와 같이 설정할 수 있습니다.

builder.Services.Configure<IdentityOptions>(opts => {
	opts.Password.RequiredLength = 6;
	opts.Password.RequireNonAlphanumeric = false;
	opts.Password.RequireLowercase = false;
	opts.Password.RequireUppercase = false;
	opts.Password.RequireDigit = false;
});

var app = builder.Build();

ASP.NET Core Identity는 IdentityOptions class를 사용해 설정되며 여기서 Password속성은 아래 표에 나열된 속성을 사용해 password validation을 설정할 수 있는 PasswordOptions class를 반환합니다.

RequiredLength 해당 속성은 int type이며 사용할 password의 최소 자리수를 지정합니다.
RequireNonAlphanumeric bool형식의 속성이며 true를 설정하는 경우 password는 최소 하나이상의 문자를 포함해야 합니다.
RequireLowercase bool형식의 속성이며 true를 설정하는 경우 password는 최소 하나이상의 소문자를 포함해야 합니다.
RequireUppercase bool형식의 속성이며 true를 설정하는 경우 password는 최소 하나이상의 대문자를 포함해야 합니다.
RequireDigit bool형식의 속성이며 true를 설정하는 경우 password는 최소 하나이상의 숫자를 포함해야 합니다.

예제에서 password는 최소 6자 이상이어야 하며 다른 제한조건은 모두 무효화하였습니다. (실제 project에서 이러한 설정은 권장하지 않습니다.) project를 실행하고 /users/create로 URL을 요청한 다음 각 input field를 다음과 같이 입력합니다.

submit button을 click 하게 되면 password는 새로운 validation규칙에 어긋나지 않음으로 다음과 같이 새로운 사용자가 생성될 것입니다.

● 사용자 상세 유효성 검증

 

Validation은 또한 사용자를 생성할 때 user name과 e-mail 주소에서도 수행됩니다. validation이 어떤 방식으로 적용되는지를 확인하기 위해 /users/create로 URL을 요청하고 각 input field를 다음과 같이 입력합니다.

위와 같이 입력하고 submit button을 click 하게 되면 다음과 같은 error message를 보게 될 것입니다.

Validation 역시 IdentityOptions class에서 정의된 User속성을 사용하는 option pattern을 통해 설정할 수 있습니다. 해당 class는 아래 표에서 나열된 속성인 UserOptions class를 반환합니다.

AllowedUserNameCharacters 해당 속성은 string형식이며 username으로 사용가능한 모든 문자를 포함하고 있습니다. 기본값은 a-z, A-z 그리고 0-9까지이며 hyphen, period, 밑줄 그리고 @문자입니다. 정규표현식의 형태는 아니며 모든 가능한 문자는 문자열을 통해 명시적으로 지정됩니다.
RequireUniqueEmail bool형식의 속성이며 true를 설정하는 경우 새로 추가될 계정의 e-mail은 기존의 다른 계정에서 사용하지 않는 주소여야 합니다.

아래 예제는 project의 Program.cs file에서 기존의 설정을 변경한 것으로 고유한 e-mail주소가 사용되도록 하며 오로지 소문자만 username에 사용가능하도록 하였습니다.

builder.Services.Configure<IdentityOptions>(opts => {
	opts.Password.RequiredLength = 6;
	opts.Password.RequireNonAlphanumeric = false;
	opts.Password.RequireLowercase = false;
	opts.Password.RequireUppercase = false;
	opts.Password.RequireDigit = false;

	opts.User.RequireUniqueEmail = true;
	opts.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyz";
});

var app = builder.Build();

project를 실행하고 /users/create로 URL을 요청합니다. 각 input field를 다음과 같이 모두 입력하고 Submit button을 click 합니다. 그러면 e-mail주소에 오류가 발생하였음을 확인할 수 있습니다. username은 여전히 사용불가능한 문자를 포함하고 있으므로 역시 error가 표시되어 있습니다.

(4) 사용자 정보 변경

 

사용자의 정보를 변경하기 위한 기능을 추가하기 위해 Editor.cshtml 이름의 Razor Page를 project의 Pages/Users folder에 아래와 같이 추가합니다.

@page "{id}"
@model MyBlazorApp.Pages.Users.EditorModel

<h5 class="bg-warning text-white text-center p-2">Edit User</h5>
<form method="post">
	<div asp-validation-summary="All" class="text-danger"></div>
	<div class="form-group">
		<label>ID</label>
		<input name="Id" class="form-control" value="@Model.Id" disabled />
		<input name="Id" type="hidden" value="@Model.Id" />
	</div>
	<div class="form-group">
		<label>User Name</label>
		<input name="UserName" class="form-control" value="@Model.UserName" />
	</div>
	<div class="form-group">
		<label>Email</label>
		<input name="Email" class="form-control" value="@Model.Email" />
	</div>
	<div class="form-group">
		<label>New Password</label>
		<input name="Password" class="form-control" value="@Model.Password" />
	</div>
	<div class="py-2">
		<button type="submit" class="btn btn-warning">Submit</button>
		<a class="btn btn-secondary" asp-page="list">Back</a>
	</div>
</form>
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.ComponentModel.DataAnnotations;

namespace MyBlazorApp.Pages.Users
{
    public class EditorModel : AdminPageModel
	{
		public UserManager<IdentityUser> UserManager;

		public EditorModel(UserManager<IdentityUser> usrManager)
		{
			UserManager = usrManager;
		}

		[BindProperty]
		public string Id { get; set; } = string.Empty;

		[BindProperty]
		public string UserName { get; set; } = string.Empty;

		[BindProperty]
		[EmailAddress]
		public string Email { get; set; } = string.Empty;

		[BindProperty]
		public string? Password { get; set; }

		public async Task OnGetAsync(string id)
		{
			IdentityUser user = await UserManager.FindByIdAsync(id);
			Id = user.Id; UserName = user.UserName; Email = user.Email;
		}

		public async Task<IActionResult> OnPostAsync()
		{
			if (ModelState.IsValid)
			{
				IdentityUser user = await UserManager.FindByIdAsync(Id);
				user.UserName = UserName;
				user.Email = Email;

				IdentityResult result = await UserManager.UpdateAsync(user);

				if (result.Succeeded && !String.IsNullOrEmpty(Password))
				{
					await UserManager.RemovePasswordAsync(user);
					result = await UserManager.AddPasswordAsync(user, Password);
				}
				if (result.Succeeded)
					return RedirectToPage("List");

				foreach (IdentityError err in result.Errors)
					ModelState.AddModelError("", err.Description);
			}

			return Page();
		}
	}
}

Editor page는 routing system을 통해 가져온 id값을 OnGetAsync method의 인수로 받아 해당 id값을 사용해 사용자를 찾도록 UserManager<T>.FindByIdAsync method로 database에 질의하게 됩니다. 그리고 해당 결과로 반환된 IdentityUser 개체의 값은 page의 view부분에 표시된 속성의 값으로 사용되고 validation error 때문에 page가 다시 표시되는 경우에도 값이 손실되지 않도록 합니다.

 

사용자가 form을 submit 하면 form에서 제공된 UserName과 Email값으로 update 된 IdentityUser개체를 database에 질의하기 위해 FindByIdAsync method가 사용됩니다. 이때 Password는 조금 다른 접근법을 취해야 하는데 새로운 password를 할당하기 전 user개체로 부터 먼저 제거되어야 합니다.

await UserManager.RemovePasswordAsync(user);
result = await UserManager.AddPasswordAsync(user, Password);

Editor page는 form이 Password값을 포함하고 UserName과 Email field의 값이 성공적으로 update 되는 경우에만 변경됩니다. ASP.NET Core Identity로부터의 Error는 validation message로 표시되며 browser는 update이 후 List page로 redirect 됩니다. /users/list로 URL을 요청한 뒤 testuser의 Edit button을 click 하고 UserName field에 'tuser'를 입력한 후 submit button을 click 합니다. 그러면 다음과 같이 변경사항이 반영된 결과를 확인할 수 있습니다.

(5) 사용자 삭제

 

기본적인 사용자 관리 application에서 필요한 마지막 기능으로 사용자 삭제기능이 있습니다. project의 Pages/Users folder에 List.cshtml에서 아래와 같이 삭제에 필요한 method를 추가합니다.

public void OnGet()
{
	Users = UserManager.Users;
}

public async Task<IActionResult> OnPostAsync(string id)
{
	IdentityUser user = await UserManager.FindByIdAsync(id);

	if (user != null)
		await UserManager.DeleteAsync(user);

	return RedirectToPage();
}

List page에서는 data table에서 이미 각각의 사용자에 대한 Delete button을 표시하고 있으며 이를 통해 IdentityUser개체에 대한 Id값을 포함하는 POST요청을 submit 함으로써 해당 사용자를 삭제할 수 있습니다. OnPostAsync method에서는 Id값을 전달받아 FindByIdAsync method를 통해 Identity를 질의하는 데 사용하고 해당 응답으로 반환된 개체를 DeleteAsync method에 전달하여 database로부터 사용자를 삭제하게 됩니다. 삭제기능을 확인해 보기 위해 /users/list로 URL을 요청하고 tuser에 대한 계정의 Delete button click을 하여 다음과 같이 사용자 개체가 삭제됨을 확인합니다.

4. 역할 관리 도구 생성

 

몇몇 application에서는 권한에 대한 2가지 수준만 시행함으로써 인증된 사용자에 대해서는 application의 전체기능을 모두 사용할 수 있도록 하고 미인증 된 사용자에 대해서는 더 적은 기는로의 접근이나 아예 접근자체가 불가능하도록 하고 있습니다.

 

[.NET/ASP.NET] - ASP.NET Core - 7. 예제 프로젝트 만들기

 

ASP.NET Core - 7. 예제 프로젝트 만들기

이번에는 향후 사용하게될 간단한 Project를 생성하고자 합니다. 해당 Project에는 샘플 Database를 사용한 Data Model과 HTML Content의 형식화를 위한 client-side package, 그리고 간단한 요청 Pipeline을 포함할

lab.cliel.com

위 글에서 부터 만들어본 예제 project의 경우에도 이러한 방법을 따르고 있으며 한 명의 사용자가 존재하고 미인증 된 사용자가 일반적인 store기능조차도 차단되는 반면 반대로 인증을 받게 되면 application의 모든 기능에 대한 접근이 가능했었습니다.

 

ASP.NET Core Identity는 더 세세한 인증기능을 필요로 하는 application을 위해 역할(role) 기능을 제공하고 있습니다. 사용자는 하나 또는 그 이상의 role에 할당될 수 있고 그들 role에 대한 membership으로 특정한 기능의 접근이 가능한지를 판단하게 됩니다. 이에 따라 어떻게 role관리도구를 만들고 해당 도구를 build 할 수 있는지 알아보도록 하겠습니다.

 

Role은 RoleManager<T>를 통해 관리되며 T는 database에서의 role에 대한 표현입니다. 이전에 ASP.NET Core Identity를 설정할때는 이미 Identity가 role을 서술하기 위해 제공하는 내장 class인 IdentityRole을 지정했는데 이는 예제에서 RoleManager<IdentityRole> class를 사용할 것임을 의미하는 것입니다. RoleManager<T> class는 아래 table의 method와 속성을 정의하고 있으며 이를 통해 role을 생성하고 관리할 수 있습니다.

CreateAsync(role) 새로운 role을 생성합니다.
DeleteAsync(role) 특정 role을 삭제합니다.
FindByIdAsync(id) 해당 id의 role을 검색합니다.
FindByNameAsync(name) 해당 name의 role을 검색합니다.
RoleExistsAsync(name) 지정한 name의 role이 존재한다면 true가 반환됩니다.
UpdateAsync(role) 지정한 role의 변경사항을 저장합니다.
Roles 현재 정의된 모든 role을 열거합니다.

아래 표는 IdentityRole class에 정의된 핵심 속성을 나타내고 있습니다.

Id 해당 속성은 role의 고유한 ID값을 나타냅니다.
Name 해당 속성은 role의 name을 나타냅니다.

role은 RoleManager<T> class를 통해 관리되지만 role의 membership은 UserManager<T>에서 제공하는 아래 표에 명시된 method에 의해 관리됩니다.

AddToRoleAsync(user, role) 해당 method는 role에 사용자를 추가합니다.
RemoveFromRoleAsync(user, role) 해당 method는 role에서 사용자를 삭제합니다.
GetRolesAsync(user) 해당 method는 지정한 사용자가 member인 role을 반환합니다.
GetUsersInRoleAsync(role) 해당 method는 지정한 role의 member인 사용자를 반환합니다.
IsInRoleAsync(user, role) 해당 method는 지정한 role에 지정한 사용자가 member라면 true를 반환합니다.

(1) Role 관리 도구 준비

 

role 관리 도구를 만들기 위해 project의 Pages foler에 Roles folder를 생성하고 _Layout.cshtml이름의 Razor Layout file을 아래와 같이 추가합니다.

<!DOCTYPE html>
<html>
<head>
	<title>Identity</title>
	<link href="/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet" />
</head>
<body>
	<div class="m-2">
		<h5 class="bg-secondary text-white text-center p-2">Role Administration</h5>
		@RenderBody()
	</div>
</body>
</html>

해당 layout은 user와 role관리 도구를 구분하기 위한 것입니다.

 

(2) Role의 열거와 삭제하기

 

project의 Pages/Roles folder에 List.cshtml이름의 Razor Page를 아래와 같이 추가합니다.

@page
@model MyBlazorApp.Pages.Roles.ListModel

<table class="table table-sm table-bordered">
	<tr>
		<th>ID</th>
		<th>Name</th>
		<th>Members</th>
		<th></th>
	</tr>
	
	@if (Model.Roles.Count() == 0)
	{
		<tr><td colspan="4" class="text-center">No Roles</td></tr>
	}
	else
	{
		foreach (IdentityRole role in Model.Roles)
		{
			<tr>
				<td>@role.Id</td>
				<td>@role.Name</td>
				<td>@(await Model.GetMembersString(role.Name))</td>
				<td class="text-center">
					<form asp-page="List" method="post">
						<input type="hidden" name="Id" value="@role.Id" />
						<a class="btn btn-sm btn-warning" asp-page="Editor" asp-route-id="@role.Id" asp-route-mode="edit">Edit</a>
						<button type="submit" class="btn btn-sm btn-danger">
							Delete
						</button>
					</form>
				</td>
			</tr>
		}
	}
</table>

<a class="btn btn-primary" asp-page="create">Create</a>
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace MyBlazorApp.Pages.Roles
{
    public class ListModel : AdminPageModel
	{
		public UserManager<IdentityUser> UserManager;
		public RoleManager<IdentityRole> RoleManager;

		public ListModel(UserManager<IdentityUser> userManager, RoleManager<IdentityRole> roleManager)
		{
			UserManager = userManager;
			RoleManager = roleManager;
		}

		public IEnumerable<IdentityRole> Roles { get; set; } = Enumerable.Empty<IdentityRole>();

		public void OnGet()
		{
			Roles = RoleManager.Roles;
		}

		public async Task<string> GetMembersString(string role)
		{
			IEnumerable<IdentityUser> users = (await UserManager.GetUsersInRoleAsync(role));
			string result = users.Count() == 0 ? "No members" : string.Join(", ", users.Take(3).Select(u => u.UserName).ToArray());

			return users.Count() > 3 ? $"{result}, (plus others)" : result;
		}

		public async Task<IActionResult> OnPostAsync(string id)
		{
			IdentityRole role = await RoleManager.FindByIdAsync(id);
			await RoleManager.DeleteAsync(role);

			return RedirectToPage();
		}
	}
}

예제에서 role은 role의 member가 3명 이상인 name 혹은 member 없다면 placeholder message로 열거됩니다. 또한 Create button과 함께 제공된 각 role에 대한 수정 및 삭제 button이 존재하고 이를 통해 사용자 관리 도구에서 사용된 것과 같은 pattern으로 관리가 가능하도록 하였습니다.

 

Delete button은 Razor Page뒤로 POST요청을 보내며 OnPostAsync method에서는 role의 개체를 가져오기 위해 FindByIdAsync method를 사용하여 이것을 다시 DeleteAsync method로 전달함으로써 database에서 해당 role을 삭제하게 됩니다.

 

(3) Role의 생성

 

project의 Pages/Roles folder에 Create.cshtml이름의 Razor Page를 아래와 같이 추가합니다.

@page
@model MyBlazorApp.Pages.Roles.CreateModel

<h5 class="bg-primary text-white text-center p-2">Create Role</h5>
<form method="post">
	<div asp-validation-summary="All" class="text-danger"></div>
	<div class="form-group">
		<label>Role Name</label>
		<input name="Name" class="form-control" value="@Model.Name" />
	</div>
	<div class="py-2">
		<button type="submit" class="btn btn-primary">Submit</button>
		<a class="btn btn-secondary" asp-page="list">Back</a>
	</div>
</form>
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace MyBlazorApp.Pages.Roles
{
    public class CreateModel : AdminPageModel
	{
		public RoleManager<IdentityRole> RoleManager;

		public CreateModel(UserManager<IdentityUser> userManager, RoleManager<IdentityRole> roleManager)
		{
			RoleManager = roleManager;
		}

		[BindProperty]
		public string Name { get; set; } = string.Empty;

		public async Task<IActionResult> OnPostAsync()
		{
			if (ModelState.IsValid)
			{
				IdentityRole role = new IdentityRole { Name = Name };
				IdentityResult result = await RoleManager.CreateAsync(role);

				if (result.Succeeded)
					return RedirectToPage("List");

				foreach (IdentityError err in result.Errors)
					ModelState.AddModelError("", err.Description);
			}
			return Page();
		}
	}
}

예제에서는 새로운 role의 이름을 지정하기 위한 input요소를 가진 form을 제공하고 있습니다. form이 submit 되면 OnPostAsync method는 새로운 IdentityRole개체를 생성하게 되고 이를 CreateAsync method로 전달하게 됩니다.

 

(4) Role Membership 할당

 

role의 membership관리를 위해 project의 Pages/Roles folder에 Editor.cshtml이름의 Razor Page를 아래와 같이 추가합니다.

@page "{id}"
@model MyBlazorApp.Pages.Roles.EditorModel

<h5 class="bg-primary text-white text-center p-2">Edit Role: @Model.Role.Name</h5>

<form method="post">
	<input type="hidden" name="rolename" value="@Model.Role.Name" />
	<div asp-validation-summary="All" class="text-danger"></div>
	<h5 class="bg-secondary text-white p-2">Members</h5>
	<table class="table table-sm table-striped table-bordered">
		<thead><tr><th>User</th><th>Email</th><th></th></tr></thead>
		<tbody>
			@if ((await Model.Members()).Count() == 0)
			{
				<tr><td colspan="3" class="text-center">No members</td></tr>
			}
			@foreach (IdentityUser user in await Model.Members())
			{
				<tr>
					<td>@user.UserName</td>
					<td>@user.Email</td>
					<td>
						<button asp-route-userid="@user.Id"
		   class="btn btn-primary btn-sm" type="submit">
							Change
						</button>
					</td>
				</tr>
			}
		</tbody>
	</table>

	<h5 class="bg-secondary text-white p-2">Non-Members</h5>
	<table class="table table-sm table-striped table-bordered">
		<thead><tr><th>User</th><th>Email</th><th></th></tr></thead>
		<tbody>
			@if ((await Model.NonMembers()).Count() == 0)
			{
				<tr><td colspan="3" class="text-center">No non-members</td></tr>
			}
			@foreach (IdentityUser user in await Model.NonMembers())
			{
				<tr>
					<td>@user.UserName</td>
					<td>@user.Email</td>
					<td>
						<button asp-route-userid="@user.Id" class="btn btn-primary btn-sm" type="submit">
							Change
						</button>
					</td>
				</tr>
			}
		</tbody>
	</table>
</form>

<a class="btn btn-secondary" asp-page="list">Back</a>
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace MyBlazorApp.Pages.Roles
{
    public class EditorModel : AdminPageModel
	{
		public UserManager<IdentityUser> UserManager;
		public RoleManager<IdentityRole> RoleManager;

		public EditorModel(UserManager<IdentityUser> userManager, RoleManager<IdentityRole> roleManager)
		{
			UserManager = userManager;
			RoleManager = roleManager;
		}

		public IdentityRole Role { get; set; } = new();

		public Task<IList<IdentityUser>> Members() => UserManager.GetUsersInRoleAsync(Role.Name);

		public async Task<IEnumerable<IdentityUser>> NonMembers() => UserManager.Users.ToList().Except(await Members());

		public async Task OnGetAsync(string id)
		{
			Role = await RoleManager.FindByIdAsync(id);
		}

		public async Task<IActionResult> OnPostAsync(string userid, string rolename)
		{
			Role = await RoleManager.FindByNameAsync(rolename);
			IdentityUser user = await UserManager.FindByIdAsync(userid);
			IdentityResult result;

			if (await UserManager.IsInRoleAsync(user, rolename))
				result = await UserManager.RemoveFromRoleAsync(user, rolename);
			else
				result = await UserManager.AddToRoleAsync(user, rolename);

			if (result.Succeeded)
				return RedirectToPage();
			else
			{
				foreach (IdentityError err in result.Errors)
					ModelState.AddModelError("", err.Description);

				return Page();
			}
		}
	}
}

예제에서 사용자에게는 누가 해당 role의 member인지를 보여주는 table과 member가 아닌 사용자를 나타내는 table을 제공하고 있습니다. 또한 각 행에는 form을 submit 하는 Change button을 포함하고 있습니다. OnPostAsync method는 database로부터 사용자를 가져오기 위해 UserManager.FindByIdAsync method를 사용하고 있습니다. IsInRoleAsync method는 사용자가 role의 member인지 아닌지를 확인하는 데 사용되며 AddToRoleAsync와 RemoveFromRoleAsync method는 각각 사용자를 추가하거나 삭제하는 데 사용됩니다.

 

project를 실행하고 /roles/list로 URL을 요청합니다. 아직은 어떠한 role도 만들어지지 않았기에 list는 비어있는 상태일 것입니다. Create button을 click 하여 input field에 'Admins'를 입력한뒤 해당 role을 생성하기 위해 Submit button을 click합니다. role이 생성되고 나면 다시 Edit button을 click하여 해당 role에 추가할 수 있는 사용자를 확인하고 Change button을 눌러 user를 role의 안팎으로 이동시켜 줍니다. 그런 뒤 back을 click 해 list에서 role의 member에 속한 사용자를 다음과 같이 확인합니다.

ASP.NET Core Identity는 role의 할당을 변경할 때 사용자의 상세에 대한 유효성검증을 다시 수행하기 때문에 사용자 정보를 현재의 제약사항과 일치하지 않게 변경하려는 경우 오류를 발생시키게 됩니다. 이는 application이 배포된 이후 제약사항이 도입되고 database가 이미 이전 role에서 만들어진 경우 발생합니다. 이 때문에 위 예제에서 Razor Page는 role에서 사용자를 추가하거나 삭제하는 동작의 결과를 확인하여 유효성검증 message를 오류로서 표시하고 있습니다.
728x90