Add password reset flow

Users with a confirmed email can now reset a forgotten password via
emailed single-use links.

Backend
- New PasswordResetToken entity (hash-only, 15-minute TTL).
- POST /api/auth/forgot-password always returns 200, never disclosing
  whether an email is registered. Internally only emits a reset email
  when a user exists with EmailConfirmedAt set, and burns any existing
  outstanding tokens for that user before issuing a new one.
- POST /api/auth/reset-password validates the token, rotates the
  password hash, and consumes the token. Single-use, expiry-checked.
- Both endpoints are rate-limited (forgot 5/hr, reset 10/15min) per the
  same partitioning the login/register endpoints already use.
- Reset email template added; uses AppBaseUrl plumbed in the previous PR.

Frontend
- /forgot-password page (email field, generic confirmation message
  regardless of whether the email is registered).
- /reset-password page reads ?token=, validates the new password
  client-side, posts to the API, then redirects to /login.
- "Forgot your password?" link added under the login form.

Tests
- 9 new integration tests cover the happy path, single-use enforcement,
  expired/unknown tokens, short-password rejection, silent 200 for
  unknown email, no email for unconfirmed users, and outstanding-token
  invalidation when a fresh request is made.
This commit is contained in:
Josh Rogers
2026-05-08 22:47:33 -05:00
parent d9ffe18b21
commit af085cfb90
13 changed files with 1285 additions and 0 deletions
@@ -0,0 +1,206 @@
using System.Net;
using System.Net.Http.Json;
using YesChef.Api.Auth;
using YesChef.Api.Features.Families;
using YesChef.Api.IntegrationTests.Infrastructure;
namespace YesChef.Api.IntegrationTests.Features;
/// <summary>
/// Covers the forgot/reset password flow. Each test sets up a user with a
/// confirmed email by going through the invite registration path so the
/// fixtures match production behavior (only confirmed-email users are
/// eligible for password reset).
/// </summary>
public class PasswordResetTests : AuthenticatedIntegrationTest
{
[Test]
public async Task Forgot_password_sends_email_for_known_confirmed_user()
{
var (_, email) = await RegisterViaInviteAsync("alice", "alice@example.com");
// Drain the invite email so assertions don't pick it up.
var beforeCount = App.Emails.Messages.Count;
var response = await AnonymousClient.PostAsJsonAsync("/api/auth/forgot-password",
new AuthEndpoints.ForgotPasswordRequest(email));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
await Assert.That(App.Emails.Messages.Count).IsEqualTo(beforeCount + 1);
var sent = App.Emails.Last;
await Assert.That(sent.ToAddress).IsEqualTo(email);
await Assert.That(sent.TextBody).Contains("/reset-password?token=");
}
[Test]
public async Task Forgot_password_returns_200_silently_for_unknown_email()
{
var beforeCount = App.Emails.Messages.Count;
var response = await AnonymousClient.PostAsJsonAsync("/api/auth/forgot-password",
new AuthEndpoints.ForgotPasswordRequest("nobody@example.com"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
await Assert.That(App.Emails.Messages.Count).IsEqualTo(beforeCount);
}
[Test]
public async Task Forgot_password_does_not_email_unconfirmed_user()
{
// The default authed user (registered via family code) has no email,
// so they're ineligible. Attach an unconfirmed email directly to
// verify the EmailConfirmedAt requirement fires.
await UseDbAsync(async db =>
{
var u = await db.Users.SingleAsync(x => x.Id == User.Id);
u.Email = "unconfirmed@example.com";
u.EmailConfirmedAt = null;
await db.SaveChangesAsync();
});
var beforeCount = App.Emails.Messages.Count;
var response = await AnonymousClient.PostAsJsonAsync("/api/auth/forgot-password",
new AuthEndpoints.ForgotPasswordRequest("unconfirmed@example.com"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
await Assert.That(App.Emails.Messages.Count).IsEqualTo(beforeCount);
}
[Test]
public async Task Reset_password_changes_password_and_consumes_token()
{
var (name, email) = await RegisterViaInviteAsync("bob", "bob@example.com");
await AnonymousClient.PostAsJsonAsync("/api/auth/forgot-password",
new AuthEndpoints.ForgotPasswordRequest(email));
var token = ExtractResetToken();
var reset = await AnonymousClient.PostAsJsonAsync("/api/auth/reset-password",
new AuthEndpoints.ResetPasswordRequest(token, "new-password-123"));
await Assert.That(reset.StatusCode).IsEqualTo(HttpStatusCode.OK);
// Old password no longer works, new one does.
var oldLogin = await AnonymousClient.PostAsJsonAsync("/api/auth/login",
new AuthEndpoints.LoginRequest(name, "pw-1234"));
await Assert.That(oldLogin.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
var newLogin = await AnonymousClient.PostAsJsonAsync("/api/auth/login",
new AuthEndpoints.LoginRequest(name, "new-password-123"));
await Assert.That(newLogin.StatusCode).IsEqualTo(HttpStatusCode.OK);
}
[Test]
public async Task Reset_token_is_single_use()
{
var (_, email) = await RegisterViaInviteAsync("carol", "carol@example.com");
await AnonymousClient.PostAsJsonAsync("/api/auth/forgot-password",
new AuthEndpoints.ForgotPasswordRequest(email));
var token = ExtractResetToken();
var first = await AnonymousClient.PostAsJsonAsync("/api/auth/reset-password",
new AuthEndpoints.ResetPasswordRequest(token, "first-new-pw"));
await Assert.That(first.StatusCode).IsEqualTo(HttpStatusCode.OK);
var second = await AnonymousClient.PostAsJsonAsync("/api/auth/reset-password",
new AuthEndpoints.ResetPasswordRequest(token, "second-attempt"));
await Assert.That(second.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
[Test]
public async Task Reset_password_rejects_unknown_token()
{
var response = await AnonymousClient.PostAsJsonAsync("/api/auth/reset-password",
new AuthEndpoints.ResetPasswordRequest("not-a-real-token", "any-password"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
[Test]
public async Task Reset_password_rejects_expired_token()
{
var (_, email) = await RegisterViaInviteAsync("dave", "dave@example.com");
await AnonymousClient.PostAsJsonAsync("/api/auth/forgot-password",
new AuthEndpoints.ForgotPasswordRequest(email));
var token = ExtractResetToken();
await UseDbAsync(async db =>
{
var t = await db.PasswordResetTokens.OrderByDescending(x => x.IssuedAt).FirstAsync();
t.ExpiresAt = DateTime.UtcNow.AddMinutes(-1);
await db.SaveChangesAsync();
});
var response = await AnonymousClient.PostAsJsonAsync("/api/auth/reset-password",
new AuthEndpoints.ResetPasswordRequest(token, "anything-1234"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
[Test]
public async Task Reset_password_rejects_short_password()
{
var (_, email) = await RegisterViaInviteAsync("eve", "eve@example.com");
await AnonymousClient.PostAsJsonAsync("/api/auth/forgot-password",
new AuthEndpoints.ForgotPasswordRequest(email));
var token = ExtractResetToken();
var response = await AnonymousClient.PostAsJsonAsync("/api/auth/reset-password",
new AuthEndpoints.ResetPasswordRequest(token, "abc"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
[Test]
public async Task New_forgot_request_invalidates_outstanding_token()
{
var (_, email) = await RegisterViaInviteAsync("frank", "frank@example.com");
await AnonymousClient.PostAsJsonAsync("/api/auth/forgot-password",
new AuthEndpoints.ForgotPasswordRequest(email));
var firstToken = ExtractResetToken();
// A second forgot request must invalidate the first token.
await AnonymousClient.PostAsJsonAsync("/api/auth/forgot-password",
new AuthEndpoints.ForgotPasswordRequest(email));
var secondToken = ExtractResetToken();
await Assert.That(secondToken).IsNotEqualTo(firstToken);
var oldAttempt = await AnonymousClient.PostAsJsonAsync("/api/auth/reset-password",
new AuthEndpoints.ResetPasswordRequest(firstToken, "ignored-1234"));
await Assert.That(oldAttempt.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
var newAttempt = await AnonymousClient.PostAsJsonAsync("/api/auth/reset-password",
new AuthEndpoints.ResetPasswordRequest(secondToken, "shiny-new-pw"));
await Assert.That(newAttempt.StatusCode).IsEqualTo(HttpStatusCode.OK);
}
/// <summary>
/// Issues an invite as the existing admin (User), registers a new user
/// against it (which sets Email + EmailConfirmedAt in one step), and
/// returns the new user's name + email so tests can assert against them.
/// </summary>
private async Task<(string Name, string Email)> RegisterViaInviteAsync(string name, string email)
{
var inviteResponse = await Client.PostAsJsonAsync("/api/family/invites",
new InviteEndpoints.CreateInviteRequest(email));
inviteResponse.EnsureSuccessStatusCode();
var inviteToken = ExtractInviteToken();
var register = await AnonymousClient.PostAsJsonAsync("/api/auth/register",
new AuthEndpoints.RegisterRequest(name, "pw-1234", null, inviteToken));
register.EnsureSuccessStatusCode();
return (name, email);
}
private string ExtractInviteToken() => ExtractTokenFromLatest("/register?invite=");
private string ExtractResetToken() => ExtractTokenFromLatest("/reset-password?token=");
private string ExtractTokenFromLatest(string marker)
{
var body = App.Emails.Last.TextBody;
var start = body.IndexOf(marker, StringComparison.Ordinal);
if (start < 0) throw new InvalidOperationException($"Marker {marker} not found in latest email.");
start += marker.Length;
var end = body.IndexOfAny(['\r', '\n', ' '], start);
if (end < 0) end = body.Length;
return Uri.UnescapeDataString(body[start..end]);
}
}