From e5bf3692f66aab8ad9b9627261ae8060b8604eda Mon Sep 17 00:00:00 2001 From: James Kolpack Date: Sun, 30 Nov 2025 23:48:58 -0500 Subject: [PATCH] Authentication implementation --- .gitignore | 1 + WebApp/Authentication/AuthController.cs | 115 +++++++++++-- WebApp/Authentication/AuthRoles.cs | 7 + .../Authentication/AuthenticationService.cs | 72 ++++++++ .../Authentication/AuthenticationSettings.cs | 6 + .../Authentication/LoginRateLimitService.cs | 135 +++++++++++++++ .../Authentication/PasswordHashGenerator.cs | 32 ++++ WebApp/Authentication/UserCredential.cs | 9 + WebApp/Components/Login.razor | 155 ++++++++++++++---- WebApp/Components/Pages/Import.razor | 3 +- WebApp/Program.cs | 32 +++- WebApp/WebApp.csproj | 1 + 12 files changed, 520 insertions(+), 48 deletions(-) create mode 100644 WebApp/Authentication/AuthRoles.cs create mode 100644 WebApp/Authentication/AuthenticationService.cs create mode 100644 WebApp/Authentication/AuthenticationSettings.cs create mode 100644 WebApp/Authentication/LoginRateLimitService.cs create mode 100644 WebApp/Authentication/PasswordHashGenerator.cs create mode 100644 WebApp/Authentication/UserCredential.cs diff --git a/.gitignore b/.gitignore index 39a6cba..f5cd2f6 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ _ReSharper*/ DataBackup/ *.db +/.claude/* diff --git a/WebApp/Authentication/AuthController.cs b/WebApp/Authentication/AuthController.cs index ce0cbed..c73d27d 100644 --- a/WebApp/Authentication/AuthController.cs +++ b/WebApp/Authentication/AuthController.cs @@ -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 CookieLogin(string email, string password) + private readonly AuthenticationService _authService; + private readonly LoginRateLimitService _rateLimitService; + private readonly ILogger _logger; + + public AuthController( + AuthenticationService authService, + LoginRateLimitService rateLimitService, + ILogger 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(); - 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 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 + { + 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 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"); } diff --git a/WebApp/Authentication/AuthRoles.cs b/WebApp/Authentication/AuthRoles.cs new file mode 100644 index 0000000..25b129a --- /dev/null +++ b/WebApp/Authentication/AuthRoles.cs @@ -0,0 +1,7 @@ +namespace WebApp.Authentication; + +public static class AuthRoles +{ + public const string Administrator = "Administrator"; + public const string Advisor = "Advisor"; +} diff --git a/WebApp/Authentication/AuthenticationService.cs b/WebApp/Authentication/AuthenticationService.cs new file mode 100644 index 0000000..43506b3 --- /dev/null +++ b/WebApp/Authentication/AuthenticationService.cs @@ -0,0 +1,72 @@ +using Microsoft.Extensions.Configuration; + +namespace WebApp.Authentication; + +public class AuthenticationService +{ + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public AuthenticationService(IConfiguration configuration, ILogger 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 }; +} diff --git a/WebApp/Authentication/AuthenticationSettings.cs b/WebApp/Authentication/AuthenticationSettings.cs new file mode 100644 index 0000000..77297a4 --- /dev/null +++ b/WebApp/Authentication/AuthenticationSettings.cs @@ -0,0 +1,6 @@ +namespace WebApp.Authentication; + +public class AuthenticationSettings +{ + public List Users { get; set; } = new(); +} diff --git a/WebApp/Authentication/LoginRateLimitService.cs b/WebApp/Authentication/LoginRateLimitService.cs new file mode 100644 index 0000000..b0731ba --- /dev/null +++ b/WebApp/Authentication/LoginRateLimitService.cs @@ -0,0 +1,135 @@ +using System.Collections.Concurrent; + +namespace WebApp.Authentication; + +public class LoginRateLimitService : IHostedService, IDisposable +{ + private readonly ConcurrentDictionary _attempts = new(); + private readonly ILogger _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 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(); + } +} diff --git a/WebApp/Authentication/PasswordHashGenerator.cs b/WebApp/Authentication/PasswordHashGenerator.cs new file mode 100644 index 0000000..632f1d6 --- /dev/null +++ b/WebApp/Authentication/PasswordHashGenerator.cs @@ -0,0 +1,32 @@ +namespace WebApp.Authentication; + +/// +/// Utility class for generating BCrypt password hashes for use in User Secrets configuration. +/// +public static class PasswordHashGenerator +{ + /// + /// Generates a BCrypt hash for the given password with work factor 11. + /// + /// The password to hash + /// BCrypt hash string suitable for storing in User Secrets + public static string GenerateHash(string password) + { + return BCrypt.Net.BCrypt.HashPassword(password, workFactor: 11); + } + + /// + /// Generates hashes for multiple passwords at once. + /// + /// Dictionary of label/password pairs + /// Dictionary of label/hash pairs + public static Dictionary GenerateHashes(Dictionary passwords) + { + var results = new Dictionary(); + foreach (var kvp in passwords) + { + results[kvp.Key] = GenerateHash(kvp.Value); + } + return results; + } +} diff --git a/WebApp/Authentication/UserCredential.cs b/WebApp/Authentication/UserCredential.cs new file mode 100644 index 0000000..bf0a65c --- /dev/null +++ b/WebApp/Authentication/UserCredential.cs @@ -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; +} diff --git a/WebApp/Components/Login.razor b/WebApp/Components/Login.razor index 7bab714..9ed4f60 100644 --- a/WebApp/Components/Login.razor +++ b/WebApp/Components/Login.razor @@ -1,44 +1,143 @@ -@page "/login" +@page "/login" +@using System.ComponentModel.DataAnnotations +@using WebApp.Authentication +@inject NavigationManager Navigation +@inject LoginRateLimitService RateLimitService +@inject IHttpContextAccessor HttpContextAccessor -

Login

+
+ + + TSA Chapter Organizer + + + Sign In + -
- -
+ @if (!string.IsNullOrEmpty(_errorMessage)) + { + @_errorMessage + } -Sign In + + -@* *@ + - + -
- - Forgot pwd? +
+ +
+ + + @if (_isSubmitting) + { + + Signing in... + } + else + { + Sign In + } + + +
-Sign In - @code { - string Password { get; set; } = "BMWvBPJXZu"; + private LoginModel _loginModel = new(); + private string? _errorMessage; + private bool _isSubmitting = false; + private bool _passwordVisibility; + private InputType _passwordInput = InputType.Password; + private string _passwordInputIcon = Icons.Material.Filled.VisibilityOff; - bool PasswordVisibility; - InputType PasswordInput = InputType.Password; - string PasswordInputIcon = Icons.Material.Filled.VisibilityOff; - - void TogglePasswordVisibility() + private class LoginModel { - @if (PasswordVisibility) + [Required(ErrorMessage = "Email is required")] + [EmailAddress(ErrorMessage = "Invalid email format")] + public string Email { get; set; } = string.Empty; + + [Required(ErrorMessage = "Password is required")] + [MinLength(8, ErrorMessage = "Password must be at least 8 characters")] + public string Password { get; set; } = string.Empty; + + public bool RememberMe { get; set; } + } + + private void TogglePasswordVisibility() + { + if (_isSubmitting) return; + + _passwordVisibility = !_passwordVisibility; + _passwordInputIcon = _passwordVisibility + ? Icons.Material.Filled.Visibility + : Icons.Material.Filled.VisibilityOff; + _passwordInput = _passwordVisibility + ? InputType.Text + : InputType.Password; + } + + private async Task HandleLogin() + { + _isSubmitting = true; + _errorMessage = null; + + try { - PasswordVisibility = false; - PasswordInputIcon = Icons.Material.Filled.VisibilityOff; - PasswordInput = InputType.Password; + // Get client IP address + var ipAddress = HttpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString() + ?? "unknown"; + + // Check rate limiting + if (RateLimitService.IsLockedOut(ipAddress)) + { + var remaining = RateLimitService.GetRemainingLockoutTime(ipAddress); + _errorMessage = $"Too many failed attempts. Please try again in {remaining?.Minutes ?? 15} minutes."; + return; + } + + // Navigate to controller endpoint with credentials + var uri = $"/Auth/CookieLogin?email={Uri.EscapeDataString(_loginModel.Email)}" + + $"&password={Uri.EscapeDataString(_loginModel.Password)}" + + $"&rememberMe={_loginModel.RememberMe}"; + + Navigation.NavigateTo(uri, forceLoad: true); } - else + catch (Exception ex) { - PasswordVisibility = true; - PasswordInputIcon = Icons.Material.Filled.Visibility; - PasswordInput = InputType.Text; + _errorMessage = "An error occurred during login. Please try again."; + Console.WriteLine($"Login error: {ex}"); + } + finally + { + _isSubmitting = false; } } -} \ No newline at end of file +} diff --git a/WebApp/Components/Pages/Import.razor b/WebApp/Components/Pages/Import.razor index 310a04f..c6974d2 100644 --- a/WebApp/Components/Pages/Import.razor +++ b/WebApp/Components/Pages/Import.razor @@ -1,7 +1,8 @@ @page "/import" -@attribute [Authorize] +@attribute [Authorize(Roles = AuthRoles.Administrator)] @using Core.Parsers @using Microsoft.EntityFrameworkCore +@using WebApp.Authentication @inject AppDbContext Context @rendermode InteractiveServer diff --git a/WebApp/Program.cs b/WebApp/Program.cs index a9ff8ec..1c2bf9c 100644 --- a/WebApp/Program.cs +++ b/WebApp/Program.cs @@ -2,6 +2,7 @@ using Data; using Microsoft.EntityFrameworkCore; using MudBlazor.Services; using WebApp; +using WebApp.Authentication; using WebApp.Components; var builder = WebApplication.CreateBuilder(args); @@ -24,7 +25,13 @@ builder.Services.AddDatabaseDeveloperPageExceptionFilter(); builder.Services.AddScoped(); builder.Services.AddScoped(); // Server-side -builder.Services.AddSingleton();//Client-side +builder.Services.AddSingleton();//Client-side + +// Add authentication services +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); // Add authentication options builder.Services.AddAuthentication("Auth") @@ -33,6 +40,12 @@ builder.Services.AddAuthentication("Auth") options.ExpireTimeSpan = TimeSpan.FromMinutes(20); options.SlidingExpiration = true; options.LoginPath = "/login"; + + // Enhanced security settings + options.Cookie.HttpOnly = true; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + options.Cookie.SameSite = SameSiteMode.Strict; + options.Cookie.Name = "TSA.Auth"; }); builder.Services.AddCascadingAuthenticationState(); @@ -47,7 +60,7 @@ if (!app.Environment.IsDevelopment()) app.UseMigrationsEndPoint(); } -//app.UseHttpsRedirection(); +app.UseHttpsRedirection(); app.UseRouting(); app.UseAuthentication(); @@ -62,4 +75,19 @@ app.MapRazorComponents() // Used for AuthController app.MapControllerRoute("default", "{controller}/{action}"); +// Development-only password hash generator endpoint +if (app.Environment.IsDevelopment()) +{ + app.MapGet("/dev/hash-password", (string password) => + { + var hash = PasswordHashGenerator.GenerateHash(password); + return Results.Ok(new + { + password, + hash, + message = "Copy the hash value to your User Secrets configuration" + }); + }).WithName("GeneratePasswordHash"); +} + app.Run(); diff --git a/WebApp/WebApp.csproj b/WebApp/WebApp.csproj index e868219..b0a87eb 100644 --- a/WebApp/WebApp.csproj +++ b/WebApp/WebApp.csproj @@ -12,6 +12,7 @@ +