[Blazor] JWT 예제

참고


개요

  • Blazor WebAssembly 프로젝트를 생성 후, JWT 예제를 구현하는 방법에 대해서 정리 진행합니다.

개발 환경

  • 개발 환경은 다음과 같습니다.
    • .NET6
    • Visual Studio 2022
    • Blazor WebAssembly

Blazor WebAssembly 프로젝트 생성

  • 먼저 Blazor WebAssemby 프로젝트 생성을 합니다.


Server Project 에 NuGet Package 추가

  • Service 프로젝트에 Microsoft.AspNetCore.Authentication.JwtBearer NuGet 패키지를 추가합니다.


Client Project 에 NuGet Package 추가

  • Client 프로젝트에 System.ComponentModel.Annotations NuGet 패키지를 추가합니다.


shared Project 에 NuGet Package 추가

  • Shared 프로젝트에 System.ComponentModel.Annotations NuGet 패키지를 추가합니다.


Shared 프로젝트 수정

  • Shared 프로젝트에 있는 WeatherForecast.cs 클래스를 다음과 같이 수정 진행합니다.
namespace JWTExample.Shared
{
    public class WeatherForecast
    {
        public DateTime Date { get; set; }

        public int TemperatureC { get; set; }

        public string? Summary { get; set; }

        public string? UserName { get; set; } // 해당 라인 새롭게 추가

        public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
    }
}

AuthModel.cs 추가

  • Shared 프로젝트에 AuthModel.cs 클래스를 다음과 같이 추가 후, 코드를 작성합니다.
using System.ComponentModel.DataAnnotations;

namespace JWTExample.Shared;

public class LoginResult
{
    public string message { get; set; }
    public string email { get; set; }
    public string jwtBearer { get; set; }
    public bool success { get; set; }
}

public class LoginModel
{
    [Required(ErrorMessage = "Email is required.")]
    [EmailAddress(ErrorMessage ="Email address is not valid.")]
    public string email { get; set; }

    [Required(ErrorMessage = "Password is required.")]
    [DataType(DataType.Password)]
    public string password { get; set; }
}

public class RegModel : LoginModel
{
    [Required(ErrorMessage = "Confirm password is required.")]
    [DataType(DataType.Password)]
    [Compare("password", ErrorMessage = "Password and confirm password do not match.")]
    public string confirmpwd { get; set; }
}

Server 프로젝트 수정

  • 다음은 Server 프로젝트에 있는 UserDatabase.cs 파일입니다.
  • UserDabase.cs 는 사용자를 저장하기 위한 데이터베스의 조잡한 예입니다.
using System.Security.Cryptography;
using System.Text;

namespace JWTExample.Server;

public class User
{
   public string Email { get; }
   public User(string email)
   {
      Email = email;
   }
}

public interface IUserDatabase
{
   Task<User> AuthenticateUser(string email, string password);
   Task<User> AddUser(string email, string password);
}

public class UserDatabase : IUserDatabase
{
   private readonly IWebHostEnvironment env;
   public UserDatabase(IWebHostEnvironment env) => this.env = env;
   private static string CreateHash(string password)
   {
      var salt = "997eff51db1544c7a3c2ddeb2053f052";
      var md5 = new HMACMD5(Encoding.UTF8.GetBytes(salt + password));
      byte[] data = md5.ComputeHash(Encoding.UTF8.GetBytes(password));
      return System.Convert.ToBase64String(data);
   }

   public async Task<User> AuthenticateUser(string email, string password)
   {
      if (string.IsNullOrEmpty(email) || string.IsNullOrEmpty(password))
         return null;
      var path = System.IO.Path.Combine(env.ContentRootPath, "Users");
      if (!System.IO.Directory.Exists(path))
         return null;
      path = System.IO.Path.Combine(path, email);
      if (!System.IO.File.Exists(path))
         return null;
      if (await System.IO.File.ReadAllTextAsync(path) != CreateHash(password))
         return null;
      return new User(email);
   }

   public async Task<User> AddUser(string email, string password)
   {
      try
      {
         if (string.IsNullOrEmpty(email) || string.IsNullOrEmpty(password))
            return null;
         var path = System.IO.Path.Combine(env.ContentRootPath, "Users"); // NOTE: THIS WILL CREATE THE "USERS" FOLDER IN THE PROJECT'S FOLDER!!!
         if (!System.IO.Directory.Exists(path))
            System.IO.Directory.CreateDirectory(path); // NOTE: MAKE SURE THERE ARE CREATE/WRITE PERMISSIONS
         path = System.IO.Path.Combine(path, email);
         if (System.IO.File.Exists(path))
            return null;
         await System.IO.File.WriteAllTextAsync(path, CreateHash(password));
         return new User(email);
      }
      catch
      {
         return null;
      }
   }
}
  • 다음은 Server 프로젝트의 Program.cs 파일 내용입니다.
