Skip to content

Identity recovery email outbox

Password sign-in protection and owner retry permits

Anonymous password failures no longer increment Identity's account-wide failed counter or set LockoutEnd (#2904). Existing imported/admin lockout and eligibility checks remain authoritative, as do password-proven MFA and authenticated step-up failure counters. The login page uses the same generic failure for unknown, wrong, blocked, and throttled attempts.

PasswordAttemptAdmission reserves work transactionally before checking a password. Across replicas it permits five anonymous attempts per canonical target and per source/target pair in five minutes, twenty requests per source in five minutes, and 120 requests globally per minute. These are attempt budgets, including successful admitted attempts, not post-check counters that concurrent callers could overshoot. Missing targets use the same admission state. A 50,000-record physical ceiling fails closed even if one-day TTL cleanup lags. Fixed windows can allow adjacent-window bursts; no claim of complete DDoS protection is made.

Source comes only from Connection.RemoteIpAddress after the existing validated trusted-proxy processing. IPv4-mapped IPv6 is normalized to IPv4; IPv6 sources share a /64 bucket. Institutional NATs and IPv6 subnets share a source budget. A broad source/global capacity attack can still affect honest users; these resource limits are not a substitute for the shared target budget against distributed guesses.

The target budget requests additional owner proof rather than disabling the account. An always-visible Verify email and retry your password route queues all valid addresses identically. The worker issues a random 256-bit, ten-minute permit for existing accounts. PasswordRetryPermits stores only its digest, fixed purpose, user ID, mailbox/stamp digests, encrypted exact local OAuth continuation, and expiry. The queued continuation is also encrypted; it is selected from the validated local return URL at request time, never overridden on redemption. This preserves the original max_age=0 authorization request and its state.

Each delivered permit is independent: later attacker requests do not supersede an older owner's link. The email link's GET only renders a confirmation/password form. An antiforgery POST checks the bound user, current mailbox/security stamp, existing eligibility/lockout, expiry, and any already-authenticated account. Atomic permit consumption allows one password check, including a wrong-password attempt. It bypasses target/pair pressure only; source/global resource bounds still apply. Replay and parallel redemption cannot authorize additional checks. No GET, permit request, or permit consumption creates a session or authentication evidence. Only the normal successful password/MFA flow signs in and establishes its usual evidence; this does not introduce a passwordless or email AMR capability.

An exhausted permitted attempt may queue an owner notice, at most once per hour per canonical address across replicas, explaining repeated attempts without claiming the account was locked. Explicit owner assistance uses a separate one-minute family cooldown. Both reuse the outbox's capacity, encrypted storage, no-retry transport, and Unknown outcome policy. Notification delivery cannot block password verification on SES latency. No raw permit, credential URL, password, address, or IP is logged.

Missing/passwordless/pre-sign-in-blocked accounts perform a configured dummy KDF instead of returning before password work. This equalizes the dominant KDF cost, not every database round trip or legacy password-hash version; tests assert work and security behavior, not a fabricated constant response delay. Existing Identity/provider rollout controls gate this code; no new flag restores the unsafe anonymous account-lockout behavior. No deployment or activation is included.

Anonymous password-reset requests (API and Razor ForgotPassword) and anonymous verification resends admit work without looking up an account, generating a token, or contacting SES. Every valid canonical address follows the same admission path, including missing, confirmed, and locked accounts. Account eligibility and email delivery are evaluated only by the hosted worker. This removes account-dependent request latency; it does not promise identical network timings.

The MVP covers these three existing entry points. Authenticated verification resends keep their existing protected continuation and account-level reservation. No activation, deployment, or live email is part of this change. The existing Identity/provider rollout boundary remains the feature gate; there is no separate switch back to the vulnerable synchronous request path.

Admission and operational bounds

RecoveryEmailOutbox and RecoveryEmailAdmission live in the configured Identity Mongo database. Admission uses a majority-write transaction to atomically apply the following fixed bounds, shared by all replicas:

