Add more secure form handling
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user