From 03392da1277950f3c9e5f80a5b5d502b44d6b31c Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 23 Jun 2026 20:21:39 +0200 Subject: [PATCH] feat(rate-limits): per-realm configurable auth rate-limit ceilings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-IP auth rate limiters (native-otp, magic-link, password-reset, email-otp, email-verification, passkey-begin, bootstrap) were hardcoded ASP.NET policies — the only way to raise a ceiling on a shared prod IdP was a code change + redeploy, which also changed it for every realm. Surfaced by AmZettel hitting the native-otp limit during normal iterative testing. Make each ceiling configurable per realm (limit + window), defaults UNCHANGED so a realm that never touches it behaves exactly as before. Reuses ADR-0011's RealmSettings cascade — the same pattern DCR's per-realm rate limits already use. How the live limiter reads per-realm config: the ASP.NET policy factories are synchronous and can't do the async settings lookup, so a thin middleware resolves the realm's AuthRateLimits (after RealmMiddleware, before UseRateLimiter) and stashes them on HttpContext.Items; the factories read the effective rule there, falling back to the shipped defaults. The realm slug + resolved limit are baked into the partition key so each realm gets its own per-IP bucket and a config edit applies on the next request. A short cache (TTL 0 in Testing) keeps the limiter's cheap-rejection property under flood. - Domain: AuthRateLimitSettings + AuthRateLimitPolicy + AuthRateLimitDefaults; nullable AuthRateLimits section on RealmSettings. - Application/service: read + update DTOs, patch (with validation) + map. - Api: AuthRateLimitResolutionMiddleware + AuthFixedWindow partition helper; the 7 per-IP policy factories now resolve their ceiling per request. - Frontend: a "Rate Limits" tab in Realm Settings (7 policies, limit + window) + German i18n. - Tests: lowered limit throttles sooner, raised limit allows past the old default; the existing boundary test still passes (defaults unchanged). - Docs: realm-settings.md "Rate Limits" section + native-apps.md pointer. Answers the AmZettel request (Atlas: requests-amzettel-native-otp-rate-limit-configurable). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/admin/realm-settings.md | 25 +++ docs/integrate/native-apps.md | 2 +- .../Authorization/AuthRateLimitConfigTests.cs | 86 +++++++++ .../AuthRateLimitResolutionMiddleware.cs | 76 ++++++++ src/dotnet/Modgud.Api/Program.cs | 170 +++++++----------- .../DTOs/RealmSettings/AuthRateLimitsDtos.cs | 38 ++++ .../DTOs/RealmSettings/RealmSettingsDtos.cs | 2 + .../RealmSettings/RealmSettingsService.cs | 76 ++++++++ .../RealmSettings/RealmSettings.cs | 7 + .../Realms/AuthRateLimitSettings.cs | 114 ++++++++++++ src/frontend-vue/public/i18n/de.json | 13 ++ src/frontend-vue/src/models/realmSettings.ts | 34 ++++ .../src/views/admin/RealmSettingsView.vue | 113 +++++++++++- 13 files changed, 647 insertions(+), 109 deletions(-) create mode 100644 src/dotnet/Modgud.Api.Tests/Authorization/AuthRateLimitConfigTests.cs create mode 100644 src/dotnet/Modgud.Api/Middleware/AuthRateLimitResolutionMiddleware.cs create mode 100644 src/dotnet/Modgud.Application/DTOs/RealmSettings/AuthRateLimitsDtos.cs create mode 100644 src/dotnet/Modgud.Domain/Realms/AuthRateLimitSettings.cs diff --git a/docs/admin/realm-settings.md b/docs/admin/realm-settings.md index 4024d9b8..b90ce89f 100644 --- a/docs/admin/realm-settings.md +++ b/docs/admin/realm-settings.md @@ -30,6 +30,9 @@ The page currently has these tabs: identification by HTTPS-URL `client_id` (linked detail page: [Client ID Metadata Documents](./client-id-metadata-documents)). Off by default. +- **Native Passwordless Grants** — the per-realm master toggle for the + cookieless `urn:cocoar:*` grants (see [Native app integration](../integrate/native-apps)) +- [Rate Limits](#rate-limits) — per-IP request ceilings for the realm's auth endpoints - [Account Deletion](#account-deletion) — grace period and recycle-bin retention policy - [Signing Keys](#signing-keys) — rotate the realm's OAuth/OIDC token-signing key @@ -168,6 +171,28 @@ Off by default. See the full feature page for when to enable it, what gets accep → **[Dynamic Client Registration](./dynamic-client-registration)** (full feature page) +## Rate Limits + +Per-IP request ceilings for this realm's auth endpoints. Each policy is a **max requests / window (minutes)** pair, partitioned by source IP and applied **per realm**. The shipped defaults are the secure production posture — the knob exists so a test realm, dev, or a legitimately bursty consumer can raise a ceiling **without a modgud code change + redeploy**, and so a hardened realm can tighten one. + +| Policy | Endpoint | Default | +| --- | --- | --- | +| Native OTP request | `POST /api/account/native/otp/request` (+ native register) | 5 / 60 min | +| Magic-link request | `POST /api/account/magic-link/request` | 5 / 60 min | +| Password-reset request | `POST /api/account/forgot-password` | 5 / 60 min | +| Email-OTP login verify | `POST /api/account/email-otp/login` | 30 / 1 min | +| Email verification resend | `POST /api/account/email/send-verification` | 5 / 60 min | +| Passkey ceremony begin / enroll | `POST /connect/passkey/begin` (+ enroll) | 60 / 5 min | +| First-admin bootstrap | `POST /api/account/bootstrap-admin` | 10 / 15 min | + +Notes: + +- A realm that never touches this tab behaves exactly as before — the defaults are the previously-hardcoded values. +- Limits are **per realm**: raising a ceiling on one realm does not affect another on the same shared IdP. +- The limiter is in-memory and resets on app restart; over-limit requests get `429 Too Many Requests`. +- A config change takes effect within a few seconds (a short cache smooths the per-request lookup). +- This governs the **request rate**; it is independent of per-challenge attempt counters (e.g. the email-OTP `MaxAttempts` brute-force lock) and of the realm's anti-enumeration uniform responses. + ## Account Deletion Controls the account-deletion lifecycle for this realm — the self-service grace period and the admin recycle-bin retention. These replace the old hardcoded 7-day confirm-token window. The mechanics of both flows are documented under [Users → recycle bin & permanent erase](./users#recycle-bin-permanent-erase) and [Profile → Privacy](../end-user/profile#privacy). diff --git a/docs/integrate/native-apps.md b/docs/integrate/native-apps.md index f2737ad7..cc72ea85 100644 --- a/docs/integrate/native-apps.md +++ b/docs/integrate/native-apps.md @@ -292,7 +292,7 @@ From then on, the app uses **Flow 3 (passkey)** as the steady-state login. | Client lacks the `gt:urn:cocoar:*` permission | `unauthorized_client` | | Wrong/expired code, link, or passkey assertion | `invalid_grant` — **uniform** message + jitter (anti-enumeration); don't parse it for "which part was wrong" | | TOTP required but missing/invalid (OTP & magic flows) | `invalid_grant` ("Two-factor authentication is required; supply totp_code.") | -| Rate limit hit (OTP request, passkey begin, token endpoint) | `429 Too Many Requests` — back off | +| Rate limit hit (OTP request, passkey begin, token endpoint) | `429 Too Many Requests` — back off. The per-IP ceilings are realm-configurable under [Realm Settings → Rate Limits](../admin/realm-settings#rate-limits) (defaults unchanged). | | Passkey begin while realm has no primary domain | `503` (admin must set the realm/client RP-ID) | ### Discovery diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/AuthRateLimitConfigTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/AuthRateLimitConfigTests.cs new file mode 100644 index 00000000..e8e5c839 --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/Authorization/AuthRateLimitConfigTests.cs @@ -0,0 +1,86 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Modgud.Api.Tests.Infrastructure; +using Modgud.Application.DTOs.RealmSettings; +using Modgud.Authentication.RealmSettings; + +namespace Modgud.Api.Tests.Authorization; + +/// +/// Verifies the per-realm configurable auth rate-limit ceilings actually drive the +/// live ASP.NET limiter (native-otp policy as the representative case). A realm that +/// lowers the limit gets throttled sooner; one that raises it past the shipped +/// default (5/h) is allowed more. The limit is resolved per request from the realm's +/// AuthRateLimits via AuthRateLimitResolutionMiddleware (TTL=0 in Testing, so a patch +/// takes effect immediately). Requests share one budget via the X-Test-RateLimit +/// header — distinct per test so the limit value baked into the partition key keeps +/// the two tests isolated. +/// +[Collection(IntegrationTestCollection.Name)] +public class AuthRateLimitConfigTests : IntegrationTestBase +{ + public AuthRateLimitConfigTests(SharedPostgresFixture fixture) : base(fixture) { } + + [Fact] + public async Task LoweredNativeOtpLimit_ThrottlesSooner() + { + await SetNativeOtpLimitAsync(permitLimit: 2, windowMinutes: 60); + var anon = Factory.CreateClient(); + + // With the ceiling lowered to 2, the first two pass the limiter and the + // third is throttled — proving the realm override drives the live limiter. + for (var i = 1; i <= 2; i++) + { + var passed = await SendAsync(anon, "lowered", $"probe{i}@nowhere.example"); + Assert.NotEqual(HttpStatusCode.TooManyRequests, passed.StatusCode); + } + + var rejected = await SendAsync(anon, "lowered", "probe-over@nowhere.example"); + Assert.Equal(HttpStatusCode.TooManyRequests, rejected.StatusCode); + } + + [Fact] + public async Task RaisedNativeOtpLimit_AllowsPastShippedDefault() + { + // Raise the ceiling well past the shipped default of 5/h. + await SetNativeOtpLimitAsync(permitLimit: 20, windowMinutes: 60); + var anon = Factory.CreateClient(); + + // Six requests — one more than the old hardcoded default — must all pass the + // limiter. Under the un-raised 5/h ceiling the sixth would have been 429. + for (var i = 1; i <= 6; i++) + { + var passed = await SendAsync(anon, "raised", $"probe{i}@nowhere.example"); + Assert.NotEqual(HttpStatusCode.TooManyRequests, passed.StatusCode); + } + } + + private async Task SetNativeOtpLimitAsync(int permitLimit, int windowMinutes) + { + using var scope = Factory.Services.CreateScope(); + scope.ServiceProvider.GetRequiredService() + .HttpContext = new DefaultHttpContext { Items = { ["TenantId"] = "system" } }; + var settings = scope.ServiceProvider.GetRequiredService(); + var result = await settings.PatchAsync(new UpdateRealmSettingsDto + { + AuthRateLimits = new UpdateAuthRateLimitsDto + { + NativeOtp = new RateLimitRuleDto { PermitLimit = permitLimit, WindowMinutes = windowMinutes }, + }, + }, TestContext.Current.CancellationToken); + Assert.False(result.IsError); + } + + private static Task SendAsync(HttpClient client, string budget, string email) + { + var req = new HttpRequestMessage(HttpMethod.Post, "/api/account/native/otp/request") + { + Content = JsonContent.Create(new { Email = email }), + }; + // Shared budget so the requests partition together; distinct per test. + req.Headers.Add("X-Test-RateLimit", $"auth-rl-config-{budget}"); + return client.SendAsync(req, TestContext.Current.CancellationToken); + } +} diff --git a/src/dotnet/Modgud.Api/Middleware/AuthRateLimitResolutionMiddleware.cs b/src/dotnet/Modgud.Api/Middleware/AuthRateLimitResolutionMiddleware.cs new file mode 100644 index 00000000..4f3c446d --- /dev/null +++ b/src/dotnet/Modgud.Api/Middleware/AuthRateLimitResolutionMiddleware.cs @@ -0,0 +1,76 @@ +using System.Collections.Concurrent; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Hosting; +using Modgud.Authentication.RealmSettings; +using Modgud.Domain.Realms; +using Modgud.Infrastructure.Persistence.Tenancy; + +namespace Modgud.Api.Middleware; + +/// +/// Bridges the per-realm auth rate-limit config into the ASP.NET rate limiter. +/// +/// The limiter's policy factories (AddPolicy(...) in Program.cs) +/// run synchronously and cannot resolve the realm's configured ceilings (an async +/// Marten lookup). This middleware does that lookup once per request — after +/// RealmMiddleware has resolved the tenant and before UseRateLimiter — +/// and stashes the realm's on +/// under , where the factories +/// read it. Absent (e.g. an endpoint that doesn't rate-limit, or a resolution +/// failure) ⇒ the factory falls back to the shipped . +/// +/// It only does the lookup for endpoints that actually opt into a limiter +/// policy ( metadata), and caches the +/// per-realm result for a few seconds so a flood against an anonymous auth endpoint +/// doesn't turn each would-be-throttled request into a fresh DB hit — keeping the +/// limiter's cheap-rejection property. Config edits take effect within the TTL. +/// +public sealed class AuthRateLimitResolutionMiddleware(RequestDelegate next, IHostEnvironment env) +{ + public const string ItemsKey = "Modgud.AuthRateLimits"; + + // No cache in Testing — each request reloads so a PATCH to the realm's limits + // takes effect immediately and rate-limit tests are deterministic. In every + // other environment a short TTL keeps the limiter's cheap-rejection property + // under flood; config edits take effect within the window. + private readonly TimeSpan _cacheTtl = + env.IsEnvironment("Testing") ? TimeSpan.Zero : TimeSpan.FromSeconds(10); + + // realm slug → (expiry, settings). One instance for the app lifetime, so the + // cache is process-wide. A null Value is a legitimately-cached "no overrides". + private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); + + private readonly record struct CacheEntry(DateTimeOffset Expires, AuthRateLimitSettings? Value); + + public async Task InvokeAsync(HttpContext context) + { + if (context.GetEndpoint()?.Metadata.GetMetadata() is not null) + { + var slug = context.Items[TenantConstants.HttpContextTenantIdKey] as string + ?? TenantConstants.SystemTenantId; + + if (!_cache.TryGetValue(slug, out var entry) || entry.Expires <= DateTimeOffset.UtcNow) + { + try + { + var realmSettings = context.RequestServices.GetRequiredService(); + var doc = await realmSettings.LoadAsync(context.RequestAborted); + entry = new CacheEntry(DateTimeOffset.UtcNow + _cacheTtl, doc.AuthRateLimits); + if (_cacheTtl > TimeSpan.Zero) _cache[slug] = entry; + } + catch + { + // Tenant not resolved / DB hiccup: leave Items unset so the + // policy factory uses the shipped defaults. Never block the + // request on a settings-resolution failure. + entry = default; + } + } + + if (entry.Expires != default) + context.Items[ItemsKey] = entry.Value; + } + + await next(context); + } +} diff --git a/src/dotnet/Modgud.Api/Program.cs b/src/dotnet/Modgud.Api/Program.cs index 852c30da..5cd24c0e 100644 --- a/src/dotnet/Modgud.Api/Program.cs +++ b/src/dotnet/Modgud.Api/Program.cs @@ -37,6 +37,7 @@ using Modgud.Api.Features.Users; using Modgud.Api.Helper; using Modgud.Domain.Common; +using AuthRateLimitPolicy = Modgud.Domain.Realms.AuthRateLimitPolicy; using Modgud.Authentication.Domain; using Modgud.Infrastructure; using Modgud.Infrastructure.OAuth; @@ -518,112 +519,31 @@ }); }); - options.AddPolicy("bootstrap", context => - { - var key = PerIpRateLimitPartitionKey(context, isTestEnv); - return System.Threading.RateLimiting.RateLimitPartition.GetFixedWindowLimiter( - partitionKey: key, - factory: _ => new System.Threading.RateLimiting.FixedWindowRateLimiterOptions - { - PermitLimit = 10, - Window = TimeSpan.FromMinutes(15), - QueueLimit = 0, - }); - }); - - options.AddPolicy("password-reset", context => - { - var key = PerIpRateLimitPartitionKey(context, isTestEnv); - return System.Threading.RateLimiting.RateLimitPartition.GetFixedWindowLimiter( - partitionKey: key, - factory: _ => new System.Threading.RateLimiting.FixedWindowRateLimiterOptions - { - PermitLimit = 5, - Window = TimeSpan.FromHours(1), - QueueLimit = 0, - }); - }); - - options.AddPolicy("magic-link", context => - { - var key = PerIpRateLimitPartitionKey(context, isTestEnv); - return System.Threading.RateLimiting.RateLimitPartition.GetFixedWindowLimiter( - partitionKey: key, - factory: _ => new System.Threading.RateLimiting.FixedWindowRateLimiterOptions - { - PermitLimit = 5, - Window = TimeSpan.FromHours(1), - QueueLimit = 0, - }); - }); - - // Email-verification (re)send — same bucket sizing as magic-link. - // Covers both authenticated 1-click and the anonymous self-service - // form; the endpoint itself returns a generic response either way. - options.AddPolicy("email-verification", context => - { - var key = PerIpRateLimitPartitionKey(context, isTestEnv); - return System.Threading.RateLimiting.RateLimitPartition.GetFixedWindowLimiter( - partitionKey: key, - factory: _ => new System.Threading.RateLimiting.FixedWindowRateLimiterOptions - { - PermitLimit = 5, - Window = TimeSpan.FromHours(1), - QueueLimit = 0, - }); - }); - - // Audit #24 — email-OTP code VERIFY. The endpoint is anonymous (partial-2FA - // state) and the per-challenge MaxAttempts counter is the only other - // brute-force defense; without a request cap, a concurrent burst that all - // read Attempts=0 before any increment commits could evaluate many guesses - // against the 6-digit code before the lockout trips. A per-IP fixed window - // bounds that burst. Sized well above any legitimate verify cadence. - options.AddPolicy("email-otp", context => - { - var key = PerIpRateLimitPartitionKey(context, isTestEnv); - return System.Threading.RateLimiting.RateLimitPartition.GetFixedWindowLimiter( - partitionKey: key, - factory: _ => new System.Threading.RateLimiting.FixedWindowRateLimiterOptions - { - PermitLimit = 30, - Window = TimeSpan.FromMinutes(1), - QueueLimit = 0, - }); - }); - - // ADR-0010 native passwordless OTP request — anonymous email-sending - // endpoint, same per-IP SMTP cap class as magic-link (5/hour). The - // dedicated boundary test sets X-Test-RateLimit to share a budget on - // purpose and assert the 429 (see PerIpRateLimitPartitionKey). - options.AddPolicy("native-otp", context => - { - var key = PerIpRateLimitPartitionKey(context, isTestEnv); - return System.Threading.RateLimiting.RateLimitPartition.GetFixedWindowLimiter( - partitionKey: key, - factory: _ => new System.Threading.RateLimiting.FixedWindowRateLimiterOptions - { - PermitLimit = 5, - Window = TimeSpan.FromHours(1), - QueueLimit = 0, - }); - }); - - // ADR-0010 Phase 2 — anonymous passkey "begin" endpoint. Cheap (no email/ - // SMTP, just a challenge + a single-use ceremony doc), so more generous - // than native-otp; still per-IP bounded to cap ceremony-doc spam. - options.AddPolicy("passkey-begin", context => - { - var key = PerIpRateLimitPartitionKey(context, isTestEnv); - return System.Threading.RateLimiting.RateLimitPartition.GetFixedWindowLimiter( - partitionKey: key, - factory: _ => new System.Threading.RateLimiting.FixedWindowRateLimiterOptions - { - PermitLimit = 60, - Window = TimeSpan.FromMinutes(5), - QueueLimit = 0, - }); - }); + // The per-IP auth limiters below now read their ceiling per request from + // the realm's configured AuthRateLimits (resolved by + // AuthRateLimitResolutionMiddleware, falling back to AuthRateLimitDefaults). + // The default values are unchanged from the previously-hardcoded ones — a + // realm that never touches the feature behaves exactly as before. See + // AuthFixedWindow for the realm+limit-aware partition key. + options.AddPolicy("bootstrap", context => AuthFixedWindow(context, AuthRateLimitPolicy.Bootstrap, isTestEnv)); + options.AddPolicy("password-reset", context => AuthFixedWindow(context, AuthRateLimitPolicy.PasswordReset, isTestEnv)); + options.AddPolicy("magic-link", context => AuthFixedWindow(context, AuthRateLimitPolicy.MagicLink, isTestEnv)); + // Email-verification (re)send. Covers both authenticated 1-click and the + // anonymous self-service form; the endpoint returns a generic response either way. + options.AddPolicy("email-verification", context => AuthFixedWindow(context, AuthRateLimitPolicy.EmailVerification, isTestEnv)); + // Audit #24 — email-OTP code VERIFY. Anonymous (partial-2FA state); the + // per-challenge MaxAttempts counter is the only other brute-force defense, + // so a per-IP window bounds a concurrent guess burst. Default sized well + // above any legitimate verify cadence (30/min). + options.AddPolicy("email-otp", context => AuthFixedWindow(context, AuthRateLimitPolicy.EmailOtp, isTestEnv)); + // ADR-0010 native passwordless OTP request — anonymous email-sending endpoint + // (default 5/hour). The dedicated boundary test sets X-Test-RateLimit to share + // a budget on purpose and assert the 429 (see PerIpRateLimitPartitionKey). + options.AddPolicy("native-otp", context => AuthFixedWindow(context, AuthRateLimitPolicy.NativeOtp, isTestEnv)); + // ADR-0010 Phase 2 — anonymous passkey "begin" endpoint. Cheap (no email/SMTP, + // just a challenge + a single-use ceremony doc), so a more generous default + // (60/5min); still per-IP bounded to cap ceremony-doc spam. + options.AddPolicy("passkey-begin", context => AuthFixedWindow(context, AuthRateLimitPolicy.PasskeyBegin, isTestEnv)); }); builder.Services.AddHttpContextAccessor(); @@ -1242,6 +1162,10 @@ // (see /connect/* + /api/account/bootstrap-admin + /api/account/forgot-password + // /api/account/magic-link below). Endpoints without an explicit // policy are not rate-limited at the app layer. + // Resolve the realm's configured auth rate-limit ceilings and stash them on + // HttpContext.Items BEFORE the limiter runs, so the (synchronous) policy + // factories can read per-realm limits. Runs after RealmMiddleware (tenant set). + app.UseMiddleware(); app.UseRateLimiter(); // Observability surface: /metrics (Prometheus scrape) + /health/live + @@ -1696,3 +1620,37 @@ static string PerIpRateLimitPartitionKey(HttpContext context, bool isTestEnv) var shared = context.Request.Headers["X-Test-RateLimit"].ToString(); return string.IsNullOrEmpty(shared) ? Guid.NewGuid().ToString("N") : shared; } + +/// +/// Builds a per-IP fixed-window partition whose ceiling comes from the realm's +/// configured (stashed on +/// Items by ), +/// falling back to the shipped . +/// The realm slug AND the resolved limit are baked into the partition key: each realm +/// gets its own per-IP bucket (so its ceiling is coherent on a shared IdP), and a +/// config change yields a fresh partition so the new limit applies on the next +/// request (the stale limiter idles out). +/// +static System.Threading.RateLimiting.RateLimitPartition AuthFixedWindow( + HttpContext context, AuthRateLimitPolicy policy, bool isTestEnv) +{ + var settings = context.Items.TryGetValue( + Modgud.Api.Middleware.AuthRateLimitResolutionMiddleware.ItemsKey, out var raw) + ? raw as Modgud.Domain.Realms.AuthRateLimitSettings + : null; + var rule = Modgud.Domain.Realms.AuthRateLimitSettings.Effective(settings, policy); + + var ipPart = PerIpRateLimitPartitionKey(context, isTestEnv); + var realm = context.Items[ + Modgud.Infrastructure.Persistence.Tenancy.TenantConstants.HttpContextTenantIdKey] as string ?? "-"; + var key = $"{policy}|{realm}|{ipPart}|{rule.PermitLimit}|{rule.WindowMinutes}"; + + return System.Threading.RateLimiting.RateLimitPartition.GetFixedWindowLimiter( + partitionKey: key, + factory: _ => new System.Threading.RateLimiting.FixedWindowRateLimiterOptions + { + PermitLimit = rule.PermitLimit, + Window = TimeSpan.FromMinutes(rule.WindowMinutes), + QueueLimit = 0, + }); +} diff --git a/src/dotnet/Modgud.Application/DTOs/RealmSettings/AuthRateLimitsDtos.cs b/src/dotnet/Modgud.Application/DTOs/RealmSettings/AuthRateLimitsDtos.cs new file mode 100644 index 00000000..7c8281ce --- /dev/null +++ b/src/dotnet/Modgud.Application/DTOs/RealmSettings/AuthRateLimitsDtos.cs @@ -0,0 +1,38 @@ +namespace Modgud.Application.DTOs.RealmSettings; + +/// A single rate-limit ceiling on the wire: at most +/// requests per from one source IP (per realm). +public record RateLimitRuleDto +{ + public int PermitLimit { get; init; } + public int WindowMinutes { get; init; } +} + +/// Read shape for the per-realm auth rate-limit ceilings. Every field is +/// non-null = the EFFECTIVE rule (the realm override if set, else the shipped +/// default), so the SPA renders concrete numbers without knowing the defaults. +public record AuthRateLimitsDto +{ + public RateLimitRuleDto NativeOtp { get; init; } = new(); + public RateLimitRuleDto MagicLink { get; init; } = new(); + public RateLimitRuleDto PasswordReset { get; init; } = new(); + public RateLimitRuleDto EmailOtp { get; init; } = new(); + public RateLimitRuleDto EmailVerification { get; init; } = new(); + public RateLimitRuleDto PasskeyBegin { get; init; } = new(); + public RateLimitRuleDto Bootstrap { get; init; } = new(); +} + +/// Patch payload: a null policy field = no change; a non-null rule +/// replaces that policy's ceiling (stored as a realm override). Setting a rule +/// back to the default values simply stores those values — functionally identical +/// to inheriting, so there is no separate "reset" verb. +public record UpdateAuthRateLimitsDto +{ + public RateLimitRuleDto? NativeOtp { get; init; } + public RateLimitRuleDto? MagicLink { get; init; } + public RateLimitRuleDto? PasswordReset { get; init; } + public RateLimitRuleDto? EmailOtp { get; init; } + public RateLimitRuleDto? EmailVerification { get; init; } + public RateLimitRuleDto? PasskeyBegin { get; init; } + public RateLimitRuleDto? Bootstrap { get; init; } +} diff --git a/src/dotnet/Modgud.Application/DTOs/RealmSettings/RealmSettingsDtos.cs b/src/dotnet/Modgud.Application/DTOs/RealmSettings/RealmSettingsDtos.cs index cf45e0d6..b6e35270 100644 --- a/src/dotnet/Modgud.Application/DTOs/RealmSettings/RealmSettingsDtos.cs +++ b/src/dotnet/Modgud.Application/DTOs/RealmSettings/RealmSettingsDtos.cs @@ -16,6 +16,7 @@ public record RealmSettingsDto public DcrSettingsDto Dcr { get; init; } = new(); public CimdSettingsDto Cimd { get; init; } = new(); public NativeGrantSettingsDto NativeGrants { get; init; } = new(); + public AuthRateLimitsDto AuthRateLimits { get; init; } = new(); public BrandingSettingsDto Branding { get; init; } = new(); public RegistrationFieldsSettingsDto RegistrationFields { get; init; } = new(); public DeletionSettingsDto Deletion { get; init; } = new(); @@ -38,6 +39,7 @@ public record UpdateRealmSettingsDto public UpdateDcrSettingsDto? Dcr { get; init; } public UpdateCimdSettingsDto? Cimd { get; init; } public UpdateNativeGrantSettingsDto? NativeGrants { get; init; } + public UpdateAuthRateLimitsDto? AuthRateLimits { get; init; } public UpdateBrandingSettingsDto? Branding { get; init; } public UpdateRegistrationFieldsSettingsDto? RegistrationFields { get; init; } public UpdateDeletionSettingsDto? Deletion { get; init; } diff --git a/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs b/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs index b1d2040c..b77609b7 100644 --- a/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs +++ b/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs @@ -79,6 +79,13 @@ public async Task> PatchAsync(UpdateRealmSettingsDto d doc.NativeGrants = native.Value; } + if (dto.AuthRateLimits is not null) + { + var arl = ApplyAuthRateLimitsPatch(doc.AuthRateLimits, dto.AuthRateLimits); + if (arl.IsError) return arl.FirstError; + doc.AuthRateLimits = arl.Value; + } + if (dto.Branding is not null) { var branding = ApplyBrandingPatch(doc.Branding, dto.Branding); @@ -146,6 +153,7 @@ private SelfRegistrationSettings ApplySelfRegistrationPatch( Dcr = MapDcrToDto(doc.Dcr), Cimd = MapCimdToDto(doc.Cimd), NativeGrants = MapNativeGrantsToDto(doc.NativeGrants), + AuthRateLimits = MapAuthRateLimitsToDto(doc.AuthRateLimits), Branding = MapBrandingToDto(doc.Branding), RegistrationFields = MapRegistrationFieldsToDto(doc.RegistrationFields), Deletion = MapDeletionToDto(doc.Deletion), @@ -383,6 +391,74 @@ internal static DcrSettingsDto MapDcrToDto(DcrSettings? s) }; } + // Per-policy whole-rule replacement: a non-null patch field replaces that + // policy's ceiling (stored as a realm override); a null field leaves the + // existing override (or the inherited default) untouched. + private static ErrorOr ApplyAuthRateLimitsPatch( + AuthRateLimitSettings? current, UpdateAuthRateLimitsDto patch) + { + var s = current ?? new AuthRateLimitSettings(); + + foreach (var (name, rule) in new (string, RateLimitRuleDto?)[] + { + (nameof(patch.NativeOtp), patch.NativeOtp), + (nameof(patch.MagicLink), patch.MagicLink), + (nameof(patch.PasswordReset), patch.PasswordReset), + (nameof(patch.EmailOtp), patch.EmailOtp), + (nameof(patch.EmailVerification), patch.EmailVerification), + (nameof(patch.PasskeyBegin), patch.PasskeyBegin), + (nameof(patch.Bootstrap), patch.Bootstrap), + }) + { + if (rule is not null && ValidateRateLimitRule(name, rule) is { } err) return err; + } + + return s with + { + NativeOtp = patch.NativeOtp is { } a ? ToRule(a) : s.NativeOtp, + MagicLink = patch.MagicLink is { } b ? ToRule(b) : s.MagicLink, + PasswordReset = patch.PasswordReset is { } c ? ToRule(c) : s.PasswordReset, + EmailOtp = patch.EmailOtp is { } d ? ToRule(d) : s.EmailOtp, + EmailVerification = patch.EmailVerification is { } e ? ToRule(e) : s.EmailVerification, + PasskeyBegin = patch.PasskeyBegin is { } f ? ToRule(f) : s.PasskeyBegin, + Bootstrap = patch.Bootstrap is { } g ? ToRule(g) : s.Bootstrap, + }; + + static RateLimitRule ToRule(RateLimitRuleDto d) + => new() { PermitLimit = d.PermitLimit, WindowMinutes = d.WindowMinutes }; + } + + private static Error? ValidateRateLimitRule(string policy, RateLimitRuleDto rule) + { + if (rule.PermitLimit is < 1 or > 100_000) + return Error.Validation($"AuthRateLimits.{policy}.PermitLimit", + "PermitLimit must be between 1 and 100000."); + if (rule.WindowMinutes is < 1 or > 1440) + return Error.Validation($"AuthRateLimits.{policy}.WindowMinutes", + "WindowMinutes must be between 1 and 1440 (24 hours)."); + return null; + } + + internal static AuthRateLimitsDto MapAuthRateLimitsToDto(AuthRateLimitSettings? s) + { + static RateLimitRuleDto Eff(AuthRateLimitSettings? settings, AuthRateLimitPolicy p) + { + var r = AuthRateLimitSettings.Effective(settings, p); + return new RateLimitRuleDto { PermitLimit = r.PermitLimit, WindowMinutes = r.WindowMinutes }; + } + + return new AuthRateLimitsDto + { + NativeOtp = Eff(s, AuthRateLimitPolicy.NativeOtp), + MagicLink = Eff(s, AuthRateLimitPolicy.MagicLink), + PasswordReset = Eff(s, AuthRateLimitPolicy.PasswordReset), + EmailOtp = Eff(s, AuthRateLimitPolicy.EmailOtp), + EmailVerification = Eff(s, AuthRateLimitPolicy.EmailVerification), + PasskeyBegin = Eff(s, AuthRateLimitPolicy.PasskeyBegin), + Bootstrap = Eff(s, AuthRateLimitPolicy.Bootstrap), + }; + } + private static ErrorOr ApplyCimdPatch(CimdSettings? current, UpdateCimdSettingsDto patch) { var s = current ?? new CimdSettings(); diff --git a/src/dotnet/Modgud.Domain/RealmSettings/RealmSettings.cs b/src/dotnet/Modgud.Domain/RealmSettings/RealmSettings.cs index 800c848a..d78aa106 100644 --- a/src/dotnet/Modgud.Domain/RealmSettings/RealmSettings.cs +++ b/src/dotnet/Modgud.Domain/RealmSettings/RealmSettings.cs @@ -59,6 +59,13 @@ public class RealmSettings /// additional, separate gate. public NativeGrantSettings? NativeGrants { get; set; } + /// Per-realm overrides for the per-IP auth rate-limit ceilings + /// (native-otp, magic-link, password-reset, email-otp, email-verification, + /// passkey-begin, bootstrap). Null = never configured; every policy uses its + /// shipped . A null rule for an individual + /// policy likewise falls back to that policy's default. + public AuthRateLimitSettings? AuthRateLimits { get; set; } + /// Per-realm SPA branding (product name, logo, primary color, /// favicon). Null = SPA falls back to the Cocoar default. Surfaced via /// the anonymous /api/app-info so the login page renders branded diff --git a/src/dotnet/Modgud.Domain/Realms/AuthRateLimitSettings.cs b/src/dotnet/Modgud.Domain/Realms/AuthRateLimitSettings.cs new file mode 100644 index 00000000..525c7e71 --- /dev/null +++ b/src/dotnet/Modgud.Domain/Realms/AuthRateLimitSettings.cs @@ -0,0 +1,114 @@ +namespace Modgud.Domain.Realms; + +/// +/// The set of per-IP auth rate-limit policies whose ceiling a realm admin can +/// raise/lower. These mirror the hardcoded ASP.NET limiter policies of the same +/// name registered in Program.cs; lets +/// a realm override the limit/window per policy, with the code defaults +/// () as the baseline. +/// +public enum AuthRateLimitPolicy +{ + /// Native passwordless OTP request + native register (5/h). + NativeOtp, + /// Magic-link request (5/h). + MagicLink, + /// Forgot-password / password-reset request (5/h). + PasswordReset, + /// Email-OTP login (30/min). + EmailOtp, + /// Email verification resend (5/h). + EmailVerification, + /// Native passkey begin / enroll-begin / enroll (60/5min). + PasskeyBegin, + /// First-admin bootstrap (10/15min). + Bootstrap, +} + +/// +/// A single rate-limit ceiling: at most requests per +/// from one partition (per-IP, per-realm). Whole +/// minutes are enough for every auth policy (the tightest is 1 minute). +/// +public record RateLimitRule +{ + public int PermitLimit { get; init; } + public int WindowMinutes { get; init; } +} + +/// +/// The shipped defaults for each — kept in code +/// so a realm that never touches the feature behaves exactly as before. They are +/// the single source of truth for both the live limiter (fallback when a realm has +/// no override) and the admin UI (shown as placeholders / reset target). +/// +public static class AuthRateLimitDefaults +{ + public static RateLimitRule For(AuthRateLimitPolicy policy) => policy switch + { + AuthRateLimitPolicy.NativeOtp => new RateLimitRule { PermitLimit = 5, WindowMinutes = 60 }, + AuthRateLimitPolicy.MagicLink => new RateLimitRule { PermitLimit = 5, WindowMinutes = 60 }, + AuthRateLimitPolicy.PasswordReset => new RateLimitRule { PermitLimit = 5, WindowMinutes = 60 }, + AuthRateLimitPolicy.EmailOtp => new RateLimitRule { PermitLimit = 30, WindowMinutes = 1 }, + AuthRateLimitPolicy.EmailVerification => new RateLimitRule { PermitLimit = 5, WindowMinutes = 60 }, + AuthRateLimitPolicy.PasskeyBegin => new RateLimitRule { PermitLimit = 60, WindowMinutes = 5 }, + AuthRateLimitPolicy.Bootstrap => new RateLimitRule { PermitLimit = 10, WindowMinutes = 15 }, + _ => new RateLimitRule { PermitLimit = 5, WindowMinutes = 60 }, + }; + + /// The ASP.NET limiter policy name (matches RequireRateLimiting + /// call sites and AddPolicy registrations in Program.cs). + public static string PolicyName(AuthRateLimitPolicy policy) => policy switch + { + AuthRateLimitPolicy.NativeOtp => "native-otp", + AuthRateLimitPolicy.MagicLink => "magic-link", + AuthRateLimitPolicy.PasswordReset => "password-reset", + AuthRateLimitPolicy.EmailOtp => "email-otp", + AuthRateLimitPolicy.EmailVerification => "email-verification", + AuthRateLimitPolicy.PasskeyBegin => "passkey-begin", + AuthRateLimitPolicy.Bootstrap => "bootstrap", + _ => "native-otp", + }; +} + +/// +/// Per-realm overrides for the per-IP auth rate-limit ceilings. Lives as a +/// nullable sub-document on the tenant-DB +/// aggregate (null = the realm has never touched the feature → every policy uses +/// its ). A null rule for an individual policy +/// likewise falls back to that policy's default, so the doc only ever stores the +/// limits an admin actually changed. +/// +/// Owned by the realm-admin (not Control-Plane). The default values keep the +/// secure production posture; the knob exists so test realms, dev, or legitimately +/// bursty consumers can raise a ceiling without a modgud code change + redeploy. +/// +public record AuthRateLimitSettings +{ + public RateLimitRule? NativeOtp { get; init; } + public RateLimitRule? MagicLink { get; init; } + public RateLimitRule? PasswordReset { get; init; } + public RateLimitRule? EmailOtp { get; init; } + public RateLimitRule? EmailVerification { get; init; } + public RateLimitRule? PasskeyBegin { get; init; } + public RateLimitRule? Bootstrap { get; init; } + + /// The configured override for a policy, or null if it inherits the default. + public RateLimitRule? Get(AuthRateLimitPolicy policy) => policy switch + { + AuthRateLimitPolicy.NativeOtp => NativeOtp, + AuthRateLimitPolicy.MagicLink => MagicLink, + AuthRateLimitPolicy.PasswordReset => PasswordReset, + AuthRateLimitPolicy.EmailOtp => EmailOtp, + AuthRateLimitPolicy.EmailVerification => EmailVerification, + AuthRateLimitPolicy.PasskeyBegin => PasskeyBegin, + AuthRateLimitPolicy.Bootstrap => Bootstrap, + _ => null, + }; + + /// The effective rule for a policy: the realm override if set, else + /// the shipped default. Static so the live limiter can resolve from a possibly + /// null settings section in one call. + public static RateLimitRule Effective(AuthRateLimitSettings? settings, AuthRateLimitPolicy policy) + => settings?.Get(policy) ?? AuthRateLimitDefaults.For(policy); +} diff --git a/src/frontend-vue/public/i18n/de.json b/src/frontend-vue/public/i18n/de.json index 3f9dce8b..dae9d141 100644 --- a/src/frontend-vue/public/i18n/de.json +++ b/src/frontend-vue/public/i18n/de.json @@ -1361,6 +1361,7 @@ "dcr": "Dynamic Client Registration", "cimd": "Client-ID-Metadaten (CIMD)", "nativeGrants": "Native passwortlose Grants", + "authRateLimits": "Rate-Limits", "branding": "Branding", "deletion": "Konto-Löschung", "signingKeys": "Signing-Schlüssel", @@ -1441,6 +1442,18 @@ "refreshTokenDays": "Refresh-Token-Lebensdauer (Tage)", "optInWarning": "Pro-Client-Opt-in weiterhin nötig: Ein Client kann einen nativen Grant erst nutzen, wenn er die passende Grant-Type-Permission trägt (gt:urn:cocoar:otp / :magic / :passkey), aktiviert im Grants-Tab des Clients. Dieser Realm-Schalter ist notwendig, aber nicht hinreichend. Nur Katalog-Clients kommen infrage — DCR-/CIMD-Clients sind ausgeschlossen." }, + "authRateLimits": { + "hint": "Pro-IP-Anfrage-Obergrenzen für die Auth-Endpoints dieses Realms: höchstens „Max. Anfragen“ pro „Fenster“ (Minuten) von einer Quell-IP. Die Defaults sind die sichere Produktions-Vorgabe — erhöhe sie nur für Test-Realms, Dev oder legitim stoßweise Consumer; senke sie zum Verschärfen. Jeder Wert gilt pro Realm.", + "permitLimit": "Max. Anfragen", + "windowMinutes": "Fenster (Minuten)", + "nativeOtp": "Native-OTP-Anfrage (passwortloser Login-Code)", + "magicLink": "Magic-Link-Anfrage", + "passwordReset": "Passwort-Reset-Anfrage", + "emailOtp": "E-Mail-OTP-Login-Prüfung", + "emailVerification": "E-Mail-Verifizierung erneut senden", + "passkeyBegin": "Passkey-Ceremony Begin / Enroll", + "bootstrap": "First-Admin-Bootstrap" + }, "regFields": { "hint": "Welche Identitätsfelder bei der Kontoerstellung gefordert sind (Admin-Anlage, Selbstregistrierung, native passwortlose Registrierung). E-Mail ist immer Pflicht. Pro Application überschreibbar." } diff --git a/src/frontend-vue/src/models/realmSettings.ts b/src/frontend-vue/src/models/realmSettings.ts index c76d01e6..fe8bf1da 100644 --- a/src/frontend-vue/src/models/realmSettings.ts +++ b/src/frontend-vue/src/models/realmSettings.ts @@ -8,6 +8,7 @@ export interface RealmSettingsDto { Dcr: DcrSettingsDto Cimd: CimdSettingsDto NativeGrants: NativeGrantSettingsDto + AuthRateLimits: AuthRateLimitsDto Branding: BrandingSettingsDto RegistrationFields: RegistrationFieldsSettingsDto Deletion: DeletionSettingsDto @@ -21,6 +22,7 @@ export interface UpdateRealmSettingsDto { Dcr?: UpdateDcrSettingsDto | null Cimd?: UpdateCimdSettingsDto | null NativeGrants?: UpdateNativeGrantSettingsDto | null + AuthRateLimits?: UpdateAuthRateLimitsDto | null Branding?: UpdateBrandingSettingsDto | null RegistrationFields?: UpdateRegistrationFieldsSettingsDto | null Deletion?: UpdateDeletionSettingsDto | null @@ -176,3 +178,35 @@ export interface UpdateNativeGrantSettingsDto { AccessTokenLifetimeMinutes?: number RefreshTokenLifetimeDays?: number } + +// Per-IP auth rate-limit ceilings, configurable per realm. Each policy is a +// { PermitLimit, WindowMinutes } pair; the read shape carries the EFFECTIVE +// values (the realm override if set, else the shipped default), so the form +// shows concrete numbers. Lowering tightens, raising relaxes — defaults keep +// the secure production posture. Mirrors AuthRateLimitsDtos.cs. +export interface RateLimitRuleDto { + PermitLimit: number + WindowMinutes: number +} + +export interface AuthRateLimitsDto { + NativeOtp: RateLimitRuleDto + MagicLink: RateLimitRuleDto + PasswordReset: RateLimitRuleDto + EmailOtp: RateLimitRuleDto + EmailVerification: RateLimitRuleDto + PasskeyBegin: RateLimitRuleDto + Bootstrap: RateLimitRuleDto +} + +// PATCH shape: a missing policy = no change; a present rule replaces that +// policy's ceiling (stored as a realm override). +export interface UpdateAuthRateLimitsDto { + NativeOtp?: RateLimitRuleDto + MagicLink?: RateLimitRuleDto + PasswordReset?: RateLimitRuleDto + EmailOtp?: RateLimitRuleDto + EmailVerification?: RateLimitRuleDto + PasskeyBegin?: RateLimitRuleDto + Bootstrap?: RateLimitRuleDto +} diff --git a/src/frontend-vue/src/views/admin/RealmSettingsView.vue b/src/frontend-vue/src/views/admin/RealmSettingsView.vue index 84e63c5b..a99f9f68 100644 --- a/src/frontend-vue/src/views/admin/RealmSettingsView.vue +++ b/src/frontend-vue/src/views/admin/RealmSettingsView.vue @@ -29,6 +29,8 @@ import type { UpdateCimdSettingsDto, NativeGrantSettingsDto, UpdateNativeGrantSettingsDto, + AuthRateLimitsDto, + UpdateAuthRateLimitsDto, DeletionSettingsDto, UpdateDeletionSettingsDto, RegistrationFieldsSettingsDto, @@ -51,7 +53,7 @@ watch(language, () => ui.set((ctx) => { ctx.content.hasSubNav = true }), { immediate: true }) -type TabId = 'self-registration' | 'registration-fields' | 'dcr' | 'cimd' | 'native-grants' | 'deletion' | 'signing-keys' +type TabId = 'self-registration' | 'registration-fields' | 'dcr' | 'cimd' | 'native-grants' | 'auth-rate-limits' | 'deletion' | 'signing-keys' const activeTab = ref('self-registration') const canRotateSigningKey = computed(() => authStore.hasPermission('realm-settings:write')) @@ -159,6 +161,54 @@ function nativeGrantsFromDto(d: NativeGrantSettingsDto): NativeGrantFormState { } } +// ── Auth rate-limit form state (per-IP ceilings, configurable per realm) ── +type RateLimitPolicyKey = + 'NativeOtp' | 'MagicLink' | 'PasswordReset' | 'EmailOtp' + | 'EmailVerification' | 'PasskeyBegin' | 'Bootstrap' + +type AuthRateLimitsFormState = Record + +// Display order + labels for the rate-limit grid. Labels carry the endpoint so an +// admin knows which flow each ceiling gates. +const rateLimitPolicies: { key: RateLimitPolicyKey; labelKey: string; fallback: string }[] = [ + { key: 'NativeOtp', labelKey: 'admin.realmSettings.authRateLimits.nativeOtp', fallback: 'Native OTP request (passwordless login code)' }, + { key: 'MagicLink', labelKey: 'admin.realmSettings.authRateLimits.magicLink', fallback: 'Magic-link request' }, + { key: 'PasswordReset', labelKey: 'admin.realmSettings.authRateLimits.passwordReset', fallback: 'Password-reset request' }, + { key: 'EmailOtp', labelKey: 'admin.realmSettings.authRateLimits.emailOtp', fallback: 'Email-OTP login verify' }, + { key: 'EmailVerification', labelKey: 'admin.realmSettings.authRateLimits.emailVerification', fallback: 'Email verification resend' }, + { key: 'PasskeyBegin', labelKey: 'admin.realmSettings.authRateLimits.passkeyBegin', fallback: 'Passkey ceremony begin / enroll' }, + { key: 'Bootstrap', labelKey: 'admin.realmSettings.authRateLimits.bootstrap', fallback: 'First-admin bootstrap' }, +] + +function emptyAuthRateLimits(): AuthRateLimitsFormState { + return { + NativeOtp: { PermitLimit: 5, WindowMinutes: 60 }, + MagicLink: { PermitLimit: 5, WindowMinutes: 60 }, + PasswordReset: { PermitLimit: 5, WindowMinutes: 60 }, + EmailOtp: { PermitLimit: 30, WindowMinutes: 1 }, + EmailVerification: { PermitLimit: 5, WindowMinutes: 60 }, + PasskeyBegin: { PermitLimit: 60, WindowMinutes: 5 }, + Bootstrap: { PermitLimit: 10, WindowMinutes: 15 }, + } +} + +const authRateLimitsForm = ref(emptyAuthRateLimits()) +const originalAuthRateLimits = ref(null) + +function authRateLimitsFromDto(d: AuthRateLimitsDto): AuthRateLimitsFormState { + const copy = (r: { PermitLimit: number; WindowMinutes: number }) => + ({ PermitLimit: r.PermitLimit, WindowMinutes: r.WindowMinutes }) + return { + NativeOtp: copy(d.NativeOtp), + MagicLink: copy(d.MagicLink), + PasswordReset: copy(d.PasswordReset), + EmailOtp: copy(d.EmailOtp), + EmailVerification: copy(d.EmailVerification), + PasskeyBegin: copy(d.PasskeyBegin), + Bootstrap: copy(d.Bootstrap), + } +} + // ── Deletion-policy form state ─────────────────────────────────────── interface DeletionFormState { GraceDays: number @@ -251,6 +301,8 @@ onMounted(async () => { cimdForm.value = cimdFromDto(dto.Cimd) originalNativeGrants.value = dto.NativeGrants nativeGrantsForm.value = nativeGrantsFromDto(dto.NativeGrants) + originalAuthRateLimits.value = dto.AuthRateLimits + authRateLimitsForm.value = authRateLimitsFromDto(dto.AuthRateLimits) originalDeletion.value = dto.Deletion deletionForm.value = deletionFromDto(dto.Deletion) originalRegFields.value = dto.RegistrationFields @@ -351,6 +403,22 @@ function buildNativeGrantsPatch(): UpdateNativeGrantSettingsDto | undefined { return Object.keys(patch).length === 0 ? undefined : patch } +function buildAuthRateLimitsPatch(): UpdateAuthRateLimitsDto | undefined { + const orig = originalAuthRateLimits.value + if (!orig) return undefined + const cur = authRateLimitsForm.value + const patch: UpdateAuthRateLimitsDto = {} + + for (const { key } of rateLimitPolicies) { + const o = orig[key] + const c = cur[key] + if (c.PermitLimit !== o.PermitLimit || c.WindowMinutes !== o.WindowMinutes) + patch[key] = { PermitLimit: c.PermitLimit, WindowMinutes: c.WindowMinutes } + } + + return Object.keys(patch).length === 0 ? undefined : patch +} + function buildDeletionPatch(): UpdateDeletionSettingsDto | undefined { const orig = originalDeletion.value if (!orig) return undefined @@ -383,9 +451,10 @@ async function save() { const dcrPatch = buildDcrPatch() const cimdPatch = buildCimdPatch() const nativeGrantsPatch = buildNativeGrantsPatch() + const authRateLimitsPatch = buildAuthRateLimitsPatch() const deletionPatch = buildDeletionPatch() const regFieldsPatch = buildRegFieldsPatch() - if (!selfRegPatch && !dcrPatch && !cimdPatch && !nativeGrantsPatch && !deletionPatch && !regFieldsPatch) { + if (!selfRegPatch && !dcrPatch && !cimdPatch && !nativeGrantsPatch && !authRateLimitsPatch && !deletionPatch && !regFieldsPatch) { savedFlash.value = true setTimeout(() => { savedFlash.value = false }, 1200) return @@ -398,6 +467,7 @@ async function save() { Dcr?: UpdateDcrSettingsDto Cimd?: UpdateCimdSettingsDto NativeGrants?: UpdateNativeGrantSettingsDto + AuthRateLimits?: UpdateAuthRateLimitsDto Deletion?: UpdateDeletionSettingsDto RegistrationFields?: UpdateRegistrationFieldsSettingsDto } = {} @@ -405,6 +475,7 @@ async function save() { if (dcrPatch) payload.Dcr = dcrPatch if (cimdPatch) payload.Cimd = cimdPatch if (nativeGrantsPatch) payload.NativeGrants = nativeGrantsPatch + if (authRateLimitsPatch) payload.AuthRateLimits = authRateLimitsPatch if (deletionPatch) payload.Deletion = deletionPatch if (regFieldsPatch) payload.RegistrationFields = regFieldsPatch const updated = await settingsStore.patch(payload) @@ -416,6 +487,8 @@ async function save() { cimdForm.value = cimdFromDto(updated.Cimd) originalNativeGrants.value = updated.NativeGrants nativeGrantsForm.value = nativeGrantsFromDto(updated.NativeGrants) + originalAuthRateLimits.value = updated.AuthRateLimits + authRateLimitsForm.value = authRateLimitsFromDto(updated.AuthRateLimits) originalDeletion.value = updated.Deletion deletionForm.value = deletionFromDto(updated.Deletion) originalRegFields.value = updated.RegistrationFields @@ -466,6 +539,9 @@ async function rotateSigningKey() { {{ t('admin.realmSettings.tabs.nativeGrants', {}, 'Native Passwordless Grants') }} + + {{ t('admin.realmSettings.tabs.authRateLimits', {}, 'Rate Limits') }} + {{ t('admin.realmSettings.tabs.deletion', {}, 'Account Deletion') }} @@ -753,6 +829,39 @@ async function rotateSigningKey() { + +
+

+ {{ t('admin.realmSettings.authRateLimits.hint', {}, 'Per-IP request ceilings for this realm’s auth endpoints: at most PermitLimit requests per Window (minutes) from one source IP. The defaults are the secure production posture — raise them only for test realms, dev, or legitimately bursty consumers; lower them to tighten. Each value applies per realm.') }} +

+ +
+
{{ t(p.labelKey, {}, p.fallback) }}
+ + + + + + +
+ +
+ + {{ t('common.save', {}, 'Save') }} + +
+
+
+