58edbd9c8f
TaxBaik CI/CD / build-and-deploy (push) Successful in 48s
**Implementation:**
- AuthService: Split token generation
* AccessToken: 15 minutes
* RefreshToken: 7 days (10080 minutes)
* New: GenerateTokenPair() method
* New: RefreshAccessTokenAsync() method
- AuthTokenPair: New record (accessToken, refreshToken, expiresIn)
- AuthController: New /api/auth/refresh endpoint
* POST /api/auth/refresh?refreshToken=...
* Response: { accessToken, refreshToken, expiresIn }
* RefreshTokenRequest DTO
- TokenRefreshHandler: New DelegatingHandler
* Automatic Bearer token injection
* 401 response handling
* Auto-refresh with retry
* localStorage sync (accessToken, refreshToken, tokenExpiry)
- CustomAuthenticationStateProvider: Token storage split
* Before: auth_token (single)
* After: accessToken, refreshToken, tokenExpiry
* LoginAsync signature updated
- Login.razor: Handle token pair
* LoginResponse: { accessToken, refreshToken, expiresIn }
* Call new LoginAsync(accessToken, refreshToken, expiresIn)
- Program.cs: TokenRefreshHandler registration
* AddScoped<TokenRefreshHandler>()
* AdminDashboardClient pipeline: .AddHttpMessageHandler<TokenRefreshHandler>()
**SOLID Principles:**
✓ S (Single Responsibility): TokenRefreshHandler handles only token refresh
✓ D (Dependency Inversion): DelegatingHandler abstracts HTTP concerns
✓ O (Open/Closed): Token lifetime extension without code changes
**Security Pattern:**
- Short-lived access tokens (15min) reduce theft window
- Refresh tokens (7d) enable persistence without storing secrets
- Automatic refresh is transparent to components
**Flow:**
Blazor → AdminDashboardClient → TokenRefreshHandler (auto-add Bearer)
→ 401 → RefreshTokenAsync() → POST /api/auth/refresh
→ Store new pair → Retry original request
Status: Token lifecycle complete, ready for SignalR integration (Phase 6)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
160 lines
5.5 KiB
C#
160 lines
5.5 KiB
C#
using System.IO.Compression;
|
|
using System.Text;
|
|
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;
|
|
using TaxBaik.Application;
|
|
using TaxBaik.Application.Services;
|
|
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();
|
|
builder.Services.Configure<Microsoft.AspNetCore.Components.Server.CircuitOptions>(options =>
|
|
{
|
|
options.DetailedErrors = true;
|
|
});
|
|
|
|
// 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 =>
|
|
{
|
|
opts.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
|
opts.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
|
})
|
|
.AddJwtBearer(opts =>
|
|
{
|
|
opts.TokenValidationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuerSigningKey = true,
|
|
IssuerSigningKey = new SymmetricSecurityKey(key),
|
|
ValidateIssuer = true,
|
|
ValidIssuer = "taxbaik-admin",
|
|
ValidateAudience = true,
|
|
ValidAudience = "taxbaik-admin-client",
|
|
ValidateLifetime = true,
|
|
ClockSkew = TimeSpan.FromMinutes(1)
|
|
};
|
|
});
|
|
|
|
// Blazor 인증
|
|
builder.Services.AddScoped<AuthService>();
|
|
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 (with automatic token refresh)
|
|
builder.Services.AddScoped<TokenRefreshHandler>();
|
|
builder.Services.AddHttpClient<IApiClient, ApiClient>();
|
|
builder.Services.AddHttpClient<IAdminDashboardClient, AdminDashboardClient>()
|
|
.AddHttpMessageHandler<TokenRefreshHandler>();
|
|
|
|
// UI & 캐시
|
|
builder.Services.AddMudServices();
|
|
builder.Services.AddMemoryCache();
|
|
builder.Services.AddResponseCompression(opts => {
|
|
opts.Providers.Add<GzipCompressionProvider>();
|
|
});
|
|
builder.Services.AddScoped<IInquiryNotificationService, TelegramInquiryNotificationService>();
|
|
|
|
// 한글 포함 다국어 문자를 유니코드 엔티티로 변환하지 않도록 설정
|
|
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
|
|
|
|
builder.Services.AddInfrastructure();
|
|
builder.Services.AddApplication();
|
|
|
|
// Register version info
|
|
var versionInfo = new VersionInfo();
|
|
var versionJsonPath = Path.Combine(AppContext.BaseDirectory, "wwwroot", "version.json");
|
|
if (File.Exists(versionJsonPath))
|
|
{
|
|
try
|
|
{
|
|
var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(versionJsonPath));
|
|
var root = json.RootElement;
|
|
if (root.TryGetProperty("version", out var versionProp))
|
|
versionInfo.Version = versionProp.GetString() ?? "unknown";
|
|
if (root.TryGetProperty("built", out var builtProp))
|
|
versionInfo.Built = builtProp.GetString() ?? "unknown";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"Warning: Failed to parse version.json: {ex.Message}");
|
|
}
|
|
}
|
|
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 migrationRunner = new TaxBaik.Infrastructure.Data.MigrationRunner(connectionString, connectionFactory);
|
|
await migrationRunner.RunAsync();
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (!app.Environment.IsDevelopment())
|
|
throw;
|
|
|
|
Console.WriteLine($"Migration warning (development only): {ex.Message}");
|
|
}
|
|
|
|
app.UsePathBase("/taxbaik");
|
|
app.UseResponseCompression();
|
|
app.UseStaticFiles();
|
|
app.UseRouting();
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
app.UseAntiforgery();
|
|
|
|
if (!app.Environment.IsDevelopment())
|
|
{
|
|
app.UseExceptionHandler("/Error");
|
|
app.UseHsts();
|
|
}
|
|
|
|
// API + Razor Pages + Blazor 매핑
|
|
app.MapControllers();
|
|
app.MapHealthChecks("/healthz");
|
|
app.MapRazorPages();
|
|
// AllowAnonymous: JWT 미들웨어가 Blazor 셸 요청을 401로 차단하지 않도록 한다.
|
|
// 인증은 Blazor AuthorizeRouteView → RedirectToLogin 에서 처리한다.
|
|
app.MapRazorComponents<TaxBaik.Web.Components.Admin.App>()
|
|
.AddInteractiveServerRenderMode()
|
|
.AllowAnonymous();
|
|
|
|
app.Run();
|