394 lines
16 KiB
C#
394 lines
16 KiB
C#
using System.IO.Compression;
|
|
using System.Text;
|
|
using System.Text.Encodings.Web;
|
|
using System.Text.Unicode;
|
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
using Microsoft.AspNetCore.DataProtection;
|
|
using Microsoft.AspNetCore.Authentication.OAuth;
|
|
using Microsoft.AspNetCore.HttpOverrides;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
using Microsoft.AspNetCore.ResponseCompression;
|
|
using MudBlazor.Services;
|
|
using Serilog;
|
|
using FluentValidation;
|
|
using System.Threading.RateLimiting;
|
|
using TaxBaik.Application;
|
|
using TaxBaik.Application.Services;
|
|
using TaxBaik.Application.Seasonal;
|
|
using TaxBaik.Application.Utils;
|
|
using TaxBaik.Infrastructure;
|
|
using TaxBaik.Web.Services;
|
|
// Client (WASM) 서비스는 Client 프로젝트에서만 사용됨
|
|
|
|
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-web-.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 (context.HostingEnvironment.IsProduction()
|
|
&& !string.IsNullOrEmpty(botToken)
|
|
&& !string.IsNullOrEmpty(systemChatId))
|
|
{
|
|
config.WriteTo.Sink(new TaxBaik.Web.Logging.TelegramSink(botToken, systemChatId), Serilog.Events.LogEventLevel.Error);
|
|
}
|
|
});
|
|
|
|
builder.Services.AddAuthorization();
|
|
builder.Services.AddProblemDetails();
|
|
builder.Services.AddHealthChecks();
|
|
var dataProtectionKeyPath = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
|
".taxbaik",
|
|
"data-protection-keys");
|
|
Directory.CreateDirectory(dataProtectionKeyPath);
|
|
builder.Services.AddDataProtection()
|
|
.SetApplicationName("TaxBaik.Web")
|
|
.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeyPath));
|
|
builder.Services.AddRateLimiter(options =>
|
|
{
|
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
|
options.AddPolicy("client-logs", httpContext =>
|
|
{
|
|
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
|
var isDevelopment = builder.Environment.IsDevelopment();
|
|
return RateLimitPartition.GetFixedWindowLimiter(
|
|
partitionKey: $"client-logs:{ip}",
|
|
factory: _ => new FixedWindowRateLimiterOptions
|
|
{
|
|
PermitLimit = isDevelopment ? 200 : 10,
|
|
Window = TimeSpan.FromMinutes(1),
|
|
QueueLimit = 0,
|
|
AutoReplenishment = true
|
|
});
|
|
});
|
|
options.AddPolicy("admin-login", httpContext =>
|
|
{
|
|
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
|
var isDevelopment = builder.Environment.IsDevelopment();
|
|
return RateLimitPartition.GetFixedWindowLimiter(
|
|
partitionKey: $"admin-login:{ip}",
|
|
factory: _ => new FixedWindowRateLimiterOptions
|
|
{
|
|
PermitLimit = isDevelopment ? 100 : 5,
|
|
Window = TimeSpan.FromMinutes(1),
|
|
QueueLimit = 0,
|
|
AutoReplenishment = true
|
|
});
|
|
});
|
|
});
|
|
|
|
// Razor Pages
|
|
builder.Services.AddSingleton<IIpLockoutService, IpLockoutService>();
|
|
builder.Services.AddRazorPages();
|
|
|
|
// Session & TempData (쿠키 저장소)
|
|
builder.Services.AddSession(options =>
|
|
{
|
|
options.IdleTimeout = TimeSpan.FromMinutes(20);
|
|
options.Cookie.HttpOnly = true;
|
|
options.Cookie.IsEssential = true;
|
|
options.Cookie.Name = "TaxBaik.SessionId";
|
|
options.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax;
|
|
});
|
|
builder.Services.AddDistributedMemoryCache();
|
|
// TempData는 기본적으로 쿠키 저장소 사용 (위 세션 설정 상속)
|
|
|
|
// 인증: 관리자 쿠키 + 포털 쿠키
|
|
var connectionString = builder.Configuration.GetConnectionString("Default")
|
|
?? throw new InvalidOperationException("Missing connection string");
|
|
|
|
var authenticationBuilder = builder.Services.AddAuthentication(opts =>
|
|
{
|
|
opts.DefaultAuthenticateScheme = PortalAuthDefaults.Scheme;
|
|
opts.DefaultChallengeScheme = PortalAuthDefaults.Scheme;
|
|
})
|
|
.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 = "/portal/login";
|
|
opts.AccessDeniedPath = "/portal/login";
|
|
opts.SlidingExpiration = true;
|
|
opts.ExpireTimeSpan = TimeSpan.FromDays(7);
|
|
})
|
|
.AddCookie(AdminAuthDefaults.Scheme, opts =>
|
|
{
|
|
opts.Cookie.Name = AdminAuthDefaults.CookieName;
|
|
opts.Cookie.HttpOnly = true;
|
|
opts.Cookie.SameSite = SameSiteMode.Lax;
|
|
opts.Cookie.SecurePolicy = isProduction ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
|
|
opts.LoginPath = "/admin/login";
|
|
opts.AccessDeniedPath = "/admin/login";
|
|
opts.SlidingExpiration = true;
|
|
opts.ExpireTimeSpan = TimeSpan.FromHours(12);
|
|
})
|
|
.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;
|
|
});
|
|
|
|
builder.Services.AddAuthorization();
|
|
|
|
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 = "/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 = "/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 = "/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() ?? ""));
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
// Telegram Notification
|
|
builder.Services.AddHttpClient<ITelegramNotificationService, TelegramNotificationService>();
|
|
|
|
// 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.AddScoped<AuthService>();
|
|
|
|
builder.Services.Configure<PortalAuthOptions>(builder.Configuration.GetSection("Authentication"));
|
|
|
|
// 한글 포함 다국어 문자를 유니코드 엔티티로 변환하지 않도록 설정
|
|
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
|
|
|
|
builder.Services.AddInfrastructure();
|
|
builder.Services.AddApplication();
|
|
builder.Services.AddValidatorsFromAssemblyContaining<TaxBaik.Application.DTOs.CreateBlogPostDtoValidator>();
|
|
builder.Services.AddScoped<IInquiryNotificationService, TelegramInquiryNotificationService>();
|
|
builder.Services.AddScoped<TaxBaik.Web.Services.SitemapValidationService>();
|
|
|
|
// Register version info
|
|
var versionInfo = new VersionInfo();
|
|
var assembly = typeof(Program).Assembly;
|
|
var assemblyVersion = assembly.GetName().Version?.ToString() ?? "unknown";
|
|
var informationalVersion = assembly.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false)
|
|
.OfType<System.Reflection.AssemblyInformationalVersionAttribute>()
|
|
.FirstOrDefault()?.InformationalVersion;
|
|
|
|
versionInfo.Version = informationalVersion ?? assemblyVersion;
|
|
versionInfo.Built = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd HH:mm:ss 'UTC'");
|
|
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("/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}");
|
|
}
|
|
|
|
// PathBase는 사용하지 않음
|
|
|
|
app.UseResponseCompression();
|
|
|
|
// 정적 파일 제공
|
|
app.UseStaticFiles();
|
|
app.UseStaticFiles("/portal");
|
|
|
|
app.UseSession(); // TempData 쿠키 저장소
|
|
app.UseRouting();
|
|
|
|
if (!app.Environment.IsDevelopment())
|
|
{
|
|
app.UseExceptionHandler("/Error");
|
|
app.UseHsts();
|
|
}
|
|
|
|
app.UseRateLimiter();
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
app.UseAntiforgery();
|
|
app.Use(async (context, next) =>
|
|
{
|
|
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
|
|
context.Response.Headers["X-Frame-Options"] = "DENY";
|
|
context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
|
|
context.Response.Headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()";
|
|
context.Response.Headers["X-Permitted-Cross-Domain-Policies"] = "none";
|
|
if (!context.Response.Headers.ContainsKey("Content-Security-Policy"))
|
|
{
|
|
context.Response.Headers["Content-Security-Policy"] = "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net data:; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://www.googletagmanager.com https://static.cloudflareinsights.com; connect-src 'self' https: https://cloudflareinsights.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self';";
|
|
}
|
|
|
|
await next();
|
|
});
|
|
|
|
// Razor Pages + 정적 자산 매핑
|
|
app.MapHealthChecks("/healthz");
|
|
app.MapRazorPages(); // Sitemap.cshtml, Rss.cshtml, Feed.cshtml
|
|
app.MapStaticAssets();
|
|
|
|
// 애플리케이션 시작/종료 로깅
|
|
try
|
|
{
|
|
Log.Information("애플리케이션 시작: {Environment}", app.Environment.EnvironmentName);
|
|
await app.RunAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Fatal(ex, "애플리케이션 강종");
|
|
// NOTE: RunAsync() 후 app.Services는 dispose됨.
|
|
// Telegram 알림은 별도 모니터링 백그라운드 작업으로 처리
|
|
throw;
|
|
}
|
|
finally
|
|
{
|
|
Log.Information("애플리케이션 종료");
|
|
Log.CloseAndFlush();
|
|
}
|
|
|