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:
@@ -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 |
Reference in New Issue
Block a user