From 78559c7ece5c3ec6503ac3e6cbdfd1089229391a Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Wed, 24 Jun 2026 23:34:01 +0200 Subject: [PATCH] Add rate limit tables --- apps/server/AliasVault.Api/Program.cs | 1 + .../Services/EffectiveRateLimit.cs | 16 + .../Services/RateLimitResolver.cs | 124 ++ .../Services/RateLimitService.cs | 60 + .../Databases/AliasServerDb/AccountTier.cs | 25 + .../AliasServerDb/AliasServerDbContext.cs | 12 + .../Databases/AliasServerDb/AliasVaultUser.cs | 7 - .../20260624212335_AddRateLimits.Designer.cs | 1118 +++++++++++++++++ .../20260624212335_AddRateLimits.cs | 91 ++ .../AliasServerDbContextModelSnapshot.cs | 74 +- .../Databases/AliasServerDb/RateLimit.cs | 108 ++ .../Databases/AliasServerDb/RateLimitType.cs | 19 + .../Databases/AliasServerDb/UserEmailClaim.cs | 1 + .../Models/ServerSettingsModel.cs | 13 - .../Services/ServerSettingsService.cs | 12 - 15 files changed, 1646 insertions(+), 35 deletions(-) create mode 100644 apps/server/AliasVault.Api/Services/EffectiveRateLimit.cs create mode 100644 apps/server/AliasVault.Api/Services/RateLimitResolver.cs create mode 100644 apps/server/AliasVault.Api/Services/RateLimitService.cs create mode 100644 apps/server/Databases/AliasServerDb/AccountTier.cs create mode 100644 apps/server/Databases/AliasServerDb/Migrations/20260624212335_AddRateLimits.Designer.cs create mode 100644 apps/server/Databases/AliasServerDb/Migrations/20260624212335_AddRateLimits.cs create mode 100644 apps/server/Databases/AliasServerDb/RateLimit.cs create mode 100644 apps/server/Databases/AliasServerDb/RateLimitType.cs diff --git a/apps/server/AliasVault.Api/Program.cs b/apps/server/AliasVault.Api/Program.cs index 7fd290343..9923ca071 100644 --- a/apps/server/AliasVault.Api/Program.cs +++ b/apps/server/AliasVault.Api/Program.cs @@ -88,6 +88,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); +builder.Services.AddScoped(); builder.Services.AddHttpContextAccessor(); builder.Services.AddLogging(logging => diff --git a/apps/server/AliasVault.Api/Services/EffectiveRateLimit.cs b/apps/server/AliasVault.Api/Services/EffectiveRateLimit.cs new file mode 100644 index 000000000..57a7a5df2 --- /dev/null +++ b/apps/server/AliasVault.Api/Services/EffectiveRateLimit.cs @@ -0,0 +1,16 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Api.Services; + +/// +/// A resolved limit for a user: over a window ( 0 = +/// absolute cap, > 0 = rolling window). +/// +/// The rolling window length in seconds, or 0 for an absolute cap. +/// The maximum allowed count for the window. +public sealed record EffectiveRateLimit(int WindowSeconds, int MaxCount); diff --git a/apps/server/AliasVault.Api/Services/RateLimitResolver.cs b/apps/server/AliasVault.Api/Services/RateLimitResolver.cs new file mode 100644 index 000000000..061f553b3 --- /dev/null +++ b/apps/server/AliasVault.Api/Services/RateLimitResolver.cs @@ -0,0 +1,124 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Api.Services; + +using AliasServerDb; + +/// +/// Rate limit resolver logic which determines the limits that apply to a given user. +/// +public static class RateLimitResolver +{ + /// + /// Resolves the limits that apply to the given user. For each window the most specific scope wins + /// (per-user > per-tier > global); a winning MaxCount of 0 means unlimited. + /// + /// The candidate rules. + /// The user to resolve limits for. + /// The limit type to resolve. + /// The current UTC time, used to evaluate effective-from/until windows. + /// The limits that must all be satisfied. Empty means no limit applies. + public static IReadOnlyList Resolve(IEnumerable rules, AliasVaultUser user, RateLimitType limitType, DateTime now) + { + var tier = ResolveTier(user); + + var applicableRules = rules + .Where(r => r.Enabled) + .Where(r => r.LimitType == limitType) + .Where(r => (r.EffectiveFrom is null || r.EffectiveFrom <= now) && (r.EffectiveUntil is null || r.EffectiveUntil >= now)) + .Where(r => r.AppliesToAccountAgeMaxDays is null || user.CreatedAt > now.AddDays(-r.AppliesToAccountAgeMaxDays.Value)); + + var windows = new HashSet(); + var userMax = new Dictionary(); + var tierMax = new Dictionary(); + var globalMax = new Dictionary(); + + foreach (var rule in applicableRules) + { + if (rule.UserId is not null) + { + if (rule.UserId == user.Id) + { + Record(windows, userMax, rule.WindowSeconds, rule.MaxCount); + } + } + else if (rule.Tier is not null) + { + if (rule.Tier == tier) + { + Record(windows, tierMax, rule.WindowSeconds, rule.MaxCount); + } + } + else + { + Record(windows, globalMax, rule.WindowSeconds, rule.MaxCount); + } + } + + var result = new List(); + foreach (var window in windows) + { + int max; + if (userMax.TryGetValue(window, out var um)) + { + max = um; + } + else if (tierMax.TryGetValue(window, out var tm)) + { + max = tm; + } + else if (globalMax.TryGetValue(window, out var gm)) + { + max = gm; + } + else + { + continue; + } + + // 0 = unlimited, so not enforced. + if (max > 0) + { + result.Add(new EffectiveRateLimit(window, max)); + } + } + + return result; + } + + /// + /// Resolves the user's tier. Tiers are not implemented yet, so every user is by default. + /// + /// The user to resolve the tier for. + /// The account tier that applies to the user. + public static AccountTier ResolveTier(AliasVaultUser user) => AccountTier.Free; + + /// + /// Records a candidate MaxCount for a scope+window. A concrete limit wins over unlimited (0), and between + /// multiple concrete limits the most restrictive (smallest) is kept. + /// + private static void Record(HashSet windows, Dictionary map, int window, int max) + { + windows.Add(window); + + if (!map.TryGetValue(window, out var existing)) + { + map[window] = max; + return; + } + + if (existing == 0) + { + map[window] = max; + } + else if (max > 0) + { + map[window] = Math.Min(existing, max); + } + } +} diff --git a/apps/server/AliasVault.Api/Services/RateLimitService.cs b/apps/server/AliasVault.Api/Services/RateLimitService.cs new file mode 100644 index 000000000..4b2af4623 --- /dev/null +++ b/apps/server/AliasVault.Api/Services/RateLimitService.cs @@ -0,0 +1,60 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Api.Services; + +using AliasServerDb; +using AliasVault.Shared.Providers.Time; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; + +/// +/// Resolves the rate-limit rules that apply to a given user. Rules are read from the RateLimits table and cached +/// in memory briefly so the vault-sync hot path does not hit the database on every request. +/// +/// IDbContextFactory instance. +/// IMemoryCache instance used to cache the enabled rules. +/// ITimeProvider instance. +public class RateLimitService(IAliasServerDbContextFactory dbContextFactory, IMemoryCache cache, ITimeProvider timeProvider) +{ + private const int CacheDurationSeconds = 60; + + private const string EnabledRulesCacheKey = "RateLimits_EnabledRules"; + + /// + /// Resolves the limits that apply to the given user for the given limit type. + /// + /// The user to resolve limits for. + /// The limit type to resolve. + /// The limits that must all be satisfied. Empty means no limit applies. + public async Task> ResolveAsync(AliasVaultUser user, RateLimitType limitType) + { + var rules = await GetEnabledRulesAsync(); + return RateLimitResolver.Resolve(rules, user, limitType, timeProvider.UtcNow); + } + + /// + /// Returns the enabled rules from cache, refreshing at most once every . + /// + /// The list of enabled rules. + private async Task> GetEnabledRulesAsync() + { + if (cache.TryGetValue(EnabledRulesCacheKey, out List? cached) && cached is not null) + { + return cached; + } + + await using var dbContext = await dbContextFactory.CreateDbContextAsync(); + var rules = await dbContext.RateLimits + .AsNoTracking() + .Where(x => x.Enabled) + .ToListAsync(); + + cache.Set(EnabledRulesCacheKey, rules, TimeSpan.FromSeconds(CacheDurationSeconds)); + return rules; + } +} diff --git a/apps/server/Databases/AliasServerDb/AccountTier.cs b/apps/server/Databases/AliasServerDb/AccountTier.cs new file mode 100644 index 000000000..7514b773e --- /dev/null +++ b/apps/server/Databases/AliasServerDb/AccountTier.cs @@ -0,0 +1,25 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasServerDb; + +/// +/// Subscription tier of an account, used to target a rule at a class of users. +/// Tiers are not implemented yet, acts as placeholder for future implementation. +/// +public enum AccountTier +{ + /// + /// The default tier for all accounts. + /// + Free = 0, + + /// + /// Premium (paid) tier. Not yet implemented, acts as placeholder for unit tests. + /// + Premium = 1, +} diff --git a/apps/server/Databases/AliasServerDb/AliasServerDbContext.cs b/apps/server/Databases/AliasServerDb/AliasServerDbContext.cs index 67216e2dc..9c09c508c 100644 --- a/apps/server/Databases/AliasServerDb/AliasServerDbContext.cs +++ b/apps/server/Databases/AliasServerDb/AliasServerDbContext.cs @@ -147,6 +147,11 @@ public class AliasServerDbContext : WorkerStatusDbContext, IDataProtectionKeyCon /// public DbSet BlockedIpRanges { get; set; } + /// + /// Gets or sets the RateLimits DbSet. + /// + public DbSet RateLimits { get; set; } + /// /// Sets up the connection string if it is not already configured. /// @@ -285,5 +290,12 @@ public class AliasServerDbContext : WorkerStatusDbContext, IDataProtectionKeyCon .WithMany() .HasForeignKey(m => m.UserId) .OnDelete(DeleteBehavior.Cascade); + + // Configure RateLimit - AliasVaultUser relationship + modelBuilder.Entity() + .HasOne(r => r.User) + .WithMany() + .HasForeignKey(r => r.UserId) + .OnDelete(DeleteBehavior.Cascade); } } diff --git a/apps/server/Databases/AliasServerDb/AliasVaultUser.cs b/apps/server/Databases/AliasServerDb/AliasVaultUser.cs index 0767528ee..f98c0fbdb 100644 --- a/apps/server/Databases/AliasServerDb/AliasVaultUser.cs +++ b/apps/server/Databases/AliasServerDb/AliasVaultUser.cs @@ -69,13 +69,6 @@ public class AliasVaultUser : IdentityUser /// public int MaxEmailAgeDays { get; set; } = 0; - /// - /// Gets or sets the per-user alias creation limit. 0 means no per-user limit (global settings may apply). - /// A value greater than 0 is a explicit per-user cap that always applies to this account and takes priority - /// over any other global limit. - /// - public int MaxAliases { get; set; } = 0; - /// /// Gets or sets the date of the user's last activity (login, API call, etc.). /// Updated automatically on successful authentication events. diff --git a/apps/server/Databases/AliasServerDb/Migrations/20260624212335_AddRateLimits.Designer.cs b/apps/server/Databases/AliasServerDb/Migrations/20260624212335_AddRateLimits.Designer.cs new file mode 100644 index 000000000..f682c0f14 --- /dev/null +++ b/apps/server/Databases/AliasServerDb/Migrations/20260624212335_AddRateLimits.Designer.cs @@ -0,0 +1,1118 @@ +// +using System; +using AliasServerDb; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace AliasServerDb.Migrations +{ + [DbContext(typeof(AliasServerDbContext))] + [Migration("20260624212335_AddRateLimits")] + partial class AddRateLimits + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.6") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true) + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AliasServerDb.AdminRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("NormalizedName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("AdminRoles"); + }); + + modelBuilder.Entity("AliasServerDb.AdminUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LastPasswordChanged") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasColumnType("text"); + + b.Property("NormalizedUserName") + .HasColumnType("text"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("AdminUsers"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("NormalizedName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("AliasVaultRoles"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("Blocked") + .HasColumnType("boolean"); + + b.Property("BlockedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ConcurrencyStamp") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("EmailsReceived") + .HasColumnType("integer"); + + b.Property("LastActivityDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxEmailAgeDays") + .HasColumnType("integer"); + + b.Property("MaxEmails") + .HasColumnType("integer"); + + b.Property("NormalizedEmail") + .HasColumnType("text"); + + b.Property("NormalizedUserName") + .HasColumnType("text"); + + b.Property("PasswordChangedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("ShadowBlocked") + .HasColumnType("boolean"); + + b.Property("ShadowBlockedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SrpIdentity") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("AliasVaultUsers"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceIdentifier") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpireDate") + .HasMaxLength(255) + .HasColumnType("timestamp with time zone"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("PreviousTokenValue") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AliasVaultUserRefreshTokens"); + }); + + modelBuilder.Entity("AliasServerDb.AuthLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdditionalInfo") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Browser") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Client") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Country") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DeviceType") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("FailureReason") + .HasColumnType("integer"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsSuccess") + .HasColumnType("boolean"); + + b.Property("IsSuspiciousActivity") + .HasColumnType("boolean"); + + b.Property("OperatingSystem") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequestPath") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "EventType" }, "IX_EventType"); + + b.HasIndex(new[] { "IpAddress" }, "IX_IpAddress"); + + b.HasIndex(new[] { "Timestamp" }, "IX_Timestamp"); + + b.HasIndex(new[] { "Username", "IsSuccess", "Timestamp" }, "IX_Username_IsSuccess_Timestamp") + .IsDescending(false, false, true); + + b.HasIndex(new[] { "Username", "Timestamp" }, "IX_Username_Timestamp") + .IsDescending(false, true); + + b.ToTable("AuthLogs"); + }); + + modelBuilder.Entity("AliasServerDb.BlockedIpRange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BlockLogin") + .HasColumnType("boolean"); + + b.Property("BlockRegistration") + .HasColumnType("boolean"); + + b.Property("BlockShadow") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("IpRange") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Reason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "Enabled" }, "IX_BlockedIpRange_Enabled"); + + b.HasIndex(new[] { "IpRange" }, "IX_BlockedIpRange_IpRange") + .IsUnique(); + + b.ToTable("BlockedIpRanges"); + }); + + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DateSystem") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedSymmetricKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("From") + .IsRequired() + .HasColumnType("text"); + + b.Property("FromDomain") + .IsRequired() + .HasColumnType("text"); + + b.Property("FromLocal") + .IsRequired() + .HasColumnType("text"); + + b.Property("MessageHtml") + .HasColumnType("text"); + + b.Property("MessagePlain") + .HasColumnType("text"); + + b.Property("MessagePreview") + .HasColumnType("text"); + + b.Property("MessageSource") + .IsRequired() + .HasColumnType("text"); + + b.Property("PushNotificationSent") + .HasColumnType("boolean"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property("To") + .IsRequired() + .HasColumnType("text"); + + b.Property("ToDomain") + .IsRequired() + .HasColumnType("text"); + + b.Property("ToLocal") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserEncryptionKeyId") + .HasMaxLength(255) + .HasColumnType("uuid"); + + b.Property("Visible") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("DateSystem"); + + b.HasIndex("PushNotificationSent"); + + b.HasIndex("UserEncryptionKeyId"); + + b.HasIndex("Visible"); + + b.HasIndex("To", "DateSystem"); + + b.ToTable("Emails"); + }); + + modelBuilder.Entity("AliasServerDb.EmailAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("EmailId") + .HasColumnType("integer"); + + b.Property("Filename") + .IsRequired() + .HasColumnType("text"); + + b.Property("Filesize") + .HasColumnType("integer"); + + b.Property("MimeType") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("EmailId"); + + b.ToTable("EmailAttachments"); + }); + + modelBuilder.Entity("AliasServerDb.Log", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Application") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Exception") + .IsRequired() + .HasColumnType("text"); + + b.Property("Level") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("LogEvent") + .IsRequired() + .HasColumnType("text") + .HasColumnName("LogEvent"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("MessageTemplate") + .IsRequired() + .HasColumnType("text"); + + b.Property("Properties") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceContext") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TimeStamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Application"); + + b.HasIndex("TimeStamp"); + + b.ToTable("Logs", (string)null); + }); + + modelBuilder.Entity("AliasServerDb.MobileLoginRequest", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ClearedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ClientIpAddress") + .HasColumnType("text"); + + b.Property("ClientPublicKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedDecryptionKey") + .HasColumnType("text"); + + b.Property("FulfilledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MobileIpAddress") + .HasColumnType("text"); + + b.Property("RetrievedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "ClientIpAddress" }, "IX_ClientIpAddress"); + + b.HasIndex(new[] { "CreatedAt" }, "IX_CreatedAt"); + + b.HasIndex(new[] { "MobileIpAddress" }, "IX_MobileIpAddress"); + + b.HasIndex(new[] { "RetrievedAt", "ClearedAt", "FulfilledAt" }, "IX_RetrievedAt_ClearedAt_FulfilledAt"); + + b.HasIndex(new[] { "UserId" }, "IX_UserId"); + + b.ToTable("MobileLoginRequests"); + }); + + modelBuilder.Entity("AliasServerDb.RateLimit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppliesToAccountAgeMaxDays") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("EffectiveFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("LimitType") + .HasColumnType("integer"); + + b.Property("MaxCount") + .HasColumnType("integer"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Tier") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("WindowSeconds") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Tier"); + + b.HasIndex("UserId"); + + b.HasIndex("LimitType", "Enabled"); + + b.ToTable("RateLimits"); + }); + + modelBuilder.Entity("AliasServerDb.ServerSetting", b => + { + b.Property("Key") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Key"); + + b.ToTable("ServerSettings"); + }); + + modelBuilder.Entity("AliasServerDb.TaskRunnerJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EndTime") + .HasColumnType("time without time zone"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("IsOnDemand") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RunDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StartTime") + .HasColumnType("time without time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("TaskRunnerJobs"); + }); + + modelBuilder.Entity("AliasServerDb.UserEmailClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AddressDomain") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AddressLocal") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Disabled") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("Address") + .IsUnique(); + + b.HasIndex("UserId", "CreatedAt"); + + b.HasIndex("UserId", "Disabled"); + + b.ToTable("UserEmailClaims"); + }); + + modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserEncryptionKeys"); + }); + + modelBuilder.Entity("AliasServerDb.Vault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Client") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialsCount") + .HasColumnType("integer"); + + b.Property("EmailClaimsCount") + .HasColumnType("integer"); + + b.Property("EncryptionSettings") + .IsRequired() + .HasColumnType("text"); + + b.Property("EncryptionType") + .IsRequired() + .HasColumnType("text"); + + b.Property("FileSize") + .HasColumnType("integer"); + + b.Property("RevisionNumber") + .HasColumnType("bigint"); + + b.Property("Salt") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("VaultBlob") + .IsRequired() + .HasColumnType("text"); + + b.Property("Verifier") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Vaults"); + }); + + modelBuilder.Entity("AliasVault.WorkerStatus.Database.WorkerServiceStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CurrentStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DesiredStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Heartbeat") + .HasColumnType("timestamp with time zone"); + + b.Property("ServiceName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.ToTable("WorkerServiceStatuses"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.HasOne("AliasServerDb.UserEncryptionKey", "EncryptionKey") + .WithMany("Emails") + .HasForeignKey("UserEncryptionKeyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EncryptionKey"); + }); + + modelBuilder.Entity("AliasServerDb.EmailAttachment", b => + { + b.HasOne("AliasServerDb.Email", "Email") + .WithMany("Attachments") + .HasForeignKey("EmailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Email"); + }); + + modelBuilder.Entity("AliasServerDb.MobileLoginRequest", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.RateLimit", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.UserEmailClaim", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany("EmailClaims") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany("EncryptionKeys") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.Vault", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany("Vaults") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUser", b => + { + b.Navigation("EmailClaims"); + + b.Navigation("EncryptionKeys"); + + b.Navigation("Vaults"); + }); + + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.Navigation("Attachments"); + }); + + modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b => + { + b.Navigation("Emails"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/apps/server/Databases/AliasServerDb/Migrations/20260624212335_AddRateLimits.cs b/apps/server/Databases/AliasServerDb/Migrations/20260624212335_AddRateLimits.cs new file mode 100644 index 000000000..c2bce8f9f --- /dev/null +++ b/apps/server/Databases/AliasServerDb/Migrations/20260624212335_AddRateLimits.cs @@ -0,0 +1,91 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AliasServerDb.Migrations +{ + /// + public partial class AddRateLimits : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MaxAliases", + table: "AliasVaultUsers"); + + migrationBuilder.CreateTable( + name: "RateLimits", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + LimitType = table.Column(type: "integer", nullable: false), + UserId = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), + Tier = table.Column(type: "integer", nullable: true), + WindowSeconds = table.Column(type: "integer", nullable: false), + MaxCount = table.Column(type: "integer", nullable: false), + AppliesToAccountAgeMaxDays = table.Column(type: "integer", nullable: true), + Enabled = table.Column(type: "boolean", nullable: false), + Notes = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + EffectiveFrom = table.Column(type: "timestamp with time zone", nullable: true), + EffectiveUntil = table.Column(type: "timestamp with time zone", nullable: true), + CreatedBy = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RateLimits", x => x.Id); + table.ForeignKey( + name: "FK_RateLimits_AliasVaultUsers_UserId", + column: x => x.UserId, + principalTable: "AliasVaultUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_UserEmailClaims_UserId_CreatedAt", + table: "UserEmailClaims", + columns: new[] { "UserId", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_RateLimits_LimitType_Enabled", + table: "RateLimits", + columns: new[] { "LimitType", "Enabled" }); + + migrationBuilder.CreateIndex( + name: "IX_RateLimits_Tier", + table: "RateLimits", + column: "Tier"); + + migrationBuilder.CreateIndex( + name: "IX_RateLimits_UserId", + table: "RateLimits", + column: "UserId"); + + // Remove obsolete settings that are now handled by this new RateLimits table. + migrationBuilder.Sql( + "DELETE FROM \"ServerSettings\" WHERE \"Key\" IN ('MaxAliasesForNewAccounts', 'NewAccountAliasLimitDays');"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RateLimits"); + + migrationBuilder.DropIndex( + name: "IX_UserEmailClaims_UserId_CreatedAt", + table: "UserEmailClaims"); + + migrationBuilder.AddColumn( + name: "MaxAliases", + table: "AliasVaultUsers", + type: "integer", + nullable: false, + defaultValue: 0); + } + } +} diff --git a/apps/server/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs b/apps/server/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs index 03bb99641..eca916ffc 100644 --- a/apps/server/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs +++ b/apps/server/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs @@ -156,9 +156,6 @@ namespace AliasServerDb.Migrations b.Property("LockoutEnd") .HasColumnType("timestamp with time zone"); - b.Property("MaxAliases") - .HasColumnType("integer"); - b.Property("MaxEmailAgeDays") .HasColumnType("integer"); @@ -604,6 +601,65 @@ namespace AliasServerDb.Migrations b.ToTable("MobileLoginRequests"); }); + modelBuilder.Entity("AliasServerDb.RateLimit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppliesToAccountAgeMaxDays") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("EffectiveFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("LimitType") + .HasColumnType("integer"); + + b.Property("MaxCount") + .HasColumnType("integer"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Tier") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("WindowSeconds") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Tier"); + + b.HasIndex("UserId"); + + b.HasIndex("LimitType", "Enabled"); + + b.ToTable("RateLimits"); + }); + modelBuilder.Entity("AliasServerDb.ServerSetting", b => { b.Property("Key") @@ -699,6 +755,8 @@ namespace AliasServerDb.Migrations b.HasIndex("Address") .IsUnique(); + b.HasIndex("UserId", "CreatedAt"); + b.HasIndex("UserId", "Disabled"); b.ToTable("UserEmailClaims"); @@ -991,6 +1049,16 @@ namespace AliasServerDb.Migrations b.Navigation("User"); }); + modelBuilder.Entity("AliasServerDb.RateLimit", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("User"); + }); + modelBuilder.Entity("AliasServerDb.UserEmailClaim", b => { b.HasOne("AliasServerDb.AliasVaultUser", "User") diff --git a/apps/server/Databases/AliasServerDb/RateLimit.cs b/apps/server/Databases/AliasServerDb/RateLimit.cs new file mode 100644 index 000000000..29b455bbc --- /dev/null +++ b/apps/server/Databases/AliasServerDb/RateLimit.cs @@ -0,0 +1,108 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasServerDb; + +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +/// +/// A rate-limit / quota rule used by the API to throttle different types of usage. +/// - set = per-user, +/// - set = per-tier, +/// - else global (most specific wins). +/// - 0 = global maximum (all-time). > 0 = max allowed within a rolling window. +/// - 0 = unlimited. +/// +[Index(nameof(LimitType), nameof(Enabled))] +[Index(nameof(UserId))] +[Index(nameof(Tier))] +public class RateLimit +{ + /// + /// Gets or sets the unique identifier for the rule. + /// + [Key] + public Guid Id { get; set; } + + /// + /// Gets or sets the action this rule governs. + /// + public RateLimitType LimitType { get; set; } = RateLimitType.AliasCreation; + + /// + /// Gets or sets the user this rule applies to (per-user override). Null for tier-level and global rules. + /// + [StringLength(255)] + public string? UserId { get; set; } + + /// + /// Gets or sets the navigation property to the user this rule applies to. + /// + [ForeignKey(nameof(UserId))] + public virtual AliasVaultUser? User { get; set; } + + /// + /// Gets or sets the account tier this rule applies to. Null for per-user and global rules. + /// + public AccountTier? Tier { get; set; } + + /// + /// Gets or sets the rolling window length in seconds (0 = absolute cap on currently-held aliases). + /// + public int WindowSeconds { get; set; } + + /// + /// Gets or sets the maximum allowed count for the window (0 = unlimited). + /// + public int MaxCount { get; set; } + + /// + /// Gets or sets the account age (in days) below which the rule applies; null = applies regardless of age. + /// Used to restrict new accounts more tightly (the limit lifts once the account is older). + /// + public int? AppliesToAccountAgeMaxDays { get; set; } + + /// + /// Gets or sets a value indicating whether this rule is enforced. Disabled rules are retained for auditing. + /// + public bool Enabled { get; set; } = true; + + /// + /// Gets or sets an optional note describing why the rule exists. + /// + [MaxLength(1000)] + public string? Notes { get; set; } + + /// + /// Gets or sets an optional UTC timestamp before which the rule is not enforced. + /// + public DateTime? EffectiveFrom { get; set; } + + /// + /// Gets or sets an optional UTC timestamp after which the rule is no longer enforced. + /// + public DateTime? EffectiveUntil { get; set; } + + /// + /// Gets or sets an optional identifier of who created the rule. + /// + [MaxLength(255)] + public string? CreatedBy { get; set; } + + /// + /// Gets or sets the creation date of the rule. + /// + public DateTime CreatedAt { get; set; } + + /// + /// Gets or sets the last update date of the rule. + /// + public DateTime UpdatedAt { get; set; } +} diff --git a/apps/server/Databases/AliasServerDb/RateLimitType.cs b/apps/server/Databases/AliasServerDb/RateLimitType.cs new file mode 100644 index 000000000..1a8f76773 --- /dev/null +++ b/apps/server/Databases/AliasServerDb/RateLimitType.cs @@ -0,0 +1,19 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasServerDb; + +/// +/// The action a rule governs. +/// +public enum RateLimitType +{ + /// + /// Limits the creation of new email aliases. + /// + AliasCreation = 0, +} diff --git a/apps/server/Databases/AliasServerDb/UserEmailClaim.cs b/apps/server/Databases/AliasServerDb/UserEmailClaim.cs index d42e77986..8b4899c38 100644 --- a/apps/server/Databases/AliasServerDb/UserEmailClaim.cs +++ b/apps/server/Databases/AliasServerDb/UserEmailClaim.cs @@ -16,6 +16,7 @@ using Microsoft.EntityFrameworkCore; /// [Index(nameof(Address), IsUnique = true)] [Index(nameof(UserId), nameof(Disabled))] +[Index(nameof(UserId), nameof(CreatedAt))] public class UserEmailClaim { /// diff --git a/apps/server/Shared/AliasVault.Shared.Server/Models/ServerSettingsModel.cs b/apps/server/Shared/AliasVault.Shared.Server/Models/ServerSettingsModel.cs index 07132534c..b7d7afd7f 100644 --- a/apps/server/Shared/AliasVault.Shared.Server/Models/ServerSettingsModel.cs +++ b/apps/server/Shared/AliasVault.Shared.Server/Models/ServerSettingsModel.cs @@ -84,17 +84,4 @@ public class ServerSettingsModel /// Set to 0 to disable IP-based registration rate limiting. /// public int MaxRegistrationsPerIpPer24Hours { get; set; } = 5; - - /// - /// Gets or sets the age (in days) below which an account is considered "new" for alias-creation limiting purposes. - /// Defaults to 0 (disabled). When 0, or when MaxAliasesForNewAccounts is 0, no new-account alias limit is applied. - /// - public int NewAccountAliasLimitDays { get; set; } - - /// - /// Gets or sets the maximum number of active aliases a "new" account (younger than NewAccountAliasLimitDays) may - /// have. Defaults to 0 (unlimited). When the limit is reached, additional alias creation is silently skipped - /// during vault sync. - /// - public int MaxAliasesForNewAccounts { get; set; } } diff --git a/apps/server/Shared/AliasVault.Shared.Server/Services/ServerSettingsService.cs b/apps/server/Shared/AliasVault.Shared.Server/Services/ServerSettingsService.cs index 9bd77d044..af30f1bb8 100644 --- a/apps/server/Shared/AliasVault.Shared.Server/Services/ServerSettingsService.cs +++ b/apps/server/Shared/AliasVault.Shared.Server/Services/ServerSettingsService.cs @@ -201,16 +201,6 @@ public class ServerSettingsService(IAliasServerDbContextFactory dbContextFactory model.MobileLoginLogRetentionDays = mobileLoginDays; } - if (int.TryParse(settings.GetValueOrDefault("NewAccountAliasLimitDays"), out var newAccountAliasLimitDays)) - { - model.NewAccountAliasLimitDays = newAccountAliasLimitDays; - } - - if (int.TryParse(settings.GetValueOrDefault("MaxAliasesForNewAccounts"), out var maxAliasesForNewAccounts)) - { - model.MaxAliasesForNewAccounts = maxAliasesForNewAccounts; - } - cache.Set(AllSettingsCacheKey, model, TimeSpan.FromSeconds(AllSettingsCacheDurationSeconds)); return model; @@ -236,7 +226,5 @@ public class ServerSettingsService(IAliasServerDbContextFactory dbContextFactory await SetSettingAsync("RefreshTokenLifetimeLong", model.RefreshTokenLifetimeLong.ToString()); await SetSettingAsync("MaxRegistrationsPerIpPer24Hours", model.MaxRegistrationsPerIpPer24Hours.ToString()); await SetSettingAsync("MobileLoginLogRetentionDays", model.MobileLoginLogRetentionDays.ToString()); - await SetSettingAsync("NewAccountAliasLimitDays", model.NewAccountAliasLimitDays.ToString()); - await SetSettingAsync("MaxAliasesForNewAccounts", model.MaxAliasesForNewAccounts.ToString()); } }