Bound Value
Pending work, including leased/sending jobs 1,000
Requests across all sources and addresses 120 per one-minute window
Requests per source IP 20 per one-minute window
Address/family cooldown 60 seconds; both reset entry points share a family
Work lifetime 15 minutes from admission
Worker lease 60 seconds
Pre-submission attempts At most 3, with 5/10-second retry backoff
Transport deadline 30 seconds, linked to host shutdown
Admission deadline 5 seconds, linked to the HTTP request
Terminal/throttle record retention 1 day, then Mongo TTL cleanup

The source is HttpContext.Connection.RemoteIpAddress after the host's existing forwarded-header handling, not a caller-supplied queue key. Unknown sources share one bucket. Limits apply even to missing addresses and cooldown-suppressed requests. They bound queue growth, not an entitlement to delivery during abuse or outages.

Accepted or cooldown-suppressed API requests return empty 202 responses. Rate limits return generic 429; capacity or persistence failure returns generic 503 without claiming acceptance. Razor presents the corresponding generic retry error instead of its requested-message confirmation. These outcomes depend on admission state, never on account lookup. Cancellation before transaction commit aborts work; after commit the durable job is independent of the disconnected caller.

Addresses are encrypted with the existing shared ASP.NET Data Protection key ring using a dedicated purpose. Admission keys contain SHA-256 digests, not raw addresses or IPs; those digests are pseudonymous and are not a substitute for database access controls. Tokens and complete credential-bearing links exist only in worker memory and the outbound message, never in queue documents or application logs. Logging uses bounded operation/outcome and exception-type fields, not exception messages.

Lease, restart, and delivery semantics

A worker atomically claims one eligible job with a new ownership value. It decrypts the address, looks up the account, and creates the token only when needed. Missing accounts and already-confirmed verification requests settle without transport work. Existing reset eligibility is preserved, including locked accounts; reset does not itself unlock or sign in an account.

Before contacting transport, the worker must durably change its unexpired owned lease to Sending. A dedicated SES client has MaxErrorRetry = 0; unrelated email callers retain their existing client and retry policy. Failed pre-submission work can retry within the attempt/lifetime bounds. Expired pre-submission leases can be reclaimed by another process; old owners cannot submit or settle the new lease.

After entering Sending, a false result, exception, timeout, cancellation, crash, or lost acknowledgement is conservatively Unknown. Expired Sending leases are also settled Unknown, never automatically resubmitted. This is not exactly-once email delivery: SES may have accepted a message whose acknowledgement was lost, and a crash before the actual call may result in no email. A later explicit user request after the cooldown can enqueue new work.

Terminal settlement and decrementing pending capacity occur in one transaction, at most once. Only terminal jobs receive DeleteAt; TTL cannot remove a pending job before its capacity is released. The worker reaps expired jobs in bounded batches and discards encrypted addresses when settling them.

Startup checks topology and executes a transaction. Standalone Mongo, missing permissions, or unavailable queue initialization fails host startup clearly rather than exposing a healthy host that silently cannot deliver mail. Local development must use the existing replica-set Docker setup. Atlas replica sets/sharded clusters and the shared persistent Data Protection key ring remain required; this change does not provision either dependency. Losing the key ring makes queued work undecryptable and exhausts its bounded pre-send attempts without sending.

API reset links retain the configured frontend /reset-password destination. Razor reset and anonymous verification links use the configured Identity issuer with /Account/ResetPassword and /Account/VerifyEmail respectively. Destinations must be HTTPS (loopback HTTP is allowed locally); the incoming Host header never selects a credential destination. Query values are encoded. Anonymous requests cannot choose a continuation; authenticated verification retains its existing protected return-link contract.

Replica-set integration tests cover atomic limits, expiry boundaries, stale owners, restart/reaping, unknown transport outcomes, shutdown, encrypted persistence, link destinations, and poisoned log sentinels. Request tests prove that known/missing/ confirmed/locked variants do not invoke account lookup or transport; delayed transport does not block subsequent queue admission.