diff --git a/.env.example b/.env.example
index 330d377..8c4c89f 100644
--- a/.env.example
+++ b/.env.example
@@ -2,3 +2,16 @@ POSTGRES_PASSWORD=change-me-strong-password
JWT_SECRET=change-me-generate-a-random-64-char-string
FAMILY_CODE=your-family-invite-phrase
DOMAIN=yeschef.yourdomain.com
+
+# SMTP — required for password reset and email-based invites.
+# Leave SMTP_HOST empty to fall back to a logging sender (dev only;
+# emails are logged instead of delivered).
+SMTP_HOST=
+SMTP_PORT=587
+SMTP_USERNAME=
+SMTP_PASSWORD=
+SMTP_FROM_ADDRESS=no-reply@yourdomain.com
+SMTP_FROM_NAME=YesChef
+
+# Public base URL used to build links in outgoing emails. Defaults to https://${DOMAIN}.
+APP_BASE_URL=https://yeschef.yourdomain.com
diff --git a/docker-compose.yml b/docker-compose.yml
index d41ada2..6c798ac 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -24,6 +24,13 @@ services:
ConnectionStrings__DefaultConnection: "Host=postgres;Database=yeschef;Username=yeschef;Password=${POSTGRES_PASSWORD}"
Jwt__Secret: ${JWT_SECRET}
FamilyCode: ${FAMILY_CODE}
+ Smtp__Host: ${SMTP_HOST:-}
+ Smtp__Port: ${SMTP_PORT:-587}
+ Smtp__Username: ${SMTP_USERNAME:-}
+ Smtp__Password: ${SMTP_PASSWORD:-}
+ Smtp__FromAddress: ${SMTP_FROM_ADDRESS:-}
+ Smtp__FromName: ${SMTP_FROM_NAME:-YesChef}
+ AppBaseUrl: ${APP_BASE_URL:-https://${DOMAIN}}
expose:
- "5000"
depends_on:
diff --git a/src/backend/YesChef.Api.IntegrationTests/Features/AuthRateLimitTests.cs b/src/backend/YesChef.Api.IntegrationTests/Features/AuthRateLimitTests.cs
new file mode 100644
index 0000000..16a3361
--- /dev/null
+++ b/src/backend/YesChef.Api.IntegrationTests/Features/AuthRateLimitTests.cs
@@ -0,0 +1,93 @@
+using System.Net;
+using System.Net.Http.Json;
+using YesChef.Api.Auth;
+using YesChef.Api.IntegrationTests.Infrastructure;
+
+namespace YesChef.Api.IntegrationTests.Features;
+
+///
+/// Verifies the auth-endpoint rate limits actually fire. Uses a
+/// non-"Testing" environment so the limiter is not bypassed; that means a
+/// fresh app/factory is constructed per test rather than reusing the base
+/// class's Testing-env app.
+///
+public class AuthRateLimitTests : IntegrationTest
+{
+ [Test]
+ public async Task Login_returns_429_after_exhausting_limit()
+ {
+ await using var liveApp = new YesChefAppFactory
+ {
+ ConnectionString = await CloneDatabaseAsync(),
+ EnvironmentName = "Production",
+ };
+ using var client = liveApp.CreateClient();
+
+ // Limit is 10 per 15-minute window. Eleventh call must be rejected.
+ // Using a non-existent user keeps each request a 401 — wrong-password
+ // path still counts toward the limit.
+ HttpResponseMessage? lastResponse = null;
+ for (var i = 0; i < 11; i++)
+ {
+ lastResponse?.Dispose();
+ lastResponse = await client.PostAsJsonAsync("/api/auth/login",
+ new AuthEndpoints.LoginRequest("ghost", "anything"));
+ }
+
+ await Assert.That(lastResponse!.StatusCode).IsEqualTo(HttpStatusCode.TooManyRequests);
+ await Assert.That(lastResponse.Headers.Contains("Retry-After")).IsTrue();
+ lastResponse.Dispose();
+ }
+
+ [Test]
+ public async Task Register_returns_429_after_exhausting_limit()
+ {
+ await using var liveApp = new YesChefAppFactory
+ {
+ ConnectionString = await CloneDatabaseAsync(),
+ EnvironmentName = "Production",
+ };
+ using var client = liveApp.CreateClient();
+
+ // Limit is 5 per hour. Sixth call must be rejected. Use unique names so
+ // we don't trip the duplicate-name check before the limiter does.
+ HttpResponseMessage? lastResponse = null;
+ for (var i = 0; i < 6; i++)
+ {
+ lastResponse?.Dispose();
+ lastResponse = await client.PostAsJsonAsync("/api/auth/register",
+ new AuthEndpoints.RegisterRequest($"rate-{i}", "pw-1234", YesChefAppFactory.FamilyCode));
+ }
+
+ await Assert.That(lastResponse!.StatusCode).IsEqualTo(HttpStatusCode.TooManyRequests);
+ lastResponse.Dispose();
+ }
+
+ [Test]
+ public async Task Testing_env_bypasses_limit()
+ {
+ // The base class's app uses the Testing env; firing well past the
+ // production limit must keep returning 401 (not 429). Sanity check
+ // that the existing AuthEndpointsTests aren't going to start flaking.
+ for (var i = 0; i < 15; i++)
+ {
+ using var response = await AnonymousClient.PostAsJsonAsync("/api/auth/login",
+ new AuthEndpoints.LoginRequest("ghost", "anything"));
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
+ }
+ }
+
+ ///
+ /// The IntegrationTest base allocates one DB per test. We need a second
+ /// independent connection string for the live-env factory; clone again
+ /// from the same Postgres fixture.
+ ///
+ private async Task CloneDatabaseAsync()
+ {
+ var db = await Postgres.CreateDatabaseAsync();
+ // Leak the TestDatabase wrapper — the connection string lives as long
+ // as the Postgres fixture (per-session). Acceptable for a tiny number
+ // of rate-limit tests.
+ return db.ConnectionString;
+ }
+}
diff --git a/src/backend/YesChef.Api.IntegrationTests/Infrastructure/YesChefAppFactory.cs b/src/backend/YesChef.Api.IntegrationTests/Infrastructure/YesChefAppFactory.cs
index 4e99178..42dfe33 100644
--- a/src/backend/YesChef.Api.IntegrationTests/Infrastructure/YesChefAppFactory.cs
+++ b/src/backend/YesChef.Api.IntegrationTests/Infrastructure/YesChefAppFactory.cs
@@ -1,6 +1,5 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
-using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using YesChef.Api.Data;
@@ -20,9 +19,16 @@ public sealed class YesChefAppFactory : WebApplicationFactory
public required string ConnectionString { get; init; }
+ ///
+ /// Hosting environment override. Defaults to "Testing", which the API
+ /// uses to disable rate limits. Tests that exercise rate-limit behavior
+ /// can set this to a non-Testing value (e.g. "Production").
+ ///
+ public string EnvironmentName { get; init; } = "Testing";
+
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
- builder.UseEnvironment("Testing");
+ builder.UseEnvironment(EnvironmentName);
builder.ConfigureAppConfiguration((_, config) =>
{
diff --git a/src/backend/YesChef.Api/Auth/AuthEndpoints.cs b/src/backend/YesChef.Api/Auth/AuthEndpoints.cs
index c8ad7f6..4a9d533 100644
--- a/src/backend/YesChef.Api/Auth/AuthEndpoints.cs
+++ b/src/backend/YesChef.Api/Auth/AuthEndpoints.cs
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using YesChef.Api.Data;
using YesChef.Api.Entities;
@@ -45,7 +46,7 @@ public static class AuthEndpoints
var token = jwt.GenerateToken(user, family.Id, role);
return Results.Ok(new AuthResponse(token, user.Name, role.ToString()));
- });
+ }).RequireRateLimiting(AuthRateLimits.RegisterPolicy);
group.MapPost("/login", async (LoginRequest request, YesChefDb db, JwtTokenService jwt) =>
{
@@ -68,7 +69,7 @@ public static class AuthEndpoints
var token = jwt.GenerateToken(user, membership.FamilyId, membership.Role);
return Results.Ok(new AuthResponse(token, user.Name, membership.Role.ToString()));
- });
+ }).RequireRateLimiting(AuthRateLimits.LoginPolicy);
group.MapGet("/me", async (HttpContext http, YesChefDb db) =>
{
diff --git a/src/backend/YesChef.Api/Auth/AuthRateLimits.cs b/src/backend/YesChef.Api/Auth/AuthRateLimits.cs
new file mode 100644
index 0000000..c3fa428
--- /dev/null
+++ b/src/backend/YesChef.Api/Auth/AuthRateLimits.cs
@@ -0,0 +1,7 @@
+namespace YesChef.Api.Auth;
+
+public static class AuthRateLimits
+{
+ public const string LoginPolicy = "auth-login";
+ public const string RegisterPolicy = "auth-register";
+}
diff --git a/src/backend/YesChef.Api/Email/EmailMessage.cs b/src/backend/YesChef.Api/Email/EmailMessage.cs
new file mode 100644
index 0000000..a076e31
--- /dev/null
+++ b/src/backend/YesChef.Api/Email/EmailMessage.cs
@@ -0,0 +1,3 @@
+namespace YesChef.Api.Email;
+
+public record EmailMessage(string ToAddress, string Subject, string HtmlBody, string TextBody);
diff --git a/src/backend/YesChef.Api/Email/EmailOptions.cs b/src/backend/YesChef.Api/Email/EmailOptions.cs
new file mode 100644
index 0000000..8c88785
--- /dev/null
+++ b/src/backend/YesChef.Api/Email/EmailOptions.cs
@@ -0,0 +1,13 @@
+namespace YesChef.Api.Email;
+
+public class EmailOptions
+{
+ public string? Host { get; set; }
+ public int Port { get; set; } = 587;
+ public string? Username { get; set; }
+ public string? Password { get; set; }
+ public string? FromAddress { get; set; }
+ public string FromName { get; set; } = "YesChef";
+
+ public bool IsConfigured => !string.IsNullOrWhiteSpace(Host) && !string.IsNullOrWhiteSpace(FromAddress);
+}
diff --git a/src/backend/YesChef.Api/Email/IEmailSender.cs b/src/backend/YesChef.Api/Email/IEmailSender.cs
new file mode 100644
index 0000000..4bf4d3b
--- /dev/null
+++ b/src/backend/YesChef.Api/Email/IEmailSender.cs
@@ -0,0 +1,6 @@
+namespace YesChef.Api.Email;
+
+public interface IEmailSender
+{
+ Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default);
+}
diff --git a/src/backend/YesChef.Api/Email/LoggingEmailSender.cs b/src/backend/YesChef.Api/Email/LoggingEmailSender.cs
new file mode 100644
index 0000000..0d05c31
--- /dev/null
+++ b/src/backend/YesChef.Api/Email/LoggingEmailSender.cs
@@ -0,0 +1,17 @@
+namespace YesChef.Api.Email;
+
+///
+/// Fallback email sender used when SMTP is not configured (typically local dev).
+/// Logs the message instead of sending so flows that depend on email continue
+/// to work without a real SMTP server.
+///
+public class LoggingEmailSender(ILogger logger) : IEmailSender
+{
+ public Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default)
+ {
+ logger.LogWarning(
+ "[LoggingEmailSender] SMTP not configured — would send email:\n To: {To}\n Subject: {Subject}\n Text body:\n{Text}",
+ message.ToAddress, message.Subject, message.TextBody);
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/backend/YesChef.Api/Email/SmtpEmailSender.cs b/src/backend/YesChef.Api/Email/SmtpEmailSender.cs
new file mode 100644
index 0000000..7a90f1c
--- /dev/null
+++ b/src/backend/YesChef.Api/Email/SmtpEmailSender.cs
@@ -0,0 +1,44 @@
+using MailKit.Net.Smtp;
+using MailKit.Security;
+using Microsoft.Extensions.Options;
+using MimeKit;
+
+namespace YesChef.Api.Email;
+
+public class SmtpEmailSender(IOptions options, ILogger logger) : IEmailSender
+{
+ private readonly EmailOptions _options = options.Value;
+
+ public async Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default)
+ {
+ if (!_options.IsConfigured)
+ throw new InvalidOperationException("SMTP is not configured. Set Smtp:Host and Smtp:FromAddress.");
+
+ var mime = new MimeMessage();
+ mime.From.Add(new MailboxAddress(_options.FromName, _options.FromAddress!));
+ mime.To.Add(MailboxAddress.Parse(message.ToAddress));
+ mime.Subject = message.Subject;
+ mime.Body = new BodyBuilder
+ {
+ HtmlBody = message.HtmlBody,
+ TextBody = message.TextBody,
+ }.ToMessageBody();
+
+ using var client = new SmtpClient();
+ // STARTTLS on 587, implicit TLS on 465, plain on 25 (dev only).
+ var socketOptions = _options.Port switch
+ {
+ 465 => SecureSocketOptions.SslOnConnect,
+ 25 => SecureSocketOptions.None,
+ _ => SecureSocketOptions.StartTls,
+ };
+
+ await client.ConnectAsync(_options.Host!, _options.Port, socketOptions, cancellationToken);
+ if (!string.IsNullOrEmpty(_options.Username))
+ await client.AuthenticateAsync(_options.Username, _options.Password ?? string.Empty, cancellationToken);
+ await client.SendAsync(mime, cancellationToken);
+ await client.DisconnectAsync(quit: true, cancellationToken);
+
+ logger.LogInformation("Sent email to {Recipient} with subject {Subject}", message.ToAddress, message.Subject);
+ }
+}
diff --git a/src/backend/YesChef.Api/Program.cs b/src/backend/YesChef.Api/Program.cs
index d90ad87..c42469b 100644
--- a/src/backend/YesChef.Api/Program.cs
+++ b/src/backend/YesChef.Api/Program.cs
@@ -1,10 +1,14 @@
using System.Security.Claims;
using System.Text;
+using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using YesChef.Api.Auth;
using YesChef.Api.Data;
+using YesChef.Api.Email;
using YesChef.Api.Entities;
using YesChef.Api.Features.Families;
using YesChef.Api.Features.Recipes;
@@ -18,6 +22,15 @@ builder.Services.AddDbContext(options =>
builder.Services.AddSingleton();
+builder.Services.Configure(builder.Configuration.GetSection("Smtp"));
+builder.Services.AddSingleton(sp =>
+{
+ var options = sp.GetRequiredService>().Value;
+ return options.IsConfigured
+ ? new SmtpEmailSender(sp.GetRequiredService>(), sp.GetRequiredService>())
+ : new LoggingEmailSender(sp.GetRequiredService>());
+});
+
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
@@ -78,6 +91,47 @@ builder.Services.AddAuthorization(options =>
});
builder.Services.AddSignalR();
+builder.Services.AddRateLimiter(options =>
+{
+ options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
+ options.OnRejected = (context, _) =>
+ {
+ // Surface a Retry-After header when the limiter knows the window.
+ if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
+ context.HttpContext.Response.Headers.RetryAfter =
+ ((int)retryAfter.TotalSeconds).ToString(System.Globalization.CultureInfo.InvariantCulture);
+ return ValueTask.CompletedTask;
+ };
+
+ // In Testing the existing integration suite hammers /register and /login
+ // and would otherwise trip the limits. Bypass with NoLimiter in Test only.
+ var bypassLimits = builder.Environment.IsEnvironment("Testing");
+
+ options.AddPolicy(AuthRateLimits.LoginPolicy, 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,
+ });
+ });
+
+ options.AddPolicy(AuthRateLimits.RegisterPolicy, 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,
+ });
+ });
+});
+
var app = builder.Build();
using (var scope = app.Services.CreateScope())
@@ -112,6 +166,7 @@ using (var scope = app.Services.CreateScope())
await db.SaveChangesAsync();
}
+app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
diff --git a/src/backend/YesChef.Api/YesChef.Api.csproj b/src/backend/YesChef.Api/YesChef.Api.csproj
index 125fa9b..d5c8bff 100644
--- a/src/backend/YesChef.Api/YesChef.Api.csproj
+++ b/src/backend/YesChef.Api/YesChef.Api.csproj
@@ -7,6 +7,7 @@
+
runtime; build; native; contentfiles; analyzers; buildtransitive