From a039bb53a45e0e91916b8bcca6ca3d27346a61f5 Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Fri, 26 Jun 2026 22:00:16 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20JWT=20=ED=86=A0=ED=81=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20Admin=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=99=84=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthService로 JWT 토큰 생성 및 검증 - CustomAuthenticationStateProvider를 통한 Blazor 인증 통합 - LocalStorageService로 토큰 관리 - Login.razor 완전 재작성 (실제 DB 검증, 토큰 발급) - BCrypt 기반 비밀번호 검증 - admin/admin123으로 테스트 가능 Co-Authored-By: Claude Haiku 4.5 --- TaxBaik.Admin/Components/Pages/Login.razor | 52 +++++++--- TaxBaik.Admin/Components/Routes.razor | 27 +++--- TaxBaik.Admin/Components/_Imports.razor | 2 + TaxBaik.Admin/Program.cs | 14 +-- TaxBaik.Admin/Services/AuthService.cs | 96 +++++++++++++++++++ .../CustomAuthenticationStateProvider.cs | 59 ++++++++++++ .../Services/ILocalStorageService.cs | 8 ++ TaxBaik.Admin/Services/LocalStorageService.cs | 43 +++++++++ TaxBaik.Admin/TaxBaik.Admin.csproj | 2 + TaxBaik.Admin/appsettings.json | 5 +- 10 files changed, 274 insertions(+), 34 deletions(-) create mode 100644 TaxBaik.Admin/Services/AuthService.cs create mode 100644 TaxBaik.Admin/Services/CustomAuthenticationStateProvider.cs create mode 100644 TaxBaik.Admin/Services/ILocalStorageService.cs create mode 100644 TaxBaik.Admin/Services/LocalStorageService.cs diff --git a/TaxBaik.Admin/Components/Pages/Login.razor b/TaxBaik.Admin/Components/Pages/Login.razor index 938ea95..e259353 100644 --- a/TaxBaik.Admin/Components/Pages/Login.razor +++ b/TaxBaik.Admin/Components/Pages/Login.razor @@ -1,9 +1,10 @@ @page "/login" @using System.ComponentModel.DataAnnotations -@using Microsoft.AspNetCore.Authentication -@using Microsoft.AspNetCore.Authentication.Cookies @layout TaxBaik.Admin.Components.Layout.BlankLayout @attribute [AllowAnonymous] +@inject AuthService AuthService +@inject NavigationManager NavigationManager +@inject CustomAuthenticationStateProvider AuthStateProvider 로그인 @@ -24,7 +25,17 @@ } 로그인 + Size="Size.Large" OnClick="HandleLogin" Disabled="isLoading"> + @if (isLoading) + { + + 로그인 중... + } + else + { + 로그인 + } + @@ -32,30 +43,43 @@ @code { private MudForm form; private bool isFormValid = false; + private bool isLoading = false; private string errorMessage = ""; private LoginModel model = new(); private async Task HandleLogin() { - // 기본 사용자명: admin / 비밀번호: admin123 - if (model.Username == "admin" && model.Password == "admin123") + if (isLoading) + return; + + isLoading = true; + errorMessage = ""; + + try { - // 임시: 대시보드로 리다이렉트 (향후 실제 쿠키 인증으로 개선) - NavigationManager.NavigateTo("/taxbaik/admin/dashboard", forceLoad: true); + var token = await AuthService.AuthenticateAndGenerateTokenAsync(model.Username, model.Password); + + if (token == null) + { + errorMessage = "사용자명 또는 비밀번호가 올바르지 않습니다."; + isLoading = false; + return; + } + + await AuthStateProvider.LoginAsync(token); + NavigationManager.NavigateTo("/taxbaik/admin/dashboard", forceLoad: false); } - else + catch (Exception ex) { - errorMessage = "사용자명 또는 비밀번호가 올바르지 않습니다."; + errorMessage = "로그인 중 오류가 발생했습니다."; + isLoading = false; } } - [Inject] - private NavigationManager NavigationManager { get; set; } - private class LoginModel { - public string Username { get; set; } - public string Password { get; set; } + public string Username { get; set; } = ""; + public string Password { get; set; } = ""; } } diff --git a/TaxBaik.Admin/Components/Routes.razor b/TaxBaik.Admin/Components/Routes.razor index 4db38c6..b54245a 100644 --- a/TaxBaik.Admin/Components/Routes.razor +++ b/TaxBaik.Admin/Components/Routes.razor @@ -1,14 +1,17 @@ @using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Authorization - - - - - - - 찾을 수 없음 - -

요청한 페이지를 찾을 수 없습니다.

-
-
-
+ + + + + + + + 찾을 수 없음 + +

요청한 페이지를 찾을 수 없습니다.

+
+
+
+
diff --git a/TaxBaik.Admin/Components/_Imports.razor b/TaxBaik.Admin/Components/_Imports.razor index 50c33cc..1c588e0 100644 --- a/TaxBaik.Admin/Components/_Imports.razor +++ b/TaxBaik.Admin/Components/_Imports.razor @@ -7,3 +7,5 @@ @using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Authorization @using MudBlazor +@using TaxBaik.Admin.Services +@attribute [Authorize] diff --git a/TaxBaik.Admin/Program.cs b/TaxBaik.Admin/Program.cs index c9d9d8f..f540d24 100644 --- a/TaxBaik.Admin/Program.cs +++ b/TaxBaik.Admin/Program.cs @@ -1,18 +1,18 @@ using System.Text.Encodings.Web; using System.Text.Unicode; -using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Components.Authorization; using MudBlazor.Services; +using TaxBaik.Admin.Services; using TaxBaik.Application; using TaxBaik.Infrastructure; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) - .AddCookie(opts => { - opts.LoginPath = "/login"; - opts.ExpireTimeSpan = TimeSpan.FromHours(8); - opts.Cookie.SameSite = SameSiteMode.Lax; - }); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(sp => sp.GetRequiredService()); +builder.Services.AddScoped(); +builder.Services.AddCascadingAuthenticationState(); builder.Services.AddAuthorizationCore(); builder.Services.AddRazorComponents() diff --git a/TaxBaik.Admin/Services/AuthService.cs b/TaxBaik.Admin/Services/AuthService.cs new file mode 100644 index 0000000..509ef4f --- /dev/null +++ b/TaxBaik.Admin/Services/AuthService.cs @@ -0,0 +1,96 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using BCrypt.Net; +using Microsoft.IdentityModel.Tokens; +using TaxBaik.Domain.Entities; +using TaxBaik.Domain.Interfaces; + +namespace TaxBaik.Admin.Services; + +public class AuthService +{ + private readonly IAdminUserRepository _adminUserRepository; + private readonly ILogger _logger; + private readonly string _jwtSecretKey; + private readonly int _tokenExpirationMinutes = 480; // 8시간 + + public AuthService(IAdminUserRepository adminUserRepository, ILogger logger, IConfiguration configuration) + { + _adminUserRepository = adminUserRepository; + _logger = logger; + _jwtSecretKey = configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("Missing 'Jwt:SecretKey' configuration."); + } + + public async Task AuthenticateAndGenerateTokenAsync(string username, string password) + { + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) + { + return null; + } + + var user = await _adminUserRepository.GetByUsernameAsync(username); + if (user == null) + { + _logger.LogWarning("로그인 시도: 존재하지 않는 사용자 '{Username}'", username); + return null; + } + + if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash)) + { + _logger.LogWarning("로그인 시도: 잘못된 비밀번호 '{Username}'", username); + return null; + } + + _logger.LogInformation("로그인 성공: {Username}", username); + return GenerateJwtToken(user); + } + + private string GenerateJwtToken(AdminUser user) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSecretKey)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Name, user.Username), + new Claim("username", user.Username) + }; + + var token = new JwtSecurityToken( + issuer: "taxbaik-admin", + audience: "taxbaik-admin-client", + claims: claims, + expires: DateTime.UtcNow.AddMinutes(_tokenExpirationMinutes), + signingCredentials: creds); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public ClaimsPrincipal? ValidateToken(string token) + { + try + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSecretKey)); + var handler = new JwtSecurityTokenHandler(); + var principal = handler.ValidateToken(token, new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = key, + ValidateIssuer = true, + ValidIssuer = "taxbaik-admin", + ValidateAudience = true, + ValidAudience = "taxbaik-admin-client", + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero + }, out SecurityToken validatedToken); + + return principal; + } + catch + { + return null; + } + } +} diff --git a/TaxBaik.Admin/Services/CustomAuthenticationStateProvider.cs b/TaxBaik.Admin/Services/CustomAuthenticationStateProvider.cs new file mode 100644 index 0000000..602a5c5 --- /dev/null +++ b/TaxBaik.Admin/Services/CustomAuthenticationStateProvider.cs @@ -0,0 +1,59 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Components.Authorization; + +namespace TaxBaik.Admin.Services; + +public class CustomAuthenticationStateProvider : AuthenticationStateProvider +{ + private readonly ILocalStorageService _localStorage; + private readonly AuthService _authService; + private readonly ILogger _logger; + + public CustomAuthenticationStateProvider(ILocalStorageService localStorage, AuthService authService, ILogger logger) + { + _localStorage = localStorage; + _authService = authService; + _logger = logger; + } + + public override async Task GetAuthenticationStateAsync() + { + try + { + var token = await _localStorage.GetItemAsStringAsync("auth_token"); + + if (string.IsNullOrEmpty(token)) + { + return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); + } + + var principal = _authService.ValidateToken(token); + if (principal == null) + { + await _localStorage.RemoveItemAsync("auth_token"); + return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); + } + + return new AuthenticationState(principal); + } + catch (Exception ex) + { + _logger.LogError(ex, "인증 상태 조회 중 오류 발생"); + return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); + } + } + + public async Task LoginAsync(string token) + { + await _localStorage.SetItemAsStringAsync("auth_token", token); + + var principal = _authService.ValidateToken(token); + NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); + } + + public async Task LogoutAsync() + { + await _localStorage.RemoveItemAsync("auth_token"); + NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); + } +} diff --git a/TaxBaik.Admin/Services/ILocalStorageService.cs b/TaxBaik.Admin/Services/ILocalStorageService.cs new file mode 100644 index 0000000..342530c --- /dev/null +++ b/TaxBaik.Admin/Services/ILocalStorageService.cs @@ -0,0 +1,8 @@ +namespace TaxBaik.Admin.Services; + +public interface ILocalStorageService +{ + Task GetItemAsStringAsync(string key); + Task SetItemAsStringAsync(string key, string value); + Task RemoveItemAsync(string key); +} diff --git a/TaxBaik.Admin/Services/LocalStorageService.cs b/TaxBaik.Admin/Services/LocalStorageService.cs new file mode 100644 index 0000000..fa73188 --- /dev/null +++ b/TaxBaik.Admin/Services/LocalStorageService.cs @@ -0,0 +1,43 @@ +using Microsoft.JSInterop; + +namespace TaxBaik.Admin.Services; + +public class LocalStorageService : ILocalStorageService +{ + private readonly IJSRuntime _jsRuntime; + + public LocalStorageService(IJSRuntime jsRuntime) + { + _jsRuntime = jsRuntime; + } + + public async Task GetItemAsStringAsync(string key) + { + try + { + return await _jsRuntime.InvokeAsync("localStorage.getItem", key); + } + catch + { + return null; + } + } + + public async Task SetItemAsStringAsync(string key, string value) + { + try + { + await _jsRuntime.InvokeVoidAsync("localStorage.setItem", key, value); + } + catch { } + } + + public async Task RemoveItemAsync(string key) + { + try + { + await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", key); + } + catch { } + } +} diff --git a/TaxBaik.Admin/TaxBaik.Admin.csproj b/TaxBaik.Admin/TaxBaik.Admin.csproj index 8f8c88c..b7bea4b 100644 --- a/TaxBaik.Admin/TaxBaik.Admin.csproj +++ b/TaxBaik.Admin/TaxBaik.Admin.csproj @@ -14,6 +14,8 @@ + + diff --git a/TaxBaik.Admin/appsettings.json b/TaxBaik.Admin/appsettings.json index 10f68b8..d965bed 100644 --- a/TaxBaik.Admin/appsettings.json +++ b/TaxBaik.Admin/appsettings.json @@ -5,5 +5,8 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "Jwt": { + "SecretKey": "dev-secret-key-change-in-production-min-32-chars!" + } }