diff --git a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs index a3854a6..1587f87 100644 --- a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs @@ -36,6 +36,7 @@ public class EmailAuthController( DatabaseContext db, AuthService authService, MailService mailService, + EmailRateLimiter rateLimiter, KeyCacheService keyCacheService, UserRendererService userRenderer, IClock clock, @@ -68,6 +69,9 @@ public class EmailAuthController( return NoContent(); } + if (IsRateLimited()) + return NoContent(); + mailService.QueueAccountCreationEmail(req.Email, state); return NoContent(); } @@ -221,6 +225,9 @@ public class EmailAuthController( return NoContent(); } + if (IsRateLimited()) + return NoContent(); + mailService.QueueAddEmailAddressEmail(req.Email, state, CurrentUser.Username); return NoContent(); } @@ -274,4 +281,34 @@ public class EmailAuthController( if (!config.EmailAuth.Enabled) throw new ApiError.BadRequest("Email authentication is not enabled on this instance."); } + + /// + /// Checks whether the context's IP address is rate limited from dispatching emails. + /// + private bool IsRateLimited() + { + if (HttpContext.Connection.RemoteIpAddress == null) + { + _logger.Information( + "No remote IP address in HTTP context for email-related request, ignoring as we can't rate limit it" + ); + return true; + } + + if ( + !rateLimiter.IsLimited( + HttpContext.Connection.RemoteIpAddress.ToString(), + out Duration retryAfter + ) + ) + { + return false; + } + + _logger.Information( + "IP address cannot send email until {RetryAfter}, ignoring", + retryAfter + ); + return true; + } } diff --git a/Foxnouns.Backend/Database/DatabaseContext.cs b/Foxnouns.Backend/Database/DatabaseContext.cs index 7407c5b..ddf7853 100644 --- a/Foxnouns.Backend/Database/DatabaseContext.cs +++ b/Foxnouns.Backend/Database/DatabaseContext.cs @@ -66,7 +66,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options) public DbSet Applications { get; init; } = null!; public DbSet TemporaryKeys { get; init; } = null!; public DbSet DataExports { get; init; } = null!; - public DbSet SentEmails { get; init; } = null!; public DbSet PrideFlags { get; init; } = null!; public DbSet UserFlags { get; init; } = null!; @@ -86,7 +85,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options) modelBuilder.Entity().HasIndex(m => m.Sid).IsUnique(); modelBuilder.Entity().HasIndex(k => k.Key).IsUnique(); modelBuilder.Entity().HasIndex(d => d.Filename).IsUnique(); - modelBuilder.Entity().HasIndex(e => new { e.Email, e.SentAt }); // Two indexes on auth_methods, one for fediverse auth and one for all other types. modelBuilder diff --git a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs index a9f59e6..cfe2513 100644 --- a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs +++ b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs @@ -302,33 +302,6 @@ namespace Foxnouns.Backend.Database.Migrations b.ToTable("pride_flags", (string)null); }); - modelBuilder.Entity("Foxnouns.Backend.Database.Models.SentEmail", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasColumnName("id"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Email") - .IsRequired() - .HasColumnType("text") - .HasColumnName("email"); - - b.Property("SentAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("sent_at"); - - b.HasKey("Id") - .HasName("pk_sent_emails"); - - b.HasIndex("Email", "SentAt") - .HasDatabaseName("ix_sent_emails_email_sent_at"); - - b.ToTable("sent_emails", (string)null); - }); - modelBuilder.Entity("Foxnouns.Backend.Database.Models.TemporaryKey", b => { b.Property("Id") diff --git a/Foxnouns.Backend/Database/Models/SentEmail.cs b/Foxnouns.Backend/Database/Models/SentEmail.cs deleted file mode 100644 index 09f03d9..0000000 --- a/Foxnouns.Backend/Database/Models/SentEmail.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.ComponentModel.DataAnnotations.Schema; -using NodaTime; - -namespace Foxnouns.Backend.Database.Models; - -public class SentEmail -{ - [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; init; } - - public required string Email { get; init; } - public required Instant SentAt { get; init; } -} diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs index 30d97d8..41c9712 100644 --- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs +++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs @@ -110,6 +110,7 @@ public static class WebApplicationExtensions .AddSingleton(SystemClock.Instance) .AddSnowflakeGenerator() .AddSingleton() + .AddSingleton() .AddScoped() .AddScoped() .AddScoped() diff --git a/Foxnouns.Backend/Services/DataCleanupService.cs b/Foxnouns.Backend/Services/DataCleanupService.cs index 5e40c08..3d60462 100644 --- a/Foxnouns.Backend/Services/DataCleanupService.cs +++ b/Foxnouns.Backend/Services/DataCleanupService.cs @@ -33,9 +33,6 @@ public class DataCleanupService( public async Task InvokeAsync(CancellationToken ct = default) { - _logger.Debug("Cleaning up sent email cache"); - await CleanEmailsAsync(ct); - _logger.Debug("Cleaning up expired users"); await CleanUsersAsync(ct); @@ -43,14 +40,6 @@ public class DataCleanupService( await CleanExportsAsync(ct); } - private async Task CleanEmailsAsync(CancellationToken ct = default) - { - Instant expiry = clock.GetCurrentInstant() - Duration.FromHours(2); - int count = await db.SentEmails.Where(e => e.SentAt < expiry).ExecuteDeleteAsync(ct); - if (count != 0) - _logger.Information("Deleted {Count} entries from the sent email cache", expiry); - } - private async Task CleanUsersAsync(CancellationToken ct = default) { Instant selfDeleteExpires = clock.GetCurrentInstant() - User.DeleteAfter; diff --git a/Foxnouns.Backend/Services/EmailRateLimiter.cs b/Foxnouns.Backend/Services/EmailRateLimiter.cs new file mode 100644 index 0000000..9e73792 --- /dev/null +++ b/Foxnouns.Backend/Services/EmailRateLimiter.cs @@ -0,0 +1,36 @@ +using System.Collections.Concurrent; +using System.Threading.RateLimiting; +using NodaTime; +using NodaTime.Extensions; + +namespace Foxnouns.Backend.Services; + +public class EmailRateLimiter +{ + private readonly ConcurrentDictionary _limiters = new(); + + private readonly FixedWindowRateLimiterOptions _limiterOptions = + new() { Window = TimeSpan.FromHours(2), PermitLimit = 3 }; + + private RateLimiter GetLimiter(string bucket) => + _limiters.GetOrAdd(bucket, _ => new FixedWindowRateLimiter(_limiterOptions)); + + public bool IsLimited(string bucket, out Duration retryAfter) + { + RateLimiter limiter = GetLimiter(bucket); + RateLimitLease lease = limiter.AttemptAcquire(); + + if (!lease.IsAcquired) + { + retryAfter = lease.TryGetMetadata(MetadataName.RetryAfter, out TimeSpan timeSpan) + ? timeSpan.ToDuration() + : default; + } + else + { + retryAfter = Duration.Zero; + } + + return !lease.IsAcquired; + } +} diff --git a/Foxnouns.Backend/Services/MailService.cs b/Foxnouns.Backend/Services/MailService.cs index e162b33..a1444d9 100644 --- a/Foxnouns.Backend/Services/MailService.cs +++ b/Foxnouns.Backend/Services/MailService.cs @@ -15,22 +15,11 @@ using Coravel.Mailer.Mail; using Coravel.Mailer.Mail.Interfaces; using Coravel.Queuing.Interfaces; -using Foxnouns.Backend.Database; -using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Mailables; -using Microsoft.EntityFrameworkCore; -using NodaTime; namespace Foxnouns.Backend.Services; -public class MailService( - ILogger logger, - IMailer mailer, - IQueue queue, - IClock clock, - Config config, - IServiceProvider serviceProvider -) +public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config config) { private readonly ILogger _logger = logger.ForContext(); @@ -78,29 +67,7 @@ public class MailService( { try { - // ReSharper disable SuggestVarOrType_SimpleTypes - await using var scope = serviceProvider.CreateAsyncScope(); - await using var db = scope.ServiceProvider.GetRequiredService(); - // ReSharper restore SuggestVarOrType_SimpleTypes - - Instant now = clock.GetCurrentInstant(); - - int count = await db.SentEmails.CountAsync(e => - e.Email == to && e.SentAt > (now - Duration.FromHours(1)) - ); - if (count >= 2) - { - _logger.Information( - "Have already sent 2 or more emails to {ToAddress} in the past hour, not sending new email", - to - ); - return; - } - await mailer.SendAsync(mailable); - - db.SentEmails.Add(new SentEmail { Email = to, SentAt = now }); - await db.SaveChangesAsync(); } catch (Exception exc) {