feat(web): add auth and fix deployment checks
Quant Engine CI/CD Pipeline / validate-core (push) Failing after 9s
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (push) Failing after 6s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (push) Has been skipped
Snapshot Admin Deployment / build-and-deploy (push) Failing after 2m30s
Deploy to Production / Build & Deploy to Production (push) Failing after 3m49s
Quant Engine CI/CD Pipeline / validate-core (push) Failing after 9s
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (push) Failing after 6s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (push) Has been skipped
Snapshot Admin Deployment / build-and-deploy (push) Failing after 2m30s
Deploy to Production / Build & Deploy to Production (push) Failing after 3m49s
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 처리)한다.
|
||||
|
||||
@@ -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 등록
|
||||
|
||||
@@ -123,6 +123,12 @@ public class ApplicationServiceTests
|
||||
public (string Domain, string TargetRef)? LastReleasedLock { get; private set; }
|
||||
|
||||
public Task<IEnumerable<Setting>> GetSettingsAsync() => Task.FromResult(Enumerable.Empty<Setting>());
|
||||
public Task<IEnumerable<WorkspaceAccount>> GetAccountsAsync() => Task.FromResult(Enumerable.Empty<WorkspaceAccount>());
|
||||
public Task<WorkspaceAccount?> GetAccountByUsernameAsync(string username) => Task.FromResult<WorkspaceAccount?>(null);
|
||||
public Task<bool> UpsertAccountAsync(WorkspaceAccount account) => Task.FromResult(true);
|
||||
public Task<WorkspaceSession?> GetSessionByTokenHashAsync(string tokenHash) => Task.FromResult<WorkspaceSession?>(null);
|
||||
public Task<bool> UpsertSessionAsync(WorkspaceSession session) => Task.FromResult(true);
|
||||
public Task<bool> RevokeSessionAsync(string tokenHash, string revokedAt) => Task.FromResult(true);
|
||||
public Task<Setting?> GetSettingByKeyAsync(string key) => Task.FromResult<Setting?>(null);
|
||||
public Task<bool> UpsertSettingAsync(Setting setting) { LastSetting = setting; return Task.FromResult(true); }
|
||||
public Task<bool> DeleteSettingAsync(string key) => Task.FromResult(true);
|
||||
|
||||
@@ -6,6 +6,14 @@ namespace QuantEngine.Core.Interfaces
|
||||
{
|
||||
public interface IWorkspaceRepository
|
||||
{
|
||||
// Accounts
|
||||
Task<IEnumerable<WorkspaceAccount>> GetAccountsAsync();
|
||||
Task<WorkspaceAccount?> GetAccountByUsernameAsync(string username);
|
||||
Task<bool> UpsertAccountAsync(WorkspaceAccount account);
|
||||
Task<WorkspaceSession?> GetSessionByTokenHashAsync(string tokenHash);
|
||||
Task<bool> UpsertSessionAsync(WorkspaceSession session);
|
||||
Task<bool> RevokeSessionAsync(string tokenHash, string revokedAt);
|
||||
|
||||
// Settings
|
||||
Task<IEnumerable<Setting>> GetSettingsAsync();
|
||||
Task<Setting?> GetSettingByKeyAsync(string key);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -17,6 +17,89 @@ namespace QuantEngine.Infrastructure.Repositories
|
||||
_connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
// Accounts
|
||||
public async Task<IEnumerable<WorkspaceAccount>> GetAccountsAsync()
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
return await conn.QueryAsync<WorkspaceAccount>(@"
|
||||
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<WorkspaceAccount?> GetAccountByUsernameAsync(string username)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
return await conn.QueryFirstOrDefaultAsync<WorkspaceAccount>(@"
|
||||
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<bool> 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<WorkspaceSession?> GetSessionByTokenHashAsync(string tokenHash)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
return await conn.QueryFirstOrDefaultAsync<WorkspaceSession>(@"
|
||||
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<bool> 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<bool> 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<IEnumerable<Setting>> GetSettingsAsync()
|
||||
{
|
||||
|
||||
+49
-9
@@ -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<AuthenticationState> GetAuthenticationStateAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var username = await _localStorage.GetAsync<string>(StorageKey);
|
||||
if (!string.IsNullOrEmpty(username))
|
||||
var token = await _localStorage.GetAsync<string>(TokenKey);
|
||||
var username = await _localStorage.GetAsync<string>(UsernameKey);
|
||||
var role = await _localStorage.GetAsync<string>(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<string>(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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
private async Task HandleLogoutAsync()
|
||||
{
|
||||
var customProvider = (CustomAuthenticationStateProvider)AuthStateProvider;
|
||||
await customProvider.MarkUserAsLoggedOutAsync();
|
||||
await customProvider.LogoutFromServerAsync();
|
||||
NavigationManager.NavigateTo("login");
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<p class="brand-subtitle">은퇴자산포트폴리오 투자 관리 시스템</p>
|
||||
</div>
|
||||
|
||||
<form @onsubmit="HandleLoginAsync" class="auth-form">
|
||||
<form @onsubmit="HandleLoginAsync" @onsubmit:preventDefault="true" class="auth-form">
|
||||
<div class="form-group">
|
||||
<label for="username">관리자 아이디</label>
|
||||
<input type="text" id="username" class="form-control" @bind="Username" placeholder="아이디를 입력하세요" autocomplete="username" />
|
||||
@@ -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<LoginResponse>();
|
||||
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("");
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/quant/" />
|
||||
<base href="/" />
|
||||
<ResourcePreloader />
|
||||
<!-- Fluent UI CSS -->
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
|
||||
@@ -12,7 +12,8 @@
|
||||
<link rel="stylesheet" href="@Assets["app.css"]" />
|
||||
<link rel="stylesheet" href="@Assets["QuantEngine.Web.styles.css"]" />
|
||||
<ImportMap />
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||
<link rel="alternate icon" type="image/png" href="favicon.png" />
|
||||
<HeadOutlet @rendermode="InteractiveWebAssembly" />
|
||||
</head>
|
||||
|
||||
|
||||
@@ -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<LocalStorageService>();
|
||||
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthenticationStateProvider>();
|
||||
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<IDbConnectionFactory>(new DbConnectionFactory(connectionString));
|
||||
builder.Services.AddSingleton<DbMigrator>();
|
||||
builder.Services.AddScoped<IWorkspaceRepository, WorkspaceRepository>();
|
||||
builder.Services.AddScoped<IPostgresqlHistoryStore, PostgresqlHistoryStore>();
|
||||
builder.Services.AddScoped<IPostgresqlHistorySnapshotReader, PostgresqlHistorySnapshotReader>();
|
||||
@@ -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<DbMigrator>();
|
||||
var tokenCache = scope.ServiceProvider.GetRequiredService<ITokenCache>();
|
||||
var collectionRepo = scope.ServiceProvider.GetRequiredService<ICollectionRepository>();
|
||||
var workspaceRepo = scope.ServiceProvider.GetRequiredService<IWorkspaceRepository>();
|
||||
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="QuantEngine favicon">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#00f2fe"/>
|
||||
<stop offset="100%" stop-color="#4facfe"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="16" fill="#0b1020"/>
|
||||
<circle cx="32" cy="32" r="22" fill="url(#g)" opacity="0.18"/>
|
||||
<path d="M20 24h12c7.2 0 12 4.5 12 10.8 0 4.6-2.4 8.1-6.5 9.8L45 44h-8l-6.2-6.2H28V44h-8V24Zm8 6v4h4.6c2 0 3.4-.8 3.4-2.1 0-1.4-1.4-1.9-3.3-1.9H28Z" fill="url(#g)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 602 B |
@@ -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
|
||||
|
||||
@@ -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."
|
||||
Reference in New Issue
Block a user