feat: use a FixedWindowRateLimiter keyed by IP to rate limit emails
we don't talk about the sent_emails table :)
This commit is contained in:
parent
1ce4f9d278
commit
51e335f090
8 changed files with 75 additions and 87 deletions
|
@ -36,6 +36,7 @@ public class EmailAuthController(
|
||||||
DatabaseContext db,
|
DatabaseContext db,
|
||||||
AuthService authService,
|
AuthService authService,
|
||||||
MailService mailService,
|
MailService mailService,
|
||||||
|
EmailRateLimiter rateLimiter,
|
||||||
KeyCacheService keyCacheService,
|
KeyCacheService keyCacheService,
|
||||||
UserRendererService userRenderer,
|
UserRendererService userRenderer,
|
||||||
IClock clock,
|
IClock clock,
|
||||||
|
@ -68,6 +69,9 @@ public class EmailAuthController(
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IsRateLimited())
|
||||||
|
return NoContent();
|
||||||
|
|
||||||
mailService.QueueAccountCreationEmail(req.Email, state);
|
mailService.QueueAccountCreationEmail(req.Email, state);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
@ -221,6 +225,9 @@ public class EmailAuthController(
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IsRateLimited())
|
||||||
|
return NoContent();
|
||||||
|
|
||||||
mailService.QueueAddEmailAddressEmail(req.Email, state, CurrentUser.Username);
|
mailService.QueueAddEmailAddressEmail(req.Email, state, CurrentUser.Username);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
@ -274,4 +281,34 @@ public class EmailAuthController(
|
||||||
if (!config.EmailAuth.Enabled)
|
if (!config.EmailAuth.Enabled)
|
||||||
throw new ApiError.BadRequest("Email authentication is not enabled on this instance.");
|
throw new ApiError.BadRequest("Email authentication is not enabled on this instance.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether the context's IP address is rate limited from dispatching emails.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -66,7 +66,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
|
||||||
public DbSet<Application> Applications { get; init; } = null!;
|
public DbSet<Application> Applications { get; init; } = null!;
|
||||||
public DbSet<TemporaryKey> TemporaryKeys { get; init; } = null!;
|
public DbSet<TemporaryKey> TemporaryKeys { get; init; } = null!;
|
||||||
public DbSet<DataExport> DataExports { get; init; } = null!;
|
public DbSet<DataExport> DataExports { get; init; } = null!;
|
||||||
public DbSet<SentEmail> SentEmails { get; init; } = null!;
|
|
||||||
|
|
||||||
public DbSet<PrideFlag> PrideFlags { get; init; } = null!;
|
public DbSet<PrideFlag> PrideFlags { get; init; } = null!;
|
||||||
public DbSet<UserFlag> UserFlags { get; init; } = null!;
|
public DbSet<UserFlag> UserFlags { get; init; } = null!;
|
||||||
|
@ -86,7 +85,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
|
||||||
modelBuilder.Entity<Member>().HasIndex(m => m.Sid).IsUnique();
|
modelBuilder.Entity<Member>().HasIndex(m => m.Sid).IsUnique();
|
||||||
modelBuilder.Entity<TemporaryKey>().HasIndex(k => k.Key).IsUnique();
|
modelBuilder.Entity<TemporaryKey>().HasIndex(k => k.Key).IsUnique();
|
||||||
modelBuilder.Entity<DataExport>().HasIndex(d => d.Filename).IsUnique();
|
modelBuilder.Entity<DataExport>().HasIndex(d => d.Filename).IsUnique();
|
||||||
modelBuilder.Entity<SentEmail>().HasIndex(e => new { e.Email, e.SentAt });
|
|
||||||
|
|
||||||
// Two indexes on auth_methods, one for fediverse auth and one for all other types.
|
// Two indexes on auth_methods, one for fediverse auth and one for all other types.
|
||||||
modelBuilder
|
modelBuilder
|
||||||
|
|
|
@ -302,33 +302,6 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
b.ToTable("pride_flags", (string)null);
|
b.ToTable("pride_flags", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.SentEmail", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasColumnName("id");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("Email")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text")
|
|
||||||
.HasColumnName("email");
|
|
||||||
|
|
||||||
b.Property<Instant>("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 =>
|
modelBuilder.Entity("Foxnouns.Backend.Database.Models.TemporaryKey", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
|
|
|
@ -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; }
|
|
||||||
}
|
|
|
@ -110,6 +110,7 @@ public static class WebApplicationExtensions
|
||||||
.AddSingleton<IClock>(SystemClock.Instance)
|
.AddSingleton<IClock>(SystemClock.Instance)
|
||||||
.AddSnowflakeGenerator()
|
.AddSnowflakeGenerator()
|
||||||
.AddSingleton<MailService>()
|
.AddSingleton<MailService>()
|
||||||
|
.AddSingleton<EmailRateLimiter>()
|
||||||
.AddScoped<UserRendererService>()
|
.AddScoped<UserRendererService>()
|
||||||
.AddScoped<MemberRendererService>()
|
.AddScoped<MemberRendererService>()
|
||||||
.AddScoped<AuthService>()
|
.AddScoped<AuthService>()
|
||||||
|
|
|
@ -33,9 +33,6 @@ public class DataCleanupService(
|
||||||
|
|
||||||
public async Task InvokeAsync(CancellationToken ct = default)
|
public async Task InvokeAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
_logger.Debug("Cleaning up sent email cache");
|
|
||||||
await CleanEmailsAsync(ct);
|
|
||||||
|
|
||||||
_logger.Debug("Cleaning up expired users");
|
_logger.Debug("Cleaning up expired users");
|
||||||
await CleanUsersAsync(ct);
|
await CleanUsersAsync(ct);
|
||||||
|
|
||||||
|
@ -43,14 +40,6 @@ public class DataCleanupService(
|
||||||
await CleanExportsAsync(ct);
|
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)
|
private async Task CleanUsersAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Instant selfDeleteExpires = clock.GetCurrentInstant() - User.DeleteAfter;
|
Instant selfDeleteExpires = clock.GetCurrentInstant() - User.DeleteAfter;
|
||||||
|
|
36
Foxnouns.Backend/Services/EmailRateLimiter.cs
Normal file
36
Foxnouns.Backend/Services/EmailRateLimiter.cs
Normal file
|
@ -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<string, RateLimiter> _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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -15,22 +15,11 @@
|
||||||
using Coravel.Mailer.Mail;
|
using Coravel.Mailer.Mail;
|
||||||
using Coravel.Mailer.Mail.Interfaces;
|
using Coravel.Mailer.Mail.Interfaces;
|
||||||
using Coravel.Queuing.Interfaces;
|
using Coravel.Queuing.Interfaces;
|
||||||
using Foxnouns.Backend.Database;
|
|
||||||
using Foxnouns.Backend.Database.Models;
|
|
||||||
using Foxnouns.Backend.Mailables;
|
using Foxnouns.Backend.Mailables;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using NodaTime;
|
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Services;
|
namespace Foxnouns.Backend.Services;
|
||||||
|
|
||||||
public class MailService(
|
public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config config)
|
||||||
ILogger logger,
|
|
||||||
IMailer mailer,
|
|
||||||
IQueue queue,
|
|
||||||
IClock clock,
|
|
||||||
Config config,
|
|
||||||
IServiceProvider serviceProvider
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
private readonly ILogger _logger = logger.ForContext<MailService>();
|
private readonly ILogger _logger = logger.ForContext<MailService>();
|
||||||
|
|
||||||
|
@ -78,29 +67,7 @@ public class MailService(
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// ReSharper disable SuggestVarOrType_SimpleTypes
|
|
||||||
await using var scope = serviceProvider.CreateAsyncScope();
|
|
||||||
await using var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
|
|
||||||
// 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);
|
await mailer.SendAsync(mailable);
|
||||||
|
|
||||||
db.SentEmails.Add(new SentEmail { Email = to, SentAt = now });
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
}
|
}
|
||||||
catch (Exception exc)
|
catch (Exception exc)
|
||||||
{
|
{
|
||||||
|
|
Loading…
Reference in a new issue