Add more secure form handling

This commit is contained in:
2025-12-02 22:36:41 -05:00
parent 1c38003027
commit 54875e970c
9 changed files with 76 additions and 75 deletions
+6 -8
View File
@@ -22,12 +22,11 @@ namespace WebApp.Authentication
} }
[HttpPost] [HttpPost]
[HttpGet] // Support both for navigation from Blazor
[AllowAnonymous] [AllowAnonymous]
public async Task<IActionResult> CookieLogin( public async Task<IActionResult> CookieLogin(
string email, [FromForm] string email,
string password, [FromForm] string password,
bool rememberMe = false) [FromForm] bool rememberMe = false)
{ {
try try
{ {
@@ -42,8 +41,8 @@ namespace WebApp.Authentication
"Login attempt from locked out IP: {IpAddress}. Remaining: {Remaining}", "Login attempt from locked out IP: {IpAddress}. Remaining: {Remaining}",
ipAddress, remaining); ipAddress, remaining);
TempData["LoginError"] = $"Too many failed attempts. Try again in {remaining?.Minutes ?? 15} minutes."; var errorMsg = Uri.EscapeDataString($"Too many failed attempts. Try again in {remaining?.Minutes ?? 15} minutes.");
return Redirect("/login"); return Redirect($"/login?error={errorMsg}");
} }
// Validate credentials // Validate credentials
@@ -58,8 +57,7 @@ namespace WebApp.Authentication
"Failed login attempt for {Email} from {IpAddress}", "Failed login attempt for {Email} from {IpAddress}",
email, ipAddress); email, ipAddress);
TempData["LoginError"] = "Invalid email or password."; return Redirect("/login?error=Invalid%20email%20or%20password.");
return Redirect("/login");
} }
// Success - clear rate limit tracking // Success - clear rate limit tracking
+62 -65
View File
@@ -3,9 +3,16 @@
@layout EmptyLayout @layout EmptyLayout
@using System.ComponentModel.DataAnnotations @using System.ComponentModel.DataAnnotations
@using WebApp.Authentication @using WebApp.Authentication
@using Microsoft.AspNetCore.Antiforgery
@using Microsoft.AspNetCore.Components.Web
@using MudInputType = MudBlazor.InputType
@using Microsoft.JSInterop
@using Microsoft.AspNetCore.WebUtilities
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject LoginRateLimitService RateLimitService @inject LoginRateLimitService RateLimitService
@inject IHttpContextAccessor HttpContextAccessor @inject IHttpContextAccessor HttpContextAccessor
@inject IAntiforgery Antiforgery
@inject IJSRuntime JS
<div class="d-flex justify-center align-center" style="min-height: 100vh;"> <div class="d-flex justify-center align-center" style="min-height: 100vh;">
<MudPaper Elevation="3" Class="pa-8" MaxWidth="400px" Style="width: 100%;"> <MudPaper Elevation="3" Class="pa-8" MaxWidth="400px" Style="width: 100%;">
@@ -21,17 +28,18 @@
<MudAlert Severity="Severity.Error" Class="mb-4">@_errorMessage</MudAlert> <MudAlert Severity="Severity.Error" Class="mb-4">@_errorMessage</MudAlert>
} }
<EditForm Model="@_loginModel" OnValidSubmit="HandleLogin"> <form id="loginForm" method="post" action="/Auth/CookieLogin" @onkeydown="HandleKeyDown">
<DataAnnotationsValidator /> <input type="hidden" name="__RequestVerificationToken" value="@_antiforgeryToken" />
<input type="hidden" id="emailInput" name="email" value="" />
<input type="hidden" id="passwordInput" name="password" value="" />
<input type="hidden" id="rememberMeInput" name="rememberMe" value="" />
<MudTextField @bind-Value="_loginModel.Email" <MudTextField @bind-Value="_loginModel.Email"
Label="Email" Label="Email"
Variant="Variant.Outlined" Variant="Variant.Outlined"
InputType="InputType.Email" InputType="MudInputType.Email"
Required="true" Required="true"
Class="mb-4" Class="mb-4" />
Disabled="@_isSubmitting"
For="@(() => _loginModel.Email)" />
<MudTextField @bind-Value="_loginModel.Password" <MudTextField @bind-Value="_loginModel.Password"
Label="Password" Label="Password"
@@ -39,107 +47,96 @@
InputType="@_passwordInput" InputType="@_passwordInput"
Required="true" Required="true"
Class="mb-4" Class="mb-4"
Disabled="@_isSubmitting"
Adornment="Adornment.End" Adornment="Adornment.End"
AdornmentIcon="@_passwordInputIcon" AdornmentIcon="@_passwordInputIcon"
OnAdornmentClick="TogglePasswordVisibility" OnAdornmentClick="TogglePasswordVisibility" />
For="@(() => _loginModel.Password)" />
<div class="d-flex justify-space-between align-center mb-4"> <div class="d-flex justify-space-between align-center mb-4">
<MudCheckBox @bind-Value="_loginModel.RememberMe" <MudCheckBox @bind-Value="_loginModel.RememberMe"
Label="Remember me?" Label="Remember me?"
Color="Color.Primary" Color="Color.Primary" />
Disabled="@_isSubmitting" />
</div> </div>
<MudButton ButtonType="ButtonType.Submit" <MudButton ButtonType="ButtonType.Button"
Variant="Variant.Filled" Variant="Variant.Filled"
Color="Color.Primary" Color="Color.Primary"
Size="Size.Large" Size="Size.Large"
FullWidth="true" FullWidth="true"
Disabled="@_isSubmitting"> OnClick="HandleFormSubmit">
@if (_isSubmitting) <span>Sign In</span>
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
<span>Signing in...</span>
}
else
{
<span>Sign In</span>
}
</MudButton> </MudButton>
</EditForm> </form>
</MudPaper> </MudPaper>
</div> </div>
@code { @code {
private LoginModel _loginModel = new(); private LoginModel _loginModel = new();
private string? _errorMessage; private string? _errorMessage;
private bool _isSubmitting = false;
private bool _passwordVisibility; private bool _passwordVisibility;
private InputType _passwordInput = InputType.Password; private MudInputType _passwordInput = MudInputType.Password;
private string _passwordInputIcon = Icons.Material.Filled.VisibilityOff; private string _passwordInputIcon = Icons.Material.Filled.VisibilityOff;
private string _antiforgeryToken = string.Empty;
protected override void OnInitialized()
{
// Generate antiforgery token
var httpContext = HttpContextAccessor.HttpContext;
if (httpContext != null)
{
var tokenSet = Antiforgery.GetAndStoreTokens(httpContext);
_antiforgeryToken = tokenSet.RequestToken ?? string.Empty;
}
// Check for error message from query parameter (set by controller on failed login)
var uri = new Uri(Navigation.Uri);
var queryParams = QueryHelpers.ParseQuery(uri.Query);
if (queryParams.TryGetValue("error", out var errorValue))
{
_errorMessage = errorValue.ToString();
}
}
private class LoginModel private class LoginModel
{ {
[Required(ErrorMessage = "Email is required")]
[EmailAddress(ErrorMessage = "Invalid email format")]
public string Email { get; set; } = string.Empty; 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 string Password { get; set; } = string.Empty;
public bool RememberMe { get; set; } public bool RememberMe { get; set; }
} }
private void TogglePasswordVisibility() private void TogglePasswordVisibility()
{ {
if (_isSubmitting) return;
_passwordVisibility = !_passwordVisibility; _passwordVisibility = !_passwordVisibility;
_passwordInputIcon = _passwordVisibility _passwordInputIcon = _passwordVisibility
? Icons.Material.Filled.Visibility ? Icons.Material.Filled.Visibility
: Icons.Material.Filled.VisibilityOff; : Icons.Material.Filled.VisibilityOff;
_passwordInput = _passwordVisibility _passwordInput = _passwordVisibility
? InputType.Text ? MudInputType.Text
: InputType.Password; : MudInputType.Password;
} }
private void HandleLogin() private async Task HandleKeyDown(KeyboardEventArgs e)
{ {
_isSubmitting = true; if (e.Key == "Enter")
_errorMessage = null;
try
{ {
// Get client IP address // Blur the active element to ensure MudTextField bindings update
var ipAddress = HttpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString() await JS.InvokeVoidAsync("eval", "document.activeElement.blur()");
?? "unknown";
// Check rate limiting // Small delay to allow bindings to process
if (RateLimitService.IsLockedOut(ipAddress)) await Task.Delay(50);
{
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 // Now submit the form
var uri = $"/Auth/CookieLogin?email={Uri.EscapeDataString(_loginModel.Email)}" + await HandleFormSubmit();
$"&password={Uri.EscapeDataString(_loginModel.Password)}" + }
$"&rememberMe={_loginModel.RememberMe}"; }
Navigation.NavigateTo(uri, forceLoad: true); private async Task HandleFormSubmit()
} {
catch (Exception ex) // Update hidden inputs with current model values, then submit the form
{ await JS.InvokeVoidAsync("eval", $@"
_errorMessage = "An error occurred during login. Please try again."; document.getElementById('emailInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Email)};
Console.WriteLine($"Login error: {ex}"); document.getElementById('passwordInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Password)};
} document.getElementById('rememberMeInput').value = '{_loginModel.RememberMe.ToString().ToLower()}';
finally document.getElementById('loginForm').submit();
{ ");
_isSubmitting = false;
}
} }
} }
@@ -11,6 +11,7 @@
<EditForm Model="EventDefinition" OnValidSubmit="OnValidSubmit" Enhance> <EditForm Model="EventDefinition" OnValidSubmit="OnValidSubmit" Enhance>
<AntiforgeryToken />
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<MudGrid> <MudGrid>
<MudItem xs="12" sm="7"> <MudItem xs="12" sm="7">
@@ -12,6 +12,7 @@
<EditForm Model="EventDefinition" OnValidSubmit="OnValidSubmit" Enhance> <EditForm Model="EventDefinition" OnValidSubmit="OnValidSubmit" Enhance>
<AntiforgeryToken />
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<MudGrid> <MudGrid>
<MudItem xs="12" sm="7"> <MudItem xs="12" sm="7">
@@ -11,6 +11,7 @@
<EditForm Model="Student" OnValidSubmit="OnValidSubmit" Enhance> <EditForm Model="Student" OnValidSubmit="OnValidSubmit" Enhance>
<AntiforgeryToken />
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<MudGrid> <MudGrid>
<MudItem xs="12" sm="7"> <MudItem xs="12" sm="7">
@@ -20,6 +20,7 @@ else
/* https://www.mudblazor.com/components/form */ /* https://www.mudblazor.com/components/form */
/* https://medium.com/@husainalbar/applying-mudblazor-for-crud-operations-in-our-blazor-project-a343037a52ef */ /* https://medium.com/@husainalbar/applying-mudblazor-for-crud-operations-in-our-blazor-project-a343037a52ef */
<EditForm method="post" Model="Student" OnValidSubmit="UpdateStudent" FormName="edit" Enhance> <EditForm method="post" Model="Student" OnValidSubmit="UpdateStudent" FormName="edit" Enhance>
<AntiforgeryToken />
<DataAnnotationsValidator/> <DataAnnotationsValidator/>
<MudGrid> <MudGrid>
<MudItem xs="12" sm="7"> <MudItem xs="12" sm="7">
@@ -11,6 +11,7 @@
<MudDivider /> <MudDivider />
<EditForm method="post" Model="Team" OnValidSubmit="AddTeam" FormName="create" Enhance> <EditForm method="post" Model="Team" OnValidSubmit="AddTeam" FormName="create" Enhance>
<AntiforgeryToken />
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<MudGrid> <MudGrid>
<MudItem xs="12" sm="7"> <MudItem xs="12" sm="7">
@@ -16,6 +16,7 @@
else else
{ {
<EditForm method="post" Model="Team" OnValidSubmit="UpdateTeam" FormName="edit" Enhance> <EditForm method="post" Model="Team" OnValidSubmit="UpdateTeam" FormName="edit" Enhance>
<AntiforgeryToken />
<DataAnnotationsValidator/> <DataAnnotationsValidator/>
<MudGrid> <MudGrid>
<MudItem xs="12" sm="7"> <MudItem xs="12" sm="7">
+2 -2
View File
@@ -38,8 +38,8 @@ builder.Services.AddDatabaseDeveloperPageExceptionFilter();
builder.Services.AddScoped<ClipboardService>(); builder.Services.AddScoped<ClipboardService>();
builder.Services.AddScoped<StateContainer>(); // Server-side // State container for maintaining state per user connection (Blazor Server)
builder.Services.AddSingleton<StateContainer>();//Client-side builder.Services.AddScoped<StateContainer>();
// Add authentication services // Add authentication services
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();