feat: Migrate admin UI from Blazor WASM/MudBlazor to Razor Pages/Cookie Auth/Tabler
- Remove QuantEngine.Web.Client from .sln (keep on disk for reference) - Replace Blazor Interactive WebAssembly with server-rendered Razor Pages - Implement Cookie Authentication (HttpOnly, SameSite=Lax, 12h expiry) - Add AuthService with BCrypt password hashing + auto-migration from SHA-256 - Implement IpLockoutService (3 strikes → 15-min ban) - Create Admin folder structure with Layout + shared partials - Implement Dashboard, Collection, Users index pages (base structure) - Remove hardcoded backdoors (master_recovery, dev auth bypass) - Remove hardcoded localhost:5265 URLs - Add Tabler UI base styling (Bootstrap 5 CDN + custom admin.css) - Update CLAUDE.md with new UI standards and auth policies - Build: 0 errors, 0 warnings (ready for dev testing) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,563 +1,182 @@
|
||||
using QuantEngine.Web.Components;
|
||||
using QuantEngine.Infrastructure.Data;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using QuantEngine.Web.Infrastructure;
|
||||
using Npgsql;
|
||||
using FastEndpoints;
|
||||
using QuantEngine.Infrastructure.Repositories;
|
||||
using QuantEngine.Infrastructure.Services;
|
||||
using QuantEngine.Core.Interfaces;
|
||||
using QuantEngine.Application.Services;
|
||||
using QuantEngine.Application.Interfaces;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
using static QuantEngine.Application.Services.DataCollectionService;
|
||||
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;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MudBlazor.Services;
|
||||
using QuantEngine.Web.Services;
|
||||
using Hangfire;
|
||||
using Npgsql;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
|
||||
// Serilog Configuration with Telegram Sink
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.WriteTo.Console()
|
||||
.WriteTo.Sink(new TelegramSink("8734507814:AAFyacLMai8GB4K-hQ_Nd3t3D01A-h1ZdV0", "-5460205872"))
|
||||
.WriteTo.File("logs/quantengine-.log", rollingInterval: RollingInterval.Day)
|
||||
.CreateLogger();
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Host.UseSerilog();
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddRazorPages();
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveWebAssemblyComponents();
|
||||
|
||||
// Authentication and Custom State Provider (Shared client components)
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
builder.Services.AddAuthentication("QuantAdminScheme")
|
||||
.AddScheme<AuthenticationSchemeOptions, QuantAdminAuthHandler>("QuantAdminScheme", _ => { });
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddScoped<LocalStorageService>();
|
||||
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthenticationStateProvider>();
|
||||
builder.Services.AddAuthorizationCore();
|
||||
|
||||
builder.Services.AddMudServices();
|
||||
|
||||
// PostgreSQL Dapper Setup
|
||||
var configuredConnectionString = builder.Configuration.GetConnectionString("DefaultConnection");
|
||||
var fallbackConnectionString = "Host=127.0.0.1;Database=quantenginedb;Username=quantengine_app;Password=CHANGE_ME;Search Path=quantengine;";
|
||||
var connectionString = string.IsNullOrWhiteSpace(configuredConnectionString) || configuredConnectionString.Contains("Password=;", StringComparison.OrdinalIgnoreCase)
|
||||
? fallbackConnectionString
|
||||
: configuredConnectionString;
|
||||
var configuredDatabase = new Npgsql.NpgsqlConnectionStringBuilder(connectionString).Database;
|
||||
if (!string.Equals(configuredDatabase, "quantenginedb", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("QuantEngine must use the quantenginedb PostgreSQL database.");
|
||||
}
|
||||
var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||
builder.Services.AddSingleton(dataSource);
|
||||
builder.Services.AddSingleton<IDbConnectionFactory>(new DbConnectionFactory(dataSource));
|
||||
builder.Services.AddSingleton<DbMigrator>();
|
||||
builder.Services.AddScoped<IWorkspaceRepository, WorkspaceRepository>();
|
||||
builder.Services.AddScoped<IPostgresqlHistoryStore, PostgresqlHistoryStore>();
|
||||
builder.Services.AddScoped<IPostgresqlHistorySnapshotReader, PostgresqlHistorySnapshotReader>();
|
||||
builder.Services.AddScoped<HistoryIngestionService>();
|
||||
|
||||
// Hangfire Background Job Scheduling
|
||||
try
|
||||
{
|
||||
var hangfireConnectionString = builder.Configuration.GetConnectionString("HangfireConnection") ?? connectionString;
|
||||
builder.Services.AddHangfireServices(hangfireConnectionString);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning("Hangfire initialization failed: {Message}", ex.Message);
|
||||
}
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Host.UseSerilog();
|
||||
|
||||
// Collection Pipeline Services (PostgreSQL-backed implementations)
|
||||
builder.Services.AddScoped<ICollectionRepository, CollectionRepository>();
|
||||
builder.Services.AddScoped<ITokenCache, PostgresTokenCache>();
|
||||
builder.Services.AddScoped<IKisApiClient, KisApiClient>();
|
||||
// Note: DataCollectionService has complex dependencies - will be enabled when DB is ready
|
||||
// builder.Services.AddScoped<PriceDataNormalizer>();
|
||||
// builder.Services.AddScoped<SourcePriorityResolver>();
|
||||
// builder.Services.AddScoped<ICollectionOrchestrator, KisDataCollectionOrchestrator>();
|
||||
// builder.Services.AddScoped<DataCollectionService>();
|
||||
// Authentication & Authorization
|
||||
builder.Services.AddAuthentication(opts =>
|
||||
{
|
||||
opts.DefaultAuthenticateScheme = AdminAuthDefaults.Scheme;
|
||||
opts.DefaultChallengeScheme = AdminAuthDefaults.Scheme;
|
||||
})
|
||||
.AddCookie(AdminAuthDefaults.Scheme, opts =>
|
||||
{
|
||||
opts.Cookie.Name = AdminAuthDefaults.CookieName;
|
||||
opts.Cookie.HttpOnly = true;
|
||||
opts.Cookie.SameSite = SameSiteMode.Lax;
|
||||
opts.Cookie.SecurePolicy = builder.Environment.IsProduction() ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
|
||||
opts.LoginPath = "/Account/Login";
|
||||
opts.AccessDeniedPath = "/Account/AccessDenied";
|
||||
opts.SlidingExpiration = true;
|
||||
opts.ExpireTimeSpan = TimeSpan.FromHours(12);
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient<ApiClient>(client =>
|
||||
{
|
||||
// Configure default base address for relative HttpClient calls within server assembly calls
|
||||
client.BaseAddress = new Uri("http://localhost:5265/");
|
||||
});
|
||||
builder.Services.AddScoped<ApiClient>();
|
||||
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri("http://localhost:5265/") });
|
||||
builder.Services.AddFastEndpoints();
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddAntiforgery();
|
||||
|
||||
var app = builder.Build();
|
||||
app.UseFastEndpoints();
|
||||
// Razor Pages with authorization conventions
|
||||
builder.Services.AddRazorPages(options =>
|
||||
{
|
||||
options.Conventions.AuthorizeFolder("/Admin", AdminAuthDefaults.Scheme);
|
||||
options.Conventions.AllowAnonymousToPage("/Account/Login");
|
||||
options.Conventions.AllowAnonymousToPage("/Account/AccessDenied");
|
||||
});
|
||||
|
||||
var adminSettings = app.Configuration.GetSection("AdminSettings");
|
||||
var adminUsername = adminSettings["Username"] ?? "admin";
|
||||
var adminPassword = adminSettings["Password"] ?? string.Empty;
|
||||
// Authentication Services
|
||||
builder.Services.AddScoped<IIpLockoutService, IpLockoutService>();
|
||||
builder.Services.AddScoped<AuthService>();
|
||||
|
||||
// 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>();
|
||||
// PostgreSQL Dapper Setup
|
||||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
||||
?? throw new InvalidOperationException("Connection string 'DefaultConnection' is required.");
|
||||
|
||||
var configuredDatabase = new NpgsqlConnectionStringBuilder(connectionString).Database;
|
||||
if (!string.Equals(configuredDatabase, "quantenginedb", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("QuantEngine must use the quantenginedb PostgreSQL database.");
|
||||
}
|
||||
|
||||
var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||
builder.Services.AddSingleton(dataSource);
|
||||
builder.Services.AddSingleton<IDbConnectionFactory>(new DbConnectionFactory(dataSource));
|
||||
builder.Services.AddSingleton<DbMigrator>();
|
||||
|
||||
// Repository Services
|
||||
builder.Services.AddScoped<IWorkspaceRepository, WorkspaceRepository>();
|
||||
builder.Services.AddScoped<IPostgresqlHistoryStore, PostgresqlHistoryStore>();
|
||||
builder.Services.AddScoped<IPostgresqlHistorySnapshotReader, PostgresqlHistorySnapshotReader>();
|
||||
builder.Services.AddScoped<HistoryIngestionService>();
|
||||
builder.Services.AddScoped<ICollectionRepository, CollectionRepository>();
|
||||
builder.Services.AddScoped<ITokenCache, PostgresTokenCache>();
|
||||
builder.Services.AddScoped<IKisApiClient, KisApiClient>();
|
||||
|
||||
// Hangfire Background Jobs
|
||||
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");
|
||||
var hangfireConnectionString = builder.Configuration.GetConnectionString("HangfireConnection") ?? connectionString;
|
||||
builder.Services.AddHangfireServices(hangfireConnectionString);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"Database initialization warning: {ex.Message}");
|
||||
Log.Warning("Hangfire initialization failed: {Message}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
app.UseHsts();
|
||||
}
|
||||
// FluentValidation
|
||||
builder.Services.AddValidatorsFromAssemblyContaining<QuantEngine.Infrastructure.Repositories.WorkspaceRepository>();
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
// FastEndpoints (for API endpoints)
|
||||
builder.Services.AddFastEndpoints();
|
||||
|
||||
// CRITICAL: Static assets MUST be served before StatusCodePages middleware
|
||||
// This ensures app.css, _framework/, and other static files are served correctly
|
||||
app.MapStaticAssets();
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure static file MIME types for Blazor
|
||||
var provider = new FileExtensionContentTypeProvider();
|
||||
provider.Mappings[".wasm"] = "application/wasm";
|
||||
provider.Mappings[".js"] = "application/javascript";
|
||||
provider.Mappings[".mjs"] = "application/javascript";
|
||||
provider.Mappings[".json"] = "application/json";
|
||||
provider.Mappings[".svg"] = "image/svg+xml";
|
||||
provider.Mappings[".woff"] = "font/woff";
|
||||
provider.Mappings[".woff2"] = "font/woff2";
|
||||
app.UseSerilogRequestLogging();
|
||||
app.UseFastEndpoints();
|
||||
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
ContentTypeProvider = provider,
|
||||
ServeUnknownFileTypes = true,
|
||||
DefaultContentType = "application/octet-stream"
|
||||
});
|
||||
|
||||
// Redirect status code pages only for non-API routes (AFTER static files)
|
||||
// Exclude /Account/* (Razor Pages) from 404 redirect
|
||||
app.UseStatusCodePages(async ctx =>
|
||||
{
|
||||
var path = ctx.HttpContext.Request.Path.Value ?? "";
|
||||
if (!path.StartsWith("/api", StringComparison.OrdinalIgnoreCase)
|
||||
&& !path.StartsWith("/Account/", StringComparison.OrdinalIgnoreCase))
|
||||
// Database migration on startup
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
ctx.HttpContext.Response.Redirect("/not-found");
|
||||
var migrator = scope.ServiceProvider.GetRequiredService<DbMigrator>();
|
||||
var workspaceRepo = scope.ServiceProvider.GetRequiredService<IWorkspaceRepository>();
|
||||
var collectionRepo = scope.ServiceProvider.GetRequiredService<ICollectionRepository>();
|
||||
var tokenCache = scope.ServiceProvider.GetRequiredService<ITokenCache>();
|
||||
|
||||
try
|
||||
{
|
||||
migrator.Migrate();
|
||||
await workspaceRepo.GetAccountsAsync();
|
||||
await collectionRepo.GetDashboardStateAsync();
|
||||
await tokenCache.GetCachedTokenAsync("_init_test_");
|
||||
Log.Information("Database migration and initialization successful");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!app.Environment.IsDevelopment())
|
||||
throw;
|
||||
Log.Warning("Database initialization warning (development only): {Message}", ex.Message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.UseAntiforgery();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
// Error handling & HSTS
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
app.UseHsts();
|
||||
}
|
||||
|
||||
// Initialize Hangfire (dashboard and schedules)
|
||||
try
|
||||
{
|
||||
app.UseHangfireSetup(app.Services);
|
||||
app.UseHttpsRedirection();
|
||||
app.UseStaticFiles();
|
||||
app.UseRouting();
|
||||
app.UseAntiforgery();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// Hangfire Dashboard
|
||||
try
|
||||
{
|
||||
app.UseHangfireSetup(app.Services);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning("Hangfire setup failed: {Message}", ex.Message);
|
||||
}
|
||||
|
||||
// Root redirect: unauthenticated → /Account/Login, authenticated → /Admin/Dashboard
|
||||
app.MapGet("/", context =>
|
||||
{
|
||||
if (context.User?.Identity?.IsAuthenticated ?? false)
|
||||
context.Response.Redirect("/Admin/Dashboard");
|
||||
else
|
||||
context.Response.Redirect("/Account/Login");
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
// Login redirect convenience route
|
||||
app.MapGet("/login", context =>
|
||||
{
|
||||
context.Response.Redirect("/Account/Login", permanent: false);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
app.MapRazorPages();
|
||||
|
||||
app.Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning("Hangfire setup failed: {Message}", ex.Message);
|
||||
Log.Fatal(ex, "Application terminated unexpectedly");
|
||||
throw;
|
||||
}
|
||||
|
||||
// Root path - redirect unauthenticated to /Account/Login (secure SSR Razor Page)
|
||||
app.MapGet("/", async (HttpContext ctx) =>
|
||||
finally
|
||||
{
|
||||
var isAuthenticated = ctx.User?.Identity?.IsAuthenticated ?? false;
|
||||
// Check cookie parity for server-side root routing redirect
|
||||
var hasCookie = ctx.Request.Cookies.ContainsKey("quant_auth_token");
|
||||
if (!isAuthenticated && !hasCookie)
|
||||
{
|
||||
ctx.Response.Redirect("/Account/Login");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Authenticated users get Blazor dashboard
|
||||
ctx.Response.Redirect("/dashboard");
|
||||
}
|
||||
await Task.CompletedTask;
|
||||
});
|
||||
|
||||
// Map /login to secure SSR Razor Page /Account/Login
|
||||
app.MapGet("/login", (HttpContext ctx) =>
|
||||
{
|
||||
ctx.Response.Redirect("/Account/Login", permanent: false);
|
||||
});
|
||||
|
||||
// Login API (API-First for Blazor WASM client authentication)
|
||||
app.MapPost("/api/auth/login", async (JsonElement payload, HttpContext httpContext, IWorkspaceRepository workspaceRepo, IWebHostEnvironment env) =>
|
||||
{
|
||||
static string? ReadString(JsonElement root, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return property.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
var username = ReadString(payload, "Username", "username");
|
||||
var password = ReadString(payload, "Password", "password");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
return Results.BadRequest(new { success = false, error = "missing_credentials" });
|
||||
}
|
||||
|
||||
WorkspaceAccount? account = null;
|
||||
try
|
||||
{
|
||||
account = await workspaceRepo.GetAccountByUsernameAsync(username.Trim());
|
||||
}
|
||||
catch (Exception dbEx)
|
||||
{
|
||||
// Database fallback for development: allow admin:admin
|
||||
Console.WriteLine($"[Login] Database lookup failed: {dbEx.Message}");
|
||||
if (string.Equals(username, "admin", StringComparison.OrdinalIgnoreCase) && string.Equals(password, "admin"))
|
||||
{
|
||||
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",
|
||||
devToken,
|
||||
new Microsoft.AspNetCore.Http.CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = devIsSecureEnv,
|
||||
SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax,
|
||||
Expires = devExpiresAt,
|
||||
Path = "/"
|
||||
}
|
||||
);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
success = true,
|
||||
username = "admin",
|
||||
role = "Admin",
|
||||
accessToken = devToken,
|
||||
expiresAt = devExpiresAt.ToString("O")
|
||||
});
|
||||
}
|
||||
return Results.Json(new { success = false, error = "database_unavailable" }, statusCode: 503);
|
||||
}
|
||||
|
||||
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(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
|
||||
});
|
||||
|
||||
// Set HTTP-only cookie for server-side authentication
|
||||
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 = isSecureEnv, // Dynamic SSL Secure binding based on active request env
|
||||
SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax,
|
||||
Expires = expiresAt,
|
||||
Path = "/"
|
||||
}
|
||||
);
|
||||
|
||||
Console.WriteLine($"[Auth/Login] Cookie append completed");
|
||||
Console.WriteLine($"[Auth/Login] Response headers count: {httpContext.Response.Headers.Count}");
|
||||
|
||||
// Also return token for localStorage backup (for SPA navigation)
|
||||
var result = Results.Ok(new
|
||||
{
|
||||
success = true,
|
||||
username = account.Username,
|
||||
role = account.Role,
|
||||
accessToken = rawToken,
|
||||
expiresAt = expiresAt.ToString("O")
|
||||
});
|
||||
|
||||
Console.WriteLine($"[Auth/Login] About to return 200 OK response");
|
||||
return result;
|
||||
}).DisableAntiforgery();
|
||||
|
||||
app.MapGet("/api/auth/me", async (HttpContext context, IWorkspaceRepository workspaceRepo) =>
|
||||
{
|
||||
// Try to get token from Bearer header first, then fall back to cookie
|
||||
var token = "";
|
||||
|
||||
var authHeader = context.Request.Headers.Authorization.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(authHeader) && authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
token = authHeader["Bearer ".Length..].Trim();
|
||||
}
|
||||
else if (context.Request.Cookies.TryGetValue("quant_auth_token", out var cookieToken))
|
||||
{
|
||||
token = cookieToken;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
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 });
|
||||
}
|
||||
catch (Exception dbEx)
|
||||
{
|
||||
// Database fallback for development: any token is valid for "admin" user
|
||||
Console.WriteLine($"[Auth/me] Database lookup failed: {dbEx.Message}");
|
||||
Console.WriteLine($"[Auth/me] Allowing token in dev mode for user 'admin'");
|
||||
return Results.Ok(new { authenticated = true, username = "admin", role = "Admin" });
|
||||
}
|
||||
});
|
||||
|
||||
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"));
|
||||
|
||||
// Clear authentication cookie
|
||||
context.Response.Cookies.Delete("quant_auth_token");
|
||||
|
||||
return Results.Ok(new { success = true });
|
||||
}).DisableAntiforgery();
|
||||
|
||||
app.MapPost("/api/auth/admin/reset-password", async (HttpContext context, JsonElement payload, IWorkspaceRepository workspaceRepo) =>
|
||||
{
|
||||
static string? ReadString(JsonElement root, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return property.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
var username = ReadString(payload, "adminUsername", "AdminUsername", "username", "Username");
|
||||
var password = ReadString(payload, "adminPassword", "AdminPassword", "password", "Password");
|
||||
var targetUsername = ReadString(payload, "targetUsername", "TargetUsername", "usernameToReset", "UsernameToReset");
|
||||
var newPassword = ReadString(payload, "newPassword", "NewPassword");
|
||||
|
||||
if (!string.Equals(username, adminUsername, StringComparison.Ordinal) || !string.Equals(password, adminPassword, StringComparison.Ordinal))
|
||||
{
|
||||
// Emergency master recovery payload key check bypass to safeguard operations
|
||||
var isMasterBypass = string.Equals(username, "master_recovery") && string.Equals(password, "QuantEngine_2026_RecoveryKey!");
|
||||
if (!isMasterBypass)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(targetUsername) || string.IsNullOrWhiteSpace(newPassword))
|
||||
{
|
||||
return Results.BadRequest(new { success = false, error = "missing_target_or_password" });
|
||||
}
|
||||
|
||||
var account = await workspaceRepo.GetAccountByUsernameAsync(targetUsername.Trim());
|
||||
if (account is null)
|
||||
{
|
||||
return Results.NotFound(new { success = false, error = "account_not_found" });
|
||||
}
|
||||
|
||||
var passwordHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(newPassword)));
|
||||
account.PasswordHash = passwordHash;
|
||||
account.UpdatedAt = DateTimeOffset.UtcNow.ToString("O");
|
||||
var updated = await workspaceRepo.UpsertAccountAsync(account);
|
||||
if (!updated)
|
||||
{
|
||||
return Results.StatusCode(500);
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
success = true,
|
||||
username = account.Username,
|
||||
updatedAt = account.UpdatedAt
|
||||
});
|
||||
}).DisableAntiforgery();
|
||||
|
||||
app.MapGet("/api/operational-report", async (IWebHostEnvironment env) =>
|
||||
{
|
||||
var path = Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "..", "..", "Temp", "operational_report.json"));
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return Results.NotFound(new { gate = "FAIL", error = "operational_report_missing" });
|
||||
}
|
||||
var json = await File.ReadAllTextAsync(path);
|
||||
// Directly return raw JSON string with correct Content-Type to bypass using-disposed JSON document serializer issue
|
||||
return Results.Content(json, "application/json");
|
||||
});
|
||||
|
||||
app.MapGet("/api/history/{domain}", async (string domain, int? limit, IPostgresqlHistorySnapshotReader reader) =>
|
||||
{
|
||||
var rows = await reader.ReadAsync(domain, limit ?? 500);
|
||||
return Results.Ok(new
|
||||
{
|
||||
formula_id = "POSTGRESQL_HISTORY_SNAPSHOT_API_V1",
|
||||
gate = "PASS",
|
||||
domain,
|
||||
limit = limit ?? 500,
|
||||
rows
|
||||
});
|
||||
});
|
||||
|
||||
app.MapPost("/api/history/{domain}", async (string domain, JsonElement payload, HistoryIngestionService ingestor) =>
|
||||
{
|
||||
if (payload.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return Results.BadRequest(new { gate = "FAIL", error = "payload_must_be_object" });
|
||||
}
|
||||
|
||||
var dict = JsonSerializer.Deserialize<Dictionary<string, object?>>(payload.GetRawText())
|
||||
?? new Dictionary<string, object?>();
|
||||
var affected = domain switch
|
||||
{
|
||||
"decision_result_history" => await ingestor.AppendDecisionAsync(dict),
|
||||
"factor_output_history" => await ingestor.AppendFactorOutputAsync(dict),
|
||||
"market_raw_history" => await ingestor.AppendMarketRawAsync(dict),
|
||||
"market_vs_engine_gap_history" => await ingestor.AppendGapAsync(dict),
|
||||
_ => -1
|
||||
};
|
||||
if (affected < 0)
|
||||
{
|
||||
return Results.BadRequest(new { gate = "FAIL", error = "unsupported_domain" });
|
||||
}
|
||||
return Results.Ok(new
|
||||
{
|
||||
formula_id = "POSTGRESQL_HISTORY_APPEND_API_V1",
|
||||
gate = "PASS",
|
||||
domain,
|
||||
affected
|
||||
});
|
||||
});
|
||||
|
||||
// Map Razor Pages FIRST - highest priority for /Account/* routes
|
||||
app.MapRazorPages();
|
||||
|
||||
// Map Blazor Components - catches all remaining routes
|
||||
app.MapRazorComponents<App>()
|
||||
.AddInteractiveWebAssemblyRenderMode()
|
||||
.AddAdditionalAssemblies(typeof(QuantEngine.Web.Client._Imports).Assembly);
|
||||
|
||||
app.Run();
|
||||
|
||||
internal sealed class QuantAdminAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public QuantAdminAuthHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
}
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
// Check quant_auth_token cookie for server-side authorization of static Page routes
|
||||
if (Request.Cookies.TryGetValue("quant_auth_token", out var token) && !string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
var claims = new[] {
|
||||
new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, "admin"),
|
||||
new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Role, "Admin")
|
||||
};
|
||||
var identity = new System.Security.Claims.ClaimsIdentity(claims, Scheme.Name);
|
||||
var principal = new System.Security.Claims.ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
}
|
||||
|
||||
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
|
||||
{
|
||||
// Redirect securely to Razor Page Login endpoint
|
||||
Response.Redirect("/Account/Login");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user