Search
Duplicate

ASP.NET Core Identify Framework/ 3. Front-End 구성

패키지 설치

Blazor 프로젝트에 암호화와 인증을 위해 아래의 패키지들을 설치한다.
Microsoft.AspNetCore.Components.Authorization
System.IdentityModel.Tokens.Jwt
BCrypt.Net-Next
Blazored.SessionStorage

AuthenticationStateProvider 구성

Blazor 페이지에서 현재 사용자가 인증 상태에 따라 화면을 다르게 보여주기 위해 사용자가 인증 되었는지를 판별하는 태그로 <AuthorizeView>를 사용하는데, 이 태그를 사용하려면 AuthenticationStateProvider 클래스를 상속 받는 클래스를 구현해야 한다.
우선 Blazor 프로젝트에 아래의 클래스를 생성한다.
public class AuthStateProvider : AuthenticationStateProvider { private readonly ISessionStorageService sessionStorage; public AuthStateProvider(ISessionStorageService sessionStorage) { this.sessionStorage = sessionStorage; } // 최로 로딩할 때 불리는 함수. session storage에 저장된 토큰이 있었으면 가져와서 인증한다. public override async Task<AuthenticationState> GetAuthenticationStateAsync() { var savedToken = await sessionStorage.GetItemAsync<string>("TokenAuth"); ClaimsIdentity claimsIdentity = new ClaimsIdentity(); if (!string.IsNullOrWhiteSpace(savedToken)) { string token = new JwtSecurityTokenHandler().ReadJwtToken(savedToken).RawData; claimsIdentity = new ClaimsIdentity(token); } ClaimsPrincipal claimPrincipal = new ClaimsPrincipal(claimsIdentity); return await Task.FromResult(new AuthenticationState(claimPrincipal)); } // 사용자 로그인이 성공했을 때 사용 public void MarkUserAsAuthenticated(IEnumerable<Claim> claims) { ClaimsIdentity claimsIdentity = new ClaimsIdentity(claims: claims, authenticationType: "bearer"); // .NET 4.5 이후 생성자에 문자열 --아무 문자열이든 상관 없음-- 을 넣어줘야 IsAuthenticated가 true가 됨. ClaimsPrincipal claimPrincipal = new ClaimsPrincipal(claimsIdentity); // 인증 상태 변화를 알린다. NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(claimPrincipal))); } // 사용자가 로그아웃 했을 때 사용 public void MarkUserAsLoggedOut() { ClaimsIdentity claimsIdentity = new ClaimsIdentity(); ClaimsPrincipal claimPrincipal = new ClaimsPrincipal(claimsIdentity); // 인증 상태 변화를 알린다. NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(claimPrincipal))); } }
C#
이렇게 만든 클래스는 Program.cs에 등록해 줘야 한다. 인증 관련 내용을 추가하는 김에 SessionStorage를 사용하기 위한 것도 추가해 준다.
// AuthenticationStateProvider 클래스 추가 builder.Services.AddScoped<AuthenticationStateProvider, AuthStateProvider>(); // 권한 부여 관련 서비스 추가 builder.Services.AddOptions(); builder.Services.AddAuthorizationCore(); // blazor에서 seesion storage를 사용하기 위한 서비스 추가 builder.Services.AddBlazoredSessionStorage();
C#

서비스 구성

다음으로 Blazor Client에서 Web API Server에 네트워크로 연결하기 위한 Service를 구성한다.
관례에 따라 Blazor 프로젝트에 Services 폴더를 만들고, 그 하위에 Contracts 폴더를 만든 후에 Contract 하위에는 IAuthService 인터페이스를 만들고, Services 하위에는 AuthService 클래스를 만든다.
IAuthService 인터페이스는 아래와 같이 구성한다.
public interface IAuthService { Task<bool> Register(string email, string name, string password); Task<bool> RegisterAdmin(string email, string name, string password); Task<bool> Login(string email, string password); Task<bool> Logout(); }
C#
AuthService는 IAuthService를 상속받아 구현하고, 생성자에서 HttpClient 파라미터로 받는다.
public class AuthService : IAuthService { private readonly HttpClient httpClient; private readonly AuthStateProvider authStateProvider; private readonly ISessionStorageService sessionStorage; public AuthService(HttpClient httpClient, AuthenticationStateProvider authenticationStateProvider, ISessionStorageService sessionStorage) { this.httpClient = httpClient; this.authStateProvider = (AuthStateProvider)authenticationStateProvider; // AuthenticationStateProvider은 구현한 AuthStateProvider 클래스로 변환 this.sessionStorage = sessionStorage; } }
C#
사용자 등록은 아래와 같이 구현한다.
// 일반 사용자 public async Task<bool> Register(string email, string name, string password) { try { // BCrypt를 이용해서 password를 해싱한다. // Salt 값은 BC.GenerateSalt(workFactor)를 이용해 별도로 구한 후에 key로 저장해 두고 사용한다. // 참고로 BC.GenerateSalt()의 기본값은 11인데, 수치가 높을수록 보안에는 유리하지만, 이 경우 연산 시간이 14초가 넘어가기 때문에 로그인하는데 너무 오래 걸리므로, 테스트 해보고 적절한 횟수를 찾는 것이 좋다. // workFactor는 지수값이기 때문에 1만 올라가도 연산 시간은 2배씩 길어진다. string passwordHash = BC.HashPassword(password, "<Salt 값>"); DtoRegister register = new DtoRegister() { Email = email, UserName = name, PasswordHash = passwordHash }; HttpResponseMessage response = await this.httpClient.PostAsJsonAsync<DtoRegister>("api/auth/register", register).ConfigureAwait(false); if (response.IsSuccessStatusCode) { return true; } else { string message = await response.Content.ReadAsStringAsync().ConfigureAwait(false); throw new Exception(message); } } catch (Exception) { // Log exception throw; } } // admin 사용자 public async Task<bool> RegisterAdmin(string email, string name, string password) { try { string passwordHash = BC.HashPassword(password, "<Salt 값>"); DtoRegister register = new DtoRegister() { Email = email, UserName = name, PasswordHash = passwordHash }; HttpResponseMessage response = await this.httpClient.PostAsJsonAsync<DtoRegister>("api/auth/registerAdmin", register).ConfigureAwait(false); if (response.IsSuccessStatusCode) { return true; } else { string message = await response.Content.ReadAsStringAsync().ConfigureAwait(false); throw new Exception(message); } } catch (Exception) { // Log exception throw; } }
C#
로그인은 다음과 같이 구현한다
public async Task<bool> Login(string email, string password) { try { string passwordHash = BC.HashPassword(password, "<Salt 값>"); DtoLogin login = new DtoLogin() { Email = email, PasswordHash = passwordHash }; HttpResponseMessage response = await this.httpClient.PostAsJsonAsync<DtoLogin>("api/auth/login", login).ConfigureAwait(false); if (response.IsSuccessStatusCode) { if (response.StatusCode != HttpStatusCode.NoContent) { DtoLoginResult? result = await response.Content.ReadFromJsonAsync<DtoLoginResult>().ConfigureAwait(false); if (result != null) { // 발급 받은 Token은 session storage에 저장해 둔다. await this.sessionStorage.SetItemAsync("TokenAuth", result.Token); // 이걸 해야 이후 서버에 요청 보낼 때 인가가 됨. this.httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("bearer", result.Token); // token을 읽어서 claims을 추출하고 그 정보를 AuthenticationProvider에게 보내 인증 정보를 업데이트 한다. IEnumerable<Claim> claims = new JwtSecurityTokenHandler().ReadJwtToken(result.Token).Claims; this.authStateProvider.MarkUserAsAuthenticated(claims: claims); return true; } } return false; } else { string message = await response.Content.ReadAsStringAsync().ConfigureAwait(false); throw new Exception(message); } } catch (Exception) { // Log exception throw; } }
C#
로그아웃은 다음과 같이 구현한다.
public async Task<bool> Logout() { // session storage에 저장된 token 정보를 지운다. await this.sessionStorage.RemoveItemAsync("TokenAuth"); // header를 비운다. this.httpClient.DefaultRequestHeaders.Authorization = null; // AuthenticationProvider에게 인증 정보를 초기화 시킨다. this.authStateProvider.MarkUserAsLoggedOut(); return true; }
C#
이렇게 만든 Service의 인터페이스와 클래스는 Program.cs에 등록해 줘야 한다. —이렇게 등록만 해주면 Server와 통신하는 것은 등록된 Server URL을 이용해서 ASP가 알아서 해준다.
builder.Services.AddScoped<IAuthService, AuthService>();
C#

사용자 인증 페이지 구성

App.razor 수정

구현한 인증 정보를 사용하기 위해 App.razor 파일을 아래와 같이 AuthorizeRouteView 태그를 사용하는 형태로 업데이트 한다.
<Router AppAssembly="@typeof(Program).Assembly"> <Found Context="routeData"> <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" /> </Found> <NotFound> <CascadingAuthenticationState> <LayoutView Layout="@typeof(MainLayout)"> <p>Sorry, there's nothing at this address.</p> </LayoutView> </CascadingAuthenticationState> </NotFound> </Router>
XML

LoginDisplay.razor 추가

화면 상단에 Login/ Logout 버튼을 보여주기 위해 Shared에 LoginDisplay.razor를 만들고 아래와 같이 AuthorizeView 태그를 사용하여 구현한다.
<AuthorizeView> <Authorized> Hello, @context.User.Identity.Name! <a href="LogOut">Log out</a> </Authorized> <NotAuthorized> <a href="Register">Register</a> <a href="Login">Log in</a> </NotAuthorized> </AuthorizeView>
XML

MainLayout.razor 수정

기본 MainLayout.razor 파일에 구현한 LoginDisplay 페이지를 보여주도록 수정한다.
@inherits LayoutComponentBase <div class="page"> <div class="sidebar"> <NavMenu /> </div> <main> <div class="top-row px-4"> <LoginDisplay /> <a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a> </div> <article class="content px-4"> @Body </article> </main> </div>
XML

FetchData.razor 파일 수정

최종적으로 인증 구현을 테스트 하기 위해 FetchData 내용을 수정한다. 이 페이지는 Web API의 WeatherForecastController와 연결되는 페이지로, 해당 컨트롤러에서 [Authorize] 특성을 부여한 후 잘 동작하는지 확인하도록 아래와 같이 코드를 수정한다.
@page "/fetchdata" @inject HttpClient Http @using ToDo.Shared.DTO <PageTitle>Weather forecast</PageTitle> <h1>Weather forecast</h1> <p>This component demonstrates fetching data from the server.</p> @if (!string.IsNullOrWhiteSpace(this.ErrorMessage)) { <div class="alert alert-danger" role="alert"> @this.ErrorMessage </div> } else if (this.forecasts == null) { <p><em>Loading...</em></p> } else { <table class="table"> <thead> <tr> <th>Date</th> <th>Temp. (C)</th> <th>Temp. (F)</th> <th>Summary</th> </tr> </thead> <tbody> @foreach (var forecast in this.forecasts) { <tr> <td>@forecast.Date.ToShortDateString()</td> <td>@forecast.TemperatureC</td> <td>@forecast.TemperatureF</td> <td>@forecast.Summary</td> </tr> } </tbody> </table> } @code { private DtoWeatherForecast[]? forecasts; string ErrorMessage { get; set; } = string.Empty; protected override async Task OnInitializedAsync() { try { this.forecasts = await Http.GetFromJsonAsync<DtoWeatherForecast[]>("WeatherForecast"); this.ErrorMessage = string.Empty; } catch (Exception ex) { this.ErrorMessage = ex.Message; } } }
C#

사용자 등록 페이지 구성

Regiter 모델 정의

사용자 등록 기능을 구현하기 앞서 우선 Register 모델을 아래와 같이 정의한다. 이 모델은 Client에서만 사용하는 것이므로 Blazor 프로젝트에 Models 폴더를 만들고 추가한다.
public class ModelRegister { [Required] public string UserName { get; set; } [Required] [EmailAddress] public string Email { get; set; } [Required] [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] [DataType(DataType.Password)] public string Password { get; set; } [DataType(DataType.Password)] [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")] public string ConfirmPassword { get; set; } }
C#

RegisterBase 클래스 정의

Blazor의 razor 페이지에서 다뤄야 할 코드가 많아지면 해당 코드를 ComponentBase를 상속 받는 클래스로 작성하고 razor 페이지는 해당 클래스를 상속 받는 식으로 구현하면 관리하기에 좋다. Blazor 프로젝트의 Pages 폴더에 RegisterBase 클래스를 정의한다. —razor 파일의 이름 + Base를 붙여서 클래스 이름을 만드는게 관례이다.
public class RegisterBase : ComponentBase { [Inject] IAuthService AuthService { get; set; } [Inject] NavigationManager NavigationManager { get; set; } protected ModelRegister RegisterModel { get; set; } = new ModelRegister(); protected string ErrorMessage { get; set; } = string.Empty; protected async Task HandleRegistration() { try { var result = await this.AuthService.Register(email: this.RegisterModel.Email, name: this.RegisterModel.UserName, password: this.RegisterModel.Password).ConfigureAwait(false); if (result) { // login에 성공하면 login 페이지로 이동한다. this.NavigationManager.NavigateTo("/login"); } this.ErrorMessage = string.Empty; } catch (Exception ex) { this.ErrorMessage = ex.Message; // log Exception } } }
C#
만일 ComponentBase를 상속 받는 클래스에서 참조해야 하는 서비스가 있다면 위와 같이 [Inject] 특성을 이용하여 참조할 수 있다. 이 경우 해당 서비스는 프로퍼티로 선언되어야 한다.
추가로 razor 페이지는 이 클래스를 상속 받아 구현하기 때문에 razor 페이지에서 사용되는 속성이나 메서드는 protected로 선언해야 한다.

Register.razor 파일 정의

구현한 RegisterBase 를 이용한 razor 파일은 아래와 같이 구현한다. —RegisterBase에서 선언한 Register 모델을 바인딩하고, Validation까지 점검한다.
@page "/register" @inherits RegisterBase <h1>Register</h1> @if (!string.IsNullOrWhiteSpace(this.ErrorMessage)) { <div class="alert alert-danger" role="alert"> @this.ErrorMessage </div> } else { <div class="card"> <div class="card-body"> <h5 class="card-title">Please enter your details</h5> <EditForm Model="this.RegisterModel" OnValidSubmit="this.HandleRegistration"> <DataAnnotationsValidator /> <ValidationSummary /> <div class="form-group"> <label for="name">UserName</label> <InputText Id="userName" class="form-control" @bind-Value="this.RegisterModel.UserName" /> </div> <div class="form-group"> <label for="email">Email address</label> <InputText Id="email" class="form-control" @bind-Value="this.RegisterModel.Email" /> <ValidationMessage For="@(() => this.RegisterModel.Email)" /> </div> <div class="form-group"> <label for="password">Password</label> <InputText Id="password" type="password" class="form-control" @bind-Value="this.RegisterModel.Password" /> <ValidationMessage For="@(() => this.RegisterModel.Password)" /> </div> <div class="form-group"> <label for="password">Confirm Password</label> <InputText Id="password" type="password" class="form-control" @bind-Value="this.RegisterModel.ConfirmPassword" /> <ValidationMessage For="@(() => this.RegisterModel.ConfirmPassword)" /> </div> <button type="submit" class="btn btn-primary">Submit</button> </EditForm> </div> </div> }
C#

로그인 페이지 구성

Login 모델 정의

사용자 등록과 비슷하게 로그인 페이지도 구성한다. 우선 Login 정보를 처리할 모델을 정의한다.
public class ModelLogin { [Required] [EmailAddress] public string Email { get; set; } [Required] [DataType(DataType.Password)] public string Password { get; set; } }
C#

LoginBase 클래스 정의

사용자 등록과 비슷하게 LoginBase 클래스를 정의한다.
public class LoginBase : ComponentBase { [Inject] IAuthService AuthService { get; set; } [Inject] NavigationManager NavigationManager { get; set; } protected ModelLogin LoginModel { get; set; } = new ModelLogin(); protected string ErrorMessage { get; set; } = string.Empty; protected async Task HandleLogin() { try { var result = await this.AuthService.Login(email: this.LoginModel.Email, password: this.LoginModel.Password).ConfigureAwait(false); if (result) { this.NavigationManager.NavigateTo("/"); } this.ErrorMessage = string.Empty; } catch (Exception ex) { this.ErrorMessage = ex.Message; // log Exception } } }
C#

Login.razor 파일 정의

사용자 등록과 비슷하게 Login.razor 파일을 구성한다.
@page "/login" @inherits LoginBase <h1>Login</h1> @if (!string.IsNullOrWhiteSpace(this.ErrorMessage)) { <div class="alert alert-danger" role="alert"> @this.ErrorMessage </div> } else { <div class="card"> <div class="card-body"> <h5 class="card-title">Please enter your details</h5> <EditForm Model="this.LoginModel" OnValidSubmit="this.HandleLogin"> <DataAnnotationsValidator /> <ValidationSummary /> <div class="form-group"> <label for="email">Email address</label> <InputText Id="email" Class="form-control" @bind-Value="this.LoginModel.Email" /> <ValidationMessage For="@(() => this.LoginModel.Email)" /> </div> <div class="form-group"> <label for="password">Password</label> <InputText Id="password" type="password" Class="form-control" @bind-Value="this.LoginModel.Password" /> <ValidationMessage For="@(() => this.LoginModel.Password)" /> </div> <button type="submit" class="btn btn-primary">Submit</button> </EditForm> </div> </div> }
C#

로그아웃 페이지 구성

사용자 등록이나 로그인과 다르게 로그아웃 자체는 별다른 데이터가 필요하지 않다. 간단하게 아래와 같이 Logout.razor 페이지만 구성한다.
@page "/logout" @inject IAuthService AuthService @inject NavigationManager NavigationManager @code { protected override async Task OnInitializedAsync() { await AuthService.Logout(); NavigationManager.NavigateTo("/"); } }
C#
여기까지 했으면 .NET의 Identify를 이용한 사용자 등록과 로그인/로그아웃, JWT를 이용한 인증, 인가 등이 구현된 상태이다. 실제로 프로젝트를 테스트 해보면서 잘 동작하는지 확인하고, Web API에서 WeatherForecastController에 [Authorize], [Authorize(Roles = UserRole.ADMIN)] 등을 붙여 보면서 권한 관리도 잘 되는지 확인해 보자.

시리즈

이 글은 아래와 같은 시리즈로 이루어짐

참조 자료