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:
2026-07-11 16:57:41 +09:00
parent 3ec0941f50
commit c57ad182b0
32 changed files with 1097 additions and 1135 deletions
@@ -0,0 +1,7 @@
namespace QuantEngine.Web.Services;
public static class AdminAuthDefaults
{
public const string Scheme = "AdminCookie";
public const string CookieName = "QuantEngine.Admin.Auth";
}
@@ -0,0 +1,68 @@
using QuantEngine.Core.Models;
using QuantEngine.Core.Interfaces;
using BCrypt.Net;
namespace QuantEngine.Web.Services;
public class AuthService
{
private readonly IWorkspaceRepository _workspaceRepository;
private readonly IIpLockoutService _lockoutService;
public AuthService(IWorkspaceRepository workspaceRepository, IIpLockoutService lockoutService)
{
_workspaceRepository = workspaceRepository;
_lockoutService = lockoutService;
}
public async Task<WorkspaceAccount?> AuthenticateAsync(string username, string password, string ipAddress)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
return null;
if (_lockoutService.IsLockedOut(ipAddress))
return null;
var account = await _workspaceRepository.GetAccountByUsernameAsync(username.Trim());
if (account is null || !string.Equals(account.IsActive, "true", StringComparison.OrdinalIgnoreCase))
{
_lockoutService.RecordFailedAttempt(ipAddress);
return null;
}
bool passwordMatches = false;
if (account.PasswordHash?.StartsWith("$2") == true)
{
try
{
passwordMatches = BCrypt.Net.BCrypt.Verify(password, account.PasswordHash);
}
catch
{
passwordMatches = false;
}
}
else if (!string.IsNullOrWhiteSpace(account.PasswordHash))
{
var hashedInput = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(password)));
if (string.Equals(account.PasswordHash, hashedInput, StringComparison.OrdinalIgnoreCase))
{
passwordMatches = true;
var bcryptedHash = BCrypt.Net.BCrypt.HashPassword(password);
account.PasswordHash = bcryptedHash;
account.UpdatedAt = DateTime.UtcNow.ToString("O");
await _workspaceRepository.UpsertAccountAsync(account);
}
}
if (!passwordMatches)
{
_lockoutService.RecordFailedAttempt(ipAddress);
return null;
}
_lockoutService.ClearFailedAttempts(ipAddress);
return account;
}
}
@@ -0,0 +1,61 @@
namespace QuantEngine.Web.Services;
public interface IIpLockoutService
{
bool IsLockedOut(string ipAddress);
void RecordFailedAttempt(string ipAddress);
void ClearFailedAttempts(string ipAddress);
}
public class IpLockoutService : IIpLockoutService
{
private readonly Dictionary<string, (int Attempts, DateTime LockedUntil)> _attemptLog = [];
private const int MaxFailedAttempts = 3;
private const int LockoutDurationMinutes = 15;
private readonly object _lock = new();
public bool IsLockedOut(string ipAddress)
{
lock (_lock)
{
if (_attemptLog.TryGetValue(ipAddress, out var record))
{
if (DateTime.UtcNow < record.LockedUntil)
return true;
_attemptLog.Remove(ipAddress);
}
return false;
}
}
public void RecordFailedAttempt(string ipAddress)
{
lock (_lock)
{
if (_attemptLog.TryGetValue(ipAddress, out var record))
{
record.Attempts++;
if (record.Attempts >= MaxFailedAttempts)
{
record.LockedUntil = DateTime.UtcNow.AddMinutes(LockoutDurationMinutes);
}
_attemptLog[ipAddress] = record;
}
else
{
_attemptLog[ipAddress] = (1, DateTime.UtcNow);
}
}
}
public void ClearFailedAttempts(string ipAddress)
{
lock (_lock)
{
_attemptLog.Remove(ipAddress);
}
}
}