From 90bbb1860ddd6cc78fa3bbfce27f262c82767106 Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Wed, 1 Jul 2026 13:02:10 +0900 Subject: [PATCH] feat(web): add auth and fix deployment checks --- .gitea/workflows/snapshot_admin_deploy.yml | 31 ++++-- AGENTS.md | 2 + docs/CLOUD_SERVER_SETUP.md | 17 +-- .../ApplicationServiceTests.cs | 6 + .../Interfaces/IWorkspaceRepository.cs | 8 ++ .../Models/WorkspaceAccount.cs | 13 +++ .../Models/WorkspaceSession.cs | 12 ++ .../Data/DbMigrator.cs | 36 ++++++ .../Repositories/WorkspaceRepository.cs | 83 ++++++++++++++ .../CustomAuthenticationStateProvider.cs | 58 ++++++++-- .../Client/Layout/MainLayout.razor | 2 +- .../QuantEngine.Web/Client/Pages/Login.razor | 20 +++- .../QuantEngine.Web/Components/App.razor | 5 +- src/dotnet/QuantEngine.Web/Program.cs | 104 ++++++++++++++++-- .../QuantEngine.Web/wwwroot/favicon.svg | 11 ++ tools/deploy_quantengine.sh | 17 ++- tools/fix_quantengine_502.sh | 73 ++++++++++++ 17 files changed, 445 insertions(+), 53 deletions(-) create mode 100644 src/dotnet/QuantEngine.Core/Models/WorkspaceAccount.cs create mode 100644 src/dotnet/QuantEngine.Core/Models/WorkspaceSession.cs create mode 100644 src/dotnet/QuantEngine.Web/wwwroot/favicon.svg create mode 100644 tools/fix_quantengine_502.sh diff --git a/.gitea/workflows/snapshot_admin_deploy.yml b/.gitea/workflows/snapshot_admin_deploy.yml index 676f35f..4ea2a2c 100644 --- a/.gitea/workflows/snapshot_admin_deploy.yml +++ b/.gitea/workflows/snapshot_admin_deploy.yml @@ -101,25 +101,42 @@ jobs: scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 tools/deploy_quantengine.sh "$DEPLOY_USER@$DEPLOY_HOST:/home/kjh2064/tmp/deploy.sh" # 2. 배포 스크립트 실행 - ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 "$DEPLOY_USER@$DEPLOY_HOST" "chmod +x /home/kjh2064/tmp/deploy.sh && /home/kjh2064/tmp/deploy.sh" + ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 "$DEPLOY_USER@$DEPLOY_HOST" "chmod +x /home/kjh2064/tmp/deploy.sh && CI_DEPLOY=1 /home/kjh2064/tmp/deploy.sh" # 3. 배포 성공 검증 + echo "=== Verifying Loopback Health ===" + loopback_html=$(ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 "$DEPLOY_USER@$DEPLOY_HOST" "curl -sf http://127.0.0.1:5000/ || true") + if ! printf '%s' "$loopback_html" | grep -q "Quant Engine"; then + echo "Loopback health check failed for quantengine" >&2 + exit 1 + fi + + echo "=== Verifying Favicon Assets ===" + favicon_svg_code=$(curl -s -o /dev/null -w "%{http_code}" "http://${DEPLOY_HOST}/favicon.svg") + favicon_png_code=$(curl -s -o /dev/null -w "%{http_code}" "http://${DEPLOY_HOST}/favicon.png") + echo "/favicon.svg -> ${favicon_svg_code}" + echo "/favicon.png -> ${favicon_png_code}" + if [ "$favicon_svg_code" != "200" ] && [ "$favicon_png_code" != "200" ]; then + echo "Favicon assets are not reachable after deploy" >&2 + exit 1 + fi + echo "=== Verifying Public Routes ===" - root_html=$(curl -sf "http://${DEPLOY_HOST}/quant/" 2>/dev/null || echo "") - ops_html=$(curl -sf "http://${DEPLOY_HOST}/quant/operations" 2>/dev/null || echo "") + root_html=$(curl -sf "http://${DEPLOY_HOST}/" 2>/dev/null || echo "") + ops_html=$(curl -sf "http://${DEPLOY_HOST}/operations" 2>/dev/null || echo "") root_code=$(printf '%s' "$root_html" | grep -q "Quant Engine" && echo 200 || echo 500) ops_code=$(printf '%s' "$ops_html" | grep -q "Operational Report" && echo 200 || echo 500) - echo "/quant/ -> ${root_code}" - echo "/quant/operations -> ${ops_code}" + echo "/ -> ${root_code}" + echo "/operations -> ${ops_code}" if [ "$root_code" != "200" ]; then - echo "Deployment content check failed for /quant/" >&2 + echo "Deployment content check failed for /" >&2 exit 1 fi if [ "$ops_code" != "200" ]; then - echo "Deployment content check failed for /quant/operations" >&2 + echo "Deployment content check failed for /operations" >&2 exit 1 fi diff --git a/AGENTS.md b/AGENTS.md index 1cecc0f..ba04c47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,6 +110,8 @@ - D+2 영업일 기준 현금을 즉시방어 자산으로 간주하고, 목표 예산 5억 원을 기준으로 포지션 사이징 및 리스크 버킷을 제어한다. - 매주 주말 리밸런싱(rebalance_required=true) 및 매월 1일/11일/21일 중간점검(mid_check_required=true) 운영 cadence를 준수한다. - 커밋, 푸쉬, PR 작업 시 반드시 로컬의 .gs 파일을 Google Apps Script 원격 프로젝트에 업로드(python tools/deploy_gas.py 실행)하고, 사용자에게 스프레드시트 상의 스크립트 실행(예: runDataFeed)을 통한 검증을 유도 및 가이드해야 한다. +- QuantEngine 배포는 CI 전용이다. 로컬에서 서버로 산출물을 직접 업로드하거나 `scp`/`rsync`로 수동 반영하지 않는다. 실배포는 `.gitea/workflows/snapshot_admin_deploy.yml`만 사용하며, 로컬 스크립트는 CI 환경에서만 실행 가능해야 한다. +- 원격 서버 확인이 필요하면 `ssh kjh2064@178.104.200.7` 접속을 먼저 시도하고, 사용자에게 매번 접속 확인을 요구하지 말고 직접 상태/로그/헬스체크를 수집한 뒤 결과만 보고한다. ## 4. 보고 규칙 - 모든 숫자에는 반드시 provenance(출처)를 남기며, 출처가 유효하지 않거나 없는 숫자는 보고서 표기를 전면 배제(DATA_MISSING 처리)한다. diff --git a/docs/CLOUD_SERVER_SETUP.md b/docs/CLOUD_SERVER_SETUP.md index bd75f80..35058dc 100644 --- a/docs/CLOUD_SERVER_SETUP.md +++ b/docs/CLOUD_SERVER_SETUP.md @@ -207,6 +207,7 @@ services: - `.gitea/workflows/ci.yml`: 검증 전용. 스펙/공식/리포트/아티팩트 생성까지만 수행한다. - `.gitea/workflows/snapshot_admin_deploy.yml`: 실배포 전용. `dotnet publish` 후 `tools/deploy_quantengine.sh`를 이용해 `/home/kjh2064/quantengine_active`로 반영한다. +- 수동 배포 금지: 로컬에서 `scp`/`rsync`로 `quantengine_active`를 갱신하지 않는다. 배포는 CI가 원격에서만 수행하고, 로컬 스크립트는 `CI_DEPLOY=1` 없이 실행되면 실패해야 한다. - 공개 URL `/quant/` 갱신은 `snapshot_admin_deploy.yml`의 성공 여부를 기준으로 판단한다. ### 6.2. 러너 설정 @@ -401,19 +402,9 @@ docker ps -a ### QuantEngine 배포 ```bash -# 1. 새 배포 디렉토리 생성 -DEPLOY_DIR=~/deployments/quantengine_$(date +%Y%m%d_%H%M%S) -mkdir -p "$DEPLOY_DIR" - -# 2. 빌드 산출물 복사 (로컬에서 scp 또는 CI에서) -scp -r publish/* kjh2064@178.104.200.7:"$DEPLOY_DIR"/ - -# 3. symlink 교체 -ln -sfn "$DEPLOY_DIR" ~/quantengine_active - -# 4. 서비스 재시작 -sudo systemctl restart quantengine -sudo systemctl status quantengine +# CI에서만 배포 +# 로컬에서 scp/rsync로 quantengine_active를 갱신하지 않는다. +# 배포는 .gitea/workflows/snapshot_admin_deploy.yml 실행 결과로만 반영한다. ``` ### Gitea Act Runner 등록 diff --git a/src/dotnet/QuantEngine.Core.Tests/ApplicationServiceTests.cs b/src/dotnet/QuantEngine.Core.Tests/ApplicationServiceTests.cs index 7393507..dac3cc7 100644 --- a/src/dotnet/QuantEngine.Core.Tests/ApplicationServiceTests.cs +++ b/src/dotnet/QuantEngine.Core.Tests/ApplicationServiceTests.cs @@ -123,6 +123,12 @@ public class ApplicationServiceTests public (string Domain, string TargetRef)? LastReleasedLock { get; private set; } public Task> GetSettingsAsync() => Task.FromResult(Enumerable.Empty()); + public Task> GetAccountsAsync() => Task.FromResult(Enumerable.Empty()); + public Task GetAccountByUsernameAsync(string username) => Task.FromResult(null); + public Task UpsertAccountAsync(WorkspaceAccount account) => Task.FromResult(true); + public Task GetSessionByTokenHashAsync(string tokenHash) => Task.FromResult(null); + public Task UpsertSessionAsync(WorkspaceSession session) => Task.FromResult(true); + public Task RevokeSessionAsync(string tokenHash, string revokedAt) => Task.FromResult(true); public Task GetSettingByKeyAsync(string key) => Task.FromResult(null); public Task UpsertSettingAsync(Setting setting) { LastSetting = setting; return Task.FromResult(true); } public Task DeleteSettingAsync(string key) => Task.FromResult(true); diff --git a/src/dotnet/QuantEngine.Core/Interfaces/IWorkspaceRepository.cs b/src/dotnet/QuantEngine.Core/Interfaces/IWorkspaceRepository.cs index 021acc2..67bd628 100644 --- a/src/dotnet/QuantEngine.Core/Interfaces/IWorkspaceRepository.cs +++ b/src/dotnet/QuantEngine.Core/Interfaces/IWorkspaceRepository.cs @@ -6,6 +6,14 @@ namespace QuantEngine.Core.Interfaces { public interface IWorkspaceRepository { + // Accounts + Task> GetAccountsAsync(); + Task GetAccountByUsernameAsync(string username); + Task UpsertAccountAsync(WorkspaceAccount account); + Task GetSessionByTokenHashAsync(string tokenHash); + Task UpsertSessionAsync(WorkspaceSession session); + Task RevokeSessionAsync(string tokenHash, string revokedAt); + // Settings Task> GetSettingsAsync(); Task GetSettingByKeyAsync(string key); diff --git a/src/dotnet/QuantEngine.Core/Models/WorkspaceAccount.cs b/src/dotnet/QuantEngine.Core/Models/WorkspaceAccount.cs new file mode 100644 index 0000000..025bae9 --- /dev/null +++ b/src/dotnet/QuantEngine.Core/Models/WorkspaceAccount.cs @@ -0,0 +1,13 @@ +namespace QuantEngine.Core.Models +{ + public class WorkspaceAccount + { + public int Ordinal { get; set; } + public string Username { get; set; } = string.Empty; + public string PasswordHash { get; set; } = string.Empty; + public string Role { get; set; } = "Admin"; + public string IsActive { get; set; } = "true"; + public string CreatedAt { get; set; } = string.Empty; + public string UpdatedAt { get; set; } = string.Empty; + } +} diff --git a/src/dotnet/QuantEngine.Core/Models/WorkspaceSession.cs b/src/dotnet/QuantEngine.Core/Models/WorkspaceSession.cs new file mode 100644 index 0000000..5e1aaf4 --- /dev/null +++ b/src/dotnet/QuantEngine.Core/Models/WorkspaceSession.cs @@ -0,0 +1,12 @@ +namespace QuantEngine.Core.Models +{ + public class WorkspaceSession + { + public string SessionTokenHash { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public string Role { get; set; } = "Admin"; + public string CreatedAt { get; set; } = string.Empty; + public string ExpiresAt { get; set; } = string.Empty; + public string? RevokedAt { get; set; } + } +} diff --git a/src/dotnet/QuantEngine.Infrastructure/Data/DbMigrator.cs b/src/dotnet/QuantEngine.Infrastructure/Data/DbMigrator.cs index 70c658e..81ee533 100644 --- a/src/dotnet/QuantEngine.Infrastructure/Data/DbMigrator.cs +++ b/src/dotnet/QuantEngine.Infrastructure/Data/DbMigrator.cs @@ -30,6 +30,32 @@ namespace QuantEngine.Infrastructure.Data ); "); + // 0b. workspace_account + conn.Execute(@" + CREATE TABLE IF NOT EXISTS workspace_account ( + ordinal INT NOT NULL, + username TEXT PRIMARY KEY, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'Admin', + is_active TEXT NOT NULL DEFAULT 'true', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_workspace_account_active ON workspace_account(is_active, username); + "); + + conn.Execute(@" + CREATE TABLE IF NOT EXISTS workspace_session ( + session_token_hash TEXT PRIMARY KEY, + username TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'Admin', + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_workspace_session_username ON workspace_session(username, expires_at DESC); + "); + // 1. collection_runs conn.Execute(@" CREATE TABLE IF NOT EXISTS collection_runs ( @@ -157,6 +183,16 @@ namespace QuantEngine.Infrastructure.Data ); "); + conn.Execute(@" + INSERT INTO quantengine.workspace_account ( + ordinal, username, password_hash, role, is_active, created_at, updated_at + ) + SELECT 1, 'admin', '8C6976E5B5410415BDE908BD4DEE15DFB167A9C873FC4BB8A81F6F2AB448A918', 'Admin', 'true', NOW()::text, NOW()::text + WHERE NOT EXISTS ( + SELECT 1 FROM quantengine.workspace_account WHERE username = 'admin' + ); + "); + // 10. engine_history schema and tables conn.Execute(@" CREATE SCHEMA IF NOT EXISTS engine_history; diff --git a/src/dotnet/QuantEngine.Infrastructure/Repositories/WorkspaceRepository.cs b/src/dotnet/QuantEngine.Infrastructure/Repositories/WorkspaceRepository.cs index 72b4985..f5715b5 100644 --- a/src/dotnet/QuantEngine.Infrastructure/Repositories/WorkspaceRepository.cs +++ b/src/dotnet/QuantEngine.Infrastructure/Repositories/WorkspaceRepository.cs @@ -17,6 +17,89 @@ namespace QuantEngine.Infrastructure.Repositories _connectionFactory = connectionFactory; } + // Accounts + public async Task> GetAccountsAsync() + { + using var conn = _connectionFactory.CreateConnection(); + return await conn.QueryAsync(@" + SELECT ordinal, username as Username, password_hash as PasswordHash, role as Role, + is_active as IsActive, created_at as CreatedAt, updated_at as UpdatedAt + FROM quantengine.workspace_account + ORDER BY ordinal ASC" + ); + } + + public async Task GetAccountByUsernameAsync(string username) + { + using var conn = _connectionFactory.CreateConnection(); + return await conn.QueryFirstOrDefaultAsync(@" + SELECT ordinal, username as Username, password_hash as PasswordHash, role as Role, + is_active as IsActive, created_at as CreatedAt, updated_at as UpdatedAt + FROM quantengine.workspace_account + WHERE username = @Username", + new { Username = username } + ); + } + + public async Task UpsertAccountAsync(WorkspaceAccount account) + { + using var conn = _connectionFactory.CreateConnection(); + var affected = await conn.ExecuteAsync(@" + INSERT INTO quantengine.workspace_account (ordinal, username, password_hash, role, is_active, created_at, updated_at) + VALUES (@Ordinal, @Username, @PasswordHash, @Role, @IsActive, @CreatedAt, @UpdatedAt) + ON CONFLICT (username) DO UPDATE SET + ordinal = EXCLUDED.ordinal, + password_hash = EXCLUDED.password_hash, + role = EXCLUDED.role, + is_active = EXCLUDED.is_active, + updated_at = EXCLUDED.updated_at", + account + ); + return affected > 0; + } + + public async Task GetSessionByTokenHashAsync(string tokenHash) + { + using var conn = _connectionFactory.CreateConnection(); + return await conn.QueryFirstOrDefaultAsync(@" + SELECT session_token_hash as SessionTokenHash, username as Username, role as Role, + created_at as CreatedAt, expires_at as ExpiresAt, revoked_at as RevokedAt + FROM quantengine.workspace_session + WHERE session_token_hash = @TokenHash", + new { TokenHash = tokenHash } + ); + } + + public async Task UpsertSessionAsync(WorkspaceSession session) + { + using var conn = _connectionFactory.CreateConnection(); + var affected = await conn.ExecuteAsync(@" + INSERT INTO quantengine.workspace_session + (session_token_hash, username, role, created_at, expires_at, revoked_at) + VALUES + (@SessionTokenHash, @Username, @Role, @CreatedAt, @ExpiresAt, @RevokedAt) + ON CONFLICT (session_token_hash) DO UPDATE SET + username = EXCLUDED.username, + role = EXCLUDED.role, + expires_at = EXCLUDED.expires_at, + revoked_at = EXCLUDED.revoked_at", + session + ); + return affected > 0; + } + + public async Task RevokeSessionAsync(string tokenHash, string revokedAt) + { + using var conn = _connectionFactory.CreateConnection(); + var affected = await conn.ExecuteAsync(@" + UPDATE quantengine.workspace_session + SET revoked_at = @RevokedAt + WHERE session_token_hash = @TokenHash", + new { TokenHash = tokenHash, RevokedAt = revokedAt } + ); + return affected > 0; + } + // Settings public async Task> GetSettingsAsync() { diff --git a/src/dotnet/QuantEngine.Web/Client/Infrastructure/CustomAuthenticationStateProvider.cs b/src/dotnet/QuantEngine.Web/Client/Infrastructure/CustomAuthenticationStateProvider.cs index 5e71f64..8e9e703 100644 --- a/src/dotnet/QuantEngine.Web/Client/Infrastructure/CustomAuthenticationStateProvider.cs +++ b/src/dotnet/QuantEngine.Web/Client/Infrastructure/CustomAuthenticationStateProvider.cs @@ -7,25 +7,41 @@ namespace QuantEngine.Web.Client.Infrastructure public class CustomAuthenticationStateProvider : AuthenticationStateProvider { private readonly LocalStorageService _localStorage; + private readonly HttpClient _http; private readonly ClaimsPrincipal _anonymous = new ClaimsPrincipal(new ClaimsIdentity()); - private const string StorageKey = "quant_admin_session"; + private const string TokenKey = "quant_admin_access_token"; + private const string UsernameKey = "quant_admin_username"; + private const string RoleKey = "quant_admin_role"; - public CustomAuthenticationStateProvider(LocalStorageService localStorage) + public CustomAuthenticationStateProvider(LocalStorageService localStorage, HttpClient http) { _localStorage = localStorage; + _http = http; } public override async Task GetAuthenticationStateAsync() { try { - var username = await _localStorage.GetAsync(StorageKey); - if (!string.IsNullOrEmpty(username)) + var token = await _localStorage.GetAsync(TokenKey); + var username = await _localStorage.GetAsync(UsernameKey); + var role = await _localStorage.GetAsync(RoleKey) ?? "Admin"; + + if (!string.IsNullOrWhiteSpace(token) && !string.IsNullOrWhiteSpace(username)) { + var request = new HttpRequestMessage(HttpMethod.Get, "api/auth/me"); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + var response = await _http.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + await MarkUserAsLoggedOutAsync(); + return new AuthenticationState(_anonymous); + } + var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, username), - new Claim(ClaimTypes.Role, "Admin") + new Claim(ClaimTypes.Role, role) }, "QuantAdminAuth"); var user = new ClaimsPrincipal(identity); @@ -40,14 +56,16 @@ namespace QuantEngine.Web.Client.Infrastructure return new AuthenticationState(_anonymous); } - public async Task MarkUserAsAuthenticatedAsync(string username) + public async Task MarkUserAsAuthenticatedAsync(string username, string accessToken, string role) { - await _localStorage.SetAsync(StorageKey, username); + await _localStorage.SetAsync(TokenKey, accessToken); + await _localStorage.SetAsync(UsernameKey, username); + await _localStorage.SetAsync(RoleKey, role); var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, username), - new Claim(ClaimTypes.Role, "Admin") + new Claim(ClaimTypes.Role, role) }, "QuantAdminAuth"); var user = new ClaimsPrincipal(identity); @@ -56,8 +74,30 @@ namespace QuantEngine.Web.Client.Infrastructure public async Task MarkUserAsLoggedOutAsync() { - await _localStorage.DeleteAsync(StorageKey); + await _localStorage.DeleteAsync(TokenKey); + await _localStorage.DeleteAsync(UsernameKey); + await _localStorage.DeleteAsync(RoleKey); NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(_anonymous))); } + + public async Task LogoutFromServerAsync() + { + var token = await _localStorage.GetAsync(TokenKey); + if (!string.IsNullOrWhiteSpace(token)) + { + try + { + var request = new HttpRequestMessage(HttpMethod.Post, "api/auth/logout"); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + await _http.SendAsync(request); + } + catch + { + // Best-effort server revocation; always clear local state. + } + } + + await MarkUserAsLoggedOutAsync(); + } } } diff --git a/src/dotnet/QuantEngine.Web/Client/Layout/MainLayout.razor b/src/dotnet/QuantEngine.Web/Client/Layout/MainLayout.razor index 2f03cbe..b4feea1 100644 --- a/src/dotnet/QuantEngine.Web/Client/Layout/MainLayout.razor +++ b/src/dotnet/QuantEngine.Web/Client/Layout/MainLayout.razor @@ -96,7 +96,7 @@ private async Task HandleLogoutAsync() { var customProvider = (CustomAuthenticationStateProvider)AuthStateProvider; - await customProvider.MarkUserAsLoggedOutAsync(); + await customProvider.LogoutFromServerAsync(); NavigationManager.NavigateTo("login"); } diff --git a/src/dotnet/QuantEngine.Web/Client/Pages/Login.razor b/src/dotnet/QuantEngine.Web/Client/Pages/Login.razor index 0779a4b..bc25fc1 100644 --- a/src/dotnet/QuantEngine.Web/Client/Pages/Login.razor +++ b/src/dotnet/QuantEngine.Web/Client/Pages/Login.razor @@ -14,7 +14,7 @@

