Search
Duplicate

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

패키지 설치

Blazor 프로젝트에 암호화와 인증을 위해 아래의 패키지들을 설치한다.
Microsoft.AspNetCore.Components.Authorization
System.IdentityModel.Tokens.Jwt
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 { DtoRegister register = new DtoRegister() { Email = email, UserName = name, Password = password }; 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 { DtoRegister register = new DtoRegister() { Email = email, UserName = name, Password = password }; 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 { DtoLogin login = new DtoLogin() { Email = email, Password = password }; 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 태그를 사용하여 구현한다.
@using Microsoft.AspNetCore.Components.Authorization <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#
복사

사용자 등록 페이지 구성

Register 모델 정의

사용자 등록 기능을 구현하기 앞서 우선 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#
복사

Register 클래스 정의

Blazor의 razor 페이지에서 다뤄야 할 코드가 많아지면 해당 코드를 별도의 클래스로 분리하면 관리하기에 좋다. Blazor 프로젝트의 Pages 폴더에 Register.razor.cs 클래스를 생성하고 class 앞에 partial 키워드를 붙여준다. —razor 파일의 이름.razor.cs 라는 이름으로 만들면 알아서 두 파일을 연결해 준다.
public partial class Register { [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 파일 정의

구현한 Register.cs 를 이용한 razor 파일은 아래와 같이 구현한다. —RegisterBase에서 선언한 Register 모델을 바인딩하고, Validation까지 점검한다.
@page "/register" <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#
복사

Login 클래스 정의

사용자 등록과 비슷하게 Login.razor.cs 클래스를 정의한다.
public partial class Login { [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" <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)] 등을 붙여 보면서 권한 관리도 잘 되는지 확인해 보자.

시리즈

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

참조 자료