Add rate limit tables

This commit is contained in:
Leendert de Borst
2026-06-24 23:34:01 +02:00
parent 17381e59aa
commit 78559c7ece
15 changed files with 1646 additions and 35 deletions

View File

@@ -88,6 +88,7 @@ builder.Services.AddScoped<ServerSettingsService>();
builder.Services.AddScoped<RegistrationRateLimitService>();
builder.Services.AddScoped<IpBlockListService>();
builder.Services.AddSingleton<FaviconRateLimitService>();
builder.Services.AddScoped<RateLimitService>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddLogging(logging =>

View File

@@ -0,0 +1,16 @@
//-----------------------------------------------------------------------
// <copyright file="EffectiveRateLimit.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Api.Services;
/// <summary>
/// A resolved limit for a user: <paramref name="MaxCount"/> over a window (<paramref name="WindowSeconds"/> 0 =
/// absolute cap, &gt; 0 = rolling window).
/// </summary>
/// <param name="WindowSeconds">The rolling window length in seconds, or 0 for an absolute cap.</param>
/// <param name="MaxCount">The maximum allowed count for the window.</param>
public sealed record EffectiveRateLimit(int WindowSeconds, int MaxCount);

View File

@@ -0,0 +1,124 @@
//-----------------------------------------------------------------------
// <copyright file="RateLimitResolver.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Api.Services;
using AliasServerDb;
/// <summary>
/// Rate limit resolver logic which determines the limits that apply to a given user.
/// </summary>
public static class RateLimitResolver
{
/// <summary>
/// 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.
/// </summary>
/// <param name="rules">The candidate rules.</param>
/// <param name="user">The user to resolve limits for.</param>
/// <param name="limitType">The limit type to resolve.</param>
/// <param name="now">The current UTC time, used to evaluate effective-from/until windows.</param>
/// <returns>The limits that must all be satisfied. Empty means no limit applies.</returns>
public static IReadOnlyList<EffectiveRateLimit> Resolve(IEnumerable<RateLimit> 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<int>();
var userMax = new Dictionary<int, int>();
var tierMax = new Dictionary<int, int>();
var globalMax = new Dictionary<int, int>();
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<EffectiveRateLimit>();
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;
}
/// <summary>
/// Resolves the user's tier. Tiers are not implemented yet, so every user is <see cref="AccountTier.Free"/> by default.
/// </summary>
/// <param name="user">The user to resolve the tier for.</param>
/// <returns>The account tier that applies to the user.</returns>
public static AccountTier ResolveTier(AliasVaultUser user) => AccountTier.Free;
/// <summary>
/// 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.
/// </summary>
private static void Record(HashSet<int> windows, Dictionary<int, int> 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);
}
}
}

View File

