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.
Prerequisites and links¶
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.