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
@@ -122,6 +122,41 @@ namespace YesChef.Api.Migrations
b.ToTable("Invites");
});
modelBuilder.Entity("YesChef.Api.Entities.PasswordResetToken", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime?>("ConsumedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("IssuedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("UserId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId", "ConsumedAt");
b.ToTable("PasswordResetTokens");
});
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
{
b.Property<int>("Id")
@@ -451,6 +486,17 @@ namespace YesChef.Api.Migrations
b.Navigation("IssuedByUser");
});
modelBuilder.Entity("YesChef.Api.Entities.PasswordResetToken", b =>
{
b.HasOne("YesChef.Api.Entities.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
{
b.HasOne("YesChef.Api.Entities.User", "CreatedByUser")