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.ResponseCompression; using Microsoft.IdentityModel.Tokens; using MudBlazor.Services; using Serilog; 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(); // Razor Pages + Blazor Server 통합 builder.Services.AddRazorPages(); builder.Services.AddRazorComponents() .AddInteractiveServerComponents() .AddInteractiveWebAssemblyComponents(); builder.Services.Configure(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(); builder.Services.AddScoped(); builder.Services.AddScoped(sp => sp.GetRequiredService()); builder.Services.AddScoped(); builder.Services.AddCascadingAuthenticationState(); builder.Services.AddAuthorization(); builder.Services.AddAuthorizationCore(); // Telegram Notification builder.Services.AddHttpClient(); // HTTP Client for API (with automatic token refresh) builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHttpClient(); var apiBaseUrl = builder.Configuration["ApiClient:BaseUrl"] ?? throw new InvalidOperationException("Missing configuration: ApiClient:BaseUrl"); builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri(apiBaseUrl); }).AddHttpMessageHandler(); builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri(apiBaseUrl); }).AddHttpMessageHandler(); builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri(apiBaseUrl); }).AddHttpMessageHandler(); builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri(apiBaseUrl); }).AddHttpMessageHandler(); builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri(apiBaseUrl); }).AddHttpMessageHandler(); builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri(apiBaseUrl); }).AddHttpMessageHandler(); // Phase 5: Tax Accounting & CRM Browser Clients builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri(apiBaseUrl); }).AddHttpMessageHandler(); builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri(apiBaseUrl); }).AddHttpMessageHandler(); builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri(apiBaseUrl); }).AddHttpMessageHandler(); builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri(apiBaseUrl); }).AddHttpMessageHandler(); builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri(apiBaseUrl); }).AddHttpMessageHandler(); builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri(apiBaseUrl); }).AddHttpMessageHandler(); // 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(); }); builder.Services.AddHostedService(); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.Configure(builder.Configuration.GetSection("Authentication")); // 한글 포함 다국어 문자를 유니코드 엔티티로 변환하지 않도록 설정 builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All)); builder.Services.AddInfrastructure(); builder.Services.AddApplication(); builder.Services.AddScoped(); // 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(); 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() .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 { using (var scope = app.Services.CreateScope()) { var telegramService = scope.ServiceProvider.GetRequiredService(); await telegramService.SendErrorAsync( "❌ 서버 오류", $"환경: {app.Environment.EnvironmentName}\n오류: {ex.Message}"); } } catch (Exception telegramEx) { Log.Error(telegramEx, "오류 알림 전송 실패"); } } throw; } finally { Log.Information("애플리케이션 종료"); Log.CloseAndFlush(); }