@@ -0,0 +1,60 @@
//-----------------------------------------------------------------------
// <copyright file="RateLimitService.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Api.Services;
using AliasServerDb;
using AliasVault.Shared.Providers.Time;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
/// <summary>
/// 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.
/// </summary>
/// <param name="dbContextFactory">IDbContextFactory instance.</param>
/// <param name="cache">IMemoryCache instance used to cache the enabled rules.</param>
/// <param name="timeProvider">ITimeProvider instance.</param>
public class RateLimitService(IAliasServerDbContextFactory dbContextFactory, IMemoryCache cache, ITimeProvider timeProvider)
{
private const int CacheDurationSeconds = 60;
private const string EnabledRulesCacheKey = "RateLimits_EnabledRules";
/// <summary>
/// Resolves the limits that apply to the given user for the given limit type.
/// </summary>
/// <param name="user">The user to resolve limits for.</param>
/// <param name="limitType">The limit type to resolve.</param>
/// <returns>The limits that must all be satisfied. Empty means no limit applies.</returns>
public async Task<IReadOnlyList<EffectiveRateLimit>> ResolveAsync(AliasVaultUser user, RateLimitType limitType)
{
var rules = await GetEnabledRulesAsync();
return RateLimitResolver.Resolve(rules, user, limitType, timeProvider.UtcNow);
}
/// <summary>
/// Returns the enabled rules from cache, refreshing at most once every <see cref="CacheDurationSeconds"/>.
/// </summary>
/// <returns>The list of enabled rules.</returns>
private async Task<List<RateLimit>> GetEnabledRulesAsync()
{
if (cache.TryGetValue(EnabledRulesCacheKey, out List<RateLimit>? 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;
}
}

View File

@@ -0,0 +1,25 @@
//-----------------------------------------------------------------------
// <copyright file="AccountTier.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasServerDb;
/// <summary>
/// Subscription tier of an account, used to target a <see cref="RateLimit"/> rule at a class of users.
/// Tiers are not implemented yet, acts as placeholder for future implementation.
/// </summary>
public enum AccountTier
{
/// <summary>
/// The default tier for all accounts.
/// </summary>
Free = 0,
/// <summary>
/// Premium (paid) tier. Not yet implemented, acts as placeholder for unit tests.
/// </summary>
Premium = 1,
}

View File

@@ -147,6 +147,11 @@ public class AliasServerDbContext : WorkerStatusDbContext, IDataProtectionKeyCon
/// </summary>
public DbSet<BlockedIpRange> BlockedIpRanges { get; set; }
/// <summary>
/// Gets or sets the RateLimits DbSet.
/// </summary>
public DbSet<RateLimit> RateLimits { get; set; }
/// <summary>
/// Sets up the connection string if it is not already configured.
/// </summary>
@@ -285,5 +290,12 @@ public class AliasServerDbContext : WorkerStatusDbContext, IDataProtectionKeyCon
.WithMany()
.HasForeignKey(m => m.UserId)
.OnDelete(DeleteBehavior.Cascade);
// Configure RateLimit - AliasVaultUser relationship
modelBuilder.Entity<RateLimit>()
.HasOne(r => r.User)
.WithMany()
.HasForeignKey(r => r.UserId)
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@@ -69,13 +69,6 @@ public class AliasVaultUser : IdentityUser
/// </summary>
public int MaxEmailAgeDays { get; set; } = 0;
/// <summary>
/// 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.
/// </summary>
public int MaxAliases { get; set; } = 0;
/// <summary>
/// Gets or sets the date of the user's last activity (login, API call, etc.).
/// Updated automatically on successful authentication events.

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,91 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace AliasServerDb.Migrations
{
/// <inheritdoc />
public partial class AddRateLimits : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "MaxAliases",
table: "AliasVaultUsers");
migrationBuilder.CreateTable(
name: "RateLimits",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
LimitType = table.Column<int>(type: "integer", nullable: false),
UserId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
Tier = table.Column<int>(type: "integer", nullable: true),
WindowSeconds = table.Column<int>(type: "integer", nullable: false),
MaxCount = table.Column<int>(type: "integer", nullable: false),
AppliesToAccountAgeMaxDays = table.Column<int>(type: "integer", nullable: true),
Enabled = table.Column<bool>(type: "boolean", nullable: false),
Notes = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
EffectiveFrom = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
EffectiveUntil = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CreatedBy = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(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');");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "RateLimits");
migrationBuilder.DropIndex(
name: "IX_UserEmailClaims_UserId_CreatedAt",
table: "UserEmailClaims");
migrationBuilder.AddColumn<int>(
name: "MaxAliases",
table: "AliasVaultUsers",
type: "integer",
nullable: false,
defaultValue: 0);
}
}
}

View File

@@ -156,9 +156,6 @@ namespace AliasServerDb.Migrations
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<int>("MaxAliases")
.HasColumnType("integer");
b.Property<int>("MaxEmailAgeDays")
.HasColumnType("integer");
@@ -604,6 +601,65 @@ namespace AliasServerDb.Migrations
b.ToTable("MobileLoginRequests");
});
modelBuilder.Entity("AliasServerDb.RateLimit", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int?>("AppliesToAccountAgeMaxDays")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTime?>("EffectiveFrom")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("EffectiveUntil")
.HasColumnType("timestamp with time zone");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<int>("LimitType")
.HasColumnType("integer");
b.Property<int>("MaxCount")
.HasColumnType("integer");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<int?>("Tier")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<int>("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<string>("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")

View File

@@ -0,0 +1,108 @@
//-----------------------------------------------------------------------
// <copyright file="RateLimit.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasServerDb;
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// A rate-limit / quota rule used by the API to throttle different types of usage.
/// - <see cref="UserId"/> set = per-user,
/// - <see cref="Tier"/> set = per-tier,
/// - else global (most specific wins).
/// - <see cref="WindowSeconds"/> 0 = global maximum (all-time). > 0 = max allowed within a rolling window.
/// - <see cref="MaxCount"/> 0 = unlimited.
/// </summary>
[Index(nameof(LimitType), nameof(Enabled))]
[Index(nameof(UserId))]
[Index(nameof(Tier))]
public class RateLimit
{
/// <summary>
/// Gets or sets the unique identifier for the rule.
/// </summary>
[Key]
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the action this rule governs.
/// </summary>
public RateLimitType LimitType { get; set; } = RateLimitType.AliasCreation;
/// <summary>
/// Gets or sets the user this rule applies to (per-user override). Null for tier-level and global rules.
/// </summary>
[StringLength(255)]
public string? UserId { get; set; }
/// <summary>
/// Gets or sets the navigation property to the user this rule applies to.
/// </summary>
[ForeignKey(nameof(UserId))]
public virtual AliasVaultUser? User { get; set; }
/// <summary>
/// Gets or sets the account tier this rule applies to. Null for per-user and global rules.
/// </summary>
public AccountTier? Tier { get; set; }
/// <summary>
/// Gets or sets the rolling window length in seconds (0 = absolute cap on currently-held aliases).
/// </summary>
public int WindowSeconds { get; set; }
/// <summary>
/// Gets or sets the maximum allowed count for the window (0 = unlimited).
/// </summary>
public int MaxCount { get; set; }
/// <summary>
/// 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).
/// </summary>
public int? AppliesToAccountAgeMaxDays { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this rule is enforced. Disabled rules are retained for auditing.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Gets or sets an optional note describing why the rule exists.
/// </summary>
[MaxLength(1000)]
public string? Notes { get; set; }
/// <summary>
/// Gets or sets an optional UTC timestamp before which the rule is not enforced.
/// </summary>
public DateTime? EffectiveFrom { get; set; }
/// <summary>
/// Gets or sets an optional UTC timestamp after which the rule is no longer enforced.
/// </summary>
public DateTime? EffectiveUntil { get; set; }
/// <summary>
/// Gets or sets an optional identifier of who created the rule.
/// </summary>
[MaxLength(255)]
public string? CreatedBy { get; set; }
/// <summary>
/// Gets or sets the creation date of the rule.
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// Gets or sets the last update date of the rule.
/// </summary>
public DateTime UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,19 @@
//-----------------------------------------------------------------------
// <copyright file="RateLimitType.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasServerDb;
/// <summary>
/// The action a <see cref="RateLimit"/> rule governs.
/// </summary>
public enum RateLimitType
{
/// <summary>
/// Limits the creation of new email aliases.
/// </summary>
AliasCreation = 0,
}

View File

@@ -16,6 +16,7 @@ using Microsoft.EntityFrameworkCore;
/// </summary>
[Index(nameof(Address), IsUnique = true)]
[Index(nameof(UserId), nameof(Disabled))]
[Index(nameof(UserId), nameof(CreatedAt))]
public class UserEmailClaim
{
/// <summary>

View File

@@ -84,17 +84,4 @@ public class ServerSettingsModel
/// Set to 0 to disable IP-based registration rate limiting.
/// </summary>
public int MaxRegistrationsPerIpPer24Hours { get; set; } = 5;
/// <summary>
/// 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.
/// </summary>
public int NewAccountAliasLimitDays { get; set; }
/// <summary>
/// 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.
/// </summary>
public int MaxAliasesForNewAccounts { get; set; }
}

View File

@@ -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());
}
}