Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
},
Expand Down
183 changes: 183 additions & 0 deletions docs/account-lockout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
# 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

Three authenticators get their own independent counter, distinguished by a `purpose` column:

| Purpose | Subject | Counted failure |
|---|---|---|
| `login` | `id:<primary key>` when the identifier names an account; `idn:<normalized>` 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` |

`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.

### 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.

### 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`:

```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 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), `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

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` (`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;
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.
Comment thread
stsepelin marked this conversation as resolved.

Next: **[Multiple Guards](/multiple-guards)**
2 changes: 2 additions & 0 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
29 changes: 27 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,19 +64,23 @@ 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],
'confirm' => ['max_attempts' => 5, 'decay_seconds' => 60],
],
```

| 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. |
| `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).

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.

Expand All @@ -91,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
Expand All @@ -102,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
Expand Down Expand Up @@ -155,7 +172,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,
],
```

Expand All @@ -166,7 +187,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.
Expand Down
6 changes: 6 additions & 0 deletions docs/confirmation.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ Authorization: Bearer <access token>

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:
Expand Down
Loading