Files
taxbaik/TaxBaik.Web/Program.cs
T
kjh2064 f0b77b0e3f
TaxBaik CI/CD / build-and-deploy (push) Successful in 2m13s
fix: correct admin render mode to use WebAssembly with proper assembly reference
Revert to InteractiveWebAssemblyRenderMode for App.razor as required by validation script.
Add back AddInteractiveWebAssemblyComponents and AddInteractiveWebAssemblyRenderMode.
Fix assembly reference to use TaxBaik.WasmClient._Imports (RootNamespace of TaxBaik.Web.Client project).

This mixed render mode architecture allows:
- App.razor: WebAssembly shell for client-side routing
- Login.razor: Server-side prerender for authentication

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-02 23:48:28 +09:00

433 lines
18 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.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OAuth;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.IdentityModel.Tokens;
using MudBlazor.Services;
using Serilog;
using System.Threading.RateLimiting;
using TaxBaik.Application;
using TaxBaik.Application.Services;
using TaxBaik.Infrastructure;
using TaxBaik.Web.Services;
using TaxBaik.Web.Services.AdminClients;
var builder = WebApplication.CreateBuilder(args);
var isProduction = builder.Environment.IsProduction();
// HTTP 요청 헤더/쿠키 크기 제한 증가 (400 Bad Request 해결)
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 100 * 1024 * 1024; // 100MB
});
// Serilog 설정
builder.Host.UseSerilog((context, config) =>
{
config
.MinimumLevel.Information()
.WriteTo.Console()
.WriteTo.File(
path: "logs/taxbaik-.log",
rollingInterval: RollingInterval.Day,
outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {Message:lj}{NewLine}{Exception}")
.Enrich.FromLogContext()
.Enrich.WithProperty("Environment", context.HostingEnvironment.EnvironmentName);
var botToken = context.Configuration["Telegram:BotToken"];
var systemChatId = context.Configuration["Telegram:SystemChatId"] ?? context.Configuration["Telegram:ChatId"];
if (!string.IsNullOrEmpty(botToken) && !string.IsNullOrEmpty(systemChatId))
{
config.WriteTo.Sink(new TaxBaik.Web.Logging.TelegramSink(botToken, systemChatId), Serilog.Events.LogEventLevel.Error);
}
});
// Controllers (API)
builder.Services.AddControllers();
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("client-logs", httpContext =>
{
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: $"client-logs:{ip}",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
});
});
});
// Razor Pages + Blazor Server/WebAssembly 통합
builder.Services.AddRazorPages();
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents();
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);
var authenticationBuilder = 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)
};
})
.AddCookie(PortalAuthDefaults.Scheme, opts =>
{
opts.Cookie.Name = PortalAuthDefaults.CookieName;
opts.Cookie.HttpOnly = true;
opts.Cookie.SameSite = SameSiteMode.Lax;
opts.Cookie.SecurePolicy = isProduction ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
opts.LoginPath = "/taxbaik/portal/login";
opts.AccessDeniedPath = "/taxbaik/portal/login";
opts.SlidingExpiration = true;
opts.ExpireTimeSpan = TimeSpan.FromDays(7);
})
.AddCookie(PortalOAuthDefaults.ExternalScheme, opts =>
{
opts.Cookie.Name = "TaxBaik.Portal.External";
opts.Cookie.HttpOnly = true;
opts.Cookie.SameSite = SameSiteMode.Lax;
opts.Cookie.SecurePolicy = isProduction ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
});
var googleClientId = builder.Configuration["Authentication:Google:ClientId"];
var googleClientSecret = builder.Configuration["Authentication:Google:ClientSecret"];
if (!string.IsNullOrWhiteSpace(googleClientId) && !string.IsNullOrWhiteSpace(googleClientSecret))
{
authenticationBuilder.AddGoogle(PortalOAuthDefaults.GoogleScheme, opts =>
{
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
opts.ClientId = googleClientId;
opts.ClientSecret = googleClientSecret;
opts.CallbackPath = "/taxbaik/portal/signin-google";
});
}
var naverClientId = builder.Configuration["Authentication:Naver:ClientId"];
var naverClientSecret = builder.Configuration["Authentication:Naver:ClientSecret"];
if (!string.IsNullOrWhiteSpace(naverClientId) && !string.IsNullOrWhiteSpace(naverClientSecret))
{
authenticationBuilder.AddOAuth(PortalOAuthDefaults.NaverScheme, opts =>
{
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
opts.ClientId = naverClientId;
opts.ClientSecret = naverClientSecret;
opts.CallbackPath = "/taxbaik/portal/signin-naver";
opts.AuthorizationEndpoint = "https://nid.naver.com/oauth2.0/authorize";
opts.TokenEndpoint = "https://nid.naver.com/oauth2.0/token";
opts.UserInformationEndpoint = "https://openapi.naver.com/v1/nid/me";
opts.SaveTokens = true;
opts.Events = new OAuthEvents
{
OnCreatingTicket = async context =>
{
var request = new HttpRequestMessage(HttpMethod.Get, opts.UserInformationEndpoint);
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", context.AccessToken);
var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
response.EnsureSuccessStatusCode();
using var payload = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync(context.HttpContext.RequestAborted));
var responseRoot = payload.RootElement.GetProperty("response");
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, responseRoot.GetProperty("id").GetString() ?? ""));
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, responseRoot.GetProperty("name").GetString() ?? ""));
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Email, responseRoot.GetProperty("email").GetString() ?? ""));
}
};
});
}
var kakaoClientId = builder.Configuration["Authentication:Kakao:ClientId"];
var kakaoClientSecret = builder.Configuration["Authentication:Kakao:ClientSecret"];
if (!string.IsNullOrWhiteSpace(kakaoClientId) && !string.IsNullOrWhiteSpace(kakaoClientSecret))
{
authenticationBuilder.AddOAuth(PortalOAuthDefaults.KakaoScheme, opts =>
{
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
opts.ClientId = kakaoClientId;
opts.ClientSecret = kakaoClientSecret;
opts.CallbackPath = "/taxbaik/portal/signin-kakao";
opts.AuthorizationEndpoint = "https://kauth.kakao.com/oauth/authorize";
opts.TokenEndpoint = "https://kauth.kakao.com/oauth/token";
opts.UserInformationEndpoint = "https://kapi.kakao.com/v2/user/me";
opts.SaveTokens = true;
opts.Events = new OAuthEvents
{
OnCreatingTicket = async context =>
{
var request = new HttpRequestMessage(HttpMethod.Get, opts.UserInformationEndpoint);
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", context.AccessToken);
var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
response.EnsureSuccessStatusCode();
using var payload = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync(context.HttpContext.RequestAborted));
var kakaoAccount = payload.RootElement.GetProperty("kakao_account");
var profile = kakaoAccount.GetProperty("profile");
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, payload.RootElement.GetProperty("id").GetInt64().ToString()));
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, profile.GetProperty("nickname").GetString() ?? ""));
if (kakaoAccount.TryGetProperty("email", out var emailProp))
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Email, emailProp.GetString() ?? ""));
}
};
});
}
// 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();
// Telegram Notification
builder.Services.AddHttpClient<ITelegramNotificationService, TelegramNotificationService>();
// HTTP Client for API (with automatic token refresh)
builder.Services.AddScoped<ITokenStore, TokenStore>();
builder.Services.AddScoped<TokenRefreshHandler>();
builder.Services.AddHttpClient<IApiClient, ApiClient>();
var apiBaseUrl = builder.Configuration["ApiClient:BaseUrl"]
?? throw new InvalidOperationException("Missing configuration: ApiClient:BaseUrl");
builder.Services.AddHttpClient<IAdminDashboardClient, AdminDashboardClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IInquiryBrowserClient, InquiryBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IClientBrowserClient, ClientBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<ITaxFilingBrowserClient, TaxFilingBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IFaqBrowserClient, FaqBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IAnnouncementBrowserClient, AnnouncementBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>();
// Phase 5: Tax Accounting & CRM Browser Clients
builder.Services.AddHttpClient<ITaxProfileBrowserClient, TaxProfileBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<ITaxFilingScheduleBrowserClient, TaxFilingScheduleBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IConsultingActivityBrowserClient, ConsultingActivityBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IContractBrowserClient, ContractBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IRevenueTrackingBrowserClient, RevenueTrackingBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<ICommonCodeBrowserClient, CommonCodeBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>();
// UI & 캐시 (MudBlazor Theme Customization)
builder.Services.AddMudServices(config =>
{
config.SnackbarConfiguration.HideTransitionDuration = 400;
config.SnackbarConfiguration.ShowTransitionDuration = 300;
config.PopoverOptions.ThrowOnDuplicateProvider = false;
});
builder.Services.AddMemoryCache();
builder.Services.AddResponseCompression(opts => {
opts.Providers.Add<GzipCompressionProvider>();
});
builder.Services.AddHostedService<TelegramReportBackgroundService>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<PortalAuthService>();
builder.Services.Configure<PortalAuthOptions>(builder.Configuration.GetSection("Authentication"));
// 한글 포함 다국어 문자를 유니코드 엔티티로 변환하지 않도록 설정
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
builder.Services.AddInfrastructure();
builder.Services.AddApplication();
builder.Services.AddScoped<IInquiryNotificationService, TelegramInquiryNotificationService>();
// 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
});
app.Use(async (context, next) =>
{
var path = context.Request.Path.Value ?? string.Empty;
if (path.Equals("/favicon.ico", StringComparison.OrdinalIgnoreCase) ||
path.Equals("/taxbaik/favicon.ico", StringComparison.OrdinalIgnoreCase))
{
context.Response.ContentType = "image/svg+xml";
await context.Response.SendFileAsync(Path.Combine(app.Environment.WebRootPath ?? "wwwroot", "favicon.svg"));
return;
}
await next();
});
// 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.UseRateLimiter();
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();
app.MapStaticAssets();
// AllowAnonymous: JWT 미들웨어가 Blazor 셸 요청을 401로 차단하지 않도록 한다.
// 인증은 Blazor AuthorizeRouteView → RedirectToLogin 에서 처리한다.
app.MapRazorComponents<TaxBaik.Web.Components.Admin.App>()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(TaxBaik.WasmClient._Imports).Assembly)
.AllowAnonymous();
// 애플리케이션 시작/종료 로깅
try
{
Log.Information("애플리케이션 시작: {Environment}", app.Environment.EnvironmentName);
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "애플리케이션 강종");
if (!app.Environment.IsDevelopment())
{
try
{
var fatalMessage = $"환경: {app.Environment.EnvironmentName}\n오류: {ex.Message}";
if (TaxBaik.Web.Services.TelegramAlertGate.ShouldSend("telegram:fatal", fatalMessage, TimeSpan.FromMinutes(30)))
{
using var scope = app.Services.CreateScope();
var telegramService = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>();
await telegramService.SendErrorAsync(
"❌ 서버 오류",
fatalMessage);
}
}
catch (Exception telegramEx)
{
Log.Error(telegramEx, "오류 알림 전송 실패");
}
}
throw;
}
finally
{
Log.Information("애플리케이션 종료");
Log.CloseAndFlush();
}