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