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}
+
+
+
+ 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}
+
+ {/if}
+
+