From 7f82c9338503a7f784d196b79dd98bda6d206c2c Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 15 May 2026 19:06:14 +0000 Subject: [PATCH] feat(identity): add resend cooldown + verification throttling (closes #199) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds IVerificationThrottle backed by IFusionCache and wires it into the four endpoints called out in #199: - POST /Identity/Account/ResendEmailConfirmation - POST /Identity/Account/Manage/Email (re-sends change-email link) - POST /Identity/Account/Manage/SendPhoneVerificationCode - POST /Identity/Account/Manage/ConfirmPhoneNumber Resend cooldown is per-user, per-channel (default 60s). When the caller is inside the cooldown, the response shape is identical to the success path (no information leak) but a Retry-After header is set so well-behaved clients back off. Attempt cap on ConfirmPhoneNumber locks the (user, phone-channel) pair after MaxFailedAttempts (default 5) for LockoutDuration (default 15 minutes). While locked out, ChangePhoneNumberAsync is not called at all — the 6-digit search space cannot be drained against the lockout. A successful confirmation clears the counter. Configurable via Identity:VerificationThrottle in appsettings. Tests: 8 new unit tests cover first-allow, cooldown-rejects-with- retry-after, post-cooldown-allow, per-channel isolation, per-user isolation, lockout-after-max-failures, success-clears- lockout, and not-locked-out-by-default. Full Users suite still 70/70. --- docs/identity-throttling.md | 94 ++++++++++++ .../Manage/ConfirmPhoneNumberEndpoint.cs | 40 ++++- .../Pages/Account/Manage/EmailEndpoint.cs | 26 ++++ .../SendPhoneVerificationCodeEndpoint.cs | 31 +++- .../ResendEmailConfirmationEndpoint.cs | 30 +++- .../Services/IVerificationThrottle.cs | 54 +++++++ .../Services/VerificationThrottle.cs | 137 ++++++++++++++++++ .../Services/VerificationThrottleOptions.cs | 25 ++++ .../src/SimpleModule.Users/UsersModule.cs | 5 + .../SimpleModule.Users.Tests.csproj | 1 + .../Unit/VerificationThrottleTests.cs | 130 +++++++++++++++++ 11 files changed, 568 insertions(+), 5 deletions(-) create mode 100644 docs/identity-throttling.md create mode 100644 modules/Users/src/SimpleModule.Users/Services/IVerificationThrottle.cs create mode 100644 modules/Users/src/SimpleModule.Users/Services/VerificationThrottle.cs create mode 100644 modules/Users/src/SimpleModule.Users/Services/VerificationThrottleOptions.cs create mode 100644 modules/Users/tests/SimpleModule.Users.Tests/Unit/VerificationThrottleTests.cs diff --git a/docs/identity-throttling.md b/docs/identity-throttling.md new file mode 100644 index 00000000..ce67cd9a --- /dev/null +++ b/docs/identity-throttling.md @@ -0,0 +1,94 @@ +# Identity throttling + +Two protections for the email / phone confirmation flows: + +1. **Resend cooldown** — per-user, per-channel. Stops a signed-in user from + pumping verification emails or SMS to arbitrary addresses, which can + drive up provider costs. +2. **Attempt cap on code submission** — per-user, per-channel. Limits how + many bad 6-digit SMS codes (or expired email links) the user can + present before the channel is locked out for a cooling-off window. + +Both are stored via `IFusionCache` (the framework's unified cache), so +when a distributed cache backend is wired in they automatically span +process replicas without code changes. + +## Configuration + +```jsonc +// appsettings.json +{ + "Identity": { + "VerificationThrottle": { + "ResendCooldown": "00:01:00", // 60 seconds between resends + "MaxFailedAttempts": 5, // 5 wrong codes… + "LockoutDuration": "00:15:00" // …locks out for 15 minutes + } + } +} +``` + +All three are independent — increasing `LockoutDuration` does not stretch +the resend cooldown, and vice versa. + +## How it shows up + +### Resend over cooldown + +`POST /Identity/Account/ResendEmailConfirmation`, `/Identity/Account/Manage/Email`, +or `/Identity/Account/Manage/SendPhoneVerificationCode` while the cooldown +is active: + +- Returns the same Inertia page the success path renders (no information + leak about which addresses are registered for the anonymous resend endpoint). +- Sets a `Retry-After` header so well-behaved clients can back off. +- The status message on the manage pages tells the user how long to wait. + +### Lockout on code submission + +`POST /Identity/Account/Manage/ConfirmPhoneNumber` after `MaxFailedAttempts` +incorrect codes: + +- Returns the manage page with a generic *"Too many failed attempts. Please + try again later."* message. +- Subsequent attempts short-circuit at the lockout check — `ChangePhoneNumberAsync` + is never called, so a brute-forcer can't keep burning the 6-digit search + space against the lockout. +- A successful confirmation clears the counter and the lockout. + +## Auditing + +Both `Identity` endpoints run inside the existing `AuditMiddleware`, so +every resend POST and every confirmation POST already appears in the +`AuditLogs` stream with method, path, user, IP, status, and timing. The +throttle decisions are visible from the `Retry-After` header and the +distinct status messages — no separate audit-event channel needed. + +## Programmatic surface + +```csharp +public interface IVerificationThrottle +{ + Task TryAcquireResendSlotAsync(string userKey, VerificationChannel channel, CancellationToken ct); + Task RecordAttemptAsync(string userKey, VerificationChannel channel, bool succeeded, CancellationToken ct); + Task IsLockedOutAsync(string userKey, VerificationChannel channel, CancellationToken ct); +} +``` + +`userKey` is whatever string you want to scope on — in the built-in +endpoints we use `UserManager.GetUserIdAsync(user)`. For anonymous flows +you could substitute a hashed IP or the requested email. + +## Trade-offs + +- **Why not a route-level rate limit?** SimpleModule already has + `RateLimiting` middleware. Route-level limits would block obvious abuse, + but a single signed-in session pacing requests under the route's + threshold would still succeed at draining your SMS budget. The per-user + counter is what closes that hole. Use both together for defense in depth. +- **Why FusionCache instead of `IDistributedCache` directly?** The rest of + the framework already standardizes on FusionCache; introducing a second + caching abstraction here would fragment ops. +- **Why a singleton service?** State lives in the cache, not the service. + Singleton lifetime sidesteps the scoped/transient capture footguns and + makes ctor cost negligible. diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ConfirmPhoneNumberEndpoint.cs b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ConfirmPhoneNumberEndpoint.cs index 9b1d253e..dc3140aa 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ConfirmPhoneNumberEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ConfirmPhoneNumberEndpoint.cs @@ -7,6 +7,7 @@ using SimpleModule.Core; using SimpleModule.Core.Inertia; using SimpleModule.Users.Contracts; +using SimpleModule.Users.Services; namespace SimpleModule.Users.Pages.Account.Manage; @@ -23,7 +24,9 @@ public void Map(IEndpointRouteBuilder app) [FromForm] string? code, ClaimsPrincipal principal, UserManager userManager, - SignInManager signInManager + SignInManager signInManager, + IVerificationThrottle throttle, + HttpContext context ) => { var user = await userManager.GetUserAsync(principal); @@ -51,9 +54,36 @@ SignInManager signInManager ); } + var userId = await userManager.GetUserIdAsync(user); + if (await throttle.IsLockedOutAsync(userId, VerificationChannel.Phone, context.RequestAborted)) + { + return Inertia.Render( + "Users/Account/Manage/Index", + new + { + username, + phoneNumber = await userManager.GetPhoneNumberAsync(user), + isPhoneNumberConfirmed = await userManager.IsPhoneNumberConfirmedAsync( + user + ), + pendingPhoneNumber = phoneNumber, + statusMessage = "Too many failed attempts. Please try again later.", + } + ); + } + var result = await userManager.ChangePhoneNumberAsync(user, phoneNumber, code); if (!result.Succeeded) { + var attempt = await throttle.RecordAttemptAsync( + userId, + VerificationChannel.Phone, + succeeded: false, + context.RequestAborted + ); + var msg = attempt.LockedOut + ? "Too many failed attempts. Please try again later." + : "Error: Invalid or expired verification code."; return Inertia.Render( "Users/Account/Manage/Index", new @@ -64,11 +94,17 @@ SignInManager signInManager user ), pendingPhoneNumber = phoneNumber, - statusMessage = "Error: Invalid or expired verification code.", + statusMessage = msg, } ); } + await throttle.RecordAttemptAsync( + userId, + VerificationChannel.Phone, + succeeded: true, + context.RequestAborted + ); await signInManager.RefreshSignInAsync(user); return Inertia.Render( diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/EmailEndpoint.cs b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/EmailEndpoint.cs index 2578c83d..c55017ff 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/EmailEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/EmailEndpoint.cs @@ -9,6 +9,7 @@ using SimpleModule.Core; using SimpleModule.Core.Inertia; using SimpleModule.Users.Contracts; +using SimpleModule.Users.Services; namespace SimpleModule.Users.Pages.Account.Manage; @@ -51,6 +52,7 @@ public void Map(IEndpointRouteBuilder app) ClaimsPrincipal principal, UserManager userManager, IEmailSender emailSender, + IVerificationThrottle throttle, HttpContext context ) => { @@ -66,6 +68,30 @@ HttpContext context if (newEmail != email) { var userId = await userManager.GetUserIdAsync(user); + + var slot = await throttle.TryAcquireResendSlotAsync( + userId, + VerificationChannel.Email, + context.RequestAborted + ); + if (!slot.Allowed) + { + var seconds = (int)Math.Ceiling((slot.RetryAfter ?? TimeSpan.Zero).TotalSeconds); + context.Response.Headers.RetryAfter = seconds.ToString( + System.Globalization.CultureInfo.InvariantCulture + ); + return Inertia.Render( + "Users/Account/Manage/Email", + new + { + email, + isEmailConfirmed, + newEmail, + statusMessage = $"Please wait {seconds} seconds before requesting another email.", + } + ); + } + var code = await userManager.GenerateChangeEmailTokenAsync(user, newEmail); code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); var request = context.Request; diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/SendPhoneVerificationCodeEndpoint.cs b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/SendPhoneVerificationCodeEndpoint.cs index 7b56475b..de3b3dcb 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/SendPhoneVerificationCodeEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/SendPhoneVerificationCodeEndpoint.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Security.Claims; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -7,6 +8,7 @@ using SimpleModule.Core; using SimpleModule.Core.Inertia; using SimpleModule.Users.Contracts; +using SimpleModule.Users.Services; namespace SimpleModule.Users.Pages.Account.Manage; @@ -22,7 +24,9 @@ public void Map(IEndpointRouteBuilder app) [FromForm] string? phoneNumber, ClaimsPrincipal principal, UserManager userManager, - ISmsSender smsSender + ISmsSender smsSender, + IVerificationThrottle throttle, + HttpContext context ) => { var user = await userManager.GetUserAsync(principal); @@ -51,6 +55,31 @@ ISmsSender smsSender ); } + var userId = await userManager.GetUserIdAsync(user); + var slot = await throttle.TryAcquireResendSlotAsync( + userId, + VerificationChannel.Phone, + context.RequestAborted + ); + if (!slot.Allowed) + { + var seconds = (int)Math.Ceiling((slot.RetryAfter ?? TimeSpan.Zero).TotalSeconds); + context.Response.Headers.RetryAfter = seconds.ToString( + CultureInfo.InvariantCulture + ); + return Inertia.Render( + "Users/Account/Manage/Index", + new + { + username, + phoneNumber = currentPhoneNumber, + isPhoneNumberConfirmed, + pendingPhoneNumber = phoneNumber, + statusMessage = $"Please wait {seconds} seconds before requesting another code.", + } + ); + } + var code = await userManager.GenerateChangePhoneNumberTokenAsync( user, phoneNumber diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/ResendEmailConfirmationEndpoint.cs b/modules/Users/src/SimpleModule.Users/Pages/Account/ResendEmailConfirmationEndpoint.cs index 45d97a97..13227304 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/ResendEmailConfirmationEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/ResendEmailConfirmationEndpoint.cs @@ -8,6 +8,7 @@ using SimpleModule.Core; using SimpleModule.Core.Inertia; using SimpleModule.Users.Contracts; +using SimpleModule.Users.Services; namespace SimpleModule.Users.Pages.Account; @@ -15,6 +16,8 @@ public class ResendEmailConfirmationEndpoint : IViewEndpoint { public const string Route = UsersConstants.Routes.ResendEmailConfirmation; + private const string GenericMessage = "Verification email sent. Please check your email."; + public void Map(IEndpointRouteBuilder app) { app.MapGet(Route, () => Inertia.Render("Users/Account/ResendEmailConfirmation")) @@ -26,19 +29,42 @@ public void Map(IEndpointRouteBuilder app) [FromForm] string email, UserManager userManager, IEmailSender emailSender, + IVerificationThrottle throttle, HttpContext context ) => { var user = await userManager.FindByEmailAsync(email); if (user is null) { + // No user — still respond identically so we don't leak + // which addresses are registered, but skip the SMS/email + // dispatch entirely. return Inertia.Render( "Users/Account/ResendEmailConfirmation", - new { message = "Verification email sent. Please check your email." } + new { message = GenericMessage } ); } var userId = await userManager.GetUserIdAsync(user); + var slot = await throttle.TryAcquireResendSlotAsync( + userId, + VerificationChannel.Email, + context.RequestAborted + ); + if (!slot.Allowed) + { + // Reuse the same opaque response shape, but tell the + // caller (and the audit log) how long to wait. + var seconds = (int)Math.Ceiling((slot.RetryAfter ?? TimeSpan.Zero).TotalSeconds); + context.Response.Headers.RetryAfter = seconds.ToString( + System.Globalization.CultureInfo.InvariantCulture + ); + return Inertia.Render( + "Users/Account/ResendEmailConfirmation", + new { message = GenericMessage } + ); + } + var code = await userManager.GenerateEmailConfirmationTokenAsync(user); code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); var request = context.Request; @@ -50,7 +76,7 @@ HttpContext context return Inertia.Render( "Users/Account/ResendEmailConfirmation", - new { message = "Verification email sent. Please check your email." } + new { message = GenericMessage } ); } ) diff --git a/modules/Users/src/SimpleModule.Users/Services/IVerificationThrottle.cs b/modules/Users/src/SimpleModule.Users/Services/IVerificationThrottle.cs new file mode 100644 index 00000000..06256227 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/Services/IVerificationThrottle.cs @@ -0,0 +1,54 @@ +namespace SimpleModule.Users.Services; + +public enum VerificationChannel +{ + Email, + Phone, +} + +public sealed record ResendDecision(bool Allowed, TimeSpan? RetryAfter); + +public sealed record VerificationAttemptDecision(bool LockedOut, int FailuresInWindow); + +/// +/// Per-user, per-channel rate limit for verification-code resends and +/// per-user, per-channel attempt counter for failed code submissions. Both +/// counters are stored in the unified cache so they survive process recycles +/// when a distributed backend is wired in. +/// +public interface IVerificationThrottle +{ + /// + /// Attempts to consume a resend slot. Returns Allowed = true when + /// the cooldown is clear; otherwise RetryAfter is the time until + /// the next resend is allowed. + /// + Task TryAcquireResendSlotAsync( + string userKey, + VerificationChannel channel, + CancellationToken cancellationToken = default + ); + + /// + /// Records the outcome of a code-submission attempt. On succeeded = true + /// the failure counter is cleared. On a failure, the counter increments and + /// the (user, channel) pair is locked out once it crosses + /// . + /// + Task RecordAttemptAsync( + string userKey, + VerificationChannel channel, + bool succeeded, + CancellationToken cancellationToken = default + ); + + /// + /// True when the (user, channel) pair is currently locked out from + /// further attempts. + /// + Task IsLockedOutAsync( + string userKey, + VerificationChannel channel, + CancellationToken cancellationToken = default + ); +} diff --git a/modules/Users/src/SimpleModule.Users/Services/VerificationThrottle.cs b/modules/Users/src/SimpleModule.Users/Services/VerificationThrottle.cs new file mode 100644 index 00000000..6a9365ec --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/Services/VerificationThrottle.cs @@ -0,0 +1,137 @@ +using Microsoft.Extensions.Options; +using ZiggyCreatures.Caching.Fusion; + +namespace SimpleModule.Users.Services; + +/// +/// FusionCache-backed implementation of . +/// Keys are namespaced so a single cache can host every module's rate-limited +/// counter without collisions. Values are deliberately tiny ( +/// timestamps and counters) so distributed backends stay +/// cheap. +/// +public sealed class VerificationThrottle : IVerificationThrottle +{ + private readonly IFusionCache _cache; + private readonly TimeProvider _timeProvider; + private readonly VerificationThrottleOptions _options; + + public VerificationThrottle( + IFusionCache cache, + TimeProvider timeProvider, + IOptions options + ) + { + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + } + + public async Task TryAcquireResendSlotAsync( + string userKey, + VerificationChannel channel, + CancellationToken cancellationToken = default + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(userKey); + + var key = ResendKey(userKey, channel); + var nowTicks = _timeProvider.GetUtcNow().UtcTicks; + var existing = await _cache.TryGetAsync(key, token: cancellationToken); + if (existing.HasValue) + { + var nextAllowedTicks = existing.Value; + if (nextAllowedTicks > nowTicks) + { + var retryAfter = TimeSpan.FromTicks(nextAllowedTicks - nowTicks); + return new ResendDecision(false, retryAfter); + } + } + + var nextAllowed = nowTicks + _options.ResendCooldown.Ticks; + await _cache.SetAsync( + key, + nextAllowed, + options => + { + options.Duration = _options.ResendCooldown; + }, + token: cancellationToken + ); + return new ResendDecision(true, null); + } + + public async Task RecordAttemptAsync( + string userKey, + VerificationChannel channel, + bool succeeded, + CancellationToken cancellationToken = default + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(userKey); + + var failKey = FailKey(userKey, channel); + var lockKey = LockKey(userKey, channel); + + if (succeeded) + { + await _cache.RemoveAsync(failKey, token: cancellationToken); + await _cache.RemoveAsync(lockKey, token: cancellationToken); + return new VerificationAttemptDecision(false, 0); + } + + var currentMaybe = await _cache.TryGetAsync(failKey, token: cancellationToken); + var current = currentMaybe.HasValue ? currentMaybe.Value : 0; + var next = current + 1; + + if (next >= _options.MaxFailedAttempts) + { + await _cache.SetAsync( + lockKey, + true, + options => + { + options.Duration = _options.LockoutDuration; + }, + token: cancellationToken + ); + await _cache.RemoveAsync(failKey, token: cancellationToken); + return new VerificationAttemptDecision(true, next); + } + + await _cache.SetAsync( + failKey, + next, + options => + { + options.Duration = _options.LockoutDuration; + }, + token: cancellationToken + ); + return new VerificationAttemptDecision(false, next); + } + + public async Task IsLockedOutAsync( + string userKey, + VerificationChannel channel, + CancellationToken cancellationToken = default + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(userKey); + + var existing = await _cache.TryGetAsync( + LockKey(userKey, channel), + token: cancellationToken + ); + return existing.HasValue && existing.Value; + } + + private static string ResendKey(string userKey, VerificationChannel channel) => + $"verify:resend:{channel}:{userKey}"; + + private static string FailKey(string userKey, VerificationChannel channel) => + $"verify:fail:{channel}:{userKey}"; + + private static string LockKey(string userKey, VerificationChannel channel) => + $"verify:lock:{channel}:{userKey}"; +} diff --git a/modules/Users/src/SimpleModule.Users/Services/VerificationThrottleOptions.cs b/modules/Users/src/SimpleModule.Users/Services/VerificationThrottleOptions.cs new file mode 100644 index 00000000..b72a0f2f --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/Services/VerificationThrottleOptions.cs @@ -0,0 +1,25 @@ +namespace SimpleModule.Users.Services; + +/// +/// Tunes the resend cooldown and verification attempt cap that protect +/// the email and phone confirmation flows. +/// +public sealed class VerificationThrottleOptions +{ + /// + /// Minimum interval between resends for a single (user, channel) pair. + /// + public TimeSpan ResendCooldown { get; set; } = TimeSpan.FromSeconds(60); + + /// + /// Number of failed code submissions before the (user, channel) pair is + /// locked out from further attempts. + /// + public int MaxFailedAttempts { get; set; } = 5; + + /// + /// How long a (user, channel) pair stays locked out after exhausting + /// . + /// + public TimeSpan LockoutDuration { get; set; } = TimeSpan.FromMinutes(15); +} diff --git a/modules/Users/src/SimpleModule.Users/UsersModule.cs b/modules/Users/src/SimpleModule.Users/UsersModule.cs index 7750b586..c25777d8 100644 --- a/modules/Users/src/SimpleModule.Users/UsersModule.cs +++ b/modules/Users/src/SimpleModule.Users/UsersModule.cs @@ -86,6 +86,11 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddSingleton, ConsoleEmailSender>(); services.AddSingleton(); services.AddSingleton(); + + services + .AddOptions() + .Bind(configuration.GetSection("Identity:VerificationThrottle")); + services.AddSingleton(); } public void ConfigurePermissions(PermissionRegistryBuilder builder) diff --git a/modules/Users/tests/SimpleModule.Users.Tests/SimpleModule.Users.Tests.csproj b/modules/Users/tests/SimpleModule.Users.Tests/SimpleModule.Users.Tests.csproj index 175885e2..bd87670c 100644 --- a/modules/Users/tests/SimpleModule.Users.Tests/SimpleModule.Users.Tests.csproj +++ b/modules/Users/tests/SimpleModule.Users.Tests/SimpleModule.Users.Tests.csproj @@ -14,6 +14,7 @@ + diff --git a/modules/Users/tests/SimpleModule.Users.Tests/Unit/VerificationThrottleTests.cs b/modules/Users/tests/SimpleModule.Users.Tests/Unit/VerificationThrottleTests.cs new file mode 100644 index 00000000..c95fd4e5 --- /dev/null +++ b/modules/Users/tests/SimpleModule.Users.Tests/Unit/VerificationThrottleTests.cs @@ -0,0 +1,130 @@ +using FluentAssertions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; +using SimpleModule.Users.Services; +using ZiggyCreatures.Caching.Fusion; + +namespace SimpleModule.Users.Tests.Unit; + +public class VerificationThrottleTests +{ + private static (VerificationThrottle Throttle, FakeTimeProvider Time, FusionCache Cache) CreateSut( + VerificationThrottleOptions? options = null + ) + { + var cache = new FusionCache(new FusionCacheOptions { CacheName = "verify-tests" }); + var time = new FakeTimeProvider(startDateTime: new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); + var opts = Options.Create(options ?? new VerificationThrottleOptions()); + return (new VerificationThrottle(cache, time, opts), time, cache); + } + + [Fact] + public async Task TryAcquire_first_request_is_allowed() + { + var (throttle, _, _) = CreateSut(); + + var result = await throttle.TryAcquireResendSlotAsync("user-1", VerificationChannel.Email); + + result.Allowed.Should().BeTrue(); + result.RetryAfter.Should().BeNull(); + } + + [Fact] + public async Task TryAcquire_within_cooldown_is_rejected_with_retry_after() + { + var (throttle, time, _) = CreateSut( + new VerificationThrottleOptions { ResendCooldown = TimeSpan.FromSeconds(60) } + ); + + var first = await throttle.TryAcquireResendSlotAsync("user-1", VerificationChannel.Email); + time.Advance(TimeSpan.FromSeconds(10)); + var second = await throttle.TryAcquireResendSlotAsync("user-1", VerificationChannel.Email); + + first.Allowed.Should().BeTrue(); + second.Allowed.Should().BeFalse(); + second.RetryAfter.Should().BeCloseTo(TimeSpan.FromSeconds(50), precision: TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task TryAcquire_after_cooldown_is_allowed_again() + { + var (throttle, time, _) = CreateSut( + new VerificationThrottleOptions { ResendCooldown = TimeSpan.FromSeconds(30) } + ); + + await throttle.TryAcquireResendSlotAsync("user-1", VerificationChannel.Email); + time.Advance(TimeSpan.FromSeconds(31)); + var second = await throttle.TryAcquireResendSlotAsync("user-1", VerificationChannel.Email); + + second.Allowed.Should().BeTrue(); + } + + [Fact] + public async Task TryAcquire_is_per_channel() + { + var (throttle, _, _) = CreateSut( + new VerificationThrottleOptions { ResendCooldown = TimeSpan.FromMinutes(5) } + ); + + var email = await throttle.TryAcquireResendSlotAsync("user-1", VerificationChannel.Email); + var phone = await throttle.TryAcquireResendSlotAsync("user-1", VerificationChannel.Phone); + + email.Allowed.Should().BeTrue(); + phone.Allowed.Should().BeTrue(); + } + + [Fact] + public async Task TryAcquire_is_per_user() + { + var (throttle, _, _) = CreateSut( + new VerificationThrottleOptions { ResendCooldown = TimeSpan.FromMinutes(5) } + ); + + var a = await throttle.TryAcquireResendSlotAsync("user-A", VerificationChannel.Email); + var b = await throttle.TryAcquireResendSlotAsync("user-B", VerificationChannel.Email); + + a.Allowed.Should().BeTrue(); + b.Allowed.Should().BeTrue(); + } + + [Fact] + public async Task RecordAttempt_locks_out_after_max_failures() + { + var (throttle, _, _) = CreateSut( + new VerificationThrottleOptions { MaxFailedAttempts = 3 } + ); + + var a1 = await throttle.RecordAttemptAsync("user-1", VerificationChannel.Phone, succeeded: false); + var a2 = await throttle.RecordAttemptAsync("user-1", VerificationChannel.Phone, succeeded: false); + var a3 = await throttle.RecordAttemptAsync("user-1", VerificationChannel.Phone, succeeded: false); + + a1.LockedOut.Should().BeFalse(); + a2.LockedOut.Should().BeFalse(); + a3.LockedOut.Should().BeTrue(); + (await throttle.IsLockedOutAsync("user-1", VerificationChannel.Phone)).Should().BeTrue(); + } + + [Fact] + public async Task RecordAttempt_success_clears_lockout() + { + var (throttle, _, _) = CreateSut( + new VerificationThrottleOptions { MaxFailedAttempts = 2 } + ); + + await throttle.RecordAttemptAsync("user-1", VerificationChannel.Phone, succeeded: false); + await throttle.RecordAttemptAsync("user-1", VerificationChannel.Phone, succeeded: false); + (await throttle.IsLockedOutAsync("user-1", VerificationChannel.Phone)).Should().BeTrue(); + + await throttle.RecordAttemptAsync("user-1", VerificationChannel.Phone, succeeded: true); + + (await throttle.IsLockedOutAsync("user-1", VerificationChannel.Phone)).Should().BeFalse(); + } + + [Fact] + public async Task IsLockedOut_false_when_no_attempts_recorded() + { + var (throttle, _, _) = CreateSut(); + + (await throttle.IsLockedOutAsync("nobody", VerificationChannel.Email)).Should().BeFalse(); + } +}