feat: rate limit emails to two per address per hour

This commit is contained in:
sam 2024-12-11 20:42:48 +01:00
parent 5cb3faa92b
commit ff8d53814d
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
6 changed files with 189 additions and 49 deletions

View file

@ -66,6 +66,7 @@ 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!;
@ -84,6 +85,10 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
modelBuilder.Entity<Member>().HasIndex(m => new { m.UserId, m.Name }).IsUnique(); modelBuilder.Entity<Member>().HasIndex(m => new { m.UserId, m.Name }).IsUnique();
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<SentEmail>().HasIndex(e => new { e.Email, e.SentAt });
// Two indexes on auth_methods, one for fediverse auth and one for all other types.
modelBuilder modelBuilder
.Entity<AuthMethod>() .Entity<AuthMethod>()
.HasIndex(m => new .HasIndex(m => new
@ -94,7 +99,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
}) })
.HasFilter("fediverse_application_id IS NOT NULL") .HasFilter("fediverse_application_id IS NOT NULL")
.IsUnique(); .IsUnique();
modelBuilder.Entity<DataExport>().HasIndex(d => d.Filename).IsUnique();
modelBuilder modelBuilder
.Entity<AuthMethod>() .Entity<AuthMethod>()

View file

@ -0,0 +1,53 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241211193653_AddSentEmailCache")]
public partial class AddSentEmailCache : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "sent_emails",
columns: table => new
{
id = table
.Column<int>(type: "integer", nullable: false)
.Annotation(
"Npgsql:ValueGenerationStrategy",
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn
),
email = table.Column<string>(type: "text", nullable: false),
sent_at = table.Column<Instant>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("pk_sent_emails", x => x.id);
}
);
migrationBuilder.CreateIndex(
name: "ix_sent_emails_email_sent_at",
table: "sent_emails",
columns: new[] { "email", "sent_at" }
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "sent_emails");
}
}
}

View file

@ -1,5 +1,4 @@
// <auto-generated /> // <auto-generated />
using System;
using System.Collections.Generic; using System.Collections.Generic;
using Foxnouns.Backend.Database; using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Database.Models;
@ -20,7 +19,7 @@ namespace Foxnouns.Backend.Database.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "8.0.7") .HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@ -46,12 +45,12 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("name"); .HasColumnName("name");
b.Property<string[]>("RedirectUris") b.PrimitiveCollection<string[]>("RedirectUris")
.IsRequired() .IsRequired()
.HasColumnType("text[]") .HasColumnType("text[]")
.HasColumnName("redirect_uris"); .HasColumnName("redirect_uris");
b.Property<string[]>("Scopes") b.PrimitiveCollection<string[]>("Scopes")
.IsRequired() .IsRequired()
.HasColumnType("text[]") .HasColumnType("text[]")
.HasColumnName("scopes"); .HasColumnName("scopes");
@ -193,7 +192,7 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("jsonb") .HasColumnType("jsonb")
.HasColumnName("fields"); .HasColumnName("fields");
b.Property<string[]>("Links") b.PrimitiveCollection<string[]>("Links")
.IsRequired() .IsRequired()
.HasColumnType("text[]") .HasColumnType("text[]")
.HasColumnName("links"); .HasColumnName("links");
@ -303,6 +302,33 @@ 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")
@ -359,7 +385,7 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("boolean") .HasColumnType("boolean")
.HasColumnName("manually_expired"); .HasColumnName("manually_expired");
b.Property<string[]>("Scopes") b.PrimitiveCollection<string[]>("Scopes")
.IsRequired() .IsRequired()
.HasColumnType("text[]") .HasColumnType("text[]")
.HasColumnName("scopes"); .HasColumnName("scopes");
@ -428,7 +454,7 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("last_sid_reroll"); .HasColumnName("last_sid_reroll");
b.Property<string[]>("Links") b.PrimitiveCollection<string[]>("Links")
.IsRequired() .IsRequired()
.HasColumnType("text[]") .HasColumnType("text[]")
.HasColumnName("links"); .HasColumnName("links");

View file

@ -0,0 +1,13 @@
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; }
}

View file

@ -33,13 +33,24 @@ public class DataCleanupService(
public async Task InvokeAsync(CancellationToken ct = default) public async Task InvokeAsync(CancellationToken ct = default)
{ {
_logger.Information("Cleaning up expired users"); _logger.Debug("Cleaning up sent email cache");
await CleanEmailsAsync(ct);
_logger.Debug("Cleaning up expired users");
await CleanUsersAsync(ct); await CleanUsersAsync(ct);
_logger.Information("Cleaning up expired data exports"); _logger.Debug("Cleaning up expired data exports");
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;

View file

@ -12,13 +12,25 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
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(ILogger logger, IMailer mailer, IQueue queue, Config config) public class MailService(
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>();
@ -26,25 +38,18 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co
{ {
queue.QueueAsyncTask(async () => queue.QueueAsyncTask(async () =>
{ {
_logger.Debug("Sending account creation email to {ToEmail}", to); await SendEmailAsync(
try to,
{ new AccountCreationMailable(
await mailer.SendAsync( config,
new AccountCreationMailable( new AccountCreationMailableView
config, {
new AccountCreationMailableView BaseUrl = config.BaseUrl,
{ To = to,
BaseUrl = config.BaseUrl, Code = code,
To = to, }
Code = code, )
} );
)
);
}
catch (Exception exc)
{
_logger.Error(exc, "Sending account creation email");
}
}); });
} }
@ -53,25 +58,53 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co
_logger.Debug("Sending add email address email to {ToEmail}", to); _logger.Debug("Sending add email address email to {ToEmail}", to);
queue.QueueAsyncTask(async () => queue.QueueAsyncTask(async () =>
{ {
try await SendEmailAsync(
{ to,
await mailer.SendAsync( new AddEmailMailable(
new AddEmailMailable( config,
config, new AddEmailMailableView
new AddEmailMailableView {
{ BaseUrl = config.BaseUrl,
BaseUrl = config.BaseUrl, To = to,
To = to, Code = code,
Code = code, Username = username,
Username = username, }
} )
) );
);
}
catch (Exception exc)
{
_logger.Error(exc, "Sending add email address email");
}
}); });
} }
private async Task SendEmailAsync<T>(string to, Mailable<T> mailable)
{
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);
db.SentEmails.Add(new SentEmail { Email = to, SentAt = now });
await db.SaveChangesAsync();
}
catch (Exception exc)
{
_logger.Error(exc, "Sending email");
}
}
} }