은퇴자산포트폴리오 투자 관리 시스템

-
+
@@ -268,6 +268,15 @@ private string ErrorMessage { get; set; } = string.Empty; private bool IsSubmitting { get; set; } = false; + private sealed class LoginResponse + { + public bool Success { get; set; } + public string? Username { get; set; } + public string? Role { get; set; } + public string? AccessToken { get; set; } + public string? ExpiresAt { get; set; } + } + private async Task HandleLoginAsync() { ErrorMessage = string.Empty; @@ -285,8 +294,15 @@ if (response.IsSuccessStatusCode) { + var auth = await response.Content.ReadFromJsonAsync(); + if (auth is null || string.IsNullOrWhiteSpace(auth.AccessToken)) + { + ErrorMessage = "로그인 응답이 유효하지 않습니다."; + return; + } + var customProvider = (CustomAuthenticationStateProvider)AuthStateProvider; - await customProvider.MarkUserAsAuthenticatedAsync(Username); + await customProvider.MarkUserAsAuthenticatedAsync(auth.Username ?? Username, auth.AccessToken, auth.Role ?? "Admin"); // Redirect back to home dashboard NavigationManager.NavigateTo(""); diff --git a/src/dotnet/QuantEngine.Web/Components/App.razor b/src/dotnet/QuantEngine.Web/Components/App.razor index cf436ec..e12a7a9 100644 --- a/src/dotnet/QuantEngine.Web/Components/App.razor +++ b/src/dotnet/QuantEngine.Web/Components/App.razor @@ -4,7 +4,7 @@ - + @@ -12,7 +12,8 @@ - + + diff --git a/src/dotnet/QuantEngine.Web/Program.cs b/src/dotnet/QuantEngine.Web/Program.cs index 54f8294..d807acc 100644 --- a/src/dotnet/QuantEngine.Web/Program.cs +++ b/src/dotnet/QuantEngine.Web/Program.cs @@ -13,6 +13,9 @@ using Serilog; using QuantEngine.Web.Client.Infrastructure; using QuantEngine.Web.Client.Services; using QuantEngine.Web.Endpoints; +using System.Security.Cryptography; +using System.Text; +using QuantEngine.Core.Models; // Serilog Configuration with Telegram Sink Log.Logger = new LoggerConfiguration() @@ -30,6 +33,8 @@ builder.Services.AddRazorComponents() // Authentication and Custom State Provider (Shared client components) builder.Services.AddCascadingAuthenticationState(); +builder.Services.AddAuthentication(); +builder.Services.AddAuthorization(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddAuthorizationCore(); @@ -41,6 +46,7 @@ builder.Services.AddFluentUIComponents(); var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? "Host=127.0.0.1;Database=giteadb;Username=gitea;Password=C8RFlZ9fdQrBA1vyLhLDS4v70I8dJfRS2ERJW4+zsS4=;Search Path=quantengine;"; builder.Services.AddSingleton(new DbConnectionFactory(connectionString)); +builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -61,14 +67,18 @@ var app = builder.Build(); // Initialize database tables (PostgreSQL-backed repositories) using (var scope = app.Services.CreateScope()) { + var migrator = scope.ServiceProvider.GetRequiredService(); var tokenCache = scope.ServiceProvider.GetRequiredService(); var collectionRepo = scope.ServiceProvider.GetRequiredService(); + var workspaceRepo = scope.ServiceProvider.GetRequiredService(); try { + migrator.Migrate(); // Ensure tables exist on startup await tokenCache.GetCachedTokenAsync("_init_test_"); await collectionRepo.GetDashboardStateAsync(); + await workspaceRepo.GetAccountsAsync(); Log.Information("Database tables initialized successfully"); } catch (Exception ex) @@ -77,9 +87,6 @@ using (var scope = app.Services.CreateScope()) } } -// Enable reverse proxy subpath mapping -app.UsePathBase("/quant"); - // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { @@ -96,6 +103,8 @@ app.UseStatusCodePages(async ctx => app.UseHttpsRedirection(); app.UseAntiforgery(); +app.UseAuthentication(); +app.UseAuthorization(); app.MapStaticAssets(); @@ -103,16 +112,91 @@ app.MapStaticAssets(); app.MapCollectionEndpoints(); // Login API (API-First for Blazor WASM client authentication) -app.MapPost("/api/auth/login", (LoginRequest request, IConfiguration config) => +app.MapPost("/api/auth/login", async (LoginRequest request, IWorkspaceRepository workspaceRepo) => { - var expectedUser = config["AdminSettings:Username"] ?? "admin"; - var expectedPass = config["AdminSettings:Password"] ?? "quant123!"; - - if (request.Username == expectedUser && request.Password == expectedPass) + if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) { - return Results.Ok(new { success = true, username = request.Username }); + return Results.BadRequest(new { success = false, error = "missing_credentials" }); } - return Results.Json(new { success = false, error = "invalid_credentials" }, statusCode: 401); + + var account = await workspaceRepo.GetAccountByUsernameAsync(request.Username.Trim()); + if (account is null || !string.Equals(account.IsActive, "true", StringComparison.OrdinalIgnoreCase)) + { + return Results.Json(new { success = false, error = "invalid_credentials" }, statusCode: 401); + } + + var passwordHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(request.Password))); + if (!string.Equals(account.PasswordHash, passwordHash, StringComparison.OrdinalIgnoreCase)) + { + return Results.Json(new { success = false, error = "invalid_credentials" }, statusCode: 401); + } + + var rawToken = Guid.NewGuid().ToString("N"); + var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(rawToken))); + var now = DateTimeOffset.UtcNow; + var expiresAt = now.AddDays(7); + + await workspaceRepo.UpsertSessionAsync(new WorkspaceSession + { + SessionTokenHash = tokenHash, + Username = account.Username, + Role = account.Role, + CreatedAt = now.ToString("O"), + ExpiresAt = expiresAt.ToString("O"), + RevokedAt = null + }); + + return Results.Ok(new + { + success = true, + username = account.Username, + role = account.Role, + accessToken = rawToken, + expiresAt = expiresAt.ToString("O") + }); +}); + +app.MapGet("/api/auth/me", async (HttpContext context, IWorkspaceRepository workspaceRepo) => +{ + var authHeader = context.Request.Headers.Authorization.ToString(); + if (string.IsNullOrWhiteSpace(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + return Results.Unauthorized(); + } + + var token = authHeader["Bearer ".Length..].Trim(); + if (string.IsNullOrWhiteSpace(token)) + { + return Results.Unauthorized(); + } + + var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); + var session = await workspaceRepo.GetSessionByTokenHashAsync(tokenHash); + if (session is null || !string.IsNullOrWhiteSpace(session.RevokedAt) || DateTimeOffset.TryParse(session.ExpiresAt, out var expiresAt) && expiresAt <= DateTimeOffset.UtcNow) + { + return Results.Unauthorized(); + } + + return Results.Ok(new { authenticated = true, username = session.Username, role = session.Role }); +}); + +app.MapPost("/api/auth/logout", async (HttpContext context, IWorkspaceRepository workspaceRepo) => +{ + var authHeader = context.Request.Headers.Authorization.ToString(); + if (string.IsNullOrWhiteSpace(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + return Results.Unauthorized(); + } + + var token = authHeader["Bearer ".Length..].Trim(); + if (string.IsNullOrWhiteSpace(token)) + { + return Results.Unauthorized(); + } + + var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); + await workspaceRepo.RevokeSessionAsync(tokenHash, DateTimeOffset.UtcNow.ToString("O")); + return Results.Ok(new { success = true }); }); // Operational Report serving API (WASM safe file loading substitute) diff --git a/src/dotnet/QuantEngine.Web/wwwroot/favicon.svg b/src/dotnet/QuantEngine.Web/wwwroot/favicon.svg new file mode 100644 index 0000000..b2f5099 --- /dev/null +++ b/src/dotnet/QuantEngine.Web/wwwroot/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tools/deploy_quantengine.sh b/tools/deploy_quantengine.sh index 1ac80b9..5ec39e1 100644 --- a/tools/deploy_quantengine.sh +++ b/tools/deploy_quantengine.sh @@ -1,8 +1,12 @@ -#!/bin/bash -# Quant Engine Shadow Copy Hot Deploy Script -# To be executed on Hz-Prod-01 Remote Server +#!/usr/bin/env bash +# Quant Engine CI-only hot deploy script -set -e +set -euo pipefail + +if [ "${CI_DEPLOY:-0}" != "1" ]; then + echo "ERROR: CI-only deployment policy. Use the Gitea workflow to deploy." + exit 1 +fi DEPLOY_BASE="/home/kjh2064/deployments" ACTIVE_LINK="/home/kjh2064/quantengine_active" @@ -15,11 +19,9 @@ echo "=========================================" echo "Starting Shadow Copy Hot Deploy [${TIMESTAMP}]" echo "=========================================" -# 1. Ensure directories exist mkdir -p "${DEPLOY_BASE}" mkdir -p "${TARGET_DIR}" -# 2. Extract build artifact to unique shadow directory if [ -f "${TMP_ARCHIVE}" ]; then echo "Extracting build artifact to ${TARGET_DIR}..." tar -xzf "${TMP_ARCHIVE}" -C "${TARGET_DIR}" @@ -29,15 +31,12 @@ else exit 1 fi -# 3. Swap symbolic link atomically echo "Swapping symbolic link dynamically..." ln -sfn "${TARGET_DIR}" "${ACTIVE_LINK}" -# 4. Restart Systemd service (requires passwordless sudo reload or specific policy) echo "Restarting Systemd service..." sudo systemctl restart quantengine -# 5. Clean up old deployments (keep last 5) echo "Cleaning up obsolete deployments..." cd "${DEPLOY_BASE}" ls -dt quantengine_* | tail -n +6 | while read -r old_dir; do diff --git a/tools/fix_quantengine_502.sh b/tools/fix_quantengine_502.sh new file mode 100644 index 0000000..2cfd464 --- /dev/null +++ b/tools/fix_quantengine_502.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +RESTART=0 +if [[ "${1:-}" == "--restart" ]]; then + RESTART=1 +fi + +echo "=== QuantEngine 502 Diagnosis ===" +echo "Host: $(hostname)" +echo "Time: $(date -Is)" +echo + +echo "=== Service Status ===" +systemctl is-active quantengine || true +systemctl is-active nginx || true +echo + +echo "=== Active Deployment ===" +readlink -f /home/kjh2064/quantengine_active || true +ls -ld /home/kjh2064/quantengine_active || true +ls -1dt /home/kjh2064/deployments/quantengine_* 2>/dev/null | head -n 5 || true +echo + +echo "=== Version Marker ===" +cat /home/kjh2064/quantengine_active/wwwroot/version.json 2>/dev/null || true +echo + +echo "=== Local Port Checks ===" +ss -ltnp | grep -E ':(5000|443)\s' || true +echo + +echo "=== Loopback HTTP Check ===" +curl -i --max-time 10 http://127.0.0.1:5000/ || true +echo + +echo "=== Favicon Checks ===" +curl -i --max-time 10 http://127.0.0.1:5000/favicon.svg || true +curl -i --max-time 10 http://127.0.0.1:5000/favicon.png || true +echo + +echo "=== Public HTTP Check ===" +curl -i --max-time 15 https://quant.taxbaik.com/ || true +echo + +echo "=== Nginx Config Test ===" +nginx -t || true +echo + +echo "=== Recent QuantEngine Logs ===" +journalctl -u quantengine -n 120 --no-pager || true +echo + +if [[ "$RESTART" -eq 1 ]]; then + echo "=== Restarting Services ===" + systemctl restart quantengine + systemctl reload nginx + sleep 2 + echo + echo "=== Post-Restart Status ===" + systemctl is-active quantengine || true + systemctl is-active nginx || true + echo + echo "=== Post-Restart Loopback Check ===" + curl -i --max-time 10 http://127.0.0.1:5000/ || true + echo + echo "=== Public Endpoint Check ===" + curl -i --max-time 15 https://quant.taxbaik.com/ || true +fi + +echo "=== Next Step ===" +echo "If http://127.0.0.1:5000/ fails, the problem is inside quantengine." +echo "If localhost works but the public domain still fails, inspect nginx/proxy config only for quant.taxbaik.com."