// Program.cs
using JWTExample.Server;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.


builder.Services.AddTransient<IUserDatabase, UserDatabase>();  // NOTE: LOCAL AUTHENTICATION ADDED HERE; AddTransient() IS OK TO USE BECAUSE STATE IS SAVED TO THE DRIVE

// NOTE: the following block of code is newly added
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options =>
{
   options.TokenValidationParameters = new TokenValidationParameters
   {
      ValidateAudience = true,
      ValidAudience = "domain.com",
      ValidateIssuer = true,
      ValidIssuer = "domain.com",
      ValidateLifetime = true,
      ValidateIssuerSigningKey = true,
      IssuerSigningKey = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes("THIS IS THE SECRET KEY")) // NOTE: THIS SHOULD BE A SECRET KEY NOT TO BE SHARED; A GUID IS RECOMMENDED
   };
});
// NOTE: end block


builder.Services.AddControllersWithViews();
builder.Services.AddRazorPages();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
   app.UseWebAssemblyDebugging();
}
else
{
   app.UseExceptionHandler("/Error");
   // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
   app.UseHsts();
}

app.UseHttpsRedirection();

app.UseBlazorFrameworkFiles();
app.UseStaticFiles();


app.UseAuthentication(); // NOTE: line is newly added

app.UseRouting();

app.UseAuthorization(); // NOTE: line is newly addded, notice placement after UseRouting()


app.MapRazorPages();
app.MapControllers();
app.MapFallbackToFile("index.html");

app.Run();
  • 다음은 Server 프로젝트의 Controllers 폴더에 있는 인증 컨트롤러 파일입니다.
using JWTExample.Shared;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;

namespace JWTExample.Server.Controllers;

[ApiController]
public class AuthController : ControllerBase
{
   private string CreateJWT(User user)
   {
      var secretkey = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes("THIS IS THE SECRET KEY")); // NOTE: SAME KEY AS USED IN Program.cs FILE
      var credentials = new SigningCredentials(secretkey, SecurityAlgorithms.HmacSha256);

      var claims = new[] // NOTE: could also use List<Claim> here
      {
            new Claim(ClaimTypes.Name, user.Email), // NOTE: this will be the "User.Identity.Name" value
            new Claim(JwtRegisteredClaimNames.Sub, user.Email),
            new Claim(JwtRegisteredClaimNames.Email, user.Email),
            new Claim(JwtRegisteredClaimNames.Jti, user.Email) // NOTE: this could a unique ID assigned to the user by a database
         };

      var token = new JwtSecurityToken(issuer: "domain.com", audience: "domain.com", claims: claims, expires: DateTime.Now.AddMinutes(60), signingCredentials: credentials);
      return new JwtSecurityTokenHandler().WriteToken(token);
   }

   private IUserDatabase userdb { get; }

   public AuthController(IUserDatabase userdb)
   {
      this.userdb = userdb;
   }

   [HttpPost]
   [Route("api/auth/register")]
   public async Task<LoginResult> Post([FromBody] RegModel reg)
   {
      if (reg.password != reg.confirmpwd)
         return new LoginResult { message = "Password and confirm password do not match.", success = false };
      User newuser = await userdb.AddUser(reg.email, reg.password);
      if (newuser != null)
         return new LoginResult { message = "Registration successful.", jwtBearer = CreateJWT(newuser), email = reg.email, success = true };
      return new LoginResult { message = "User already exists.", success = false };
   }

   [HttpPost]
   [Route("api/auth/login")]
   public async Task<LoginResult> Post([FromBody] LoginModel log)
   {
      User user = await userdb.AuthenticateUser(log.email, log.password);
      if (user != null)
         return new LoginResult { message = "Login successful.", jwtBearer = CreateJWT(user), email = log.email, success = true };
      return new LoginResult { message = "User/password not found.", success = false };
   }
}
  • 다음은 Server 프로젝트에 있는 WeatherForecastController.cs 입니다.
