Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions docs/identity-throttling.md
Original file line number Diff line number Diff line change
@@ -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<ResendDecision> TryAcquireResendSlotAsync(string userKey, VerificationChannel channel, CancellationToken ct);
Task<VerificationAttemptDecision> RecordAttemptAsync(string userKey, VerificationChannel channel, bool succeeded, CancellationToken ct);
Task<bool> 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.
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -23,7 +24,9 @@ public void Map(IEndpointRouteBuilder app)
[FromForm] string? code,
ClaimsPrincipal principal,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager
SignInManager<ApplicationUser> signInManager,
IVerificationThrottle throttle,
HttpContext context
) =>
{
var user = await userManager.GetUserAsync(principal);
Expand Down Expand Up @@ -51,9 +54,36 @@ SignInManager<ApplicationUser> 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
Expand All @@ -64,11 +94,17 @@ SignInManager<ApplicationUser> 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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -51,6 +52,7 @@ public void Map(IEndpointRouteBuilder app)
ClaimsPrincipal principal,
UserManager<ApplicationUser> userManager,
IEmailSender<ApplicationUser> emailSender,
IVerificationThrottle throttle,
HttpContext context
) =>
{
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Security.Claims;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
Expand All @@ -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;

Expand All @@ -22,7 +24,9 @@ public void Map(IEndpointRouteBuilder app)
[FromForm] string? phoneNumber,
ClaimsPrincipal principal,
UserManager<ApplicationUser> userManager,
ISmsSender smsSender
ISmsSender smsSender,
IVerificationThrottle throttle,
HttpContext context
) =>
{
var user = await userManager.GetUserAsync(principal);
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@
using SimpleModule.Core;
using SimpleModule.Core.Inertia;
using SimpleModule.Users.Contracts;
using SimpleModule.Users.Services;

namespace SimpleModule.Users.Pages.Account;

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"))
Expand All @@ -26,19 +29,42 @@ public void Map(IEndpointRouteBuilder app)
[FromForm] string email,
UserManager<ApplicationUser> userManager,
IEmailSender<ApplicationUser> 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;
Expand All @@ -50,7 +76,7 @@ HttpContext context

return Inertia.Render(
"Users/Account/ResendEmailConfirmation",
new { message = "Verification email sent. Please check your email." }
new { message = GenericMessage }
);
}
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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);

/// <summary>
/// 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.
/// </summary>
public interface IVerificationThrottle
{
/// <summary>
/// Attempts to consume a resend slot. Returns <c>Allowed = true</c> when
/// the cooldown is clear; otherwise <c>RetryAfter</c> is the time until
/// the next resend is allowed.
/// </summary>
Task<ResendDecision> TryAcquireResendSlotAsync(
string userKey,
VerificationChannel channel,
CancellationToken cancellationToken = default
);

/// <summary>
/// Records the outcome of a code-submission attempt. On <c>succeeded = true</c>
/// the failure counter is cleared. On a failure, the counter increments and
/// the (user, channel) pair is locked out once it crosses
/// <see cref="VerificationThrottleOptions.MaxFailedAttempts"/>.
/// </summary>
Task<VerificationAttemptDecision> RecordAttemptAsync(
string userKey,
VerificationChannel channel,
bool succeeded,
CancellationToken cancellationToken = default
);

/// <summary>
/// True when the (user, channel) pair is currently locked out from
/// further attempts.
/// </summary>
Task<bool> IsLockedOutAsync(
string userKey,
VerificationChannel channel,
CancellationToken cancellationToken = default
);
}
Loading
Loading