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]);
}
}
@@ -1,7 +1,9 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using YesChef.Api.Data; using YesChef.Api.Data;
using YesChef.Api.Email;
using YesChef.Api.Entities; using YesChef.Api.Entities;
namespace YesChef.Api.Auth; namespace YesChef.Api.Auth;
@@ -17,6 +19,8 @@ public static class AuthEndpoints
public record LoginRequest(string Name, string Password); public record LoginRequest(string Name, string Password);
public record AuthResponse(string Token, string Name, string Role); public record AuthResponse(string Token, string Name, string Role);
public record InviteLookupResponse(string Email, string FamilyName); 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) 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())); return Results.Ok(new AuthResponse(token, user.Name, membership.Role.ToString()));
}).RequireRateLimiting(AuthRateLimits.LoginPolicy); }).RequireRateLimiting(AuthRateLimits.LoginPolicy);
group.MapPost("/forgot-password", async (
ForgotPasswordRequest request,
YesChefDb db,
IEmailSender email,
IOptions<AppUrlOptions> 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) => group.MapGet("/invite/{token}", async (string token, YesChefDb db) =>
{ {
var hash = Auth.InviteToken.Hash(token); var hash = Auth.InviteToken.Hash(token);
@@ -4,4 +4,6 @@ public static class AuthRateLimits
{ {
public const string LoginPolicy = "auth-login"; public const string LoginPolicy = "auth-login";
public const string RegisterPolicy = "auth-register"; public const string RegisterPolicy = "auth-register";
public const string ForgotPasswordPolicy = "auth-forgot-password";
public const string ResetPasswordPolicy = "auth-reset-password";
} }
@@ -15,6 +15,7 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
public DbSet<Recipe> Recipes => Set<Recipe>(); public DbSet<Recipe> Recipes => Set<Recipe>();
public DbSet<RecipeIngredient> RecipeIngredients => Set<RecipeIngredient>(); public DbSet<RecipeIngredient> RecipeIngredients => Set<RecipeIngredient>();
public DbSet<Invite> Invites => Set<Invite>(); public DbSet<Invite> Invites => Set<Invite>();
public DbSet<PasswordResetToken> PasswordResetTokens => Set<PasswordResetToken>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -43,6 +44,14 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
e.HasIndex(u => u.Email).IsUnique().HasFilter("\"Email\" IS NOT NULL"); e.HasIndex(u => u.Email).IsUnique().HasFilter("\"Email\" IS NOT NULL");
}); });
modelBuilder.Entity<PasswordResetToken>(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<Invite>(e => modelBuilder.Entity<Invite>(e =>
{ {
e.HasOne(i => i.Family).WithMany().HasForeignKey(i => i.FamilyId).OnDelete(DeleteBehavior.Cascade); e.HasOne(i => i.Family).WithMany().HasForeignKey(i => i.FamilyId).OnDelete(DeleteBehavior.Cascade);
@@ -29,4 +29,28 @@ public static class EmailTemplates
return new EmailMessage(toAddress, subject, html, text); 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 = $"""
<p>Hi,</p>
<p>We received a request to reset your YesChef password. Click the link below to choose a new one:</p>
<p><a href="{safeUrl}">Reset your password</a></p>
<p>This link expires in 15 minutes. If you didn't request a reset, you can ignore this email your password won't change.</p>
""";
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);
}
} }
@@ -0,0 +1,18 @@
namespace YesChef.Api.Entities;
/// <summary>
/// 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.
/// </summary>
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; }
}
@@ -0,0 +1,657 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("InviteCode")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("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<int>("UserId")
.HasColumnType("integer");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Role")
.HasColumnType("integer");
b.HasKey("UserId", "FamilyId");
b.HasIndex("FamilyId");
b.ToTable("FamilyMemberships");
});
modelBuilder.Entity("YesChef.Api.Entities.Invite", 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<int?>("ConsumedByUserId")
.HasColumnType("integer");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(254)
.HasColumnType("character varying(254)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<DateTime>("IssuedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("IssuedByUserId")
.HasColumnType("integer");
b.Property<string>("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<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")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedByUserId")
.HasColumnType("integer");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Instructions")
.HasColumnType("text");
b.Property<int?>("Servings")
.HasColumnType("integer");
b.Property<string>("SourceUrl")
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<DateTime>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Quantity")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<int>("RecipeId")
.HasColumnType("integer");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("FamilyId");
b.HasIndex("RecipeId");
b.ToTable("RecipeIngredients");
});
modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedByUserId")
.HasColumnType("integer");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<bool>("IsArchived")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("StoreId")
.HasColumnType("integer");
b.Property<DateTime>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("CheckedByUserId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<bool>("IsChecked")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<int?>("RecipeId")
.HasColumnType("integer");
b.Property<DateTime?>("RemovedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("RemovedByUserId")
.HasColumnType("integer");
b.Property<int?>("SectionId")
.HasColumnType("integer");
b.Property<int>("ShoppingListId")
.HasColumnType("integer");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("FamilyId", "Name")
.IsUnique();
b.ToTable("Stores");
});
modelBuilder.Entity("YesChef.Api.Entities.StoreSection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasMaxLength(254)
.HasColumnType("character varying(254)");
b.Property<DateTime?>("EmailConfirmedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("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
}
}
}
@@ -0,0 +1,57 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace YesChef.Api.Migrations
{
/// <inheritdoc />
public partial class AddPasswordResetTokens : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PasswordResetTokens",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<int>(type: "integer", nullable: false),
TokenHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
IssuedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ConsumedAt = table.Column<DateTime>(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" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PasswordResetTokens");
}
}
}
@@ -122,6 +122,41 @@ namespace YesChef.Api.Migrations
b.ToTable("Invites"); 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 => modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -451,6 +486,17 @@ namespace YesChef.Api.Migrations
b.Navigation("IssuedByUser"); 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 => modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
{ {
b.HasOne("YesChef.Api.Entities.User", "CreatedByUser") b.HasOne("YesChef.Api.Entities.User", "CreatedByUser")
+26
View File
@@ -134,6 +134,32 @@ builder.Services.AddRateLimiter(options =>
QueueLimit = 0, 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(); var app = builder.Build();
@@ -0,0 +1,73 @@
<script lang="ts">
import { api } from '$lib/api';
let email = $state('');
let submitted = $state(false);
let loading = $state(false);
let error = $state('');
async function handleSubmit() {
error = '';
loading = true;
try {
await api('/api/auth/forgot-password', {
method: 'POST',
body: JSON.stringify({ email })
});
submitted = true;
} catch (e) {
error = e instanceof Error ? e.message : 'Something went wrong';
} finally {
loading = false;
}
}
</script>
<div class="flex min-h-dvh items-center justify-center bg-gray-50 px-4">
<div class="w-full max-w-sm">
<div class="mb-8 text-center">
<h1 class="text-4xl font-bold text-primary">YesChef</h1>
<p class="mt-2 text-gray-500">Reset your password</p>
</div>
{#if submitted}
<div class="rounded-lg bg-primary/5 p-4 text-sm text-gray-700">
<p class="font-medium text-primary">Check your inbox</p>
<p class="mt-1">
If an account with that email exists, we've sent a link to reset your password.
The link expires in 15 minutes.
</p>
</div>
<p class="mt-6 text-center text-sm">
<a href="/login" class="font-medium text-primary">Back to sign in</a>
</p>
{:else}
<form onsubmit={e => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
<input
type="email"
bind:value={email}
placeholder="Email"
required
class="w-full rounded-lg border border-gray-300 px-4 py-3 text-lg focus:border-primary focus:ring-2 focus:ring-primary/20 focus:outline-none"
/>
{#if error}
<p class="text-sm text-danger">{error}</p>
{/if}
<button
type="submit"
disabled={loading}
class="w-full rounded-lg bg-primary py-3 text-lg font-semibold text-white transition-colors hover:bg-primary-dark disabled:opacity-50"
>
{loading ? '...' : 'Send reset link'}
</button>
</form>
<p class="mt-6 text-center text-sm text-gray-500">
Remembered it?
<a href="/login" class="font-medium text-primary">Sign in</a>
</p>
{/if}
</div>
</div>
@@ -150,6 +150,11 @@
</button> </button>
{/if} {/if}
</p> </p>
{#if mode === 'login'}
<p class="mt-2 text-center text-sm">
<a href="/forgot-password" class="text-gray-500 hover:text-primary">Forgot your password?</a>
</p>
{/if}
{/if} {/if}
</div> </div>
</div> </div>
@@ -0,0 +1,95 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { api } from '$lib/api';
let token = $state<string | null>(null);
let newPassword = $state('');
let confirmPassword = $state('');
let error = $state('');
let loading = $state(false);
let done = $state(false);
onMount(() => {
token = page.url.searchParams.get('token');
if (!token) {
error = 'This link is missing its reset token. Use the link from your email.';
}
});
async function handleSubmit() {
error = '';
if (!token) return;
if (newPassword.length < 6) {
error = 'Password must be at least 6 characters.';
return;
}
if (newPassword !== confirmPassword) {
error = 'Passwords do not match.';
return;
}
loading = true;
try {
await api('/api/auth/reset-password', {
method: 'POST',
body: JSON.stringify({ token, newPassword })
});
done = true;
setTimeout(() => goto('/login'), 1500);
} catch (e) {
error = e instanceof Error ? e.message : 'Could not reset password';
} finally {
loading = false;
}
}
</script>
<div class="flex min-h-dvh items-center justify-center bg-gray-50 px-4">
<div class="w-full max-w-sm">
<div class="mb-8 text-center">
<h1 class="text-4xl font-bold text-primary">YesChef</h1>
<p class="mt-2 text-gray-500">Choose a new password</p>
</div>
{#if done}
<div class="rounded-lg bg-primary/5 p-4 text-sm">
<p class="font-medium text-primary">Password updated</p>
<p class="mt-1 text-gray-600">Redirecting you to sign in…</p>
</div>
{:else}
<form onsubmit={e => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
<input
type="password"
bind:value={newPassword}
placeholder="New password"
required
minlength="6"
autocomplete="new-password"
class="w-full rounded-lg border border-gray-300 px-4 py-3 text-lg focus:border-primary focus:ring-2 focus:ring-primary/20 focus:outline-none"
/>
<input
type="password"
bind:value={confirmPassword}
placeholder="Confirm new password"
required
minlength="6"
autocomplete="new-password"
class="w-full rounded-lg border border-gray-300 px-4 py-3 text-lg focus:border-primary focus:ring-2 focus:ring-primary/20 focus:outline-none"
/>
{#if error}
<p class="text-sm text-danger">{error}</p>
{/if}
<button
type="submit"
disabled={loading || !token}
class="w-full rounded-lg bg-primary py-3 text-lg font-semibold text-white transition-colors hover:bg-primary-dark disabled:opacity-50"
>
{loading ? '...' : 'Set new password'}
</button>
</form>
{/if}
</div>
</div>