이번에는 이전에 구축해 둔 ASP.NET Core Identity를 사용자 인증과 application기능으로 접근하기 위한 권한에 어떻게 적용할 수 있는지를 알아볼 것입니다. 따라서 identity를 구축하기 위해 사용자에게 필요한 기능을 생성하고, endpoint로의 접근을 제어하고 Blazor가 제공하는 보안기능을 직접 구현해 볼 것입니다. 또한 web service에서 client를 인증하기 위해 가장 보편적으로 사용되는 방식이 2가지가 있는데 이에 대해서도 같이 확인해 볼 것입니다.
1. Project 준비하기
예제를 위한 project는 이전의 project를 그대로 사용할 것입니다. 다만 PowserShell을 열고 csproj file이 있는 project folder로이동한 뒤 아래 명령을 통해 이전에 생성한 Identity Database를 초기화합니다.
dotnet ef database drop --force --context IdentityContext dotnet ef database update --context IdentityContext |
현재 application project는 다수의 database context class를 포함하고 있으므로 Entity Framework Core명령은 --context의 인수를 통해 해당 명령을 적용할 context를 지정해야 합니다.
project를 실행하고 /controllers URL을 요청하여 아래와 같은 응답이 생성되는지 확인합니다.
그리고 /users/list와 /roles/list URL역시 차례로 요청하여 다음과 같은 응답이 생성되는지 확인합니다.
2. 사용자 인증
우선 project에 사용자가 자신의 자격증명을 제공하고 application에서 자신의 ID를 설정할 수 있는 인증 관련 기능을 추가해야 합니다.
인증(Authentication)과 허가(Authorization)
ASP.NET Core Identity를 사용하는 경우에는 인증과 권한에 대한 차이를 이해하는 것이 중요합니다. 인증(Authentication, AuthN)은 사용자가 자신의 자격증명을 application에 제출함으로써 사용자의 신원을 확인하는 process입니다. 예제의 경우 자격증명에 사용되는 것은 사용자이름(username)과 비밀번호(password)입니다. username은 알려질 수 있는 공공의 정보에 해당하지만 password의 경우에는 사용자 본인만을 알고 있는 정보로서 정확한 password가 제공되는 경우에만 application은 사용자의 인증을 처리할 수 있게 됩니다.
허가(Authorization, AuthZ)은 사용자의 identity하에서 application기능으로의 접근권을 부여하는 것입니다. 권한처리는 application이 지정한 기능에 대한 사용권한을 부여할지 여부를 결정하기 전에 사용자의 신원을 알고 있어야 하므로 사용자가 인증이 된 경우에만 수행됩니다.
(1) Login 기능 생성
보안정책을 적용하기 위해 application은 사용자가 직접 ASP.NET Core Identity API를 사용하는 인증과정을 거칠 수 있도록 해야 합니다. project의 Pages folder에 Account folder를 생성하고 그 안에 _Layout.cshtml이름의 Razor Page를 아래와 같이 추가합니다. 해당 layout은 인증기능을 위한 공통 content를 제공합니다.
<!DOCTYPE html>
<html>
<head>
<title>Identity</title>
<link href="/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet" />
</head>
<body>
<div class="m-2">
@RenderBody()
</div>
</body>
</html>
다음으로 project의 Pages/Account folder에 Login.cshtml이름의 Razor page를 아래와 같이 추가합니다.
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace MyBlazorApp.Pages.Account
{
public class LoginModel : PageModel
{
private SignInManager<IdentityUser> signInManager;
public LoginModel(SignInManager<IdentityUser> signinMgr)
{
signInManager = signinMgr;
}
[BindProperty]
public string UserName { get; set; } = string.Empty;
[BindProperty]
public string Password { get; set; } = string.Empty;
[BindProperty(SupportsGet = true)]
public string? ReturnUrl { get; set; }
public async Task<IActionResult> OnPostAsync()
{
if (ModelState.IsValid)
{
Microsoft.AspNetCore.Identity.SignInResult result = await signInManager.PasswordSignInAsync(UserName, Password, false, false);
if (result.Succeeded)
return Redirect(ReturnUrl ?? "/");
ModelState.AddModelError("", "Invalid username or password");
}
return Page();
}
}
}
ASP.NET Core Identity는 login처리를 위해 SigninManager<T> class를 제공하고 있으며 generic type 인수 T는 application에서 사용자를 나타내는 class인데 여기서 IdentityUser는 예제에서 사용하는 class입니다. 아래 표에서는 많이 사용되는 SigninManager<T> class의 member를 나타내고 있습니다.
PasswordSignInAsync(name, password, persist, lockout) | 해당 method는 지정한 username과 password를 사용해 인증을 시도합니다. persist 인수는 browser가 닫힌 뒤에라도 소멸되지 않는 cookie를 생성할지 여부를 설정하는 것이며 lockout 인수는 만약 인증에 실패하는 경우 해당 계정에 lock을 걸어둘지의 여부를 나타냅니다. |
SignOutAsync() | 해당 method는 사용자의 logout을 수행합니다. |
위 예제는 사용자에게 PasswordSignInAsync method를 통해 인증에 필요한 username과 password를 수집하는 form을 제공합니다.
PasswordSignInAsync method는 수행 후 SignInResult개체를 반환하는데 해당 개체는 Suceeded속성을 정의하고 있으며 인증이 성공적으로 이루어지면 true를 반환합니다.(SignInResult class는 또한 Microsoft.AspNetCore.Mvc namespace에도 정의되어 있는데 때문에 예제에서는 namespace까지 포함한 전체 이름을 사용하였습니다.)
ASP.NET Core application에서의 인증은 대게 사용자가 인증이 필요한 endpoint로의 접근을 시도할때 trigger 되므로 인증이 성공적으로 이루어지면 사용자를 해당 endpoint로 돌려주는 것이 일반적이며 때문에 login page에서는 RetrunUrl속성을 정의하여 사용자가 정확한 자격증명을 제공한 경우 redirection을 수행하는 데 사용하게 됩니다.
만약 사용자가 잘못된 정보를 제공하여 인증이 수행되는 경우라면 validation message를 표시하고 page가 다시 표시될 것입니다.
인증 cookie 보호
인증 cookie는 사용자의 신원정보를 포함하며 ASP.NET Core는 인증된 사용자로부터의 해당 cookie를 포함하는 요청에 대해서 신뢰성을 부여하게 됩니다. 따라서 실제 project에서는 HTTPS를 사용함으로서 ASP.NET Core Identity가 악의적인 사용자가 중간에서 가로챈 cookie를 차단할 수 있도록 하는 것이 좋습니다.
(2) ASP.NET Core Identity Cookie 확인
사용자가 인증이 되면 cookie가 응답에 추가되어 다음요청에서 이미 인증된것으로 식별될 수 있습니다. project의 Pages/Account folder에 Details.cshtml이름의 Razor Page를 아래와 같이 추가하여 cookie를 표시할 수 있도록 합니다.
@page
@model MyBlazorApp.Pages.Account.DetailsModel
<table class="table table-sm table-bordered">
<tbody>
@if (Model.Cookie == null)
{
<tr><th class="text-center">No Identity Cookie</th></tr>
}
else
{
<tr>
<th>Cookie</th>
<td class="text-break">@Model.Cookie</td>
</tr>
}
</tbody>
</table>
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace MyBlazorApp.Pages.Account
{
public class DetailsModel : PageModel
{
public string? Cookie { get; set; }
public void OnGet()
{
Cookie = Request.Cookies[".AspNetCore.Identity.Application"];
}
}
}
ASP.NET Core Identity cookie로 사용된 이름은 '.AspNetCore.Identity.Application'이며 예제에서는 요청에서 cookie를 가져와 값을 표시하거나 해당 cookie가 없는 경우 placeholder message를 표시하도록 합니다.
(3) Sign-Out Page 생성
사용자에게 직접 sing out할 수 있는 방법을 제공함으로써 명시적으로 cookie를 삭제할 수 있도록 하는 것도 중요합니다. 특히 여러 사람이 사용하는 공공목적의 computer라면 더욱 그렇습니다. project의 Pages/Account folder에 Logout.cshtml이름의 Razor Page를 아래와 같이 추가합니다.
@page
@model MyBlazorApp.Pages.Account.LogoutModel
<div class="bg-primary text-center text-white p-2"><h4>Log Out</h4></div>
<div class="m-2">
<h6>You are logged out</h6>
<a asp-page="Login" class="btn btn-secondary">OK</a>
</div>
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace MyBlazorApp.Pages.Account
{
public class LogoutModel : PageModel
{
private SignInManager<IdentityUser> signInManager;
public LogoutModel(SignInManager<IdentityUser> signInMgr)
{
signInManager = signInMgr;
}
public async Task OnGetAsync()
{
await signInManager.SignOutAsync();
}
}
}
위 예제는 이전 표에서 설명된 SignOutAsync method를 호출함으로서 흔히 말하는 Logout을 수행할 수 있도록 합니다. 이로서 ASP.NET Core Identity cookie는 browser가 다음요청에서 cookie를 포함하지 않게 하기 위해서 cookie를 삭제하게 됩니다. (이때 cookie가 다시 사용되는 경우라 하더라도 인증된 것으로 요청이 처리되지 않도록 하기 위해 cookie를 무효화합니다.)
(4) 인증 기능 확인
project를 실행하고 /users/list로 URL을 요청합니다. 여기서 Create button을 click 하고 다음과 같이 사용자를 입력한 후 submit button을 눌러 새로운 사용자 계정을 생성합니다.
이제 /account/login으로 URL을 요청하고 위에서 입력한 username과 password를 사용해 인증을 시도합니다.
반환 URL은 지정되지 않았으므로 인증이 되면 root URL로 redirect 될 것입니다. 이 상태에서 /account/details로 URL을 요청하여 ASP.NET Core Identity cookie를 확인합니다.
이번에는 /account/logout URL을 요청하여 application에서 logout 되도록 합니다. 그러면 다시 /account/details가 반환되어 다음과 같이 cookie가 삭제되었음을 확인할 수 있습니다.
(5) Identity Authentication Middleware 사용
ASP.NET Core Identity는 SignInManager<T> class에서 생성된 cookie를 감지하고 인증된 사용자의 상세를 통해 HttpContex개체를 채우는 middleware component를 제공하고 있습니다. 해당 middleware는 사용자의 상세정보를 인증 process의 인식 없이 또는 인증 process에서 생성된 cookie를 직접적으로 처리하지 않고 endpoint에 제공할 수 있습니다. 아래 예제는 project의 Program.cs file을 변경한 것으로 요청 pipeline으로 인증 middleware를 추가한 것입니다.
app.UseStaticFiles();
app.UseAuthentication();
app.MapControllers();
middleware는 HttpContext.User속성의 값을 ClaimsPrincipal개체로 설정합니다. Claims는 사용자에 대한 정보이자 해당 정보의 출처에 세부사항으로 사용자에 대해 알려진 정보를 서술하기 위한 일반적인 접근법을 제공합니다.
ClaimsPrincipal class는 .NET Core의 일부인데 대부분의 ASP.NET Core application에서 직접적으로 사용되지 않지만 그 안에서 유용하게 사용할 수 있는 2개의 중첩된 속성을 제공하고 있습니다.
ClaimsPrincipal.Identity.Name | 해당 속성은 username값을 반환하며 요청과 관련된 사용자가 존재하지 않는 경우 null을 반환합니다. |
ClaimsPrincipal.Identity.IsAuthenticated | 요청과 관련된 사용자가 인증된 경우라면 해당 속성은 true를 반환합니다. |
ClaimsPrincipal개체를 통해 제공되는 username은 project의 Pages/Account folder에 있는 Details.cshtml에서와 같이 ASP.NET Core Identity user개체를 가져오는데도 사용될 수 있습니다.
@page
@model MyBlazorApp.Pages.Account.DetailsModel
<table class="table table-sm table-bordered">
<tbody>
@if (Model.IdentityUser == null)
{
<tr><th class="text-center">No User</th></tr>
}
else
{
<tr><th>Name</th><td>@Model.IdentityUser.UserName</td></tr>
<tr><th>Email</th><td>@Model.IdentityUser.Email</td></tr>
}
</tbody>
</table>
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace MyBlazorApp.Pages.Account
{
public class DetailsModel : PageModel
{
private UserManager<IdentityUser> userManager;
public DetailsModel(UserManager<IdentityUser> manager)
{
userManager = manager;
}
public IdentityUser? IdentityUser { get; set; }
public async Task OnGetAsync()
{
if (User.Identity != null && User.Identity.IsAuthenticated)
{
IdentityUser = await
userManager.FindByNameAsync(User.Identity.Name);
}
}
}
}
HttpContext.User속성은 PageModel과 ControllerBase class에서 정의된 기존의 User 편의 속성을 통해 접근할 수 있으며 해당 Razor Page는 요청과 관련된 인증된 사용자가 존재함을 확인하고 사용자를 서술하는 IdentityUser개체를 가져오고 있습니다.
project를 실행하고 /account/login URL을 요청하여 위에서 등록한 사용자로 인증을 시도합니다. 그리고 /account/details로 URL을 요청하면 ASP.NET Core Identity middleware가 요청에서의 세부사항과 사용자를 연결시키기 위해 어떻게 cookie를 처리했는지 확인할 수 있습니다.
이중인증(Two-factor authentication) 고려하기
예제에서는 single-factor인증을 수행하고 있으며 해당 인증방식은 사용자가 password라는 사진에 미리 알고 있는 단일 정보를 사용해 인증을 수행할 수 있는 것입니다.
물론 필요하다면 이중인증 역시 사용할 수 있으며 여기서 사용자는 일반적으로 인증이 필요한 순간에 사용자에게 주어진 추가인증을 필요로 합니다. 이에 대한 가장 흔한 예로 hardware token이나 smartphone app 또는 e-mail이나 문자 message로 발송된 값을 이용하는 경우가 있을 것입니다.(엄격히 말하면 이중 인증 자체는 대부분의 web application에서 잘 쓰이지는 않지만 지문인식, 생체인식을 포함하여 어떠한 형태로도 이루어질 수 있습니다.)
보안은 공격자가 사용자의 password를 알아도 e-mail계정이나 휴대전화와 같은 두 번째 제공되는 모든 요소에 접근해야 하므로 강화될 수 있지만 사용자로 하여금 너무 번거로운 과정을 거치게 할 수 있고 잘못 사용하면 사용하지 않은 것만 못할 수 있기 때문에 신중을 기해야 합니다.
3. Endpoint로의 접근 허가
일단 application이 인증기능을 가지게 되면 사용자 Identity는 endpoint로의 접근을 제한함으로써 권한허가기능을 부여할 수 있습니다.
(1) 허가 관련 Attribute 적용
Authorize attribute는 endpoint로의 접근을 제한하는 데 사용하며 각각 별도의 action이나 page handler method 또는 보안정책이 class에 정의된 모든 method에 적용되어야 하는 경우 controller나 page model class에 적용할 수 있습니다. 예제에서는 일반사용자가 위에서 만든 역할 관리 도구로는 접근할 수 없도록 하고자 합니다. 같은 허가정책을 사용하는 여러 Razor Page 또는 controller가 필요할 때 Authorize attribute를 적용할 수 있는 공동 기반 class를 정의하면 경우에 따라 해당 속성을 생략할 수 있는 위험성을 피할 수 있고 미인증 된 접근을 공통적으로 모두 차단할 수 있으므로 효율적인 방법이라 할 수 있습니다. 이를 위해 이전예제에서도 AdminPageModel class를 정의한 것이며 이를 모든 관리자 도구 page model에 적용하였습니다. 아래 예제는 AdminPageModel class에서 허가정책을 적용하기 위해 Authorize attribute를 적용한 것입니다.
[Authorize(Roles = "Admins")]
public class AdminPageModel : PageModel
{
}
Authorize attribute가 인수 없이 적용되면 모든 미인증 된 사용자의 접근을 차단하게 됩니다. 예제에서 Roles인수는 특정한 role의 member인 사용자의 접근을 차단하기 위해 사용되었으며 여러 role을 지정해야 하는 경우 ,(comma)문자로 분리해 지정할 수 있습니다. 예제에서의 attribute는 Admins role에 할당된 사용자의 접근을 제한하는 것입니다. authorization 제한자는 상속되므로 base class의 attribute가 사용자와 role을 관리하기 위해 생성한 모든 Razor Page로의 접근을 차단하는데 적용됩니다.
controller의 전체가 아닌 일부 action method에만 접근제한이 필요한 경우 controller class에 Authorize attribute를 적용하고 AllowAnonymous attribute를 미인증된 사용자의 접근이 필요한 action method에 적용합니다.
(2) Authorization Middleware 사용
authorization policy은 application의 요청 pipeline에 추가되는 middleware component를 통해서도 적용될 수 있습니다. 아래 예제는 Program.cs file을 변경한 것으로 해당 middleware를 추가한 것입니다.
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
UseAuthorization method는 반드시 UseRouting과 UseEndpoints method사이에서, UseAuthentication method가 호출된 후 호출되어야 합니다. 이러한 순서는 authorization component가 사용자의 data에 접근하고 endpoint가 선택된 후 그리고 요청이 처리되기 전 authorization policy을 확인할 수 있도록 합니다.
(3) 접근 거부 Endpoint 생성
application은 인증실패에 대한 2가지 유형을 처리해야 합니다. 제한된 endpoint로의 요청에서 사용자가 인증되지 않았다면 authorization middleware는 login page로의 redirection을 통해 사용자가 자신의 자격증명을 제공하고 endpoint로 접근할 수 있음을 증명할 수 있도록 해는 응답을 반환해야 합니다.
하지만 인증된 사용자가 제한된 endpoint로 요청하는 경우 권한에 대한 확인과정을 통과하지 못한 경우에는 접근 거부 응답을 생성함으로써 application은 사용자에게 적절한 경고를 표시할 수 있어야 합니다. project의 Pages/Account foler에 AccessDenied.cshtml file을 아래와 같이 생성합니다.
@page
@model MyBlazorApp.Pages.Account.AccessDeniedModel
<h4 class="bg-danger text-white text-center p-2">Access Denied</h4>
<div class="m-2">
<h6>You are not authorized for this URL</h6>
<a class="btn btn-outline-danger" href="/">OK</a>
<a class="btn btn-outline-secondary" asp-page="Logout">Logout</a>
</div>
위 예제는 사용자에게 최상위 root URL로 탐색할 수 있는 button과 함께 경고 message를 표시합니다. 일반적인 경우에 관리자의 개입 없이는 사용자가 인증에 관한 문제를 해결할 수 있는 것이 없으며 가능한 한 접근거부 응답을 간단하게 유지하는 것이 좋습니다.
(4) Data 공급하기
이제 사용자가 role 관리 도구에 대한 접근을 제한했으므로 Admin role의 사용자만이 접근할 수 있게 되었습니다. 하지만 database에는 이러한 role이 없으므로 문제가 될 수 있습니다. 역할을 생성할 수 있는 허가된 계정자체가 존재하지 않으므로 관리자도구를 누구도 사용할 수 없게 되었습니다.
이전에 이미 Authorize attribute를 적용하기 전에 관리 사용자와 role을 생성할 수 있었지만 code를 변경할 때 application배포가 복잡해지는 문제는 피해야 합니다. 대신 ASP.NET Core Identity를 위한 seed data를 생성함으로써 사용자와 role관리도구에 접근할 수 있는 최소 하나의 계정이 사용될 수 있도록 할 수 있습니다. project의 Models folder에 IdentitySeedData.cs이름의 file을 아래와 같이 추가합니다.
using Microsoft.AspNetCore.Identity;
namespace MyBlazorApp.Models
{
public class IdentitySeedData
{
public static void CreateAdminAccount(IServiceProvider serviceProvider, IConfiguration configuration)
{
CreateAdminAccountAsync(serviceProvider, configuration).Wait();
}
public static async Task CreateAdminAccountAsync(IServiceProvider serviceProvider, IConfiguration configuration)
{
serviceProvider = serviceProvider.CreateScope().ServiceProvider;
UserManager<IdentityUser> userManager = serviceProvider.GetRequiredService<UserManager<IdentityUser>>();
RoleManager<IdentityRole> roleManager = serviceProvider.GetRequiredService<RoleManager<IdentityRole>>();
string username = configuration["Data:AdminUser:Name"] ?? "admin";
string email = configuration["Data:AdminUser:Email"] ?? "admin@example.com";
string password = configuration["Data:AdminUser:Password"] ?? "secret";
string role = configuration["Data:AdminUser:Role"] ?? "Admins";
if (await userManager.FindByNameAsync(username) == null) {
if (await roleManager.FindByNameAsync(role) == null)
await roleManager.CreateAsync(new IdentityRole(role));
IdentityUser user = new IdentityUser
{
UserName = username,
Email = email
};
IdentityResult result = await userManager.CreateAsync(user, password);
if (result.Succeeded)
await userManager.AddToRoleAsync(user, role);
}
}
}
}
UserManager<T>와 RoleManager<T> service는 scope 되었습니다. 이는 application시작할 때 seeding이 수행되므로 services를 요청하기 전에 새로운 scope를 생성해야 함을 의미합니다. 예제의 seeding code는 role에 할당되는 사용자를 생성하는데 seed data의 값은 application 설정으로 부터 대체값으로 가져와 code를 변경하지 않고도 seed된 계정을 쉽게 설정할 수 있습니다. 아래 예제는 Program.cs file을 변경한 것으로 application시작할때 database가 seed 되도록 하였습니다.
위험
비밀번호를 code file이나 일반 text설정 file에 남겨두면 application을 배포하고 처음 새로운 database를 초기화할 때 기본 계정의 암호를 바꿔야 하는 경우가 있으므로 주의해야 합니다.
IdentitySeedData.CreateAdminAccount(app.Services, app.Configuration);
app.Run();
(5) Authentication 절차 Test
project를 실행하고 /account/logout URL을 요청하여 만일에 대비해 application에 login 된 상태를 벗어나도록 합니다. 이 상태에서 login 하지 않고 곧장 /users/list로 URL을 요청을 시도하면 요청을 처리하기 위해 선택된 endpoint는 인증이 필요하고 요청과 관련된 인증된 사용자가 없기 때문에 곧 login화면이 표시될 것입니다.
username에 tuser를 password에 123456을 입력하고 login을 시도합니다. 해당 사용자는 returnurl로의 접근권한을 가지고 있지 않으므로 다음과 같이 접근거부에 대한 응답을 표시하게 됩니다.
logout button을 click 하고 다시 /users/list로 URL을 요청합니다. 그러면 login화면이 다시 표시될 것입니다. 이번에는 username에 admin, password에 secret를 입력합니다. 해당 사용자 계정은 Authorize attribute에 의해 지정된 role의 member로 seed data에서 생성된 계정입니다. 따라서 권한확인절차를 통과하게 되고 요청된 Razor Page가 아래와 같이 표시될 것입니다.
Authorization URL의 Changing
/account/login과 /account/accdenied URL은 ASP.NET Core authorization file에서 사용되는 기본 URL입니다. 이러한 URL은 Program.cs에서 아래와 같은 option pattern을 통해 변경할 수 있습니다.
builder.Services.Configure<CookieAuthenticationOptions>(
IdentityConstants.ApplicationScheme,
opts => {
opts.LoginPath = "/Authenticate";
opts.AccessDeniedPath = "/NotAllowed";
}
);
설정은 Microsoft.AspNetCore.Authentication.Cookies namespace에서 정의된 CookieAuthenticationOptions class를 사용해 수행됩니다. LoginPath 속성은 browser가 미인증 된 사용자가 제한된 endpoint로의 접근을 시도할 때 redirect 할 경로를 지정하며 AccessDeniedPath 속성은 인증된 사용자가 권한없이 제한된 endpoint로의 접근을 시도할때 redirect할 경로를 지정합니다.
4. Blazor Application에 대한 Access 권한
Blazor application을 보호하기 위한 가장 간단한 방법은 entry point로서 동작하는 action method나 Razor Page의 접근을 제한하는 것입니다. 아래 예제에서는 Pages folder에 있는 _Host page의 page model class에 Authorize attribute를 적용하고 있으며 해당 page는 예제 project에서 Blazor application에 대한 진입점에 해당합니다.
Oauth와 Identity Server
Microsoft 문서를 보면 web service의 인증을 위해 IdentityServer라고 하는 third-party server를 사용하고 있음을 알 수 있습니다.
IdentityServer는 인증과 권한 service를 제공하는 고품질의 open source package이며 추가기능 및 지원을 위한 유료 option을 포함하고 있습니다. IdentityServer는 또한 인증과 권한관리를 위한 표준인 OAuth를 지원하고 있으며 다양한 client-side framework의 package를 제공하고 있습니다.
Microsoft 문서에서 말하는 것은 Microsoft가 web service의 인증을 포함하는 project template에 IdentityServer를 사용하고 있다는 것입니다. 만약 ASP.NET Core template을 사용한 project에 Angular 혹은 React project를 생성했다면 인증과 관련된 것이 IdentityServer를 사용해 구현되어 있음을 확인할 수 있습니다.
복잡한 인증처리에서 IdentityServer는 훌륭하지만 적용될 수 있지만 정확하게 설정하기에 어려울 수 있고 필수적이지도 않으며 대부분의 project에서 꼭 필요한 것도 아닙니다. 복잡한 인증 scenario에서 유용하게 사용될 수 있는 건 맞지만 반드시 필요한 것이 아니면 third-party authentication server의 사용을 서두를 필요가 없습니다.
@page "/"
@model _HostModel
@{
Layout = null;
}
<!DOCTYPE html>
<html>
<head>
<title>@ViewBag.Title</title>
<link href="/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet" />
<script src="~/interop.js"></script>
<base href="~/" />
</head>
<body>
<div class="m-2">
<component type="typeof(MyBlazorApp.Blazor.Routed)" render-mode="Server" />
</div>
<script src="_framework/blazor.server.js"></script>
</body>
</html>
[Authorize]
public class _HostModel : PageModel
{
public void OnGet()
{
}
}
위 예제는 미인증 된 사용자가 Blazor application으로 접근하는 것을 차단하도록 합니다. project를 실행하고 /account/logout URL을 요청하여 browser가 인증 cookie를 가지지 않도록 한 뒤 '/'로 URL을 요청합니다. 해당 요청은 _Host page에서 처리되지만 authorization middleware가 작동하여 login page로 redirect 하게 될 것입니다.
이제 username에 tuser를 password에 123456을 입력하고 Log In button을 누르면 Blazor application으로의 접근권한이 부여되어 아래와 같은 응답이 생성될 것입니다.
(1) Blazor Component에서의 인증 수행
Endpoint로의 접근을 제한하는 것은 괜찮은 방법이기는 하지만 모든 Blaozor기능에 동일한 수준의 권한을 적용하게 됩니다. 따라서 더 세분화된 제한이 필요한 application을 위해 Blazor는 component를 URL routing을 사용해 관리하는 경우 인증/미인증에 따라 다른 content를 표시하는 AuthorizeRouteView component를 제공하고 있습니다. 아래 예제는 project의 Blazor folder에 Routed.razor file에 AuthorizeRouteView component를 아래와 같이 추가한 것입니다.
@using Microsoft.AspNetCore.Components.Authorization
<Router AppAssembly="typeof(Program).Assembly">
<Found>
<AuthorizeRouteView RouteData="@context" DefaultLayout="typeof(NavLayout)">
<NotAuthorized Context="authContext">
<h4 class="bg-danger text-white text-center p-2">Not Authorized </h4>
<div class="text-center">
You may need to log in as a different user
</div>
</NotAuthorized>
</AuthorizeRouteView>
</Found>
<NotFound>
<h4 class="bg-danger text-white text-center p-2">
No Matching Route Found
</h4>
</NotFound>
</Router>
NotAuthorized section은 사용자가 제한된 resource로의 접근을 시도할 때 해당사용자에게 제공될 content가 정의됩니다. 해당 기능을 확인해 보기 위해 Admins role에 할당된 사용자에 한정하여 CategoryList component로의 접근을 아래와 같이 제한할 것입니다.
@page "/categorylist"
@page "/category"
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize(Roles = "Admins")]
예제에서는 @attribute지시자를 사용해 Authorize attribute를 component에 적용하였습니다. project를 실행하고 /account/logout을 요청하여 혹시나 남아있을 인증 cookie를 제거하고 다시 /로 URL을 요청합니다. login page에서 username에 tuser를 password에 123456을 입력하고 인증을 시도합니다. 그리고 해당 Category button을 누르면 위에서 정의된 권한에 관한 content가 다음과 같이 표시됩니다.
이 상태에서 log out을 수행하고 이번에는 username에 admin을 password에 secret을 사용해 인증을 시도한 뒤 다시 Category component로 접근을 시도하면 이번에는 정상적인 접근이 이루어짐을 볼 수 있습니다.
(2) 허가된 사용자에 대한 content 표시하기
AuthorizeView component는 render 된 section에 대한 접근을 제한하는 데 사용됩니다. 아래 예제는 Blazor folder의 CategoryList.razor file에서 해당 component에 대한 권한을 변경함으로써 인증된 모든 사용자가 page로 접근할 수 있고 AuthorizeView component를 사용할 수 있도록 하였지만 Admins group과 관련된 사용자에게만 Manufacturer가 표시되도록 하였습니다.
@page "/categorylist"
@page "/category"
@using Microsoft.AspNetCore.Authorization;
@using Microsoft.AspNetCore.Components.Authorization
@attribute [Authorize]
<CascadingValue Name="BgTheme" Value="Theme" IsFixed="false" >
<TableTemplate RowType="Category" RowData="Categories" Highlight="@(d => d.CategoryName)" SortDirection="@(d => d.CategoryName)">
<Header>
<tr><th>ID</th><th>Name</th><th>Product</th></tr>
</Header>
<RowTemplate Context="c">
<td>@c.CategoryId</td>
<td>@c.CategoryName</td>
<td>@(String.Join(", ", c.Product.Select(p => p.ProductName)))</td>
<td>
<AuthorizeView Roles="Admins">
<Authorized>
@(String.Join(", ", c.Product!.Select(p => p.ProductManufacturer!.ManufacturerName).Distinct()))
</Authorized>
<NotAuthorized>
(Not authorized)
</NotAuthorized>
</AuthorizeView>
</td>
</RowTemplate>
</TableTemplate>
</CascadingValue>
AuthorizeView component는 Roles속성으로 설정되었는데 필요하다면 다수의 Role에 대해 ,(comma)로 구분하여 지정할 수 있습니다. Authorized section은 인증된 사용자에게 보일 content를 포함하고 있고 반면 NotAuthorized section은 미인증된 사용자에게 보여질 content를 포함하게 됩니다.
만약 미인증된 사용자를 위한 content를 표시할 필요가 없다면 NotAuthorized section은 생략할 수 있습니다.
project를 실행하고 tuser/123456으로 인증을 수행한 뒤 /category로 URL을 요청합니다. 해당 사용자는 manufacturer를 볼 수 있도록 허가되지 않았기에 다음과 같은 응답을 보게 됩니다.
logout을 수행한 후 다시 admin/secret으로 인증을 수행하고 /category로 다시 들어오면 사용자의 권한확인을 통과하여 다음과 같은 응답을 표시하게 됩니다.
5. Web Service의 인증 및 허가
위에서 사용한 인증처리방식은 사용자를 자신의 신원증명을 입력할 수 있는 Login가능한 URL로 redirect를 처리하고 있습니다. 하지만 인증및 권한을 web service로 추가할 때는 사용자에게 사용자의 신원을 증명할 수 있는 HTML form을 제공할 수 없으므로 이와는 다른 접근방식이 필요합니다. web service에 인증을 추가하는 첫 번째 절차로 redirection을 사용하지 않고 사용자가 인증이 필요한 endpoint로 요청을 시도할 때 HTTP error 응답을 수신하도록 하는 것입니다. project에 CookieAuthenticationExtensions.cs이름의 file을 추가하여 아래와 같은 확장 method를 정의합니다.
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication;
using System.Linq.Expressions;
namespace MyBlazorApp
{
public static class CookieAuthenticationExtensions
{
public static void DisableRedirectForPath(this CookieAuthenticationEvents events, Expression<Func<CookieAuthenticationEvents, Func<RedirectContext<CookieAuthenticationOptions>, Task>>> expr, string path, int statuscode)
{
string propertyName = ((MemberExpression)expr.Body).Member.Name;
var oldHandler = expr.Compile().Invoke(events);
Func<RedirectContext<CookieAuthenticationOptions>, Task> newHandler = context => {
if (context.Request.Path.StartsWithSegments(path))
context.Response.StatusCode = statuscode;
else
oldHandler(context);
return Task.CompletedTask;
};
typeof(CookieAuthenticationEvents).GetProperty(propertyName)?.SetValue(events, newHandler);
}
}
}
ASP.NET Core는 cookie기반 인증을 구성하는 데 사용되는 CookieAuthenticationOptions class를 제공하고 있으며 여기서 CookieAuthenticationOptions.Events속성은 사용자가 허가되지 않은 content를 요청할 때 발생하는 redirection을 포함하여 authentication system에 의해서 발동된 event의 handler를 설정하는 데 사용되는 CookieAuthenticationEvents개체를 반환합니다. 위 예제의 확장 method는 요청이 특정한 경로의 문자열로 시작하지 않을 때 redirection을 수행하는 event의 기본 handler를 대체합니다. 아래 예제는 위의 확장 method를 사용하도록 Program.cs를 변경한 것으로 요청이 /api로 시작할 때 OnRedirectToLogin과 OnRedirectToAccessDenied handler를 대체하도록 함으로써 redirection이 수행되지 않도록 하고 있습니다.
builder.Services.AddAuthentication(opts => {
opts.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
opts.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
}).AddCookie(opts => {
opts.Events.DisableRedirectForPath(e => e.OnRedirectToLogin, "/api", StatusCodes.Status401Unauthorized);
opts.Events.DisableRedirectForPath(e => e.OnRedirectToAccessDenied, "/api", StatusCodes.Status403Forbidden);
});
var app = builder.Build();
예제에서 AddAuthentication method는 cookie기반 인증을 선택하는 데 사용되었으며 redirection이 발동되는 event handler를 대체하기 위한 AddCookie method를 chain method로 연결하고 있습니다.
(1) 간단한 JavaScript Client 구축
web service에서 어떻게 인증이 수행되는지를 알아보기 위해 예제 project의 Data controller로부터 data를 사용하는 간단한 JavaScript client를 만들어볼 것입니다.
해당 예제의 작성을 위해 JavaScript에 대해 상세한 지식을 가지고 있을 필요는 없습니다. 중요한 것은 server side code이며 web service로 접근해 client에 대한 인증을 지원하는 방식자체입니다.
project의 wwwroot folder에 webclient.html file을 아래와 같이 추가합니다.
<!DOCTYPE html>
<html>
<head>
<title>Web Service Authentication</title>
<link href="/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet" />
<script type="text/javascript" src="webclient.js"></script>
</head>
<body>
<div id="controls" class="m-2"></div>
<div id="data" class="m-2 p-2">
No data
</div>
</body>
</html>
그리고 webclient.js이름의 JavaScript file도 wwwroot folder에 아래와 같이 추가합니다.
const username = 'tuser';
const password = '123456';
window.addEventListener('DOMContentLoaded', () => {
const controlDiv = document.getElementById('controls');
createButton(controlDiv, 'Get Data', getData);
createButton(controlDiv, 'Log In', login);
createButton(controlDiv, 'Log Out', logout);
});
function login() {
// do nothing
}
function logout() {
// do nothing
}
async function getData() {
let response = await fetch('/api/product');
if (response.ok) {
let jsonData = await response.json();
displayData(...jsonData.map(item => `${item.productId}, ${item.productName}`));
} else {
displayData(`Error: ${response.status}: ${response.statusText}`);
}
}
function displayData(...items) {
const dataDiv = document.getElementById('data');
dataDiv.innerHTML = '';
items.forEach(item => {
const itemDiv = document.createElement('div');
itemDiv.innerText = item;
itemDiv.style.wordWrap = 'break-word';
dataDiv.appendChild(itemDiv);
});
}
function createButton(parent, label, handler) {
const button = document.createElement('button');
button.classList.add('btn', 'btn-primary', 'm-2');
button.innerText = label;
button.onclick = handler;
parent.appendChild(button);
}
위 예제는 사용자에게 Data를 가져오는 것과 Log In 그리고 Log Out button을 제공하고 있습니다. Get Data button을 Click 하면 Fetch API를 통해 HTTP요청을 보내게 되며 JSON 응답을 처리하고 list를 표시하게 됩니다. 다른 button은 아직 아무것도 하지 않지만 JavaScript안에서 고정된 자격증명을 통해 ASP.NET Core application에서의 인증을 처리하는 이후 예제에서 사용하게 될 것입니다.
해당 예제는 server-side 인증 기능을 설명하기 위한 간단한 client일 뿐입니다. 만약 JavaScript client를 작성해야 한다면 Angular나 React와 같은 framework를 고려할 수도 있습니다. client를 어떻게 구축하느냐와는 상관없이 JavaScript file안에서는 예제와 같이 고정된 자격증명을 포함해서는 안됩니다.
project를 실행하고 /webclient.html로 URL을 요청하고 Get Data button을 click 합니다. JavaScript client는 HTTP요청을 Data Controller로 보내게 되고 그 결과를 다음과 같이 표시할 것입니다.
(2) Web Service 접근 제한
기본적인 권한허가 기능은 web service endpoint로의 접근을 제한하는 데 사용됩니다. 아래 예제는 project의 Controller folder에서 DataController.cs에 Authorize attribute를 적용한 것입니다.
[ApiController]
[Route("/api/Product")]
[Authorize]
public class DataController : ControllerBase
project를 실행하고 /webclient.html로 URL을 요청한 뒤 Get Data button을 눌러 HTTP요청을 보냅니다. 하지만 server는 401 Unauthorized를 다음과 같이 응답할 것입니다.
statusText는 성공의 경우 ok를 반환하지만 그렇지 않은 경우 공백일 수 있습니다.
(3) Cookie 인증 사용
인증을 구현하기 가장 간단한 방법은 표준 ASP.NET Core cookie를 기반으로 하는 것입니다. project의 Controllers folder에 ApiAccountController.cs이름의 file을 아래와 같이 추가합니다.
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace MyBlazorApp.Controllers
{
[ApiController]
[Route("/api/account")]
public class ApiAccountController : ControllerBase
{
private SignInManager<IdentityUser> signinManager;
public ApiAccountController(SignInManager<IdentityUser> mgr)
{
signinManager = mgr;
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] Credentials creds)
{
Microsoft.AspNetCore.Identity.SignInResult result = await signinManager.PasswordSignInAsync(creds.Username, creds.Password, false, false);
if (result.Succeeded)
return Ok();
return Unauthorized();
}
[HttpPost("logout")]
public async Task<IActionResult> Logout()
{
await signinManager.SignOutAsync();
return Ok();
}
public class Credentials
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
}
}
위 예제의 web service는 사용자가 login 하고 logout 할 수 있는 action을 정의하고 있습니다. 인증에 성공하면 응답은 browser가 JavaScript client에서 만들어진 요청에 자동적으로 포함하게 될 cookie를 포함하게 됩니다.
아래 예제는 project의 wwwroot folder에 있는 webclient.js file을 개선한 것으로 JavaScript client가 위에서 정의한 action method를 사용한 인증을 수행할 수 있도록 하였습니다.
window.addEventListener('DOMContentLoaded', () => {
const controlDiv = document.getElementById('controls');
createButton(controlDiv, 'Get Data', getData);
createButton(controlDiv, 'Log In', login);
createButton(controlDiv, 'Log Out', logout);
});
async function login() {
let response = await fetch("/api/account/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: username, password: password })
});
if (response.ok) {
displayData("Logged in");
} else {
displayData(`Error: ${response.status}: ${response.statusText}`);
}
}
async function logout() {
let response = await fetch("/api/account/logout", {
method: "POST"
});
if (response.ok) {
displayData("Logged out");
} else {
displayData(`Error: ${response.status}: ${response.statusText}`);
}
}
project를 실행하고 /webclient.html URL을 요청한 뒤 Log In button을 click 합니다. 'Logged in' message를 기다렸다가 Get Data button을 click 하면 browser는 인증 cookie를 포함하게 되고 요청은 인증을 통과하여 아래와 같은 응답을 표시할 것입니다.
이번에는 Log Out button을 click 하고 다시 Get Data button을 click 하면 cookie가 없으므로 요청은 실패할 것입니다.
(4) Bearer Token 인증 사용하기
모든 client가 cookie인증방식을 사용할 수 있는 것은 아니므로 모든 web service가 cookie에 의존할 수도 없습니다. 따라서 이에 대한 대안으로 bearer token을 사용할 수 있습니다. bearer token은 client에 주어진 문자열로서 web service로의 요청에 포함되는 값입니다. Client는 token의 문자열이 무엇을 의미하는지 알 수 없고 그럴 필요도 없습니다. 그저 server가 제공하는 token을 그냥 사용하기만 하면 됩니다.
흔히 JWT(JSON Web Token)라고 하는 인증은 client에 암호화된 token을 제공하는 것으로 여기에는 인증된 username을 포함합니다. client는 token값을 복호화하거나 수정할 수 없지만 요청안에는 포함시킬 수 있고 ASP.NET Core server는 token을 복호화하여 사용자를 식별하기 위한 name값을 사용하게 됩니다. JWT형식에 관한 자세한 내용은 아래 주소를 참고하시기 바랍니다.
RFC 7519: JSON Web Token (JWT) (rfc-editor.org)
주의
ASP.NET Core는 인증된 사용자로부터의 token을 포함하는 모든 요청을 신뢰합니다. cookie를 사용할 때와 마찬가지로 실제 application에서는 token이 가로채어 재사용되는 경우를 방지하기 위해 HTTPS를 사용하는 것이 좋습니다.
● Application 준비하기
예제 project에서는 JWT와 관련하여 아래 2개의 NuGet Package를 설치해야 합니다.
System.IdentityModel.Tokens.Jwt Microsoft.AspNetCore.Authentication.JwtBearer |
JWT는 기본적으로 암호화와 복구화에 사용되는 key를 필요로 하므로 project의 appsettings.json file에서 아래와 같이 해당 key를 추가해 줍니다.
"AllowedHosts": "*",
"jwtSecret": "webservice_jwt_secret",
"ConnectionStrings": {
(5) Token 생성
client는 사용자의 신원증명을 포함하는 HTTP요청을 보내게 되고 그에 대한 응답에서 JWT를 수신하게 됩니다. 아래 예제는 project의 Controllers folder에 있는 ApiAccountController.cs file을 개선한 것으로 신원증명을 수신하고 이에 대한 유효성을 검증한 뒤 token을 생성하는 action method를 추가한 것입니다.
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
namespace MyBlazorApp.Controllers
{
[ApiController]
[Route("/api/account")]
public class ApiAccountController : ControllerBase
{
private SignInManager<IdentityUser> signinManager;
private UserManager<IdentityUser> userManager;
private IConfiguration configuration;
public ApiAccountController(SignInManager<IdentityUser> mgr, UserManager<IdentityUser> usermgr, IConfiguration config)
{
signinManager = mgr;
userManager = usermgr;
configuration = config;
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] Credentials creds)
{
Microsoft.AspNetCore.Identity.SignInResult result = await signinManager.PasswordSignInAsync(creds.Username, creds.Password, false, false);
if (result.Succeeded)
return Ok();
return Unauthorized();
}
[HttpPost("logout")]
public async Task<IActionResult> Logout()
{
await signinManager.SignOutAsync();
return Ok();
}
[HttpPost("token")]
public async Task<IActionResult> Token([FromBody] Credentials creds)
{
if (await CheckPassword(creds))
{
JwtSecurityTokenHandler handler = new JwtSecurityTokenHandler();
byte[] secret = Encoding.ASCII.GetBytes(configuration["jwtSecret"]);
SecurityTokenDescriptor descriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(new Claim[] {new Claim(ClaimTypes.Name, creds.Username)}),
Expires = DateTime.UtcNow.AddHours(24),
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(secret), SecurityAlgorithms.HmacSha256Signature)
};
SecurityToken token = handler.CreateToken(descriptor);
return Ok(new
{
success = true,
token = handler.WriteToken(token)
});
}
return Unauthorized();
}
private async Task<bool> CheckPassword(Credentials creds)
{
IdentityUser user = await userManager.FindByNameAsync(creds.Username);
if (user != null)
{
return (await signinManager.CheckPasswordSignInAsync(user,
creds.Password, true)).Succeeded;
}
return false;
}
public class Credentials
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
}
}
UserManager<T> class는 PasswordValidators속성을 정의하고 있으며 IPasswordValidator<T> interface를 구현하는 일련의 개체를 반환합니다. Token action method가 호출되면 신원증명을 CheckPassword method로 전달하고 각 개체에서 ValidateAsync method를 호출하기 위해 IPasswordValidator<T>개체를 열거하게 됩니다. 모든 유효성검증과정을 통해 password가 유효성을 갖게 된다면 Token method는 token을 생성합니다.
JWT명세는 HTTP요청에서 사용자를 식별하는 것보다 더 광범위하게 사용될 수 있는 범용 token을 정의하고 있는데 이에 대한 가능한 다수의 option이 현재 예제 project에서는 필요하지 않으며 위 예제에서 생성된 token은 다음과 같은 형태의 값을 포함합니다.
{
"unique_name": "tuser",
"nbf": 1548765454,
"exp": 1579852954,
"iat": 1548765454
}
unique_name속성은 사용자의 name을 포함하고 token을 포함한 인증요청에 사용됩니다. 다른 속성은 timestamp인데 이들은 사용되지 않습니다.
위 값은 이전에 appsettings.json file에 설정한 key를 사용하여 암호화되고 client에 JSON으로 encode 된 응답으로 반환됩니다.
{
"success": true,
"token": "eyJhbGciOiXDIzI1AiIsInG5cHJ6QkpLKNHFi3..."
}
token은 위에서 보이는 것과 달리 다소 긴 문자열로 이루어져 있는데 중요한 것은 값 자체가 아닌 응답의 구조입니다. client는 위와 같은 token을 전달받아 향후 요청에서 Authorization header를 사용해 token값을 포함시키게 됩니다.
Authorization: Bearer eyJhbGciOiXDIzI1AiIsInG5cHJ6QkpLKNHFi3 |
server는 token을 받아 key를 사용해 다시 복호화하고 token값에서 unique_name속성의 값을 사용해 요청을 인증하게 됩니다. 더 이상 유효성검증은 수행되지 않으며 유효한 token을 가진 요청은 token의 unique_name값이 어떤 것이든 인증처리될 것입니다.
(6) Token을 통한 인증
다음으로 application이 token을 수신하고 검증할 수 있도록 project의 Program.cs에서 아래와 같이 설정을 적용해야 합니다.
builder.Services.AddAuthentication(opts => {
opts.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
opts.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
}).AddCookie(opts => {
opts.Events.DisableRedirectForPath(e => e.OnRedirectToLogin, "/api", StatusCodes.Status401Unauthorized);
opts.Events.DisableRedirectForPath(e => e.OnRedirectToAccessDenied, "/api", StatusCodes.Status403Forbidden);
}).AddJwtBearer(opts => {
opts.RequireHttpsMetadata = false;
opts.SaveToken = true;
opts.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(builder.Configuration["jwtSecret"])), ValidateAudience = false, ValidateIssuer = false
};
opts.Events = new JwtBearerEvents
{
OnTokenValidated = async ctx => {
var usrmgr = ctx.HttpContext.RequestServices.GetRequiredService<UserManager<IdentityUser>>();
var signinmgr = ctx.HttpContext.RequestServices.GetRequiredService<SignInManager<IdentityUser>>();
string? username = ctx.Principal?.FindFirst(ClaimTypes.Name)?.Value;
IdentityUser idUser = await usrmgr.FindByNameAsync(username);
ctx.Principal = await signinmgr.CreateUserPrincipalAsync(idUser);
}
};
});
AddJwtBearer는 인증 system에 JWT에 대한 지원을 추가하고 token을 복호화하기 위해 필요한 설정을 제공합니다. 예제에서는 token이 유효화되면 작동되는 OnTokenValidated event에 대한 handler를 추가하여 사용자 database에 질의하고 IdentityUser개체를 요청과 연결시킬 수 있도록 하였습니다. 이러한 동작은 JWT token과 ASP.NET Core Identity data사이의 중계역할을 하는 것으로 role기반 인증과 같은 기능이 원활하게 작동하도록 합니다.
(7) Token에 대한 접근 제한
제한된 endpoint로 token을 통한 접근이 가능하도록 하기 위해 project의 Controllers folder에 있는 DataController.cs에서 Data Controller에 적용된 Authorize attribute를 아래와 같이 변경합니다.
[Authorize(AuthenticationSchemes = "Identity.Application, Bearer")]
public class DataController : ControllerBase
AuthenticationSchemes인수는 controller로의 접근허가에 사용될 수 있는 인증에 대한 유형을 지정하는 데 사용됩니다. 예제의 경우 기본 cookie인증과 새로운 bearer token이 사용될 수 있도록 지정하였습니다.
(8) 요청 Data에서 Token사용하기
이제 마지막으로 token을 가져와 요청 data에 이를 포함하도록 project의 wwwroot folder에 있는 webclient.js file을 변경하여 JavaScript client를 변경합니다.
const username = 'tuser';
const password = '123456';
let token;
window.addEventListener('DOMContentLoaded', () => {
const controlDiv = document.getElementById('controls');
createButton(controlDiv, 'Get Data', getData);
createButton(controlDiv, 'Log In', login);
createButton(controlDiv, 'Log Out', logout);
});
async function login() {
let response = await fetch('/api/account/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username, password: password })
});
if (response.ok) {
token = (await response.json()).token;
displayData('Logged in', token);
}
else {
displayData(`Error: ${response.status}: ${response.statusText}`);
}
}
async function logout() {
token = '';
displayData('Logged out');
}
async function getData() {
let response = await fetch('/api/product', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
let jsonData = await response.json();
displayData(...jsonData.map(item => `${item.productId}, ${item.productName}`));
} else {
displayData(`Error: ${response.status}: ${response.statusText}`);
}
}
...생략
client는 인증응답을 수신하고 token을 할당하여 Authorization header를 설정함으로써 GetData method에서 해당 값을 사용하게 됩니다. logout요청은 필요하지 않으며 token을 저장하는 데 사용된 변수는 사용자가 Log Out button을 click 하면 간단히 값을 제거하는 것으로 끝냅니다.
주의
token을 test 할때 cookie로의 인증이 유지될 가능성이 있습니다. 이전에 사용된 cookie가 더이상 사용되지 않도록 browser의 cookie를 제거하여 해당 기능을 명확히 test할 수 있도록 해야 합니다.
project를 실행하고 Log In button을 눌러 token이 다음과 같이 생성되는지를 확인합니다.
이 상태에서 Get Data button을 click 하면 token은 server로 전송되고 사용자의 인증에 사용되어 다음과 같은 응답이 생성될 것입니다.
'.NET > ASP.NET' 카테고리의 다른 글
NET::ERR_CERT_INVALID 문제 (0) | 2023.11.27 |
---|---|
ASP.NET Core - 20. ASP.NET Core Identity (0) | 2023.04.04 |
ASP.NET Core - [Blazor] 6. DataBlazor Web Assembly (0) | 2023.03.29 |
ASP.NET Core - [Blazor] 5. Blazor Form과 Data (0) | 2023.03.22 |
ASP.NET Core - [Blazor] 3. Blazor Server 2 (0) | 2023.03.12 |