Authentication implementation

This commit is contained in:
2025-11-30 23:48:58 -05:00
parent 382fffe1d4
commit e5bf3692f6
12 changed files with 520 additions and 48 deletions
+98 -17
View File
@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
@@ -7,28 +7,109 @@ namespace WebApp.Authentication
{
public class AuthController : Controller
{
[HttpPost]
[AllowAnonymous]
public async Task<IActionResult> CookieLogin(string email, string password)
private readonly AuthenticationService _authService;
private readonly LoginRateLimitService _rateLimitService;
private readonly ILogger<AuthController> _logger;
public AuthController(
AuthenticationService authService,
LoginRateLimitService rateLimitService,
ILogger<AuthController> logger)
{
// Based on: https://www.codeproject.com/articles/Understanding-authentication-in-Blazor-and-ASP-NET
// TODO: Fix this up
// Generate the claims
var claims = new List<Claim>();
claims.Add(new Claim(ClaimTypes.Name, "John Patton"));
claims.Add(new Claim(ClaimTypes.Role, "Contributor"));
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "Auth"));
await HttpContext.SignInAsync("Auth", principal).ConfigureAwait(false);
return Redirect("/");
_authService = authService;
_rateLimitService = rateLimitService;
_logger = logger;
}
[HttpPost]
[HttpGet] // Support both for navigation from Blazor
[AllowAnonymous]
public async Task<IActionResult> CookieLogin(
string email,
string password,
bool rememberMe = false)
{
try
{
// Get client IP
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
// Check rate limiting
if (_rateLimitService.IsLockedOut(ipAddress))
{
var remaining = _rateLimitService.GetRemainingLockoutTime(ipAddress);
_logger.LogWarning(
"Login attempt from locked out IP: {IpAddress}. Remaining: {Remaining}",
ipAddress, remaining);
TempData["LoginError"] = $"Too many failed attempts. Try again in {remaining?.Minutes ?? 15} minutes.";
return Redirect("/login");
}
// Validate credentials
var result = _authService.ValidateCredentials(email, password);
if (!result.IsSuccess)
{
// Record failed attempt
_rateLimitService.RecordFailedAttempt(ipAddress);
_logger.LogWarning(
"Failed login attempt for {Email} from {IpAddress}",
email, ipAddress);
TempData["LoginError"] = "Invalid email or password.";
return Redirect("/login");
}
// Success - clear rate limit tracking
_rateLimitService.RecordSuccessfulLogin(ipAddress);
// Create claims
var claims = new List<Claim>
{
new Claim(ClaimTypes.Name, result.DisplayName!),
new Claim(ClaimTypes.Email, result.Email!),
new Claim(ClaimTypes.Role, result.Role!)
};
var claimsIdentity = new ClaimsIdentity(claims, "Auth");
var claimsPrincipal = new ClaimsPrincipal(claimsIdentity);
// Configure auth properties
var authProperties = new AuthenticationProperties
{
IsPersistent = rememberMe,
ExpiresUtc = rememberMe
? DateTimeOffset.UtcNow.AddDays(30)
: DateTimeOffset.UtcNow.AddMinutes(20)
};
await HttpContext.SignInAsync("Auth", claimsPrincipal, authProperties);
_logger.LogInformation(
"Successful login for {Email} ({Role}) from {IpAddress}",
result.Email, result.Role, ipAddress);
return Redirect("/");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during login process");
TempData["LoginError"] = "An error occurred. Please try again.";
return Redirect("/login");
}
}
[HttpPost]
[Authorize]
public async Task<IActionResult> CookieLogout()
{
await HttpContext.SignOutAsync("Auth").ConfigureAwait(false);
var userEmail = User.FindFirst(ClaimTypes.Email)?.Value;
await HttpContext.SignOutAsync("Auth");
_logger.LogInformation("User {Email} logged out", userEmail);
return Redirect("/login");
}
+7
View File
@@ -0,0 +1,7 @@
namespace WebApp.Authentication;
public static class AuthRoles
{
public const string Administrator = "Administrator";
public const string Advisor = "Advisor";
}
@@ -0,0 +1,72 @@
using Microsoft.Extensions.Configuration;
namespace WebApp.Authentication;
public class AuthenticationService
{
private readonly IConfiguration _configuration;
private readonly ILogger<AuthenticationService> _logger;
public AuthenticationService(IConfiguration configuration, ILogger<AuthenticationService> logger)
{
_configuration = configuration;
_logger = logger;
}
public AuthenticationResult ValidateCredentials(string email, string password)
{
try
{
// Bind User Secrets to AuthenticationSettings
var authSettings = new AuthenticationSettings();
_configuration.GetSection("Authentication").Bind(authSettings);
if (authSettings.Users == null || !authSettings.Users.Any())
{
_logger.LogWarning("No users configured in authentication settings");
return AuthenticationResult.Failed("Authentication system not configured");
}
// Find user by email (case-insensitive)
var user = authSettings.Users
.FirstOrDefault(u => u.Email.Equals(email, StringComparison.OrdinalIgnoreCase));
if (user == null)
{
_logger.LogDebug("User not found: {Email}", email);
return AuthenticationResult.Failed("Invalid email or password");
}
// Verify password using BCrypt
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
{
_logger.LogDebug("Invalid password for user: {Email}", email);
return AuthenticationResult.Failed("Invalid email or password");
}
// Success
_logger.LogDebug("Successful credential validation for {Email}", email);
return AuthenticationResult.Success(user.Email, user.DisplayName, user.Role);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during credential validation");
return AuthenticationResult.Failed("An error occurred during authentication");
}
}
}
public class AuthenticationResult
{
public bool IsSuccess { get; set; }
public string? Email { get; set; }
public string? DisplayName { get; set; }
public string? Role { get; set; }
public string? ErrorMessage { get; set; }
public static AuthenticationResult Success(string email, string displayName, string role)
=> new() { IsSuccess = true, Email = email, DisplayName = displayName, Role = role };
public static AuthenticationResult Failed(string error)
=> new() { IsSuccess = false, ErrorMessage = error };
}
@@ -0,0 +1,6 @@
namespace WebApp.Authentication;
public class AuthenticationSettings
{
public List<UserCredential> Users { get; set; } = new();
}
@@ -0,0 +1,135 @@
using System.Collections.Concurrent;
namespace WebApp.Authentication;
public class LoginRateLimitService : IHostedService, IDisposable
{
private readonly ConcurrentDictionary<string, LoginAttemptTracker> _attempts = new();
private readonly ILogger<LoginRateLimitService> _logger;
private Timer? _cleanupTimer;
private const int MaxAttempts = 5;
private static readonly TimeSpan LockoutDuration = TimeSpan.FromMinutes(15);
private static readonly TimeSpan CleanupInterval = TimeSpan.FromHours(1);
public class LoginAttemptTracker
{
public int FailedAttempts { get; set; }
public DateTime? LockoutUntil { get; set; }
public DateTime LastAttemptTime { get; set; } = DateTime.UtcNow;
}
public LoginRateLimitService(ILogger<LoginRateLimitService> logger)
{
_logger = logger;
}
public bool IsLockedOut(string ipAddress)
{
if (!_attempts.TryGetValue(ipAddress, out var tracker))
return false;
if (tracker.LockoutUntil.HasValue && tracker.LockoutUntil > DateTime.UtcNow)
return true;
// Lockout expired, reset
if (tracker.LockoutUntil.HasValue)
{
tracker.FailedAttempts = 0;
tracker.LockoutUntil = null;
_logger.LogInformation("Lockout expired for IP: {IpAddress}", ipAddress);
}
return false;
}
public void RecordFailedAttempt(string ipAddress)
{
var tracker = _attempts.GetOrAdd(ipAddress, _ => new LoginAttemptTracker());
tracker.FailedAttempts++;
tracker.LastAttemptTime = DateTime.UtcNow;
if (tracker.FailedAttempts >= MaxAttempts)
{
tracker.LockoutUntil = DateTime.UtcNow.Add(LockoutDuration);
_logger.LogWarning(
"IP address locked out due to {Attempts} failed login attempts: {IpAddress}. Lockout until: {LockoutUntil}",
tracker.FailedAttempts, ipAddress, tracker.LockoutUntil);
}
else
{
_logger.LogInformation(
"Failed login attempt {Attempt}/{MaxAttempts} for IP: {IpAddress}",
tracker.FailedAttempts, MaxAttempts, ipAddress);
}
}
public void RecordSuccessfulLogin(string ipAddress)
{
if (_attempts.TryRemove(ipAddress, out _))
{
_logger.LogDebug("Cleared rate limit tracking for IP: {IpAddress}", ipAddress);
}
}
public TimeSpan? GetRemainingLockoutTime(string ipAddress)
{
if (!_attempts.TryGetValue(ipAddress, out var tracker) ||
!tracker.LockoutUntil.HasValue)
return null;
var remaining = tracker.LockoutUntil.Value - DateTime.UtcNow;
return remaining > TimeSpan.Zero ? remaining : null;
}
// Background cleanup to prevent memory leaks
private void CleanupExpiredEntries(object? state)
{
try
{
var cutoffTime = DateTime.UtcNow.AddHours(-24);
var expiredKeys = _attempts
.Where(kvp => kvp.Value.LastAttemptTime < cutoffTime &&
(!kvp.Value.LockoutUntil.HasValue || kvp.Value.LockoutUntil < DateTime.UtcNow))
.Select(kvp => kvp.Key)
.ToList();
foreach (var key in expiredKeys)
{
if (_attempts.TryRemove(key, out _))
{
_logger.LogDebug("Removed expired rate limit entry for IP: {IpAddress}", key);
}
}
if (expiredKeys.Any())
{
_logger.LogInformation("Cleaned up {Count} expired rate limit entries", expiredKeys.Count);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during rate limit cleanup");
}
}
// IHostedService implementation
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Login rate limiting service started");
_cleanupTimer = new Timer(CleanupExpiredEntries, null, CleanupInterval, CleanupInterval);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Login rate limiting service stopped");
_cleanupTimer?.Change(Timeout.Infinite, 0);
return Task.CompletedTask;
}
public void Dispose()
{
_cleanupTimer?.Dispose();
}
}
@@ -0,0 +1,32 @@
namespace WebApp.Authentication;
/// <summary>
/// Utility class for generating BCrypt password hashes for use in User Secrets configuration.
/// </summary>
public static class PasswordHashGenerator
{
/// <summary>
/// Generates a BCrypt hash for the given password with work factor 11.
/// </summary>
/// <param name="password">The password to hash</param>
/// <returns>BCrypt hash string suitable for storing in User Secrets</returns>
public static string GenerateHash(string password)
{
return BCrypt.Net.BCrypt.HashPassword(password, workFactor: 11);
}
/// <summary>
/// Generates hashes for multiple passwords at once.
/// </summary>
/// <param name="passwords">Dictionary of label/password pairs</param>
/// <returns>Dictionary of label/hash pairs</returns>
public static Dictionary<string, string> GenerateHashes(Dictionary<string, string> passwords)
{
var results = new Dictionary<string, string>();
foreach (var kvp in passwords)
{
results[kvp.Key] = GenerateHash(kvp.Value);
}
return results;
}
}
+9
View File
@@ -0,0 +1,9 @@
namespace WebApp.Authentication;
public class UserCredential
{
public string Email { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
public string Role { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
}