396 lines
16 KiB
C#
396 lines
16 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.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<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);
|
|
});
|
|
builder.Services.AddHttpClient<IInquiryBrowserClient, InquiryBrowserClient>(client =>
|
|
{
|
|
client.BaseAddress = new Uri(apiBaseUrl);
|
|
});
|
|
builder.Services.AddHttpClient<IClientBrowserClient, ClientBrowserClient>(client =>
|
|
{
|
|
client.BaseAddress = new Uri(apiBaseUrl);
|
|
});
|
|
builder.Services.AddHttpClient<ITaxFilingBrowserClient, TaxFilingBrowserClient>(client =>
|
|
{
|
|
client.BaseAddress = new Uri(apiBaseUrl);
|
|
});
|
|
builder.Services.AddHttpClient<IFaqBrowserClient, FaqBrowserClient>(client =>
|
|
{
|
|
client.BaseAddress = new Uri(apiBaseUrl);
|
|
});
|
|
builder.Services.AddHttpClient<IAnnouncementBrowserClient, AnnouncementBrowserClient>(client =>
|
|
{
|
|
client.BaseAddress = new Uri(apiBaseUrl);
|
|
});
|
|
|
|
// Phase 5: Tax Accounting & CRM Browser Clients
|
|
builder.Services.AddHttpClient<ITaxProfileBrowserClient, TaxProfileBrowserClient>(client =>
|
|
{
|
|
client.BaseAddress = new Uri(apiBaseUrl);
|
|
});
|
|
|
|
builder.Services.AddHttpClient<ITaxFilingScheduleBrowserClient, TaxFilingScheduleBrowserClient>(client =>
|
|
{
|
|
client.BaseAddress = new Uri(apiBaseUrl);
|
|
});
|
|
|
|
builder.Services.AddHttpClient<IConsultingActivityBrowserClient, ConsultingActivityBrowserClient>(client =>
|
|
{
|
|
client.BaseAddress = new Uri(apiBaseUrl);
|
|
});
|
|
|
|
builder.Services.AddHttpClient<IContractBrowserClient, ContractBrowserClient>(client =>
|
|
{
|
|
client.BaseAddress = new Uri(apiBaseUrl);
|
|
});
|
|
|
|
builder.Services.AddHttpClient<IRevenueTrackingBrowserClient, RevenueTrackingBrowserClient>(client =>
|
|
{
|
|
client.BaseAddress = new Uri(apiBaseUrl);
|
|
});
|
|
|
|
builder.Services.AddHttpClient<ICommonCodeBrowserClient, CommonCodeBrowserClient>(client =>
|
|
{
|
|
client.BaseAddress = new Uri(apiBaseUrl);
|
|
});
|
|
|
|
// 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
|
|
});
|
|
|
|
// 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()
|
|
.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<ITelegramNotificationService>();
|
|
await telegramService.SendErrorAsync(
|
|
"❌ 서버 오류",
|
|
$"환경: {app.Environment.EnvironmentName}\n오류: {ex.Message}");
|
|
}
|
|
}
|
|
catch (Exception telegramEx)
|
|
{
|
|
Log.Error(telegramEx, "오류 알림 전송 실패");
|
|
}
|
|
}
|
|
throw;
|
|
}
|
|
finally
|
|
{
|
|
Log.Information("애플리케이션 종료");
|
|
Log.CloseAndFlush();
|
|
}
|