feat: harden auth ops and deployment baseline

This commit is contained in:
2026-06-27 10:53:53 +09:00
parent a6ca30eec8
commit 28060b71be
41 changed files with 714 additions and 208 deletions
@@ -11,8 +11,8 @@
</MudDialog>
@code {
[CascadingParameter] MudDialogInstance MudDialog { get; set; }
[CascadingParameter] MudDialogInstance? MudDialog { get; set; }
void Cancel() => MudDialog.Cancel();
void Confirm() => MudDialog.Close(DialogResult.Ok(true));
void Cancel() => MudDialog?.Cancel();
void Confirm() => MudDialog?.Close(DialogResult.Ok(true));
}
@@ -1,5 +1,5 @@
@using TaxBaik.Domain.Interfaces
@inject IInquiryRepository InquiryRepository
@using TaxBaik.Application.Services
@inject InquiryService InquiryService
<MudSimpleTable Striped="true" Dense="true" Class="mt-4">
<thead>
@@ -39,7 +39,7 @@
protected override async Task OnInitializedAsync()
{
var (items, _) = await InquiryRepository.GetPagedAsync(1, 1000);
var (items, _) = await InquiryService.GetPagedAsync(1, 1000);
inquiries = items.ToList();
FilterInquiries();
}
@@ -1,11 +1,12 @@
@page "/admin/blog/create"
@using TaxBaik.Application.DTOs
@using TaxBaik.Application.Services
@using TaxBaik.Domain.Interfaces
@attribute [Authorize]
@inject BlogService BlogService
@inject ICategoryRepository CategoryRepository
@inject NavigationManager Navigation
@inject Snackbar Snackbar
@inject ISnackbar Snackbar
<PageTitle>새 포스트 작성</PageTitle>
@@ -49,7 +50,7 @@
</MudPaper>
@code {
private MudForm form;
private MudForm? form;
private List<Domain.Entities.Category> categories = [];
private CreatePostModel model = new();
@@ -60,18 +61,43 @@
private async Task SavePost()
{
// TODO: Implement BlogService.CreateAsync
Navigation.NavigateTo("/taxbaik/admin/blog");
if (form == null)
return;
await form.Validate();
if (!form.IsValid)
return;
try
{
await BlogService.CreateAsync(new CreateBlogPostDto
{
Title = model.Title,
Content = model.Content,
CategoryId = model.CategoryId,
Tags = model.Tags,
SeoTitle = model.SeoTitle,
SeoDescription = model.SeoDescription,
IsPublished = model.IsPublished
});
Snackbar.Add("포스트가 저장되었습니다.", Severity.Success);
Navigation.NavigateTo("/taxbaik/admin/blog");
}
catch (ValidationException ex)
{
Snackbar.Add(ex.Message, Severity.Error);
}
}
private class CreatePostModel
{
public string Title { get; set; }
public string Content { get; set; }
public int CategoryId { get; set; }
public string Tags { get; set; }
public string SeoTitle { get; set; }
public string SeoDescription { get; set; }
public string Title { get; set; } = "";
public string Content { get; set; } = "";
public int? CategoryId { get; set; }
public string? Tags { get; set; }
public string? SeoTitle { get; set; }
public string? SeoDescription { get; set; }
public bool IsPublished { get; set; }
}
}
@@ -2,7 +2,7 @@
@attribute [Authorize]
@inject IApiClient ApiClient
@inject DialogService DialogService
@inject Snackbar Snackbar
@inject ISnackbar Snackbar
<PageTitle>블로그 관리</PageTitle>
@@ -17,7 +17,8 @@
<PropertyColumn Property="x => x.Title" Title="제목" />
<PropertyColumn Property="x => x.IsPublished" Title="발행">
<CellTemplate Context="cell">
<MudCheckBox @bind-Checked="@cell.Item.IsPublished" />
<MudCheckBox T="bool" Value="@cell.Item.IsPublished"
ValueChanged="@(async (bool value) => await TogglePublish(cell.Item, value))" />
</CellTemplate>
</PropertyColumn>
<PropertyColumn Property="x => x.ViewCount" Title="조회수" />
@@ -54,14 +55,37 @@
isLoading = false;
}
private async Task TogglePublish(int postId, bool isPublished)
private async Task TogglePublish(TaxBaik.Domain.Entities.BlogPost post, bool isPublished)
{
// Publish status update via API
var previous = post.IsPublished;
post.IsPublished = isPublished;
var result = await ApiClient.PutAsync<TaxBaik.Domain.Entities.BlogPost>($"blog/{post.Id}", new
{
post.Title,
post.Content,
post.CategoryId,
post.Tags,
post.SeoTitle,
post.SeoDescription,
post.ThumbnailUrl,
IsPublished = isPublished,
post.AuthorId
});
if (result == null)
{
post.IsPublished = previous;
Snackbar.Add("발행 상태 변경에 실패했습니다.", Severity.Error);
return;
}
Snackbar.Add("발행 상태가 변경되었습니다.", Severity.Success);
}
private async Task DeletePost(int postId)
{
await ApiClient.DeleteAsync($"blog/{postId}");
Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success);
await LoadPosts();
}
}
@@ -1,8 +1,7 @@
@page "/admin/dashboard"
@using TaxBaik.Application.Services
@using TaxBaik.Domain.Interfaces
@attribute [Authorize]
@inject IInquiryRepository InquiryRepository
@inject InquiryService InquiryService
@inject BlogService BlogService
<PageTitle>대시보드</PageTitle>
@@ -80,13 +79,14 @@
protected override async Task OnInitializedAsync()
{
var (inquiries, total) = await InquiryRepository.GetPagedAsync(1, 100);
var (inquiries, _) = await InquiryService.GetPagedAsync(1, 100);
recentInquiries = inquiries.OrderByDescending(x => x.CreatedAt).Take(5).ToList();
var now = DateTime.UtcNow;
thisMonthInquiries = inquiries.Count(x => x.CreatedAt.Year == now.Year && x.CreatedAt.Month == now.Month);
newInquiries = inquiries.Count(x => x.Status == "new");
totalPosts = 0; // TODO: get from blog service
publishedPosts = 0; // TODO: get from blog service
var stats = await BlogService.GetStatsAsync();
totalPosts = stats.TotalPosts;
publishedPosts = stats.PublishedPosts;
}
}
@@ -1,8 +1,9 @@
@page "/admin/inquiries/{InquiryId:int}"
@using TaxBaik.Domain.Interfaces
@using TaxBaik.Application.Services
@attribute [Authorize]
@inject IInquiryRepository InquiryRepository
@inject InquiryService InquiryService
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
<PageTitle>문의 상세</PageTitle>
@@ -36,7 +37,7 @@
</MudItem>
<MudItem xs="12">
<MudText Typo="Typo.subtitle1">상태</MudText>
<MudSelect @bind-Value="inquiry.Status" Label="상태 변경">
<MudSelect T="string" Value="inquiry.Status" ValueChanged="@((string status) => OnStatusChanged(status))" Label="상태 변경">
<MudSelectItem Value="@("new")">신규</MudSelectItem>
<MudSelectItem Value="@("contacted")">연락함</MudSelectItem>
<MudSelectItem Value="@("completed")">완료</MudSelectItem>
@@ -54,11 +55,27 @@ else
[Parameter]
public int InquiryId { get; set; }
private Domain.Entities.Inquiry inquiry;
private Domain.Entities.Inquiry? inquiry;
protected override async Task OnInitializedAsync()
{
var (inquiries, _) = await InquiryRepository.GetPagedAsync(1, 1000);
inquiry = inquiries.FirstOrDefault(x => x.Id == InquiryId);
inquiry = await InquiryService.GetByIdAsync(InquiryId);
}
private async Task OnStatusChanged(string status)
{
if (inquiry == null)
return;
try
{
await InquiryService.UpdateStatusAsync(inquiry.Id, status);
inquiry.Status = status;
Snackbar.Add("상태가 변경되었습니다.", Severity.Success);
}
catch (ValidationException ex)
{
Snackbar.Add(ex.Message, Severity.Error);
}
}
}
+63 -2
View File
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Controllers;
@@ -18,14 +19,61 @@ public class AuthController : ControllerBase
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
return BadRequest(new { message = "Username and password are required" });
return BadRequest(new ProblemDetails { Title = "로그인 정보가 필요합니다.", Status = StatusCodes.Status400BadRequest });
var token = await _authService.AuthenticateAndGenerateTokenAsync(request.Username, request.Password);
if (token == null)
return Unauthorized(new { message = "Invalid username or password" });
return Unauthorized(new ProblemDetails { Title = "아이디 또는 비밀번호가 올바르지 않습니다.", Status = StatusCodes.Status401Unauthorized });
return Ok(new { token, expiresIn = 28800 });
}
[HttpPost("change-password")]
[Microsoft.AspNetCore.Authorization.Authorize]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
{
var username = User.FindFirstValue(ClaimTypes.Name);
if (string.IsNullOrWhiteSpace(username))
return Unauthorized(new ProblemDetails { Title = "인증 정보가 올바르지 않습니다.", Status = StatusCodes.Status401Unauthorized });
try
{
var changed = await _authService.ChangePasswordAsync(username, request.CurrentPassword, request.NewPassword);
if (!changed)
return Unauthorized(new ProblemDetails { Title = "현재 비밀번호가 올바르지 않습니다.", Status = StatusCodes.Status401Unauthorized });
return Ok(new { message = "비밀번호가 변경되었습니다." });
}
catch (ArgumentException ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
}
[HttpPost("reset-password")]
public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest request)
{
try
{
var reset = await _authService.ResetPasswordAsync(request.Username, request.NewPassword, request.ResetToken);
if (!reset)
return Unauthorized(new ProblemDetails { Title = "재설정 토큰 또는 사용자 정보가 올바르지 않습니다.", Status = StatusCodes.Status401Unauthorized });
return Ok(new { message = "비밀번호가 재설정되었습니다." });
}
catch (InvalidOperationException)
{
return StatusCode(StatusCodes.Status503ServiceUnavailable, new ProblemDetails
{
Title = "비밀번호 재설정 토큰이 서버에 설정되어 있지 않습니다.",
Status = StatusCodes.Status503ServiceUnavailable
});
}
catch (ArgumentException ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
}
}
public class LoginRequest
@@ -33,3 +81,16 @@ public class LoginRequest
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
public class ChangePasswordRequest
{
public string CurrentPassword { get; set; } = string.Empty;
public string NewPassword { get; set; } = string.Empty;
}
public class ResetPasswordRequest
{
public string Username { get; set; } = string.Empty;
public string NewPassword { get; set; } = string.Empty;
public string ResetToken { get; set; } = string.Empty;
}
+21 -10
View File
@@ -28,7 +28,7 @@ public class BlogController : ControllerBase
{
var post = await _blogService.GetBySlugAsync(slug);
if (post == null)
return NotFound(new { message = "Post not found" });
return NotFound(new ProblemDetails { Title = "포스트를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
return Ok(post);
}
@@ -44,21 +44,32 @@ public class BlogController : ControllerBase
[Authorize]
public async Task<IActionResult> Create([FromBody] CreateBlogPostDto dto)
{
if (string.IsNullOrWhiteSpace(dto.Title) || string.IsNullOrWhiteSpace(dto.Content))
return BadRequest(new { message = "Title and content are required" });
var result = await _blogService.CreateAsync(dto);
return CreatedAtAction(nameof(GetBySlug), new { slug = result.Slug }, result);
try
{
var result = await _blogService.CreateAsync(dto);
return CreatedAtAction(nameof(GetBySlug), new { slug = result.Slug }, result);
}
catch (ValidationException ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
}
[HttpPut("{id}")]
[Authorize]
public async Task<IActionResult> Update(int id, [FromBody] CreateBlogPostDto dto)
{
var result = await _blogService.UpdateAsync(id, dto);
if (result == null)
return NotFound(new { message = "Post not found" });
return Ok(result);
try
{
var result = await _blogService.UpdateAsync(id, dto);
if (result == null)
return NotFound(new ProblemDetails { Title = "포스트를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
return Ok(result);
}
catch (ValidationException ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
}
[HttpDelete("{id}")]
+31 -14
View File
@@ -1,7 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Interfaces;
namespace TaxBaik.Web.Controllers;
@@ -10,29 +9,40 @@ namespace TaxBaik.Web.Controllers;
public class InquiryController : ControllerBase
{
private readonly InquiryService _inquiryService;
private readonly IInquiryRepository _inquiryRepository;
public InquiryController(InquiryService inquiryService, IInquiryRepository inquiryRepository)
public InquiryController(InquiryService inquiryService)
{
_inquiryService = inquiryService;
_inquiryRepository = inquiryRepository;
}
[HttpPost]
public async Task<IActionResult> Submit([FromBody] SubmitInquiryRequest request)
{
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Phone))
return BadRequest(new { message = "Name and phone are required" });
return BadRequest(new ProblemDetails { Title = "이름과 전화번호를 입력하세요.", Status = StatusCodes.Status400BadRequest });
await _inquiryService.SubmitAsync(request.Name, request.Phone, request.ServiceType, request.Message);
return Ok(new { message = "Inquiry submitted successfully" });
try
{
await _inquiryService.SubmitAsync(
request.Name,
request.Phone,
request.ServiceType,
request.Message,
request.Email,
HttpContext.Connection.RemoteIpAddress?.ToString());
return Ok(new { message = "상담 신청이 접수되었습니다." });
}
catch (ValidationException ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
}
[HttpGet]
[Authorize]
public async Task<IActionResult> GetPaged([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
var (inquiries, total) = await _inquiryRepository.GetPagedAsync(page, pageSize);
var (inquiries, total) = await _inquiryService.GetPagedAsync(page, pageSize);
return Ok(new { data = inquiries, total, page, pageSize });
}
@@ -40,9 +50,9 @@ public class InquiryController : ControllerBase
[Authorize]
public async Task<IActionResult> GetById(int id)
{
var inquiry = await _inquiryRepository.GetByIdAsync(id);
var inquiry = await _inquiryService.GetByIdAsync(id);
if (inquiry == null)
return NotFound(new { message = "Inquiry not found" });
return NotFound(new ProblemDetails { Title = "문의를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
return Ok(inquiry);
}
@@ -50,12 +60,19 @@ public class InquiryController : ControllerBase
[Authorize]
public async Task<IActionResult> UpdateStatus(int id, [FromBody] UpdateStatusRequest request)
{
var inquiry = await _inquiryRepository.GetByIdAsync(id);
var inquiry = await _inquiryService.GetByIdAsync(id);
if (inquiry == null)
return NotFound(new { message = "Inquiry not found" });
return NotFound(new ProblemDetails { Title = "문의를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
await _inquiryRepository.UpdateStatusAsync(id, request.Status);
return Ok(new { message = "Status updated" });
try
{
await _inquiryService.UpdateStatusAsync(id, request.Status);
return Ok(new { message = "상태가 변경되었습니다." });
}
catch (ValidationException ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
}
}
+10 -12
View File
@@ -1,12 +1,13 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Blog;
public class BlogIndexModel : PageModel
{
private readonly IApiClient _apiClient;
private readonly BlogService _blogService;
private readonly CategoryService _categoryService;
public List<BlogPost> Posts { get; set; } = [];
public List<Category> Categories { get; set; } = [];
@@ -15,9 +16,10 @@ public class BlogIndexModel : PageModel
public int? SelectedCategoryId { get; set; }
private const int PageSize = 12;
public BlogIndexModel(IApiClient apiClient)
public BlogIndexModel(BlogService blogService, CategoryService categoryService)
{
_apiClient = apiClient;
_blogService = blogService;
_categoryService = categoryService;
}
public async Task OnGetAsync(int page = 1, int? categoryId = null)
@@ -27,15 +29,11 @@ public class BlogIndexModel : PageModel
CurrentPage = page;
SelectedCategoryId = categoryId;
var categories = await _apiClient.GetAsync<List<Category>>("category");
Categories = categories ?? [];
Categories = (await _categoryService.GetAllAsync()).ToList();
var blogsResponse = await _apiClient.GetAsync<BlogApiResponse>($"blog?page={page}&pageSize={PageSize}");
if (blogsResponse != null)
{
Posts = blogsResponse.Data ?? [];
TotalPages = (blogsResponse.Total + PageSize - 1) / PageSize;
}
var (posts, total) = await _blogService.GetPublishedPagedAsync(page, PageSize, categoryId);
Posts = posts.ToList();
TotalPages = (total + PageSize - 1) / PageSize;
}
catch
{
+2
View File
@@ -16,6 +16,8 @@
}
<form method="post">
<div asp-validation-summary="ModelOnly" class="text-danger mb-3"></div>
<div class="mb-3">
<label for="name" class="form-label">이름 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" name="Name" required />
+13 -11
View File
@@ -1,12 +1,12 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Web.Services;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Pages;
public class ContactModel : PageModel
{
private readonly IApiClient _apiClient;
private readonly InquiryService _inquiryService;
[BindProperty]
public string Name { get; set; } = "";
@@ -26,9 +26,9 @@ public class ContactModel : PageModel
[BindProperty]
public bool Agree { get; set; }
public ContactModel(IApiClient apiClient)
public ContactModel(InquiryService inquiryService)
{
_apiClient = apiClient;
_inquiryService = inquiryService;
}
public async Task<IActionResult> OnPostAsync()
@@ -38,19 +38,21 @@ public class ContactModel : PageModel
try
{
var inquiry = new
{
await _inquiryService.SubmitAsync(
Name,
Phone,
Email,
ServiceType,
Message
};
await _apiClient.PostAsync<object>("inquiry", inquiry);
Message,
Email,
HttpContext.Connection.RemoteIpAddress?.ToString());
TempData["Success"] = "상담 신청이 접수되었습니다. 빠른 시간 내에 연락드리겠습니다.";
return RedirectToPage();
}
catch (ValidationException ex)
{
ModelState.AddModelError("", ex.Message);
return Page();
}
catch
{
ModelState.AddModelError("", "시스템 오류가 발생했습니다. 잠시 후 다시 시도해주세요.");
+6 -15
View File
@@ -1,27 +1,26 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages;
public class IndexModel : PageModel
{
private readonly IApiClient _apiClient;
private readonly BlogService _blogService;
public List<BlogPost> RecentPosts { get; set; } = [];
public IndexModel(IApiClient apiClient)
public IndexModel(BlogService blogService)
{
_apiClient = apiClient;
_blogService = blogService;
}
public async Task OnGetAsync()
{
try
{
var response = await _apiClient.GetAsync<BlogApiResponse>("blog?page=1&pageSize=3");
if (response?.Data != null)
RecentPosts = response.Data.ToList();
var (posts, _) = await _blogService.GetPublishedPagedAsync(1, 3);
RecentPosts = posts.ToList();
}
catch
{
@@ -29,11 +28,3 @@ public class IndexModel : PageModel
}
}
}
public class BlogApiResponse
{
public List<BlogPost> Data { get; set; } = [];
public int Total { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
}
+26 -6
View File
@@ -4,6 +4,7 @@ using System.Text.Encodings.Web;
using System.Text.Unicode;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.IdentityModel.Tokens;
using MudBlazor.Services;
@@ -12,16 +13,23 @@ using TaxBaik.Infrastructure;
using TaxBaik.Web.Services;
var builder = WebApplication.CreateBuilder(args);
var isProduction = builder.Environment.IsProduction();
// Controllers (API)
builder.Services.AddControllers();
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
// Razor Pages + Blazor Server 통합
builder.Services.AddRazorPages();
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
// JWT 인증
var connectionString = builder.Configuration.GetConnectionString("Default")
?? throw new InvalidOperationException("Missing connection string");
var jwtKey = builder.Configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("Missing JWT SecretKey");
if (isProduction && jwtKey.Contains("dev-secret", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("Production JWT SecretKey must not use the development default.");
var key = Encoding.ASCII.GetBytes(jwtKey);
builder.Services.AddAuthentication(opts =>
@@ -35,8 +43,12 @@ builder.Services.AddAuthentication(opts =>
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(key),
ValidateIssuer = false,
ValidateAudience = false
ValidateIssuer = true,
ValidIssuer = "taxbaik-admin",
ValidateAudience = true,
ValidAudience = "taxbaik-admin-client",
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
};
});
@@ -46,6 +58,7 @@ builder.Services.AddScoped<CustomAuthenticationStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<CustomAuthenticationStateProvider>());
builder.Services.AddScoped<ILocalStorageService, LocalStorageService>();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthorization();
builder.Services.AddAuthorizationCore();
// HTTP Client for API
@@ -82,21 +95,27 @@ builder.Services.AddSingleton(versionInfo);
var app = builder.Build();
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
// Run migrations on startup (non-blocking for development)
try
{
using (var scope = app.Services.CreateScope())
{
var connectionFactory = scope.ServiceProvider.GetRequiredService<TaxBaik.Domain.Interfaces.IDbConnectionFactory>();
var cs = builder.Configuration.GetConnectionString("Default")
?? throw new InvalidOperationException("Missing connection string");
var migrationRunner = new TaxBaik.Infrastructure.Data.MigrationRunner(cs, connectionFactory);
var migrationRunner = new TaxBaik.Infrastructure.Data.MigrationRunner(connectionString, connectionFactory);
await migrationRunner.RunAsync();
}
}
catch (Exception ex)
{
Console.WriteLine($"⚠️ Migration warning (non-blocking): {ex.Message}");
if (!app.Environment.IsDevelopment())
throw;
Console.WriteLine($"Migration warning (development only): {ex.Message}");
}
app.UsePathBase("/taxbaik");
@@ -115,6 +134,7 @@ if (!app.Environment.IsDevelopment())
// API + Razor Pages + Blazor 매핑
app.MapControllers();
app.MapHealthChecks("/healthz");
app.MapRazorPages();
app.MapRazorComponents<TaxBaik.Web.Components.Admin.App>().AddInteractiveServerRenderMode();
+14 -5
View File
@@ -1,5 +1,6 @@
namespace TaxBaik.Web.Services;
using Microsoft.AspNetCore.Components;
using System.Text.Json;
public interface IApiClient
@@ -14,11 +15,13 @@ public interface IApiClient
public class ApiClient : IApiClient
{
private readonly HttpClient _httpClient;
private readonly NavigationManager _navigationManager;
private string? _authToken;
public ApiClient(HttpClient httpClient)
public ApiClient(HttpClient httpClient, NavigationManager navigationManager)
{
_httpClient = httpClient;
_navigationManager = navigationManager;
}
public async Task SetAuthToken(string? token)
@@ -34,7 +37,7 @@ public class ApiClient : IApiClient
{
try
{
var response = await _httpClient.GetAsync($"/taxbaik/api/{endpoint}");
var response = await _httpClient.GetAsync(BuildApiUri(endpoint));
if (!response.IsSuccessStatusCode)
return default;
@@ -53,7 +56,7 @@ public class ApiClient : IApiClient
{
var json = JsonSerializer.Serialize(data);
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync($"/taxbaik/api/{endpoint}", content);
var response = await _httpClient.PostAsync(BuildApiUri(endpoint), content);
if (!response.IsSuccessStatusCode)
return default;
@@ -73,7 +76,7 @@ public class ApiClient : IApiClient
{
var json = JsonSerializer.Serialize(data);
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var response = await _httpClient.PutAsync($"/taxbaik/api/{endpoint}", content);
var response = await _httpClient.PutAsync(BuildApiUri(endpoint), content);
if (!response.IsSuccessStatusCode)
return default;
@@ -91,11 +94,17 @@ public class ApiClient : IApiClient
{
try
{
await _httpClient.DeleteAsync($"/taxbaik/api/{endpoint}");
await _httpClient.DeleteAsync(BuildApiUri(endpoint));
}
catch
{
// Ignore
}
}
private Uri BuildApiUri(string endpoint)
{
var relative = $"api/{endpoint.TrimStart('/')}";
return new Uri(new Uri(_navigationManager.BaseUri), relative);
}
}
+50
View File
@@ -13,6 +13,7 @@ public class AuthService
private readonly IAdminUserRepository _adminUserRepository;
private readonly ILogger<AuthService> _logger;
private readonly string _jwtSecretKey;
private readonly string? _passwordResetToken;
private readonly int _tokenExpirationMinutes = 480; // 8시간
public AuthService(IAdminUserRepository adminUserRepository, ILogger<AuthService> logger, IConfiguration configuration)
@@ -20,6 +21,7 @@ public class AuthService
_adminUserRepository = adminUserRepository;
_logger = logger;
_jwtSecretKey = configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("Missing 'Jwt:SecretKey' configuration.");
_passwordResetToken = configuration["Admin:PasswordResetToken"];
}
public async Task<string?> AuthenticateAndGenerateTokenAsync(string username, string password)
@@ -49,9 +51,47 @@ public class AuthService
}
_logger.LogInformation("로그인 성공: {Username}", username);
await _adminUserRepository.UpdateLastLoginAtAsync(user.Id);
return GenerateJwtToken(user);
}
public async Task<bool> ChangePasswordAsync(string username, string currentPassword, string newPassword)
{
if (!IsValidPassword(newPassword))
throw new ArgumentException("새 비밀번호는 12자 이상이어야 합니다.", nameof(newPassword));
var user = await _adminUserRepository.GetByUsernameAsync(username);
if (user == null || string.IsNullOrWhiteSpace(user.PasswordHash))
return false;
if (!BCrypt.Net.BCrypt.Verify(currentPassword, user.PasswordHash))
return false;
await _adminUserRepository.UpdatePasswordHashAsync(user.Id, BCrypt.Net.BCrypt.HashPassword(newPassword));
_logger.LogInformation("관리자 비밀번호 변경: {Username}", username);
return true;
}
public async Task<bool> ResetPasswordAsync(string username, string newPassword, string resetToken)
{
if (string.IsNullOrWhiteSpace(_passwordResetToken))
throw new InvalidOperationException("Admin:PasswordResetToken is not configured.");
if (!TimeConstantEquals(resetToken, _passwordResetToken))
return false;
if (!IsValidPassword(newPassword))
throw new ArgumentException("새 비밀번호는 12자 이상이어야 합니다.", nameof(newPassword));
var user = await _adminUserRepository.GetByUsernameAsync(username);
if (user == null)
return false;
await _adminUserRepository.UpdatePasswordHashAsync(user.Id, BCrypt.Net.BCrypt.HashPassword(newPassword));
_logger.LogWarning("관리자 비밀번호 재설정 API 사용: {Username}", username);
return true;
}
private string GenerateJwtToken(AdminUser user)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSecretKey));
@@ -99,4 +139,14 @@ public class AuthService
return null;
}
}
private static bool IsValidPassword(string password) => !string.IsNullOrWhiteSpace(password) && password.Length >= 12;
private static bool TimeConstantEquals(string value, string expected)
{
var valueBytes = Encoding.UTF8.GetBytes(value);
var expectedBytes = Encoding.UTF8.GetBytes(expected);
return valueBytes.Length == expectedBytes.Length
&& System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(valueBytes, expectedBytes);
}
}
+4 -4
View File
@@ -12,11 +12,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MudBlazor" Version="6.9.4" />
<PackageReference Include="MudBlazor" Version="6.10.0" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.2.1" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.2.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.19.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
</ItemGroup>
</Project>