From d264e89666efc4080fa913c3e9b13cee05e683b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksandr=20=C5=A0t=C5=A1epelin?= Date: Thu, 20 Aug 2026 21:56:17 +0300 Subject: [PATCH 1/3] =?UTF-8?q?docs:=20document=20the=20opt-in=20account?= =?UTF-8?q?=20lockout=20(NIST=20SP=20800-63B=20=C2=A75.2.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lukk 0.5.0 ships `features.lockout` and none of it was documented — the flag, `max_attempts`/`release_after`, the `lukk-lockout-migrations` group, `lukk:release`, the 423, `AccountLocked`/`AccountReleased`, or the `LockoutRepository` seam. The page leads with the distinction that actually matters, because the two are easy to conflate: the existing throttles bound a RATE and decay, the lockout bounds a RUN and persists. It also states the trade-off rather than burying it — a hard lockout is a denial-of-service primitive, which is why it is off by default — and recommends `release_after=3600`, which gives up the strict §5.2.2 reading but lands exactly on OWASP ASVS V2.2.1 and lifts itself. Two pre-existing gaps fixed along the way, since the new content reads as wrong next to them: - the `features` toggle list was missing `email_verification`, `password_reset` and `registration` - the login rate limit documented `ip_max_attempts` but never `account_max_attempts`, which is the cap the lockout is actually measured against (20/60s ≈ 1,200 failures an hour, indefinitely) --- docs/.vitepress/config.mts | 1 + docs/account-lockout.md | 171 ++++++++++++++++++++++++++++++ docs/authentication.md | 2 + docs/configuration.md | 14 ++- docs/customization.md | 4 + docs/deployment.md | 2 + docs/events.md | 15 +++ docs/installation.md | 2 +- docs/security.md | 2 + docs/two-factor-authentication.md | 1 + 10 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 docs/account-lockout.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index aa9af68..3f1a8f2 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -61,6 +61,7 @@ export default withMermaid({ { text: 'Email Verification', link: '/email-verification' }, { text: 'Password Reset', link: '/password-reset' }, { text: 'Step-Up Confirmation', link: '/confirmation' }, + { text: 'Account Lockout', link: '/account-lockout' }, { text: 'Multiple Guards', link: '/multiple-guards' }, ], }, diff --git a/docs/account-lockout.md b/docs/account-lockout.md new file mode 100644 index 0000000..7097d9a --- /dev/null +++ b/docs/account-lockout.md @@ -0,0 +1,171 @@ +# Account Lockout + +**NIST SP 800-63B §5.2.2** requires a verifier to limit **consecutive** failed authentication attempts on a single account to no more than **100**. lukk's [rate limits](/configuration#rate-limits) don't satisfy that clause — they bound a *rate*, not a *run*, and they decay — so lukk ships a separate, **opt-in** persistent counter that does. + +It is off by default, and that is a deliberate security trade-off rather than an oversight: a hard lockout is a **denial-of-service primitive**. Anyone who knows an address can burn its budget on purpose and lock the owner out. Turn it on when the protocol requirement outweighs that, and read [Bounding the denial](#bounding-the-denial) before you do. + +This page is server-only — there is no client configuration. The client sees a `423`. + +## Rate limit vs. lockout + +They solve different problems and lukk runs both: + +| | Rate limit (always on) | Lockout (opt-in) | +|---|---|---| +| Bounds | A **rate** — attempts per window | A **run** — consecutive failures, ever | +| Storage | Cache, decaying | `lukk_lockouts` table, persistent | +| Cleared by | Time, or a success | A **success** only (or an explicit release) | +| Survives | Not a cache flush | A cache flush, a deploy, a restart | +| Status | `429` | `423 Locked` | +| Standard | Defense in depth | NIST SP 800-63B §5.2.2 | + +The gap the lockout closes is concrete. lukk's IP-independent per-account cap (`account_max_attempts`) is 20 failures per 60 seconds — about **1,200 per hour**, indefinitely, because the window keeps resetting. A patient attacker never trips it and never runs out of guesses. A consecutive-failure cap does run out. + +## Setup + +Publish the migration — like every lukk migration, it is publish-only, in its own group: + +```bash +php artisan vendor:publish --tag=lukk-lockout-migrations +php artisan migrate +``` + +Then enable the feature: + +```php +// config/lukk.php +'features' => [ + 'lockout' => true, + // ... +], + +'lockout' => [ + 'max_attempts' => 100, // LUKK_LOCKOUT_MAX_ATTEMPTS — §5.2.2 says no MORE than 100 + 'release_after' => 0, // LUKK_LOCKOUT_RELEASE_AFTER — seconds; 0 = hold until released +], +``` + +`max_attempts` is a **ceiling, not a target**. 100 is what the standard permits; nothing stops you setting 10. + +> [!WARNING] +> The counter keys on the `lukk.username` field. If you authenticate on a different field via [`Lukk::authenticateUsing()`](/customization#custom-login-logic), set `lukk.username` to match it — otherwise lukk never sees an identifier to count against and the lockout **silently does nothing**. (It refuses to count an empty subject rather than drop every caller into one shared, never-decaying bucket, which would lock out your entire user base at attempt 100.) + +## Bounding the denial + +`release_after` is the setting that decides what kind of feature this is, so choose it deliberately. + +**`release_after = 0`** is the strict §5.2.2 reading: a run broken only by a **success**. It is also the version an attacker can weaponize — lock an account and it stays locked until a human intervenes. + +**`release_after = 3600`** trades the strict reading for a decaying cap. 100 failures per hour is no longer "100 consecutive, ever" — but it *is* exactly **OWASP ASVS V2.2.1** ("no more than 100 failed attempts per hour on a single account"), a bar this package does not otherwise clear. **Most deployments should prefer this.** It is a 12× improvement on the throttle alone and the lock lifts itself. + +```dotenv +LUKK_LOCKOUT_MAX_ATTEMPTS=100 +LUKK_LOCKOUT_RELEASE_AFTER=3600 +``` + +If you do run with `0`, pair it with a listener on [`AccountLocked`](#events) so the account owner learns about it, and give your support team [`lukk:release`](#releasing-a-lock). + +## What's protected + +Two authenticators get their own independent counter, distinguished by a `purpose` column: + +| Purpose | Subject | Counted failure | +|---|---|---| +| `login` | The normalized identifier (`lukk.username`, trimmed + lowercased + transliterated) | A failed `POST /auth/login` | +| `two_factor` | The user id | A failed TOTP code at `POST /auth/two-factor-challenge` | + +A `login` subject is an **identifier, not a resolved user** — a lock can name an account that doesn't exist. That's intentional: resolving first would leak account existence through the lockout's timing and behaviour, and lukk's login path is deliberately [constant-time](/security). + +Counters are per [guard](/multiple-guards), so an admin guard and a customer guard never share one. + +### Recovery codes are exempt + +A locked-out user submitting a **recovery code** is not gated by a `two_factor` lock. A recovery code is ~119 bits of entropy, single-use and salted+hashed, so a consecutive cap protects nothing there — while gating it would strand a user whose second factor an attacker deliberately burned. The recovery code is the way *out* of a lock, so it can't be behind one. + +## What a locked account sees + +`423 Locked`, not `429`: + +```json +{ + "message": "This account is locked. Contact support to restore access.", + "errors": { + "email": ["This account is locked. Contact support to restore access."] + } +} +``` + +`429` means "retry later", and with `release_after` at `0` that would be a lie — the lock needs intervention, not patience. When `release_after` is set, the message instead names the remaining wait, using Laravel's own `auth.throttle` translation line. + +Both messages are `__()`-wrapped, so publish `lang/en/auth.php` and add your own keys to change them. + +The lockout check runs **before** the rate limiter, so a locked account gets the `423` rather than a `429` that misdescribes its situation. + +## Releasing a lock + +Four things clear a counter: + +- **A successful authentication.** "Consecutive" is the whole point — any success ends the run and resets the count to zero. +- **A password reset.** [Completing a reset](/password-reset) releases the `login` lock, keyed off the *resolved* user. Without this, an attacker who locked an account could keep it locked even after the owner did the one thing that should restore access — the owner would be stuck with no path left but a support ticket. +- **`release_after` elapsing**, when you've set it. This one is lazy and read-only: the lock simply *reports* unlocked, and the row is reset by the next failure or dropped by [pruning](#pruning). Nothing is written on a read path (that would break on a replica), so **no `AccountReleased` fires** for an expiry. +- **The console command**, for your support team: + +```bash +php artisan lukk:release user@example.com +php artisan lukk:release 42 --purpose=two_factor +php artisan lukk:release user@example.com --guard=admin +``` + +`--purpose` is `login` (default) or `two_factor`. `--guard` defaults to lukk's configured guard — locks are stamped with the guard that recorded them, so on a [multi-guard](/multiple-guards) app you must name the right one. The command normalizes the subject exactly the way the failure path recorded it, so pasting an address straight out of a support ticket works. It exits non-zero when no matching lock was found. + +## Pruning + +Spent counters accumulate. `lukk:prune` — already [scheduled daily](/deployment#pruning-expired-tokens) — drops released rows once they're stale: + +```bash +php artisan lukk:prune --lockout-days=30 +``` + +A **held** lock is never pruned, whatever its age — pruning must not quietly become a release path. What goes is the spent stuff: counters untouched for `--lockout-days`, and locks already past `release_after`. + +## Events {#events} + +Both are dispatched on the **transition**, not on every attempt: + +| Event | When | +|---|---| +| `Lukk\Events\AccountLocked` | A consecutive-failure run just hit the cap. | +| `Lukk\Events\AccountReleased` | A counter was cleared — by a successful authentication, a password reset, or `lukk:release`. Not by a `release_after` expiry (see [above](#releasing-a-lock)). | + +Each carries `$purpose`, `$subject`, and `$guard`. A locked-out user gets **no other signal** — they simply can't authenticate — so `AccountLocked` is where you send the "someone is trying to get into your account" mail, or a release link: + +```php +use Illuminate\Support\Facades\Event; +use Lukk\Events\AccountLocked; + +Event::listen(function (AccountLocked $event) { + Log::warning('Account locked', [ + 'purpose' => $event->purpose, + 'subject' => $event->subject, + 'guard' => $event->guard, + ]); +}); +``` + +`$subject` is an identifier, not an `Authenticatable` — resolve it yourself if you need the user, and handle the case where it doesn't match one. + +## Swapping the store + +Storage sits behind `Lukk\Contracts\LockoutRepository`, like every other seam in the package. The default `DatabaseLockoutRepository` uses a transaction with a row lock to count, so concurrent failed attempts can't race past the cap. If you rebind it, **preserve that** — a non-atomic implementation lets a burst of parallel requests blow straight through `max_attempts`. + +```php +// AppServiceProvider::register() +$this->app->bind( + \Lukk\Contracts\LockoutRepository::class, + \App\Auth\RedisLockoutRepository::class, +); +``` + +A cache-backed store is a poor fit for this specific job, though — a counter that expires isn't *consecutive*, and one that a cache flush erases isn't a durable cap. That's why the default is a table. See [Customization](/customization) for the full list of rebindable contracts. + +Next: **[Multiple Guards](/multiple-guards)** diff --git a/docs/authentication.md b/docs/authentication.md index 5ac1f39..ba113f4 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -43,6 +43,8 @@ On success you receive a token pair (the exact shape depends on the [output mode Wrong credentials return `422`. Lukk's login is **constant-time**: an unknown email runs the same hashing work as a wrong password, so neither timing nor response shape reveals which accounts exist. +Repeated failures are throttled per account and per IP ([rate limits](/configuration#rate-limits)), returning `429`. Those bound a rate; if you also need the NIST SP 800-63B §5.2.2 cap on *consecutive* failures, enable the opt-in [account lockout](/account-lockout), which answers `423` instead. + > [!NOTE] > If the user has confirmed [two-factor authentication](/two-factor-authentication) or you require [passkeys](/passkeys), login returns a challenge instead of tokens. See those pages for the second step. diff --git a/docs/configuration.md b/docs/configuration.md index 746886b..8d5b1ca 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -64,7 +64,7 @@ Every throttle lives here, each shaped as `{ max_attempts, decay_seconds }` (log ```php 'rate_limits' => [ 'ipv6_prefix' => 64, - 'login' => ['max_attempts' => 5, 'decay_seconds' => 60, 'ip_max_attempts' => 30], + 'login' => ['max_attempts' => 5, 'decay_seconds' => 60, 'ip_max_attempts' => 30, 'account_max_attempts' => 20], 'two_factor' => ['max_attempts' => 5, 'decay_seconds' => 60], 'refresh' => ['max_attempts' => 30, 'decay_seconds' => 60], 'passkeys' => ['max_attempts' => 30, 'decay_seconds' => 60], @@ -73,11 +73,13 @@ Every throttle lives here, each shaped as `{ max_attempts, decay_seconds }` (log | Limit | Default | Keyed on | Notes | |---|---|---|---| -| `login` | 5 / 60s (+ `ip_max_attempts` 30) | normalized email + IP | Failures-only: only failed attempts count, a success clears the counter; lockout returns a `429` validation error. **`ip_max_attempts`** (env `LUKK_LOGIN_IP_MAX_ATTEMPTS`) is a separate coarse per-IP cap on *all* login attempts, bounding password-spraying across many emails. | +| `login` | 5 / 60s (+ `ip_max_attempts` 30, `account_max_attempts` 20) | normalized email + IP | Failures-only: only failed attempts count, a success clears the counter, and tripping it returns a `429` validation error. **`ip_max_attempts`** (env `LUKK_LOGIN_IP_MAX_ATTEMPTS`) is a separate coarse per-IP cap on *all* login attempts, bounding password-spraying across many emails. **`account_max_attempts`** (env `LUKK_LOGIN_ACCOUNT_MAX_ATTEMPTS`) is an IP-**independent** per-account cap, so a botnet can't take `max_attempts` guesses *per source IP* against one account. | | `two_factor` | 5 / 60s | account (`sub`) | Throttles challenge-code guesses for a single account. Also guards the endpoint per IP. | | `refresh` | 30 / 60s | IP | Per-IP guard on `POST /auth/refresh`. | | `passkeys` | 30 / 60s | IP | Per-IP guard on the passkey login + assertion-options endpoints. | +These bound a **rate**, not a run: the window keeps resetting, so `account_max_attempts` at 20/60s permits ~1,200 failures an hour indefinitely. The separate, opt-in [account lockout](/account-lockout) is what caps *consecutive* failures (NIST SP 800-63B §5.2.2). + Each maps to a named limiter (`lukk-refresh`, `lukk-passkeys`, `lukk-2fa`) you can also override with your own `RateLimiter::for()`. Tune any of them with the matching env vars — `LUKK_REFRESH_MAX_ATTEMPTS`, `LUKK_2FA_DECAY`, and so on. **What "keyed on IP" actually means.** Every throttle buckets on `Lukk::rateLimitKey()`, which is the caller's address with IPv6 collapsed to **`ipv6_prefix`** (default `/64`, env `LUKK_RATE_LIMIT_IPV6_PREFIX`). A subscriber is typically handed a whole `/64`, so keying on the full address would let one visitor mint effectively unlimited buckets and walk through every per-IP limit. IPv4 is used as-is, and addresses that embed IPv4 (IPv4-mapped, NAT64's `64:ff9b::/96`) are unwrapped rather than masked — otherwise a whole translated client population would share one counter. Raise it toward `128` if your users share a `/64` (an office or campus LAN does); lower it if your attackers hold larger delegations. @@ -155,7 +157,11 @@ See [Authentication → Output modes](/authentication#output-modes) for the full 'denylist' => true, 'logout_all' => true, 'two_factor' => false, + 'lockout' => false, 'passkeys' => false, + 'email_verification' => false, + 'password_reset' => false, + 'registration' => false, ], ``` @@ -166,7 +172,11 @@ See [Authentication → Output modes](/authentication#output-modes) for the full | `denylist` | `true` | Honor the cache-backed revocation denylist. | | `logout_all` | `true` | Enable the "revoke every session" path. | | `two_factor` | `false` | Enable [two-factor authentication](/two-factor-authentication). Requires `pragmarx/google2fa`. | +| `lockout` | `false` | Enable the [account lockout](/account-lockout) — the NIST SP 800-63B §5.2.2 consecutive-failure cap. Requires the `lukk-lockout-migrations` migration. | | `passkeys` | `false` | Enable [passkeys](/passkeys). Requires a WebAuthn library. | +| `email_verification` | `false` | Enable [email verification](/email-verification). | +| `password_reset` | `false` | Enable [password reset](/password-reset). | +| `registration` | `false` | Enable [registration](/registration). | > [!WARNING] > The rotation, reuse-detection, and denylist features are the security core of the package. Disable them only if you fully understand the consequence. diff --git a/docs/customization.md b/docs/customization.md index a19af53..fa1db84 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -38,6 +38,10 @@ Lukk::authenticateUsing(function (Request $request) { }); ``` +> [!WARNING] +> If your callback authenticates on a **different field**, set `lukk.username` to match it. The login throttle and the [account lockout](/account-lockout) both key on that field — with a mismatch the lockout never sees an identifier to count and silently does nothing. + + The login **throttle** still wraps your closure — failed attempts are rate-limited exactly as on the default path. **Constant-time** behaviour, however, becomes *your* responsibility: the package's unknown-user timing equalizer only runs on the built-in email/password path, so a closure that does `User::where(...)->first()` and hashes only when the user exists leaks a user-enumeration timing oracle. Make your closure take the same time whether or not the account exists — e.g. always run a `Hash::check` against a dummy hash when no user is found. ## Custom token claims diff --git a/docs/deployment.md b/docs/deployment.md index 9e2ae2e..385cbf1 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -142,6 +142,8 @@ public function boot(): void } ``` +The same command also drops spent [account-lockout](/account-lockout) counters when that feature is on (`--lockout-days=30`); a lock that is still held is never pruned. + Only the auth service (the one holding the `refresh_tokens` table) prunes; verify-only API services have no database to prune. ### Operational requirements diff --git a/docs/events.md b/docs/events.md index a0717d7..cfdf54a 100644 --- a/docs/events.md +++ b/docs/events.md @@ -49,6 +49,21 @@ Event::listen(function (PasskeyCloneDetected $event) { The event carries `$userId` and `$credentialId`. A **zero** counter is never flagged — synced passkeys always report `0`. +### AccountLocked / AccountReleased + +When the opt-in [account lockout](/account-lockout) is on, `Lukk\Events\AccountLocked` fires the moment a consecutive-failure run hits the cap, and `Lukk\Events\AccountReleased` when a counter is cleared. Both carry `$purpose` (`login` or `two_factor`), `$subject`, and `$guard`: + +```php +use Illuminate\Support\Facades\Event; +use Lukk\Events\AccountLocked; + +Event::listen(function (AccountLocked $event) { + Log::warning('Account locked', ['purpose' => $event->purpose, 'subject' => $event->subject]); +}); +``` + +`AccountLocked` fires **once, on the transition**, and it is the only signal a locked-out user's account gets — so it's where you'd send the "someone is trying to get into your account" mail. Note that `$subject` is a submitted identifier, not a resolved user: it need not name a real account, so rate-limit anything you send off it. + ## Framework events lukk also dispatches standard Laravel auth events, so your existing listeners work unchanged: diff --git a/docs/installation.md b/docs/installation.md index 0a48cdb..a752bb2 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -19,7 +19,7 @@ php artisan migrate ``` > [!NOTE] -> lukk's migrations are **publish-only** — nothing runs until you publish it, the same convention as Sanctum and Passport. Each optional feature ([two-factor](/two-factor-authentication), [passkeys](/passkeys)) is its own publish group, so you only add its schema when you enable the feature. +> lukk's migrations are **publish-only** — nothing runs until you publish it, the same convention as Sanctum and Passport. Each optional feature ([two-factor](/two-factor-authentication), [passkeys](/passkeys), [account lockout](/account-lockout)) is its own publish group, so you only add its schema when you enable the feature. ### Generate the signing secret diff --git a/docs/security.md b/docs/security.md index bd6b1f5..982e5bc 100644 --- a/docs/security.md +++ b/docs/security.md @@ -67,6 +67,7 @@ In BFF mode the server can hydrate the authenticated `user` during server render | Refresh opaque + `sha256` at rest; never logged | RFC 9700 / OWASP | | Instant revocation (denylist by `fid`/`jti`) | OWASP Session Management | | Login throttled + constant-time (no user enumeration) | OWASP ASVS | +| Consecutive failed attempts on one account capped (**opt-in**) | NIST SP 800-63B §5.2.2, ASVS V2.2.1 | | Tokens kept out of the browser; sealed `__Host-` cookie | OAuth 2.0 for Browser-Based Apps | | Token responses non-cacheable (`Cache-Control: no-store, private`) | RFC 6749 §5.1 | | Reuse/family-revoke emits a security event | RFC 9700 §4.14.2 | @@ -82,6 +83,7 @@ In BFF mode the server can hydrate the authenticated `user` during server render - [x] Grace window prevents false logout under concurrency. - [x] Denylist (`fid`/`jti`) kills access within one request; global logout (`DELETE /auth/sessions`) works. - [x] Login throttled; password check constant-time; unknown user indistinguishable from wrong password. +- [ ] **(Optional)** [Account lockout](/account-lockout) enabled if you must meet NIST SP 800-63B §5.2.2 — the throttles bound a *rate*, not a run of consecutive failures. Off by default: a hard lockout is a denial-of-service primitive, so set `release_after` to bound the denial. - [x] HS256 secret ≥ 256-bit random (`php artisan lukk:secret`); v7 enforces the minimum. - [x] Token responses carry `Cache-Control: no-store, private`. - [x] Reuse/family-revoke dispatches `Events\RefreshTokenReused`. diff --git a/docs/two-factor-authentication.md b/docs/two-factor-authentication.md index bbb3f97..dd3ff08 100644 --- a/docs/two-factor-authentication.md +++ b/docs/two-factor-authentication.md @@ -128,6 +128,7 @@ Recovery codes let a user authenticate if they lose their device. Each code work - The TOTP secret is stored **encrypted** (it must be reversible to verify codes), while recovery codes are **salted and hashed** and shown only once. - A TOTP code cannot be replayed within its 30-second window — accepted codes are cached and rejected on reuse. - The verification window is ±1 step and should not be widened (see [Configuration](/configuration#two-factor)). +- Challenge codes are throttled per account. With the opt-in [account lockout](/account-lockout) enabled, a run of failed codes also locks the second factor (`423`) — but a **recovery code is never gated by that lock**, so an attacker can't strand a user by burning their TOTP budget. > [!WARNING] > TOTP is **not phishing-resistant**. A real-time attacker-in-the-middle (such as Evilginx) can relay a code and steal the session. For phishing-resistant authentication, use [passkeys](/passkeys). From 1185b64617ac9e0e411278811819aa47459f5c0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksandr=20=C5=A0t=C5=A1epelin?= Date: Thu, 20 Aug 2026 22:54:33 +0300 Subject: [PATCH 2/3] docs: document the confirm throttle and the identity-keyed lockout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows lukk#23. Three things the lockout page now gets right: - `confirm` joins `login` and `two_factor` as a lockout purpose, with the reasoning for why step-up counts at all (it re-verifies the same password) and why `confirm-passkey` is throttled but never locked. - The `login` subject is identity, not the submitted string — and the `idn:` fallback is explained as load-bearing rather than a leftover, since an identifier naming no account must still count or 423-vs-422 becomes an existence oracle. - A successful login and a password reset both release a confirm lock. Also adds a Throttling section to the confirmation page, the `confirm` row to the rate-limits table, a security-checklist line, and the lockout + throttle identity entries the roadmap's Shipped list was missing. --- docs/account-lockout.md | 20 +++++++++++++------- docs/configuration.md | 2 ++ docs/confirmation.md | 6 ++++++ docs/roadmap.md | 2 ++ docs/security.md | 1 + 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/docs/account-lockout.md b/docs/account-lockout.md index 7097d9a..4ce551b 100644 --- a/docs/account-lockout.md +++ b/docs/account-lockout.md @@ -67,14 +67,19 @@ If you do run with `0`, pair it with a listener on [`AccountLocked`](#events) so ## What's protected -Two authenticators get their own independent counter, distinguished by a `purpose` column: +Three authenticators get their own independent counter, distinguished by a `purpose` column: | Purpose | Subject | Counted failure | |---|---|---| -| `login` | The normalized identifier (`lukk.username`, trimmed + lowercased + transliterated) | A failed `POST /auth/login` | +| `login` | `id:` when the identifier names an account; `idn:` when it doesn't | A failed `POST /auth/login` | | `two_factor` | The user id | A failed TOTP code at `POST /auth/two-factor-challenge` | +| `confirm` | The user id | A failed password at `POST /auth/confirm-password` | -A `login` subject is an **identifier, not a resolved user** — a lock can name an account that doesn't exist. That's intentional: resolving first would leak account existence through the lockout's timing and behaviour, and lukk's login path is deliberately [constant-time](/security). +`confirm` matters because [step-up confirmation](/confirmation) re-verifies the *same* password as login. Without it, a caller already holding an access token — a stolen one, an XSS'd one, a shared device — could keep guessing behind the sudo gate while the login route stayed capped. `POST /auth/confirm-passkey` is throttled but deliberately **not** locked: an assertion is a signature, not a guessable secret, so there is nothing to cap. + +The `login` subject keys on **identity**, not on the submitted string. Normalizing (trim, lowercase, transliterate) is many-to-one across real accounts — `аdmin@example.com` with a Cyrillic а folds onto `admin@example.com` — so keying on it would let two accounts share one counter, and a password reset on either would clear the other's lock. + +The `idn:` fallback is not a leftover: an identifier that names **no account** must still accumulate a counter, or `423`-vs-`422` would answer "does this account exist?" for free. A `login` lock can therefore name an address that was never registered — a lock can name an account that doesn't exist. That's intentional: resolving first would leak account existence through the lockout's timing and behaviour, and lukk's login path is deliberately [constant-time](/security). Counters are per [guard](/multiple-guards), so an admin guard and a customer guard never share one. @@ -105,18 +110,19 @@ The lockout check runs **before** the rate limiter, so a locked account gets the Four things clear a counter: -- **A successful authentication.** "Consecutive" is the whole point — any success ends the run and resets the count to zero. -- **A password reset.** [Completing a reset](/password-reset) releases the `login` lock, keyed off the *resolved* user. Without this, an attacker who locked an account could keep it locked even after the owner did the one thing that should restore access — the owner would be stuck with no path left but a support ticket. +- **A successful authentication.** "Consecutive" is the whole point — any success ends the run and resets the count to zero. A successful **login** additionally clears a `confirm` lock: it proves the same password, and it's the self-service escape when someone locked step-up with a stolen token (they can't log in without the password, so this hands them nothing). +- **A password reset.** [Completing a reset](/password-reset) releases the `login` **and** `confirm` locks, keyed off the *resolved* user — after a reset, failures counted against the old password are meaningless. Without this, an attacker who locked an account could keep it locked even after the owner did the one thing that should restore access — the owner would be stuck with no path left but a support ticket. - **`release_after` elapsing**, when you've set it. This one is lazy and read-only: the lock simply *reports* unlocked, and the row is reset by the next failure or dropped by [pruning](#pruning). Nothing is written on a read path (that would break on a replica), so **no `AccountReleased` fires** for an expiry. - **The console command**, for your support team: ```bash php artisan lukk:release user@example.com php artisan lukk:release 42 --purpose=two_factor +php artisan lukk:release 42 --purpose=confirm php artisan lukk:release user@example.com --guard=admin ``` -`--purpose` is `login` (default) or `two_factor`. `--guard` defaults to lukk's configured guard — locks are stamped with the guard that recorded them, so on a [multi-guard](/multiple-guards) app you must name the right one. The command normalizes the subject exactly the way the failure path recorded it, so pasting an address straight out of a support ticket works. It exits non-zero when no matching lock was found. +`--purpose` is `login` (default), `two_factor`, or `confirm`. `--guard` defaults to lukk's configured guard — locks are stamped with the guard that recorded them, so on a [multi-guard](/multiple-guards) app you must name the right one. The command normalizes the subject exactly the way the failure path recorded it, so pasting an address straight out of a support ticket works. It exits non-zero when no matching lock was found. ## Pruning @@ -137,7 +143,7 @@ Both are dispatched on the **transition**, not on every attempt: | `Lukk\Events\AccountLocked` | A consecutive-failure run just hit the cap. | | `Lukk\Events\AccountReleased` | A counter was cleared — by a successful authentication, a password reset, or `lukk:release`. Not by a `release_after` expiry (see [above](#releasing-a-lock)). | -Each carries `$purpose`, `$subject`, and `$guard`. A locked-out user gets **no other signal** — they simply can't authenticate — so `AccountLocked` is where you send the "someone is trying to get into your account" mail, or a release link: +Each carries `$purpose` (`login`, `two_factor` or `confirm`), `$subject`, and `$guard`. A locked-out user gets **no other signal** — they simply can't authenticate — so `AccountLocked` is where you send the "someone is trying to get into your account" mail, or a release link: ```php use Illuminate\Support\Facades\Event; diff --git a/docs/configuration.md b/docs/configuration.md index 8d5b1ca..e7a5711 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -68,6 +68,7 @@ Every throttle lives here, each shaped as `{ max_attempts, decay_seconds }` (log 'two_factor' => ['max_attempts' => 5, 'decay_seconds' => 60], 'refresh' => ['max_attempts' => 30, 'decay_seconds' => 60], 'passkeys' => ['max_attempts' => 30, 'decay_seconds' => 60], + 'confirm' => ['max_attempts' => 5, 'decay_seconds' => 60], ], ``` @@ -77,6 +78,7 @@ Every throttle lives here, each shaped as `{ max_attempts, decay_seconds }` (log | `two_factor` | 5 / 60s | account (`sub`) | Throttles challenge-code guesses for a single account. Also guards the endpoint per IP. | | `refresh` | 30 / 60s | IP | Per-IP guard on `POST /auth/refresh`. | | `passkeys` | 30 / 60s | IP | Per-IP guard on the passkey login + assertion-options endpoints. | +| `confirm` | 5 / 60s | account **and** IP | Guards [step-up confirmation](/confirmation) (`confirm-password`, `confirm-passkey`). Password confirmation re-checks the same secret as login, so the per-user bucket is the load-bearing one — a stolen token is one identity behind any number of addresses. | These bound a **rate**, not a run: the window keeps resetting, so `account_max_attempts` at 20/60s permits ~1,200 failures an hour indefinitely. The separate, opt-in [account lockout](/account-lockout) is what caps *consecutive* failures (NIST SP 800-63B §5.2.2). diff --git a/docs/confirmation.md b/docs/confirmation.md index 95329af..2cc0142 100644 --- a/docs/confirmation.md +++ b/docs/confirmation.md @@ -55,6 +55,12 @@ Authorization: Bearer Both endpoints return the same kind of `confirmation_token` — the credential used is interchangeable. +#### Throttling + +Both endpoints are rate-limited (`rate_limits.confirm`, default **5 / 60s**), keyed on **both** the user and the IP — the per-user bucket is the load-bearing one, since a caller holding a stolen access token is a single identity behind however many addresses they like. A tripped limit returns `429`. + +Password confirmation re-verifies the *same* secret as login, so it also counts toward the opt-in [account lockout](/account-lockout) under the `confirm` purpose; a locked step-up answers `423`, and a successful login or a password reset clears it. `confirm-passkey` is throttled but never locked — an assertion is a signature, not a guessable secret, so there's nothing to cap. + ### Gating your own routes Apply the `lukk.confirm` middleware to any route that should require a fresh confirmation — account deletion, an email change, revealing an API key, and so on: diff --git a/docs/roadmap.md b/docs/roadmap.md index 975de45..bf683d8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -31,6 +31,8 @@ Grouped by theme; likely order: change password → abilities/scopes → account - **[Multiple guards](/multiple-guards)** — per-guard cryptographic token identity, guard-scoped refresh/revocation/throttling, per-guard routes (path or subdomain), boot-time isolation guardrails. - **[Registration](/registration)** — `POST /auth/register` mirroring login, fully customizable, with an auto-login toggle. - **Configurable login identifier** — `lukk.username` (default `email`); login by any unique column. +- **[Account lockout](/account-lockout)** *(opt-in)* — a persistent cap on **consecutive** failed attempts (NIST SP 800-63B §5.2.2), covering password login, the two-factor challenge and step-up confirmation; `423`, an operator release command, and lock/release events. Off by default: a hard lockout is a denial-of-service primitive. +- **Throttle identity** — every limit keys on `Lukk::rateLimitKey()`, with IPv6 collapsed to a configurable prefix (default `/64`) and `Lukk::rateLimitKeyUsing()` to replace the identity wholesale. - **[Two-factor (TOTP)](/two-factor-authentication)** — enrol, confirm, challenge at login, single-use recovery codes (+ remaining count), disable. - **[Passkeys (WebAuthn)](/passkeys)** — register, passwordless login, list, remove. - **[Step-up confirmation](/confirmation)** — "sudo" re-auth (password or passkey) gating sensitive routes via `lukk.confirm`. diff --git a/docs/security.md b/docs/security.md index 982e5bc..99fa522 100644 --- a/docs/security.md +++ b/docs/security.md @@ -83,6 +83,7 @@ In BFF mode the server can hydrate the authenticated `user` during server render - [x] Grace window prevents false logout under concurrency. - [x] Denylist (`fid`/`jti`) kills access within one request; global logout (`DELETE /auth/sessions`) works. - [x] Login throttled; password check constant-time; unknown user indistinguishable from wrong password. +- [x] Step-up confirmation throttled per user **and** per IP, so a stolen access token can't brute-force the password behind the sudo gate. - [ ] **(Optional)** [Account lockout](/account-lockout) enabled if you must meet NIST SP 800-63B §5.2.2 — the throttles bound a *rate*, not a run of consecutive failures. Off by default: a hard lockout is a denial-of-service primitive, so set `release_after` to bound the denial. - [x] HS256 secret ≥ 256-bit random (`php artisan lukk:secret`); v7 enforces the minimum. - [x] Token responses carry `Cache-Control: no-store, private`. From cac68df48b34c7189f712a0774c5f9f71a7b4506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksandr=20=C5=A0t=C5=A1epelin?= Date: Fri, 21 Aug 2026 13:38:46 +0300 Subject: [PATCH 3/3] docs: known limitations, and the 0.5.0 surface the site never covered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two accepted trade-offs now live where a user evaluating lukk will actually find them, rather than in a repo-internal register. **The grace window is a deviation from RFC 9700 §4.14.2 / OAuth 2.1**, and the rotation page now says so in those words. Both specs describe rotation as invalidate-and-detect — a replayed invalidated token "will revoke the active refresh token" — and neither provides for a tolerance window. Within `grace_seconds` lukk detects the replay and deliberately does not revoke. Previously this was written as a trade-off the spec accommodates. It isn't, and saying so is the honest version. What bounds it: strict invalidation logs out any client with two tabs open (a direct client can't be forced to single-flight); only the immediately-previous token is tolerated, since grace is measured from each token's own `rotated_at`; and Okta ships the same 30s default, with Auth0 and fosite doing the equivalent. **Look-alike identifiers share a rate-limit bucket** — the second limitation. Worth stating that it does NOT affect the account lockout, which keys on the resolved user id and so meets NIST SP 800-63B §5.2.2's "single account" scoping literally; the decaying throttle sits underneath that clause rather than implementing it. Also documents what 0.5.0 added and the site never described: the RefreshFamilyForked event (and why it's advisory — revoking on a fork means revoking on suspicion), RefreshTokenReused no longer firing for ordinary post-logout retries, per-guard refresh cookies and TTLs under cookie_mode, the 409 on re-enrolling confirmed two-factor, the reserve-before-verify lockout semantics, and the production guard against an array cache store. --- docs/account-lockout.md | 6 ++++++ docs/configuration.md | 13 +++++++++++++ docs/events.md | 21 +++++++++++++++++++++ docs/multiple-guards.md | 15 +++++++++++++++ docs/security.md | 10 +++++++++- docs/tokens-and-rotation.md | 19 +++++++++++++++++-- docs/two-factor-authentication.md | 1 + 7 files changed, 82 insertions(+), 3 deletions(-) diff --git a/docs/account-lockout.md b/docs/account-lockout.md index 4ce551b..812f6b2 100644 --- a/docs/account-lockout.md +++ b/docs/account-lockout.md @@ -87,6 +87,12 @@ Counters are per [guard](/multiple-guards), so an admin guard and a customer gua A locked-out user submitting a **recovery code** is not gated by a `two_factor` lock. A recovery code is ~119 bits of entropy, single-use and salted+hashed, so a consecutive cap protects nothing there — while gating it would strand a user whose second factor an attacker deliberately burned. The recovery code is the way *out* of a lock, so it can't be behind one. +### The attempt is consumed before the password is checked + +The counter is incremented **before** the credential is verified, then compared against the cap. Reading "is it locked?" and counting afterwards is check-then-act: a burst of concurrent requests all pass the check together and all reach the password comparison, so the real number of verifications is `max_attempts` *plus* however many arrived at once. Reserving first makes the count authoritative at the moment it is taken. + +A successful authentication releases the reservation, so the counter still means "consecutive failures" from the outside — a correct password never costs an attempt. + ## What a locked account sees `423 Locked`, not `429`: diff --git a/docs/configuration.md b/docs/configuration.md index e7a5711..8bc59ab 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -95,6 +95,16 @@ Lukk::rateLimitKeyUsing(fn (Request $request) => 'tenant-'.$request->user()?->te The value must be something the caller cannot forge — it also buckets the login limiter, so a spoofable header would let an attacker mint a fresh bucket per request. It is used verbatim as part of a cache key, so namespace anything untrusted. Returning an empty value falls back to the address rather than silently putting every caller in one bucket. +### Fork detection + +```php +'fork_threshold' => 3, // live tokens in one family before RefreshFamilyForked fires +``` + +Env: `LUKK_FORK_THRESHOLD`. Minimum 2. + +The [grace window](/tokens-and-rotation#the-grace-window) mints a sibling for a concurrent refresh, so a family legitimately carries two or three live tokens. Above this, [`RefreshFamilyForked`](/events) fires. Advisory only — see the event for why lukk doesn't act on it automatically. + ### Denylist ```php @@ -106,6 +116,9 @@ The cache store backing the revocation denylist. `null` uses your application's > [!IMPORTANT] > Across **multiple nodes** this must be a **shared, persistent** store (e.g. Redis) — not the `array` driver and not a per-node cache. The same store also backs the TOTP replay cache and the passkey/2FA throttles; if it isn't shared, a revoked token can still be honored on another node and replay protection isn't authoritative. +> [!WARNING] +> lukk **refuses to boot in production** on an `array` or `null` cache store. Token revocation, TOTP replay protection and passkey challenges all live here; an array store is per-process, so a revoked token stays valid on every other worker and the single-use guarantees stop being guarantees — silently. Outside production nothing changes, and the array driver stays the right default for a test suite. + ### Output mode ```php diff --git a/docs/events.md b/docs/events.md index cfdf54a..d99708e 100644 --- a/docs/events.md +++ b/docs/events.md @@ -31,6 +31,27 @@ The event carries two readonly properties, `$familyId` and `$reason`. The `reaso > [!IMPORTANT] > The revoke-then-dispatch happens **after** the rotation transaction commits, so the family revocation and the event stay consistent. See [Tokens & Rotation](/tokens-and-rotation) for the reuse-detection mechanics and the grace window that keeps normal concurrency from tripping a false revoke. +### RefreshFamilyForked + +The [grace window](/tokens-and-rotation#the-grace-window) tolerates a re-consumption by minting a sibling rather than revoking — that's what stops a multi-tab or SSR client logging itself out. The cost is that a thief who replays *inside* the window gets a sibling too, after which both chains rotate independently and never trip reuse detection. + +`Lukk\Events\RefreshFamilyForked` is the signal. It fires when a family carries more live, unrotated tokens than ordinary concurrency explains (`fork_threshold`, default 3 — a browser opening several tabs routinely produces two or three): + +```php +use Illuminate\Support\Facades\Event; +use Lukk\Events\RefreshFamilyForked; + +Event::listen(function (RefreshFamilyForked $event) { + Log::warning('Refresh family fan-out', [ + 'user' => $event->userId, + 'family' => $event->familyId, + 'live' => $event->liveTokens, + ]); +}); +``` + +It is **advisory by design**. Revoking automatically on a fork would mean revoking on suspicion — exactly the false logout the grace window exists to prevent — so lukk reports it and leaves the decision to you. A legitimate client settles at two or three siblings; a forked family keeps growing. + ### PasskeyCloneDetected When [passkeys](/passkeys) are enabled, an assertion whose signature counter *regresses* dispatches `Lukk\Events\PasskeyCloneDetected` — a signal that the authenticator may have been cloned. It's the credential-layer analog of refresh-token family reuse detection; listen to alert and consider disabling the credential: diff --git a/docs/multiple-guards.md b/docs/multiple-guards.md index 6c77117..fdd78c5 100644 --- a/docs/multiple-guards.md +++ b/docs/multiple-guards.md @@ -122,4 +122,19 @@ Each app's BFF seals only its guard's tokens in its own cookie; with subdomains, - **Harden the admin tier further:** mandatory phishing-resistant MFA (passkeys), shorter TTLs, network-gated host, and an immutable audit log of admin actions. - Per-guard email-verification / password-reset / 2FA / passkeys aren't wired to extra guards yet — those features run on the default guard. +## Cookie mode across guards + +In [cookie mode](/transport-modes#direct) each guard gets its **own** refresh cookie: the default guard keeps `__Host-refresh`, and every other guard is suffixed with its name (`__Host-refresh-admin`). Guards may legitimately share a host and differ only by path, and a single cookie name at `Path=/` meant logging into one silently overwrote the other's cookie — each login destroying the other session. + +Per-guard `cookie_mode`, `refresh_ttl` and `cookie.*` overrides are honoured too, so a short-lived admin session can sit alongside a long-lived user one: + +```php +'guards' => [ + 'admin' => [ + 'audience' => ['https://admin.example.com'], + 'refresh_ttl' => 60 * 60 * 8, // 8 hours, vs the default guard's 30 days + ], +], +``` + Next: **[Security](/security)** diff --git a/docs/security.md b/docs/security.md index 99fa522..9bd7dae 100644 --- a/docs/security.md +++ b/docs/security.md @@ -63,7 +63,7 @@ In BFF mode the server can hydrate the authenticated `user` during server render | Access TTL ≤ 15 min | RFC 9700 | | Refresh-token rotation | OAuth 2.1 §6 | | Reuse detection → family revoke | RFC 9700 §4.14 | -| Concurrency without false logout (grace window) | fosite / Okta reuse interval | +| Concurrency without false logout (grace window) — a [deliberate deviation](/tokens-and-rotation#a-deliberate-deviation-from-the-spec) | fosite / Okta reuse interval | | Refresh opaque + `sha256` at rest; never logged | RFC 9700 / OWASP | | Instant revocation (denylist by `fid`/`jti`) | OWASP Session Management | | Login throttled + constant-time (no user enumeration) | OWASP ASVS | @@ -72,6 +72,14 @@ In BFF mode the server can hydrate the authenticated `user` during server render | Token responses non-cacheable (`Cache-Control: no-store, private`) | RFC 6749 §5.1 | | Reuse/family-revoke emits a security event | RFC 9700 §4.14.2 | +## Known limitations + +Two behaviours are accepted trade-offs rather than gaps. Both are bounded, and both are here so you can weigh them yourself rather than discover them. + +**The rotation grace window departs from RFC 9700 §4.14.2.** A refresh token replayed *within* `grace_seconds` of a legitimate refresh yields a sibling instead of a family revoke, so a thief winning that race gets a parallel chain that reuse detection won't catch. The alternative — strict invalidate-on-replay — logs out any client with two tabs open. See [the full reasoning](/tokens-and-rotation#a-deliberate-deviation-from-the-spec); [`RefreshFamilyForked`](/events) makes the fork visible. + +**Look-alike identifiers share a rate-limit bucket.** The decaying login throttle keys on the identifier normalized (trimmed, lowercased, transliterated), which is many-to-one across distinct accounts — `аdmin@example.com` with a Cyrillic а folds onto `admin@example.com`. Two such accounts can therefore throttle each other for `decay_seconds`. This does **not** affect the [account lockout](/account-lockout), which keys on the resolved user id and so satisfies NIST SP 800-63B §5.2.2's "single account" scoping literally. Keying the throttle the same way would put a user lookup in front of every login attempt, including unauthenticated floods — a worse trade than the one it closes. + ## Security checklist - [x] Decode always passes an explicit algorithm; `alg=none` and mismatches rejected. diff --git a/docs/tokens-and-rotation.md b/docs/tokens-and-rotation.md index 9a3303a..467734e 100644 --- a/docs/tokens-and-rotation.md +++ b/docs/tokens-and-rotation.md @@ -82,12 +82,27 @@ The family is revoked **after** the transaction commits, never inside it — rev Rotation alone isn't the point; reuse detection is what makes it worth doing. When a token that has **already been consumed** (or already revoked) is presented after the grace window, that's the signature of a stolen token being replayed — so lukk revokes the **entire family** and denylists it by `fid`, killing every live access token for that session within one `access_ttl`. It also dispatches [`RefreshTokenReused`](/events) so you can alert on it. +That event fires **only** for a genuine replay. Presenting a token that was already revoked — the ordinary case of a client retrying with one it still held across a logout — still force-revokes the family and still returns `401`, but is not reported as reuse: it isn't evidence of theft, and a steady drip of benign events would bury the alarm that is. + ## The grace window The **grace window** (`grace_seconds`, default 30s) is the counterweight that prevents false positives. Legitimate concurrent refreshes — multiple tabs, SSR + hydration — present the same token nearly simultaneously; within the window the older one is served a fresh access token under the same family rather than being treated as theft. -> [!NOTE] -> **Accepted residual.** The grace window is a deliberate trade-off: a token *stolen and replayed within `grace_seconds` of a legitimate refresh* yields a fresh successor on a sibling chain instead of a family revoke — so that race produces a parallel session reuse-detection won't catch until the thief replays a *consumed* token past grace. This is the price of never falsely logging out a direct (non-BFF) client that can't be single-flighted; keep `grace_seconds` as small as your concurrency tolerates. Watch [`RefreshTokenReused`](/events) for the post-grace replays that *are* caught. +### A deliberate deviation from the spec + +Worth stating plainly, because it is the one place lukk knowingly departs from the standards it otherwise follows. + +[RFC 9700 §4.14.2](https://www.rfc-editor.org/rfc/rfc9700) and [OAuth 2.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1) describe rotation identically, and **neither provides for a tolerance window**: + +> The previous refresh token is invalidated but information about the relationship is retained by the authorization server. If a refresh token is compromised and subsequently used by both the attacker and the legitimate client, one of them will present an invalidated refresh token, which will inform the authorization server of the breach. […] it will revoke the active refresh token. + +Within `grace_seconds`, lukk **detects** that replay and deliberately does **not** revoke — it mints a sibling. Three things bound that choice: + +- **It is what the deviation buys.** Strict invalidate-on-replay means any genuinely concurrent refresh — two tabs, an SSR render racing the client — logs the user out. A direct (non-BFF) client can't be forced to single-flight, so this would be a routine false logout, not an edge case. +- **The tolerance is one token deep.** Grace is measured from each token's own `rotated_at`, so only the *immediately previous* token is tolerated; an older one in the chain is already past its window and still trips reuse detection. +- **Every major implementation does the same.** Okta ships a 30-second rotation grace period (configurable 0–60, the same default lukk uses), Auth0 a "rotation overlap period", and fosite a grace period in its refresh grant handler. + +**The residual:** a thief who replays *within* `grace_seconds` of a legitimate refresh gets a sibling, and from then on both chains rotate independently — never colliding, so never tripping reuse detection. Keep `grace_seconds` as small as your concurrency tolerates, watch [`RefreshTokenReused`](/events) for the post-grace replays that *are* caught, and watch [`RefreshFamilyForked`](/events) for the fork itself. ### How the client experiences it diff --git a/docs/two-factor-authentication.md b/docs/two-factor-authentication.md index dd3ff08..6a5b7f0 100644 --- a/docs/two-factor-authentication.md +++ b/docs/two-factor-authentication.md @@ -128,6 +128,7 @@ Recovery codes let a user authenticate if they lose their device. Each code work - The TOTP secret is stored **encrypted** (it must be reversible to verify codes), while recovery codes are **salted and hashed** and shown only once. - A TOTP code cannot be replayed within its 30-second window — accepted codes are cached and rejected on reuse. - The verification window is ±1 step and should not be widened (see [Configuration](/configuration#two-factor)). +- **Re-enrolling over confirmed 2FA is refused** with `409`. Overwriting the secret would null `two_factor_confirmed_at` and silently switch 2FA *off*, so a user who reopened the QR screen and abandoned it would be left unprotected with nothing to notify them. Call `DELETE /auth/two-factor` first — disabling should be deliberate. - Challenge codes are throttled per account. With the opt-in [account lockout](/account-lockout) enabled, a run of failed codes also locks the second factor (`423`) — but a **recovery code is never gated by that lock**, so an attacker can't strand a user by burning their TOTP budget. > [!WARNING]