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