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 @@
-
-
-
+
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)
+ {
+
+
+ @if (!string.IsNullOrEmpty(Model.ErrorMessage))
+ {
+
+ @Model.ErrorMessage
+
+ }
+
+
+ }
+ else
+ {
+
+
+ @if (!string.IsNullOrEmpty(Model.ErrorMessage))
+ {
+
+ @Model.ErrorMessage
+
+ }
+
+ @if (!string.IsNullOrEmpty(Model.SuccessMessage))
+ {
+
+ @Model.SuccessMessage
+
+ }
+
+
+ }
+
+
+
+
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();
});