From af085cfb905b87165f52b616ed845db3cce2a255 Mon Sep 17 00:00:00 2001 From: Josh Rogers Date: Fri, 8 May 2026 22:47:33 -0500 Subject: [PATCH] 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. --- .../Features/PasswordResetTests.cs | 206 ++++++ src/backend/YesChef.Api/Auth/AuthEndpoints.cs | 67 ++ .../YesChef.Api/Auth/AuthRateLimits.cs | 2 + src/backend/YesChef.Api/Data/YesChefDb.cs | 9 + .../YesChef.Api/Email/EmailTemplates.cs | 24 + .../Entities/PasswordResetToken.cs | 18 + ...9034418_AddPasswordResetTokens.Designer.cs | 657 ++++++++++++++++++ .../20260509034418_AddPasswordResetTokens.cs | 57 ++ .../Migrations/YesChefDbModelSnapshot.cs | 46 ++ src/backend/YesChef.Api/Program.cs | 26 + .../src/routes/forgot-password/+page.svelte | 73 ++ src/frontend/src/routes/login/+page.svelte | 5 + .../src/routes/reset-password/+page.svelte | 95 +++ 13 files changed, 1285 insertions(+) create mode 100644 src/backend/YesChef.Api.IntegrationTests/Features/PasswordResetTests.cs create mode 100644 src/backend/YesChef.Api/Entities/PasswordResetToken.cs create mode 100644 src/backend/YesChef.Api/Migrations/20260509034418_AddPasswordResetTokens.Designer.cs create mode 100644 src/backend/YesChef.Api/Migrations/20260509034418_AddPasswordResetTokens.cs create mode 100644 src/frontend/src/routes/forgot-password/+page.svelte create mode 100644 src/frontend/src/routes/reset-password/+page.svelte diff --git a/src/backend/YesChef.Api.IntegrationTests/Features/PasswordResetTests.cs b/src/backend/YesChef.Api.IntegrationTests/Features/PasswordResetTests.cs new file mode 100644 index 0000000..ced89e5 --- /dev/null +++ b/src/backend/YesChef.Api.IntegrationTests/Features/PasswordResetTests.cs @@ -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; + +/// +/// 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). +/// +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); + } + + /// + /// 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. + /// + 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]); + } +} diff --git a/src/backend/YesChef.Api/Auth/AuthEndpoints.cs b/src/backend/YesChef.Api/Auth/AuthEndpoints.cs index 2196acc..a6ef8e7 100644 --- a/src/backend/YesChef.Api/Auth/AuthEndpoints.cs +++ b/src/backend/YesChef.Api/Auth/AuthEndpoints.cs @@ -1,7 +1,9 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; using YesChef.Api.Data; +using YesChef.Api.Email; using YesChef.Api.Entities; namespace YesChef.Api.Auth; @@ -17,6 +19,8 @@ public static class AuthEndpoints public record LoginRequest(string Name, string Password); public record AuthResponse(string Token, string Name, string Role); public record InviteLookupResponse(string Email, string FamilyName); + public record ForgotPasswordRequest(string Email); + public record ResetPasswordRequest(string Token, string NewPassword); public static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder group) { @@ -112,6 +116,69 @@ public static class AuthEndpoints return Results.Ok(new AuthResponse(token, user.Name, membership.Role.ToString())); }).RequireRateLimiting(AuthRateLimits.LoginPolicy); + group.MapPost("/forgot-password", async ( + ForgotPasswordRequest request, + YesChefDb db, + IEmailSender email, + IOptions urlOptions) => + { + // Always return 200 — never reveal whether an email is registered. + // Probing this endpoint to enumerate users is what the rate limit + // is for; the response shape is what stops casual enumeration. + if (string.IsNullOrWhiteSpace(request.Email)) + return Results.Ok(); + + var normalized = request.Email.Trim().ToLowerInvariant(); + var user = await db.Users + .Where(u => u.Email != null && u.Email.ToLower() == normalized && u.EmailConfirmedAt != null) + .FirstOrDefaultAsync(); + if (user is null) + return Results.Ok(); + + // Burn any outstanding tokens so a stolen-but-unused link can't be + // reused after a fresh request goes out. + var outstanding = await db.PasswordResetTokens + .Where(t => t.UserId == user.Id && t.ConsumedAt == null) + .ToListAsync(); + var now = DateTime.UtcNow; + foreach (var t in outstanding) t.ConsumedAt = now; + + var rawToken = InviteToken.Generate(); + db.PasswordResetTokens.Add(new PasswordResetToken + { + UserId = user.Id, + TokenHash = InviteToken.Hash(rawToken), + IssuedAt = now, + ExpiresAt = now.AddMinutes(15), + }); + await db.SaveChangesAsync(); + + var resetUrl = $"{urlOptions.Value.BaseUrl.TrimEnd('/')}/reset-password?token={Uri.EscapeDataString(rawToken)}"; + await email.SendAsync(EmailTemplates.PasswordReset(user.Email!, resetUrl)); + + return Results.Ok(); + }).RequireRateLimiting(AuthRateLimits.ForgotPasswordPolicy); + + group.MapPost("/reset-password", async (ResetPasswordRequest request, YesChefDb db) => + { + if (string.IsNullOrWhiteSpace(request.Token) || string.IsNullOrWhiteSpace(request.NewPassword)) + return Results.BadRequest(new { error = "Token and new password are required." }); + if (request.NewPassword.Length < 6) + return Results.BadRequest(new { error = "Password must be at least 6 characters." }); + + var hashedToken = InviteToken.Hash(request.Token); + var token = await db.PasswordResetTokens + .Include(t => t.User) + .FirstOrDefaultAsync(t => t.TokenHash == hashedToken); + if (token is null || token.ConsumedAt is not null || token.ExpiresAt < DateTime.UtcNow) + return Results.BadRequest(new { error = "Reset link is invalid or has expired." }); + + token.User.PasswordHash = hasher.HashPassword(token.User, request.NewPassword); + token.ConsumedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Results.Ok(); + }).RequireRateLimiting(AuthRateLimits.ResetPasswordPolicy); + group.MapGet("/invite/{token}", async (string token, YesChefDb db) => { var hash = Auth.InviteToken.Hash(token); diff --git a/src/backend/YesChef.Api/Auth/AuthRateLimits.cs b/src/backend/YesChef.Api/Auth/AuthRateLimits.cs index c3fa428..394080e 100644 --- a/src/backend/YesChef.Api/Auth/AuthRateLimits.cs +++ b/src/backend/YesChef.Api/Auth/AuthRateLimits.cs @@ -4,4 +4,6 @@ public static class AuthRateLimits { public const string LoginPolicy = "auth-login"; public const string RegisterPolicy = "auth-register"; + public const string ForgotPasswordPolicy = "auth-forgot-password"; + public const string ResetPasswordPolicy = "auth-reset-password"; } diff --git a/src/backend/YesChef.Api/Data/YesChefDb.cs b/src/backend/YesChef.Api/Data/YesChefDb.cs index 38e66c0..c6f4284 100644 --- a/src/backend/YesChef.Api/Data/YesChefDb.cs +++ b/src/backend/YesChef.Api/Data/YesChefDb.cs @@ -15,6 +15,7 @@ public class YesChefDb(DbContextOptions options) : DbContext(options) public DbSet Recipes => Set(); public DbSet RecipeIngredients => Set(); public DbSet Invites => Set(); + public DbSet PasswordResetTokens => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -43,6 +44,14 @@ public class YesChefDb(DbContextOptions options) : DbContext(options) e.HasIndex(u => u.Email).IsUnique().HasFilter("\"Email\" IS NOT NULL"); }); + modelBuilder.Entity(e => + { + e.HasOne(t => t.User).WithMany().HasForeignKey(t => t.UserId).OnDelete(DeleteBehavior.Cascade); + e.Property(t => t.TokenHash).HasMaxLength(64); + e.HasIndex(t => t.TokenHash).IsUnique(); + e.HasIndex(t => new { t.UserId, t.ConsumedAt }); + }); + modelBuilder.Entity(e => { e.HasOne(i => i.Family).WithMany().HasForeignKey(i => i.FamilyId).OnDelete(DeleteBehavior.Cascade); diff --git a/src/backend/YesChef.Api/Email/EmailTemplates.cs b/src/backend/YesChef.Api/Email/EmailTemplates.cs index 980834b..7ecf0a6 100644 --- a/src/backend/YesChef.Api/Email/EmailTemplates.cs +++ b/src/backend/YesChef.Api/Email/EmailTemplates.cs @@ -29,4 +29,28 @@ public static class EmailTemplates return new EmailMessage(toAddress, subject, html, text); } + + public static EmailMessage PasswordReset(string toAddress, string resetUrl) + { + const string subject = "Reset your YesChef password"; + var safeUrl = WebUtility.HtmlEncode(resetUrl); + + var html = $""" +

