diff --git a/Media.JoshHeaps.Net/Database/025_password_reset_tokens.sql b/Media.JoshHeaps.Net/Database/025_password_reset_tokens.sql new file mode 100644 index 0000000..187086d --- /dev/null +++ b/Media.JoshHeaps.Net/Database/025_password_reset_tokens.sql @@ -0,0 +1,12 @@ +CREATE TABLE app.password_reset_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + token_hash VARCHAR(64) NOT NULL, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ NULL, + FOREIGN KEY (user_id) REFERENCES app.users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_prt_token_hash ON app.password_reset_tokens(token_hash); +CREATE INDEX IF NOT EXISTS idx_prt_user_id ON app.password_reset_tokens(user_id); diff --git a/Media.JoshHeaps.Net/Pages/Gallery.cshtml b/Media.JoshHeaps.Net/Pages/Gallery.cshtml index 9c8fd76..94cf68b 100644 --- a/Media.JoshHeaps.Net/Pages/Gallery.cshtml +++ b/Media.JoshHeaps.Net/Pages/Gallery.cshtml @@ -21,6 +21,7 @@

Welcome back, @Model.Dashboard?.Username!

+
@if (Model.Dashboard?.EmailVerified == false) { diff --git a/Media.JoshHeaps.Net/Pages/Login.cshtml b/Media.JoshHeaps.Net/Pages/Login.cshtml index 79ee689..6e0637e 100644 --- a/Media.JoshHeaps.Net/Pages/Login.cshtml +++ b/Media.JoshHeaps.Net/Pages/Login.cshtml @@ -60,10 +60,13 @@
-
- - +
+
+ + +
+ Forgot password?
diff --git a/Media.JoshHeaps.Net/Pages/Login.cshtml.cs b/Media.JoshHeaps.Net/Pages/Login.cshtml.cs index ec68471..cf5a8a0 100644 --- a/Media.JoshHeaps.Net/Pages/Login.cshtml.cs +++ b/Media.JoshHeaps.Net/Pages/Login.cshtml.cs @@ -20,7 +20,7 @@ public class LoginModel(AuthService authService) : PageModel public string? SuccessMessage { get; set; } public string? WarningMessage { get; set; } - public void OnGet([FromQuery] string? registered, [FromQuery] string? verified) + public void OnGet([FromQuery] string? registered, [FromQuery] string? verified, [FromQuery] string? reset) { // Check if user is already logged in var userId = HttpContext.Session.GetString("UserId"); @@ -41,6 +41,12 @@ public class LoginModel(AuthService authService) : PageModel { SuccessMessage = "Email verified! You can now sign in."; } + + // Show success message if password was just reset + if (reset == "true") + { + SuccessMessage = "Your password has been reset. You can now sign in with your new password."; + } } public async Task OnPostAsync() diff --git a/Media.JoshHeaps.Net/Pages/LoginHelp.cshtml b/Media.JoshHeaps.Net/Pages/LoginHelp.cshtml new file mode 100644 index 0000000..7df47af --- /dev/null +++ b/Media.JoshHeaps.Net/Pages/LoginHelp.cshtml @@ -0,0 +1,102 @@ +@page +@model Media.JoshHeaps.Net.Pages.LoginHelpModel +@{ + ViewData["Title"] = "Login Help"; + Layout = "_Layout"; +} + +@section Styles { + +} + +@section Scripts { + +} + +
+
+ @if (Model.ShowResetForm) + { +
+

Reset Password

+

Enter your new password below

+
+ + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) + { +
+ @Model.ErrorMessage +
+ } + +
+ @Html.AntiForgeryToken() + + +
+ +
+ + +
+
+
+
+
+
+
+ +
+ +
+ + +
+
+
+ + +
+ } + else + { +
+

Forgot Password

+

Enter your email to receive a reset link

+
+ + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) + { +
+ @Model.ErrorMessage +
+ } + + @if (!string.IsNullOrEmpty(Model.SuccessMessage)) + { +
+ @Model.SuccessMessage +
+ } + +
+ @Html.AntiForgeryToken() + +
+ + +
+
+ + +
+ } + + +
+
diff --git a/Media.JoshHeaps.Net/Pages/LoginHelp.cshtml.cs b/Media.JoshHeaps.Net/Pages/LoginHelp.cshtml.cs new file mode 100644 index 0000000..f55031c --- /dev/null +++ b/Media.JoshHeaps.Net/Pages/LoginHelp.cshtml.cs @@ -0,0 +1,111 @@ +using Media.JoshHeaps.Net.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Media.JoshHeaps.Net.Pages; + +public class LoginHelpModel(AuthService authService, EmailService emailService, ILogger logger) : PageModel +{ + [BindProperty] + public string Email { get; set; } = string.Empty; + + [BindProperty] + public string Token { get; set; } = string.Empty; + + [BindProperty] + public string NewPassword { get; set; } = string.Empty; + + [BindProperty] + public string ConfirmPassword { get; set; } = string.Empty; + + public string? ErrorMessage { get; set; } + public string? SuccessMessage { get; set; } + public bool ShowResetForm { get; set; } + + public async Task OnGetAsync([FromQuery] string? token) + { + // Redirect if already logged in + var userId = HttpContext.Session.GetString("UserId"); + if (!string.IsNullOrEmpty(userId)) + return Redirect("/Landing"); + + if (!string.IsNullOrEmpty(token)) + { + var (valid, error) = await authService.ValidatePasswordResetTokenAsync(token); + if (valid) + { + ShowResetForm = true; + Token = token; + } + else + { + ErrorMessage = error; + } + } + + return Page(); + } + + public async Task OnPostRequestResetAsync() + { + // Redirect if already logged in + var userId = HttpContext.Session.GetString("UserId"); + if (!string.IsNullOrEmpty(userId)) + return Redirect("/Landing"); + + if (string.IsNullOrWhiteSpace(Email)) + { + ErrorMessage = "Please enter your email address."; + return Page(); + } + + var (success, error, token, username) = await authService.RequestPasswordResetAsync(Email.Trim()); + + if (!success) + { + logger.LogError("Password reset request failed for {Email}: {Error}", Email, error); + } + + // Send email if we got a token back (user exists and is eligible) + if (token != null) + { + await emailService.SendPasswordResetEmailAsync(Email.Trim(), username ?? Email.Split('@')[0], token); + } + + // Always show the same message regardless of whether the email exists + SuccessMessage = "If an account exists with that email, you will receive a password reset link shortly."; + return Page(); + } + + public async Task OnPostResetPasswordAsync() + { + // Redirect if already logged in + var userId = HttpContext.Session.GetString("UserId"); + if (!string.IsNullOrEmpty(userId)) + return Redirect("/Landing"); + + if (string.IsNullOrWhiteSpace(NewPassword) || NewPassword.Length < 8) + { + ErrorMessage = "Password must be at least 8 characters."; + ShowResetForm = true; + return Page(); + } + + if (NewPassword != ConfirmPassword) + { + ErrorMessage = "Passwords do not match."; + ShowResetForm = true; + return Page(); + } + + var (success, error) = await authService.ResetPasswordAsync(Token, NewPassword); + + if (!success) + { + ErrorMessage = error; + return Page(); + } + + return Redirect("/Login?reset=true"); + } +} diff --git a/Media.JoshHeaps.Net/Services/AuthService.cs b/Media.JoshHeaps.Net/Services/AuthService.cs index 5106df3..340e11b 100644 --- a/Media.JoshHeaps.Net/Services/AuthService.cs +++ b/Media.JoshHeaps.Net/Services/AuthService.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using Media.JoshHeaps.Net; using Media.JoshHeaps.Net.Models; @@ -302,4 +304,173 @@ public class AuthService(DbExecutor db) new { userId, lastLogin = DateTime.UtcNow } ); } + + public static string GenerateSecureToken() + { + var bytes = RandomNumberGenerator.GetBytes(32); + return Convert.ToBase64String(bytes) + .Replace("+", "-") + .Replace("/", "_") + .TrimEnd('='); + } + + public static string HashToken(string token) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + public async Task<(bool Success, string? Error, string? Token, string? Username)> RequestPasswordResetAsync(string email) + { + try + { + var userRow = await db.ExecuteReaderAsync( + "SELECT id, username, is_active, locked_until FROM app.users WHERE email = @email", + reader => new + { + UserId = reader.GetInt64(0), + Username = reader.GetString(1), + IsActive = reader.GetBoolean(2), + LockedUntil = reader.IsDBNull(3) ? (DateTime?)null : reader.GetDateTime(3) + }, + new { email } + ); + + if (userRow == null) + { + // Artificial delay to prevent timing-based email enumeration + await Task.Delay(Random.Shared.Next(100, 300)); + return (true, null, null, null); + } + + // Silently succeed for inactive/locked accounts (don't reveal state) + if (!userRow.IsActive || + (userRow.LockedUntil.HasValue && userRow.LockedUntil.Value > DateTime.UtcNow)) + { + return (true, null, null, null); + } + + // Rate limit: max 3 requests per hour + var recentCount = await db.ExecuteAsync( + @"SELECT COUNT(*) FROM app.password_reset_tokens + WHERE user_id = @userId AND created_at > @cutoff", + new { userId = userRow.UserId, cutoff = DateTimeOffset.UtcNow.AddHours(-1) } + ); + + if (recentCount >= 3) + { + return (true, null, null, null); + } + + // Invalidate all existing unused tokens for this user + await db.ExecuteNonQueryAsync( + @"UPDATE app.password_reset_tokens + SET used_at = @now + WHERE user_id = @userId AND used_at IS NULL", + new { userId = userRow.UserId, now = DateTimeOffset.UtcNow } + ); + + // Generate and store new token + var token = GenerateSecureToken(); + var tokenHash = HashToken(token); + var expiresAt = DateTimeOffset.UtcNow.AddHours(1); + + await db.ExecuteNonQueryAsync( + @"INSERT INTO app.password_reset_tokens (user_id, token_hash, expires_at) + VALUES (@userId, @tokenHash, @expiresAt)", + new { userId = userRow.UserId, tokenHash, expiresAt } + ); + + return (true, null, token, userRow.Username); + } + catch (Exception ex) + { + return (false, $"Password reset request failed: {ex.Message}", null, null); + } + } + + public async Task<(bool Valid, string? Error)> ValidatePasswordResetTokenAsync(string token) + { + try + { + var tokenHash = HashToken(token); + + var tokenRow = await db.ExecuteReaderAsync( + @"SELECT expires_at, used_at FROM app.password_reset_tokens + WHERE token_hash = @tokenHash", + reader => new + { + ExpiresAt = reader.GetFieldValue(0), + UsedAt = reader.IsDBNull(1) ? (DateTimeOffset?)null : reader.GetFieldValue(1) + }, + new { tokenHash } + ); + + if (tokenRow == null) + return (false, "Invalid or expired reset link. Please request a new one."); + + if (tokenRow.UsedAt.HasValue) + return (false, "This reset link has already been used. Please request a new one."); + + if (tokenRow.ExpiresAt < DateTimeOffset.UtcNow) + return (false, "This reset link has expired. Please request a new one."); + + return (true, null); + } + catch (Exception ex) + { + return (false, $"Token validation failed: {ex.Message}"); + } + } + + public async Task<(bool Success, string? Error)> ResetPasswordAsync(string token, string newPassword) + { + try + { + var tokenHash = HashToken(token); + + var tokenRow = await db.ExecuteReaderAsync( + @"SELECT id, user_id, expires_at, used_at FROM app.password_reset_tokens + WHERE token_hash = @tokenHash", + reader => new + { + Id = reader.GetInt64(0), + UserId = reader.GetInt64(1), + ExpiresAt = reader.GetFieldValue(2), + UsedAt = reader.IsDBNull(3) ? (DateTimeOffset?)null : reader.GetFieldValue(3) + }, + new { tokenHash } + ); + + if (tokenRow == null) + return (false, "Invalid or expired reset link. Please request a new one."); + + if (tokenRow.UsedAt.HasValue) + return (false, "This reset link has already been used. Please request a new one."); + + if (tokenRow.ExpiresAt < DateTimeOffset.UtcNow) + return (false, "This reset link has expired. Please request a new one."); + + // Hash new password and update user + var passwordHash = HashPassword(newPassword); + await db.ExecuteNonQueryAsync( + @"UPDATE app.users + SET password_hash = @passwordHash, failed_login_attempts = 0, locked_until = NULL + WHERE id = @userId", + new { userId = tokenRow.UserId, passwordHash } + ); + + // Mark token as used + await db.ExecuteNonQueryAsync( + "UPDATE app.password_reset_tokens SET used_at = @now WHERE id = @tokenId", + new { tokenId = tokenRow.Id, now = DateTimeOffset.UtcNow } + ); + + return (true, null); + } + catch (Exception ex) + { + return (false, $"Password reset failed: {ex.Message}"); + } + } } diff --git a/Media.JoshHeaps.Net/Services/EmailService.cs b/Media.JoshHeaps.Net/Services/EmailService.cs index 29d7970..c39b980 100644 --- a/Media.JoshHeaps.Net/Services/EmailService.cs +++ b/Media.JoshHeaps.Net/Services/EmailService.cs @@ -107,7 +107,7 @@ If you didn't create an account, you can safely ignore this email. try { var appUrl = config["AppUrl"] ?? "http://localhost:5000"; - var resetUrl = $"{appUrl}/ResetPassword?token={resetToken}"; + var resetUrl = $"{appUrl}/LoginHelp?token={resetToken}"; var message = new MimeMessage(); message.From.Add(new MailboxAddress( diff --git a/Media.JoshHeaps.Net/wwwroot/css/auth.css b/Media.JoshHeaps.Net/wwwroot/css/auth.css index e7c7115..70fad61 100644 --- a/Media.JoshHeaps.Net/wwwroot/css/auth.css +++ b/Media.JoshHeaps.Net/wwwroot/css/auth.css @@ -152,6 +152,29 @@ display: block; } +.form-options { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} + +.form-options .checkbox-wrapper { + margin-bottom: 0; +} + +.forgot-password-link { + color: var(--text-secondary); + font-size: 13px; + text-decoration: none; + transition: color 0.2s ease; +} + +.forgot-password-link:hover { + color: var(--accent-primary); + text-decoration: underline; +} + .checkbox-wrapper { display: flex; align-items: center; diff --git a/Media.JoshHeaps.Net/wwwroot/js/auth.js b/Media.JoshHeaps.Net/wwwroot/js/auth.js index a36a8d3..f44f27a 100644 --- a/Media.JoshHeaps.Net/wwwroot/js/auth.js +++ b/Media.JoshHeaps.Net/wwwroot/js/auth.js @@ -336,9 +336,145 @@ function initRegisterForm() { }); } +// Password reset form validation +function initPasswordResetForm() { + const form = document.getElementById('resetPasswordForm'); + if (!form) return; + + const passwordInput = document.getElementById('newPassword'); + const confirmPasswordInput = document.getElementById('confirmPassword'); + + if (passwordInput) { + passwordInput.addEventListener('input', function() { + checkPasswordStrength(this.value); + if (this.value && validatePassword(this.value)) { + clearError(this); + } + + if (confirmPasswordInput && confirmPasswordInput.value) { + if (confirmPasswordInput.value === this.value) { + clearError(confirmPasswordInput); + } else { + showError(confirmPasswordInput, 'Passwords do not match'); + } + } + }); + + passwordInput.addEventListener('blur', function() { + if (!this.value) { + showError(this, 'Password is required'); + } else if (!validatePassword(this.value)) { + showError(this, 'Password must be at least 8 characters'); + } else { + clearError(this); + } + }); + } + + if (confirmPasswordInput) { + confirmPasswordInput.addEventListener('input', function() { + if (passwordInput && this.value === passwordInput.value) { + clearError(this); + } + }); + + confirmPasswordInput.addEventListener('blur', function() { + if (!this.value) { + showError(this, 'Please confirm your password'); + } else if (passwordInput && this.value !== passwordInput.value) { + showError(this, 'Passwords do not match'); + } else { + clearError(this); + } + }); + } + + form.addEventListener('submit', function(e) { + e.preventDefault(); + + let isValid = true; + + if (!passwordInput.value) { + showError(passwordInput, 'Password is required'); + isValid = false; + } else if (!validatePassword(passwordInput.value)) { + showError(passwordInput, 'Password must be at least 8 characters'); + isValid = false; + } else { + clearError(passwordInput); + } + + if (!confirmPasswordInput.value) { + showError(confirmPasswordInput, 'Please confirm your password'); + isValid = false; + } else if (confirmPasswordInput.value !== passwordInput.value) { + showError(confirmPasswordInput, 'Passwords do not match'); + isValid = false; + } else { + clearError(confirmPasswordInput); + } + + if (isValid) { + const submitBtn = form.querySelector('button[type="submit"]'); + submitBtn.disabled = true; + submitBtn.innerHTML = ' Resetting...'; + form.submit(); + } + }); +} + +// Request reset form validation +function initRequestResetForm() { + const form = document.getElementById('requestResetForm'); + if (!form) return; + + const emailInput = document.getElementById('email'); + + if (emailInput) { + emailInput.addEventListener('blur', function() { + if (!this.value.trim()) { + showError(this, 'Email is required'); + } else if (!validateEmail(this.value)) { + showError(this, 'Please enter a valid email address'); + } else { + clearError(this); + } + }); + + emailInput.addEventListener('input', function() { + if (this.value.trim() && validateEmail(this.value)) { + clearError(this); + } + }); + } + + form.addEventListener('submit', function(e) { + e.preventDefault(); + + if (!emailInput.value.trim()) { + showError(emailInput, 'Email is required'); + return; + } + + if (!validateEmail(emailInput.value)) { + showError(emailInput, 'Please enter a valid email address'); + return; + } + + clearError(emailInput); + + const submitBtn = form.querySelector('button[type="submit"]'); + submitBtn.disabled = true; + submitBtn.innerHTML = ' Sending...'; + form.submit(); + }); +} + // Initialize on page load document.addEventListener('DOMContentLoaded', function() { initPasswordToggles(); initLoginForm(); initRegisterForm(); + initPasswordResetForm(); + initRequestResetForm(); });