Authentication implementation
This commit is contained in:
@@ -23,3 +23,4 @@ _ReSharper*/
|
||||
|
||||
DataBackup/
|
||||
*.db
|
||||
/.claude/*
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+133
-34
@@ -1,44 +1,143 @@
|
||||
@page "/login"
|
||||
@page "/login"
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using WebApp.Authentication
|
||||
@inject NavigationManager Navigation
|
||||
@inject LoginRateLimitService RateLimitService
|
||||
@inject IHttpContextAccessor HttpContextAccessor
|
||||
|
||||
<h3>Login</h3>
|
||||
<div class="d-flex justify-center align-center" style="min-height: 100vh;">
|
||||
<MudPaper Elevation="3" Class="pa-8" MaxWidth="400px" Style="width: 100%;">
|
||||
<MudText Typo="Typo.h4" Align="Align.Center" GutterBottom="true">
|
||||
TSA Chapter Organizer
|
||||
</MudText>
|
||||
<MudText Typo="Typo.h6" Align="Align.Center" GutterBottom="true" Class="mb-6">
|
||||
Sign In
|
||||
</MudText>
|
||||
|
||||
<form action="Auth/CookieLogin" method="post" >
|
||||
<button type="submit" class="btn btn-primary">Login</button>
|
||||
</form>
|
||||
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Sign In</MudText>
|
||||
|
||||
@* <MudTextField T="string" Value="@("staff@mudblazor.com")" Label="E-mail" Variant="Variant.Outlined" Class="my-6"></MudTextField> *@
|
||||
|
||||
<MudTextField @bind-Value="@Password" Label="Password" Variant="Variant.Outlined" InputType="@PasswordInput" Adornment="Adornment.End" AdornmentIcon="@PasswordInputIcon" OnAdornmentClick="TogglePasswordVisibility" />
|
||||
|
||||
<div class="d-flex justify-space-between align-center">
|
||||
<MudCheckBox T="bool" Label="Remember me?" Color="Color.Primary" Class="ml-n1 my-3"></MudCheckBox>
|
||||
<MudLink Href="/pages/authentication/forgot-password">Forgot pwd?</MudLink>
|
||||
</div>
|
||||
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Link="/personal/dashboard" Size="Size.Large" FullWidth="true">Sign In</MudButton>
|
||||
|
||||
@code {
|
||||
string Password { get; set; } = "BMWvBPJXZu";
|
||||
|
||||
bool PasswordVisibility;
|
||||
InputType PasswordInput = InputType.Password;
|
||||
string PasswordInputIcon = Icons.Material.Filled.VisibilityOff;
|
||||
|
||||
void TogglePasswordVisibility()
|
||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
@if (PasswordVisibility)
|
||||
<MudAlert Severity="Severity.Error" Class="mb-4">@_errorMessage</MudAlert>
|
||||
}
|
||||
|
||||
<EditForm Model="@_loginModel" OnValidSubmit="HandleLogin">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<MudTextField @bind-Value="_loginModel.Email"
|
||||
Label="Email"
|
||||
Variant="Variant.Outlined"
|
||||
InputType="InputType.Email"
|
||||
Required="true"
|
||||
Class="mb-4"
|
||||
Disabled="@_isSubmitting"
|
||||
For="@(() => _loginModel.Email)" />
|
||||
|
||||
<MudTextField @bind-Value="_loginModel.Password"
|
||||
Label="Password"
|
||||
Variant="Variant.Outlined"
|
||||
InputType="@_passwordInput"
|
||||
Required="true"
|
||||
Class="mb-4"
|
||||
Disabled="@_isSubmitting"
|
||||
Adornment="Adornment.End"
|
||||
AdornmentIcon="@_passwordInputIcon"
|
||||
OnAdornmentClick="TogglePasswordVisibility"
|
||||
For="@(() => _loginModel.Password)" />
|
||||
|
||||
<div class="d-flex justify-space-between align-center mb-4">
|
||||
<MudCheckBox @bind-Value="_loginModel.RememberMe"
|
||||
Label="Remember me?"
|
||||
Color="Color.Primary"
|
||||
Disabled="@_isSubmitting" />
|
||||
</div>
|
||||
|
||||
<MudButton ButtonType="ButtonType.Submit"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Large"
|
||||
FullWidth="true"
|
||||
Disabled="@_isSubmitting">
|
||||
@if (_isSubmitting)
|
||||
{
|
||||
PasswordVisibility = false;
|
||||
PasswordInputIcon = Icons.Material.Filled.VisibilityOff;
|
||||
PasswordInput = InputType.Password;
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
|
||||
<span>Signing in...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
PasswordVisibility = true;
|
||||
PasswordInputIcon = Icons.Material.Filled.Visibility;
|
||||
PasswordInput = InputType.Text;
|
||||
<span>Sign In</span>
|
||||
}
|
||||
</MudButton>
|
||||
</EditForm>
|
||||
</MudPaper>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
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;
|
||||
|
||||
private class LoginModel
|
||||
{
|
||||
[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
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = "An error occurred during login. Please try again.";
|
||||
Console.WriteLine($"Login error: {ex}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isSubmitting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+29
-1
@@ -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);
|
||||
@@ -26,6 +27,12 @@ builder.Services.AddScoped<ClipboardService>();
|
||||
builder.Services.AddScoped<StateContainer>(); // Server-side
|
||||
builder.Services.AddSingleton<StateContainer>();//Client-side
|
||||
|
||||
// Add authentication services
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<AuthenticationService>();
|
||||
builder.Services.AddSingleton<LoginRateLimitService>();
|
||||
builder.Services.AddHostedService<LoginRateLimitService>(sp => sp.GetRequiredService<LoginRateLimitService>());
|
||||
|
||||
// Add authentication options
|
||||
builder.Services.AddAuthentication("Auth")
|
||||
.AddCookie("Auth", options =>
|
||||
@@ -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<App>()
|
||||
// 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();
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="BlazorSortableList" Version="2.1.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="9.0.11" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter" Version="9.0.8" />
|
||||
|
||||
Reference in New Issue
Block a user