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:
@@ -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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user