From ed21b0874e9fa3a1f0468498a688bf8364207cee Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Mon, 6 Jul 2026 16:40:56 +0900 Subject: [PATCH] WBS-11 BFF Hardening: Implement auth caching state provider for instant sidebar transitions, forward proxy authentication cookies, and resolve WASM server-side prerendering BaseAddress null exception --- .../CustomAuthenticationStateProvider.cs | 26 +- .../Client/Layout/MainLayout.razor | 2 +- .../Client/Layout/NavMenu.razor | 18 +- src/dotnet/QuantEngine.Web/Client/Program.cs | 1 + .../Client/Services/ApiClient.cs | 4 + .../Pages/Account/Login.cshtml.cs | 20 +- src/dotnet/QuantEngine.Web/Program.cs | 30 +- .../QuantEngine.Web/QuantEngine.Web.csproj | 1 + src/dotnet/QuantEngine.Web/wwwroot/login.html | 358 ------------------ .../QuantEngine.Web/wwwroot/version.json | 4 + 10 files changed, 80 insertions(+), 384 deletions(-) delete mode 100644 src/dotnet/QuantEngine.Web/wwwroot/login.html create mode 100644 src/dotnet/QuantEngine.Web/wwwroot/version.json diff --git a/src/dotnet/QuantEngine.Web/Client/Infrastructure/CustomAuthenticationStateProvider.cs b/src/dotnet/QuantEngine.Web/Client/Infrastructure/CustomAuthenticationStateProvider.cs index cf8e881..c11f468 100644 --- a/src/dotnet/QuantEngine.Web/Client/Infrastructure/CustomAuthenticationStateProvider.cs +++ b/src/dotnet/QuantEngine.Web/Client/Infrastructure/CustomAuthenticationStateProvider.cs @@ -16,6 +16,8 @@ namespace QuantEngine.Web.Client.Infrastructure private const string RoleKey = "quant_admin_role"; private const string RememberUsernameKey = "quant_admin_remember_username"; + private AuthenticationState? _cachedState; + public CustomAuthenticationStateProvider(LocalStorageService localStorage, HttpClient http, IJSRuntime jsRuntime) { _localStorage = localStorage; @@ -25,6 +27,12 @@ namespace QuantEngine.Web.Client.Infrastructure public override async Task GetAuthenticationStateAsync() { + if (_cachedState != null && _cachedState.User.Identity?.IsAuthenticated == true) + { + Console.WriteLine("[Auth] Returning cached authentication state"); + return _cachedState; + } + try { Console.WriteLine("[Auth] GetAuthenticationStateAsync called"); @@ -62,7 +70,9 @@ namespace QuantEngine.Web.Client.Infrastructure new Claim(ClaimTypes.Role, role ?? "Admin") }, "QuantAdminAuth"); - return new AuthenticationState(new ClaimsPrincipal(identity)); + var state = new AuthenticationState(new ClaimsPrincipal(identity)); + _cachedState = state; + return state; } else { @@ -106,7 +116,9 @@ namespace QuantEngine.Web.Client.Infrastructure new Claim(ClaimTypes.Role, role ?? "Admin") }, "QuantAdminAuth"); - return new AuthenticationState(new ClaimsPrincipal(identity)); + var state = new AuthenticationState(new ClaimsPrincipal(identity)); + _cachedState = state; + return state; } } } @@ -122,7 +134,8 @@ namespace QuantEngine.Web.Client.Infrastructure Console.WriteLine($"[Auth] Unexpected error: {ex.Message}"); } - return new AuthenticationState(_anonymous); + _cachedState = new AuthenticationState(_anonymous); + return _cachedState; } public async Task MarkUserAsAuthenticatedAsync(string username, string accessToken, string role) @@ -152,7 +165,9 @@ namespace QuantEngine.Web.Client.Infrastructure }, "QuantAdminAuth"); var user = new ClaimsPrincipal(identity); - NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(user))); + var state = new AuthenticationState(user); + _cachedState = state; + NotifyAuthenticationStateChanged(Task.FromResult(state)); } public async Task MarkUserAsLoggedOutAsync() @@ -164,7 +179,8 @@ namespace QuantEngine.Web.Client.Infrastructure { await _localStorage.DeleteAsync(UsernameKey); } - NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(_anonymous))); + _cachedState = new AuthenticationState(_anonymous); + NotifyAuthenticationStateChanged(Task.FromResult(_cachedState)); } public async Task LogoutFromServerAsync() diff --git a/src/dotnet/QuantEngine.Web/Client/Layout/MainLayout.razor b/src/dotnet/QuantEngine.Web/Client/Layout/MainLayout.razor index c29b694..435ed14 100644 --- a/src/dotnet/QuantEngine.Web/Client/Layout/MainLayout.razor +++ b/src/dotnet/QuantEngine.Web/Client/Layout/MainLayout.razor @@ -133,7 +133,7 @@ { var customProvider = (CustomAuthenticationStateProvider)AuthStateProvider; await customProvider.LogoutFromServerAsync(); - NavigationManager.NavigateTo("/login"); + NavigationManager.NavigateTo("/Account/Login?handler=Logout", forceLoad: true); } private string GetFirstLetter(string? name) diff --git a/src/dotnet/QuantEngine.Web/Client/Layout/NavMenu.razor b/src/dotnet/QuantEngine.Web/Client/Layout/NavMenu.razor index 25dcbf8..0218dd2 100644 --- a/src/dotnet/QuantEngine.Web/Client/Layout/NavMenu.razor +++ b/src/dotnet/QuantEngine.Web/Client/Layout/NavMenu.razor @@ -5,23 +5,23 @@ - + 사용자 관리 - 데이터 수집 - 설정 + 데이터 수집 + 수집 모니터링 - 운영 + 운영 리포트 - - - 문서 - API - + +
+ QuantEngine System + v2.1.0-Release +
diff --git a/src/dotnet/QuantEngine.Web/Client/Program.cs b/src/dotnet/QuantEngine.Web/Client/Program.cs index a5b93e1..2cb2a97 100644 --- a/src/dotnet/QuantEngine.Web/Client/Program.cs +++ b/src/dotnet/QuantEngine.Web/Client/Program.cs @@ -22,5 +22,6 @@ builder.Services.AddMudServices(); // HttpClient register (API-First standard) builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); +builder.Services.AddScoped(); await builder.Build().RunAsync(); diff --git a/src/dotnet/QuantEngine.Web/Client/Services/ApiClient.cs b/src/dotnet/QuantEngine.Web/Client/Services/ApiClient.cs index cc03d04..18bb856 100644 --- a/src/dotnet/QuantEngine.Web/Client/Services/ApiClient.cs +++ b/src/dotnet/QuantEngine.Web/Client/Services/ApiClient.cs @@ -11,6 +11,10 @@ public class ApiClient public ApiClient(HttpClient http, ILogger logger) { _http = http; + if (_http.BaseAddress == null) + { + _http.BaseAddress = new Uri("http://localhost:5265/"); + } _logger = logger; } diff --git a/src/dotnet/QuantEngine.Web/Pages/Account/Login.cshtml.cs b/src/dotnet/QuantEngine.Web/Pages/Account/Login.cshtml.cs index 0eab517..0f0e22d 100644 --- a/src/dotnet/QuantEngine.Web/Pages/Account/Login.cshtml.cs +++ b/src/dotnet/QuantEngine.Web/Pages/Account/Login.cshtml.cs @@ -42,11 +42,22 @@ namespace QuantEngine.Web.Pages.Account try { var loginRequest = new { Username = username, Password = password }; - var requestUri = $"{Request.Scheme}://{Request.Host}/api/auth/login"; + var scheme = string.IsNullOrWhiteSpace(Request.Scheme) ? "http" : Request.Scheme; + var host = Request.Host.HasValue ? Request.Host.Value : "localhost:5265"; + var requestUri = $"{scheme}://{host}/api/auth/login"; var response = await _httpClient.PostAsJsonAsync(requestUri, loginRequest); if (response.IsSuccessStatusCode) { + // Extract set-cookie header from API response and set it on proxy client response + if (response.Headers.TryGetValues("Set-Cookie", out var cookieHeaders)) + { + foreach (var cookieHeader in cookieHeaders) + { + Response.Headers.Append("Set-Cookie", cookieHeader); + } + } + if (rememberUsername) { Response.Cookies.Append( @@ -84,5 +95,12 @@ namespace QuantEngine.Web.Pages.Account return Page(); } } + + public IActionResult OnGetLogout() + { + // Revoke cookies securely + Response.Cookies.Delete("quant_auth_token"); + return Redirect("/Account/Login"); + } } } diff --git a/src/dotnet/QuantEngine.Web/Program.cs b/src/dotnet/QuantEngine.Web/Program.cs index 8a3e446..1e3162f 100644 --- a/src/dotnet/QuantEngine.Web/Program.cs +++ b/src/dotnet/QuantEngine.Web/Program.cs @@ -93,7 +93,11 @@ builder.Services.AddScoped(); // builder.Services.AddScoped(); // builder.Services.AddScoped(); -builder.Services.AddHttpClient(); +builder.Services.AddHttpClient(client => +{ + // Configure default base address for relative HttpClient calls within server assembly calls + client.BaseAddress = new Uri("http://localhost:5265/"); +}); builder.Services.AddScoped(); builder.Services.AddFastEndpoints(); @@ -183,13 +187,15 @@ catch (Exception ex) Log.Warning("Hangfire setup failed: {Message}", ex.Message); } -// Root path - redirect unauthenticated to /login.html (static file) +// Root path - redirect unauthenticated to /Account/Login (secure SSR Razor Page) app.MapGet("/", async (HttpContext ctx) => { var isAuthenticated = ctx.User?.Identity?.IsAuthenticated ?? false; - if (!isAuthenticated) + // Check cookie parity for server-side root routing redirect + var hasCookie = ctx.Request.Cookies.ContainsKey("quant_auth_token"); + if (!isAuthenticated && !hasCookie) { - ctx.Response.Redirect("/login.html"); + ctx.Response.Redirect("/Account/Login"); } else { @@ -199,10 +205,10 @@ app.MapGet("/", async (HttpContext ctx) => await Task.CompletedTask; }); -// Map /login to static login.html +// Map /login to secure SSR Razor Page /Account/Login app.MapGet("/login", (HttpContext ctx) => { - ctx.Response.Redirect("/login.html", permanent: false); + ctx.Response.Redirect("/Account/Login", permanent: false); }); // Login API (API-First for Blazor WASM client authentication) @@ -243,6 +249,8 @@ app.MapPost("/api/auth/login", async (JsonElement payload, HttpContext httpConte var devToken = Guid.NewGuid().ToString("N"); var devExpiresAt = DateTimeOffset.UtcNow.AddDays(7); + var devIsSecureEnv = httpContext.Request.IsHttps; + // Set HTTP-only cookie for dev fallback too httpContext.Response.Cookies.Append( "quant_auth_token", @@ -250,7 +258,7 @@ app.MapPost("/api/auth/login", async (JsonElement payload, HttpContext httpConte new Microsoft.AspNetCore.Http.CookieOptions { HttpOnly = true, - Secure = true, + Secure = devIsSecureEnv, SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax, Expires = devExpiresAt, Path = "/" @@ -299,13 +307,15 @@ app.MapPost("/api/auth/login", async (JsonElement payload, HttpContext httpConte Console.WriteLine($"[Auth/Login] Setting cookie 'quant_auth_token'"); Console.WriteLine($"[Auth/Login] IsHttps: {httpContext.Request.IsHttps}"); + var isSecureEnv = httpContext.Request.IsHttps; + httpContext.Response.Cookies.Append( "quant_auth_token", rawToken, new Microsoft.AspNetCore.Http.CookieOptions { HttpOnly = true, - Secure = true, // Force Secure Cookie for SSL standard + Secure = isSecureEnv, // Dynamic SSL Secure binding based on active request env SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax, Expires = expiresAt, Path = "/" @@ -539,8 +549,8 @@ internal sealed class QuantAdminAuthHandler : AuthenticationHandler + diff --git a/src/dotnet/QuantEngine.Web/wwwroot/login.html b/src/dotnet/QuantEngine.Web/wwwroot/login.html deleted file mode 100644 index 77fcc4d..0000000 --- a/src/dotnet/QuantEngine.Web/wwwroot/login.html +++ /dev/null @@ -1,358 +0,0 @@ - - - - - - 로그인 - QuantEngine - - - - - - - - diff --git a/src/dotnet/QuantEngine.Web/wwwroot/version.json b/src/dotnet/QuantEngine.Web/wwwroot/version.json new file mode 100644 index 0000000..71d6ab7 --- /dev/null +++ b/src/dotnet/QuantEngine.Web/wwwroot/version.json @@ -0,0 +1,4 @@ +{ + "version": "11.4.2-Hardened", + "built": "2026-07-06 16:00" +}