using JWTExample.Shared;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace JWTExample.Server.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class WeatherForecastController : ControllerBase
    {
        private static readonly string[] Summaries = new[]
        {
        "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
    };

        private readonly ILogger<WeatherForecastController> _logger;

        public WeatherForecastController(ILogger<WeatherForecastController> logger)
        {
            _logger = logger;
        }

        [HttpGet]
        [Authorize]
        public IEnumerable<WeatherForecast> Get()
        {
            return Enumerable.Range(1, 5).Select(index => new WeatherForecast
            {
                Date = DateTime.Now.AddDays(index),
                TemperatureC = Random.Shared.Next(-20, 55),
                Summary = Summaries[Random.Shared.Next(Summaries.Length)],
                UserName = User.Identity?.Name ?? string.Empty // NOTE: THIS LINE OF CODE IS NEWLY ADDED
            })
            .ToArray();
        }

        // NOTE: THIS ENTIRE BLOCK OF CODE IS NEWLY ADDED
        [HttpGet("{date}")]
        [Authorize]
        public WeatherForecast Get(DateTime date)
        {
            var rng = new Random();
            return new WeatherForecast
            {
                Date = date,
                TemperatureC = rng.Next(-20, 55),
                Summary = Summaries[rng.Next(Summaries.Length)],
                UserName = User.Identity?.Name ?? string.Empty
            };
        }
        // NOTE: END BLOCK
    }
}

Client 프로젝트 수정

  • Client 프로젝트에서 UserComponent.razor 페이지를 생성 후, 아래와 같이 코드를 작성합니다.
@inject IJSRuntime jsr

<p>
   @if (string.IsNullOrEmpty(username))
   {
      <span><a href="/register">Register</a> <a href="/login">Login</a></span>
   }
   else
   {
      <span>Hello, @username <a href="/logout">(Logout)</a></span>
   }
</p>

@code {

   string username = string.Empty;

   protected override async Task OnInitializedAsync()
   {
      await base.OnInitializedAsync();
      var userdata = await jsr.InvokeAsync<string>("localStorage.getItem", "user").ConfigureAwait(false);
      if (!string.IsNullOrWhiteSpace(userdata))
      {
         username = userdata.Split(';', 2)[0];
      }
   }
}
  • 다음은 Register.razor 페이지를 생성합니다.
@page "/register"
@using JWTExample.Shared
@inject HttpClient Http

<h3>Register</h3>

<p>@message</p>
<p><a href="/login">@login</a></p>

<EditForm Model="reg" OnValidSubmit="OnValid" style="max-width:500px;">
   <DataAnnotationsValidator />
   <ValidationSummary />
   <div class="mb-2">
      <InputText class="form-control" @bind-Value="reg.email" placeholder="Enter Email"></InputText>
   </div>
   <div class="mb-2">
      <InputText type="password" class="form-control" @bind-Value="reg.password" placeholder="Enter Password"></InputText>
   </div>
   <div class="mb-2">
      <InputText type="password" class="form-control" @bind-Value="reg.confirmpwd" placeholder="Confirm Password"></InputText>
   </div>
   <div class="mb-2 text-right">
      <button class="btn btn-secondary" disabled="@isDisabled">register</button>
   </div>
</EditForm>

@code {
   RegModel reg = new RegModel();
   string message = string.Empty, login = string.Empty;
   bool isDisabled = false;

   private async Task OnValid()
   {
      isDisabled = true;
      using (var msg = await Http.PostAsJsonAsync<RegModel>("/api/auth/register", reg, System.Threading.CancellationToken.None))
      {
         if (msg.IsSuccessStatusCode)
         {
            LoginResult result = await msg.Content.ReadFromJsonAsync<LoginResult>();
            message = result.message;
            if (result.success)
            {
               message += " Please LOGIN to continue.";
               login = "Click here to LOGIN.";
            }
            else
               isDisabled = false;
         }
      }
   }
}
  • 다음은 Login.razor 페이지를 생성합니다.
@page "/login"
@using JWTExample.Shared
@inject HttpClient Http
@inject IJSRuntime jsr

<h3>Login</h3>

<p>@message</p>

<EditForm Model="user" OnValidSubmit="OnValid" style="max-width:500px;">
   <DataAnnotationsValidator />
   <ValidationSummary />
   <div class="mb-2">
      <InputText class="form-control" @bind-Value="user.email" placeholder="Enter Email"></InputText>
   </div>
   <div class="mb-2">
      <InputText type="password" class="form-control" @bind-Value="user.password" placeholder="Enter Password"></InputText>
   </div>
   <div class="mb-2 text-right">
      <button class="btn btn-secondary" disabled="@isDisabled">login</button>
   </div>
</EditForm>