Hi,

+

We received a request to reset your YesChef password. Click the link below to choose a new one:

+

Reset your password

+

This link expires in 15 minutes. If you didn't request a reset, you can ignore this email — your password won't change.

+ """; + + var text = $""" + We received a request to reset your YesChef password. + + Reset link (expires in 15 minutes): + {resetUrl} + + If you didn't request a reset, you can ignore this email — your password won't change. + """; + + return new EmailMessage(toAddress, subject, html, text); + } } diff --git a/src/backend/YesChef.Api/Entities/PasswordResetToken.cs b/src/backend/YesChef.Api/Entities/PasswordResetToken.cs new file mode 100644 index 0000000..f906b81 --- /dev/null +++ b/src/backend/YesChef.Api/Entities/PasswordResetToken.cs @@ -0,0 +1,18 @@ +namespace YesChef.Api.Entities; + +/// +/// Single-use, short-lived token used by the password-reset flow. Only the +/// SHA-256 hash of the raw token is stored; the raw value goes out in the +/// reset email and is never persisted. +/// +public class PasswordResetToken +{ + public int Id { get; set; } + public int UserId { get; set; } + public User User { get; set; } = null!; + + public required string TokenHash { get; set; } + public DateTime IssuedAt { get; set; } = DateTime.UtcNow; + public DateTime ExpiresAt { get; set; } + public DateTime? ConsumedAt { get; set; } +} diff --git a/src/backend/YesChef.Api/Migrations/20260509034418_AddPasswordResetTokens.Designer.cs b/src/backend/YesChef.Api/Migrations/20260509034418_AddPasswordResetTokens.Designer.cs new file mode 100644 index 0000000..0ed380c --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/20260509034418_AddPasswordResetTokens.Designer.cs @@ -0,0 +1,657 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using YesChef.Api.Data; + +#nullable disable + +namespace YesChef.Api.Migrations +{ + [DbContext(typeof(YesChefDb))] + [Migration("20260509034418_AddPasswordResetTokens")] + partial class AddPasswordResetTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("YesChef.Api.Entities.Family", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InviteCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("InviteCode") + .IsUnique(); + + b.ToTable("Families"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyMembership", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("UserId", "FamilyId"); + + b.HasIndex("FamilyId"); + + b.ToTable("FamilyMemberships"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Invite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumedByUserId") + .HasColumnType("integer"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedByUserId") + .HasColumnType("integer"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("ConsumedByUserId"); + + b.HasIndex("IssuedByUserId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("FamilyId", "ConsumedAt"); + + b.ToTable("Invites"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.PasswordResetToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Instructions") + .HasColumnType("text"); + + b.Property("Servings") + .HasColumnType("integer"); + + b.Property("SourceUrl") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("FamilyId"); + + b.ToTable("Recipes"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Quantity") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("RecipeId"); + + b.ToTable("RecipeIngredients"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StoreId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("FamilyId"); + + b.HasIndex("StoreId"); + + b.ToTable("ShoppingLists"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CheckedByUserId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("IsChecked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("RemovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RemovedByUserId") + .HasColumnType("integer"); + + b.Property("SectionId") + .HasColumnType("integer"); + + b.Property("ShoppingListId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckedByUserId"); + + b.HasIndex("FamilyId"); + + b.HasIndex("RecipeId"); + + b.HasIndex("RemovedByUserId"); + + b.HasIndex("SectionId"); + + b.HasIndex("ShoppingListId"); + + b.ToTable("ShoppingListItems"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Store", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId", "Name") + .IsUnique(); + + b.ToTable("Stores"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.StoreSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StoreId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("StoreId", "Name") + .IsUnique(); + + b.ToTable("StoreSections"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("EmailConfirmedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasFilter("\"Email\" IS NOT NULL"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyMembership", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Invite", b => + { + b.HasOne("YesChef.Api.Entities.User", "ConsumedByUser") + .WithMany() + .HasForeignKey("ConsumedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.User", "IssuedByUser") + .WithMany() + .HasForeignKey("IssuedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ConsumedByUser"); + + b.Navigation("Family"); + + 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") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Family"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Recipe", "Recipe") + .WithMany("Ingredients") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + b.Navigation("Recipe"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.HasOne("YesChef.Api.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Family"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingListItem", b => + { + b.HasOne("YesChef.Api.Entities.User", "CheckedByUser") + .WithMany() + .HasForeignKey("CheckedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Recipe", "Recipe") + .WithMany() + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.User", "RemovedByUser") + .WithMany() + .HasForeignKey("RemovedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.StoreSection", "Section") + .WithMany() + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.ShoppingList", "ShoppingList") + .WithMany("Items") + .HasForeignKey("ShoppingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CheckedByUser"); + + b.Navigation("Family"); + + b.Navigation("Recipe"); + + b.Navigation("RemovedByUser"); + + b.Navigation("Section"); + + b.Navigation("ShoppingList"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Store", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.StoreSection", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.Navigation("Ingredients"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/backend/YesChef.Api/Migrations/20260509034418_AddPasswordResetTokens.cs b/src/backend/YesChef.Api/Migrations/20260509034418_AddPasswordResetTokens.cs new file mode 100644 index 0000000..31113db --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/20260509034418_AddPasswordResetTokens.cs @@ -0,0 +1,57 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace YesChef.Api.Migrations +{ + /// + public partial class AddPasswordResetTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PasswordResetTokens", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "integer", nullable: false), + TokenHash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + IssuedAt = table.Column(type: "timestamp with time zone", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + ConsumedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PasswordResetTokens", x => x.Id); + table.ForeignKey( + name: "FK_PasswordResetTokens_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_PasswordResetTokens_TokenHash", + table: "PasswordResetTokens", + column: "TokenHash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PasswordResetTokens_UserId_ConsumedAt", + table: "PasswordResetTokens", + columns: new[] { "UserId", "ConsumedAt" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PasswordResetTokens"); + } + } +} diff --git a/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs b/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs index 3448604..fa85c2d 100644 --- a/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs +++ b/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs @@ -122,6 +122,41 @@ namespace YesChef.Api.Migrations b.ToTable("Invites"); }); + modelBuilder.Entity("YesChef.Api.Entities.PasswordResetToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("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("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") diff --git a/src/backend/YesChef.Api/Program.cs b/src/backend/YesChef.Api/Program.cs index 700ae7b..b728025 100644 --- a/src/backend/YesChef.Api/Program.cs +++ b/src/backend/YesChef.Api/Program.cs @@ -134,6 +134,32 @@ builder.Services.AddRateLimiter(options => QueueLimit = 0, }); }); + + // Both reset endpoints share the login limit's blast radius — keep them + // tight to slow down both email-enumeration probing and token guessing. + options.AddPolicy(AuthRateLimits.ForgotPasswordPolicy, context => + { + if (bypassLimits) return RateLimitPartition.GetNoLimiter("test"); + var key = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + return RateLimitPartition.GetFixedWindowLimiter(key, _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 5, + Window = TimeSpan.FromHours(1), + QueueLimit = 0, + }); + }); + + options.AddPolicy(AuthRateLimits.ResetPasswordPolicy, context => + { + if (bypassLimits) return RateLimitPartition.GetNoLimiter("test"); + var key = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + return RateLimitPartition.GetFixedWindowLimiter(key, _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 10, + Window = TimeSpan.FromMinutes(15), + QueueLimit = 0, + }); + }); }); var app = builder.Build(); diff --git a/src/frontend/src/routes/forgot-password/+page.svelte b/src/frontend/src/routes/forgot-password/+page.svelte new file mode 100644 index 0000000..2d79492 --- /dev/null +++ b/src/frontend/src/routes/forgot-password/+page.svelte @@ -0,0 +1,73 @@ + + +
+
+
+

YesChef

+

Reset your password

+
+ + {#if submitted} +
+

Check your inbox

+

+ If an account with that email exists, we've sent a link to reset your password. + The link expires in 15 minutes. +

+
+

+ Back to sign in +

+ {:else} +
{ e.preventDefault(); handleSubmit(); }} class="space-y-4"> + + + {#if error} +

{error}

+ {/if} + + +
+ +

+ Remembered it? + Sign in +

+ {/if} +
+
diff --git a/src/frontend/src/routes/login/+page.svelte b/src/frontend/src/routes/login/+page.svelte index 918a4e4..c22894d 100644 --- a/src/frontend/src/routes/login/+page.svelte +++ b/src/frontend/src/routes/login/+page.svelte @@ -150,6 +150,11 @@ {/if}

+ {#if mode === 'login'} +

+ Forgot your password? +

+ {/if} {/if} diff --git a/src/frontend/src/routes/reset-password/+page.svelte b/src/frontend/src/routes/reset-password/+page.svelte new file mode 100644 index 0000000..04c9a98 --- /dev/null +++ b/src/frontend/src/routes/reset-password/+page.svelte @@ -0,0 +1,95 @@ + + +
+
+
+

YesChef

+

Choose a new password

+
+ + {#if done} +
+

Password updated

+

Redirecting you to sign in…

+
+ {:else} +
{ e.preventDefault(); handleSubmit(); }} class="space-y-4"> + + + + {#if error} +

{error}

+ {/if} + + +
+ {/if} +
+