[Blazor] JWT 예제
- 웹 프로그래밍/Blazor
- 2022. 12. 10. 02:01
참고
개요
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
'웹 프로그래밍 > Blazor' 카테고리의 다른 글
[Blazor] ToDoList 만들기 (0) | 2022.12.15 |
---|---|
[Blazor] LocalStorage 사용법 (1) | 2022.12.10 |
[Blazor] JWT (0) | 2022.12.09 |
[Blazor] 모범사례 5 - CssBuilder 및 StyleBuilder를 사용하여 클래스 및 스타일 조작 (0) | 2022.11.26 |
[Blazor] 모범사례 4. 올바른 매개변수 (0) | 2022.11.24 |
이 글을 공유하기