@code {
   LoginModel user = new LoginModel();
   string message = string.Empty;
   bool isDisabled = false;

   private async Task OnValid()
   {
      isDisabled = true;
      using (var msg = await Http.PostAsJsonAsync<LoginModel>("/api/auth/login", user, System.Threading.CancellationToken.None))
      {
         if (msg.IsSuccessStatusCode)
         {
            LoginResult result = await msg.Content.ReadFromJsonAsync<LoginResult>();
            message = result.message;
            isDisabled = false;
            if (result.success)
               await jsr.InvokeVoidAsync("localStorage.setItem", "user", $"{result.email};{result.jwtBearer}").ConfigureAwait(false);
         }
      }
   }
}
  • 다음은 Logout.razor 페이지 입니다.
@page "/logout"
@inject IJSRuntime jsr
@inject NavigationManager nav

@code {

   protected override async Task OnInitializedAsync()
   {
      await base.OnInitializedAsync();
      await jsr.InvokeVoidAsync("localStorage.removeItem", "user").ConfigureAwait(false);
      nav.NavigateTo("/");
   }
}
  • 다음은 FetchData.razor 페이지 입니다.
@page "/fetchdata"
@using JWTExample.Shared
@inject HttpClient Http
@inject IJSRuntime jsr

<UserComponent></UserComponent>

<h1>Weather forecast</h1>

<p>This component demonstrates fetching data from the server.</p>
@if (string.IsNullOrEmpty(userdata))
{
   <p><a href="/login">LOGIN TO ACCESS THIS DATA</a></p>
}
else
{
   if (forecasts == null)
   {
      <p><em>Loading...</em></p>
   }
   else
   {
      <p><a href="javascript:;" @onclick="GetTodaysForecast">TODAY'S FORECAST</a></p>
      <table class="table">
         <thead>
            <tr>
               <th>Date</th>
               <th>Temp. (C)</th>
               <th>Temp. (F)</th>
               <th>Summary</th>
               <th>User</th>
            </tr>
         </thead>
         <tbody>
            @foreach (var forecast in forecasts)
            {
               <tr>
                  <td>@forecast.Date.ToShortDateString()</td>
                  <td>@forecast.TemperatureC</td>
                  <td>@forecast.TemperatureF</td>
                  <td>@forecast.Summary</td>
                  <td>@forecast.UserName</td>
               </tr>
            }
         </tbody>
      </table>
   }
}

@code {

   private List<WeatherForecast> forecasts;
   string userdata;

   private async Task<string> GetJWT()
   {
      userdata = await jsr.InvokeAsync<string>("localStorage.getItem", "user").ConfigureAwait(false);
      if (!string.IsNullOrWhiteSpace(userdata))
      {
         var dataArray = userdata.Split(';', 2);
         if (dataArray.Length == 2)
            return dataArray[1];
      }
      return null;
   }

   private async Task GetTodaysForecast()
   {
      try
      {
         var requestMsg = new HttpRequestMessage(HttpMethod.Get, $"/api/weatherforecast/{DateTime.Now.ToString("yyyy-MM-dd")}");
         requestMsg.Headers.Add("Authorization", "Bearer " + await GetJWT());
         var response = await Http.SendAsync(requestMsg);
         if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) // NOTE: THEN TOKEN HAS EXPIRED
         {
            await jsr.InvokeVoidAsync("localStorage.removeItem", "user").ConfigureAwait(false);
            userdata = null;
         }
         else if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
            forecasts = null;
         else if (response.IsSuccessStatusCode)
         {
            var forecast = await response.Content.ReadFromJsonAsync<WeatherForecast>();
            forecasts.Clear();
            forecasts.Add(forecast);
         }
      }
      catch (Exception ex)
      {

      }
   }

   protected override async Task OnInitializedAsync()
   {
      await base.OnInitializedAsync();
      try
      {
         var requestMsg = new HttpRequestMessage(HttpMethod.Get, "/api/weatherforecast");
         requestMsg.Headers.Add("Authorization", "Bearer " + await GetJWT());
         var response = await Http.SendAsync(requestMsg);
         if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) // NOTE: THEN TOKEN HAS EXPIRED
         {
            await jsr.InvokeVoidAsync("localStorage.removeItem", "user").ConfigureAwait(false);
            userdata = null;
         }
         else if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
            forecasts = null;
         else if (response.IsSuccessStatusCode)
            forecasts = await response.Content.ReadFromJsonAsync<List<WeatherForecast>>();
      }
      catch (Exception ex)
      {

      }
   }
}

첨부 파일

*

728x90

이 글을 공유하기

댓글

Designed by JB FACTORY