Skip to content

Commit 7a24255

Browse files
feat(security): opt-in per-account login lockout
Add AccountLockoutConfig (disabled by default) that locks an account after N failed password logins for a TTL window. Pluggable AccountLockoutStore with in-memory and Redis (atomic Lua) backends; account keys are non-reversible keyed digests. Locked accounts return the generic bad-credentials response within the existing response-time padding (non-enumerating, no timing oracle). Complements, not replaces, IP/identifier rate limiting. Existing apps unaffected unless enabled.
1 parent b358dc5 commit 7a24255

30 files changed

Lines changed: 1439 additions & 40 deletions

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,26 @@
1+
## Unreleased
2+
3+
### Added
4+
5+
- **Opt-in per-account login lockout.** New `AccountLockoutConfig` on `LitestarAuthConfig`
6+
(`account_lockout_config`, disabled by default) locks an account key after `failure_threshold`
7+
failed password logins (default **5**) for a `window_seconds` TTL (default **900s**). Lockout state lives behind the new
8+
`AccountLockoutStore` protocol with bundled `InMemoryAccountLockoutStore` and `RedisAccountLockoutStore`
9+
backends (or a custom `store_factory`), all exported from `litestar_auth.ratelimit`. Account keys are
10+
non-reversible keyed digests of the login identifier (`account_lockout_key`), so the store never holds a
11+
plaintext email/username; enabling lockout requires an `account_lockout_key_secret` (startup fails closed
12+
with `ConfigurationError` otherwise). This complements — it does not replace — the existing IP/identifier
13+
rate limiting, covering distributed brute force against a single account that per-IP limits miss.
14+
15+
### Security
16+
17+
- **Account lockout is non-enumerating and timing-safe.** A locked account, a wrong password, and an
18+
unknown identifier all return the same generic bad-credentials response, and the lockout check runs inside
19+
the existing minimum-response-time padding so the short-circuited locked path is not a timing oracle. The
20+
failure counter resets on successful authentication; the Redis backend increments and checks atomically via
21+
Lua so multi-worker deployments share one consistent count (the in-memory backend is process-local and
22+
warns when used across workers).
23+
124
## 5.0.3 (2026-06-13)
225

326
### Added

docs/configuration/security.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Use this page for CSRF settings, token downgrade policy, schemas, dependency key
1414
| `verify_minimum_response_seconds` | `0.4` | Minimum wall-clock duration for plugin-owned `POST /auth/verify` success and invalid-token responses. |
1515
| `request_verify_minimum_response_seconds` | `0.4` | Minimum wall-clock duration for plugin-owned `POST /auth/request-verify-token` responses, including manager failures after rate-limit accounting runs. |
1616
| `id_parser` | `None` | Parse path/query user ids (e.g. `UUID`). Effective parser: `user_manager_security.id_parser` when set; otherwise `LitestarAuthConfig.id_parser` is applied (including the no-`user_manager_security` default builder path). |
17+
| `account_lockout_config` | `AccountLockoutConfig()` | Opt-in per-account password-login lockout. Disabled by default; when enabled it uses `failure_threshold=5`, `window_seconds=900.0`, and an in-memory store unless `store_factory` supplies a shared store. |
1718

1819
### CSRF cookie name and login telemetry
1920

@@ -28,6 +29,99 @@ Set optional `UserManagerSecurity.login_identifier_telemetry_secret` so structur
2829
an HMAC digest of the identifier on failures (no raw identifier in logs). Details:
2930
[Manager customization — Login failure telemetry secret](manager.md#login-failure-telemetry-secret).
3031

32+
### Per-account password-login lockout
33+
34+
`LitestarAuthConfig.account_lockout_config` enables a per-account counter for plugin-owned password
35+
login failures. It is separate from IP/request rate limiting: account lockout tracks the normalized
36+
login identifier behind a keyed digest, while `rate_limit_config.login` still throttles by its
37+
configured request identity.
38+
39+
The shipped fields are:
40+
41+
| `AccountLockoutConfig` field | Default | Meaning |
42+
| ---------------------------- | ------- | ------- |
43+
| `enabled` | `False` | Leaves account lockout inert unless explicitly enabled. |
44+
| `failure_threshold` | `5` | Number of failed password checks for the same account key before the key is locked. |
45+
| `window_seconds` | `900.0` | Counter TTL and lockout window in seconds. A successful password login before the threshold resets the counter. |
46+
| `store_factory` | `None` | Optional zero-argument factory returning an `AccountLockoutStore`. When omitted, the config memoizes an `InMemoryAccountLockoutStore`. |
47+
48+
Plugin-managed lockout key derivation uses
49+
`UserManagerSecurity.login_identifier_telemetry_secret`. Set a high-entropy, role-separated value
50+
before enabling lockout:
51+
52+
```python
53+
from litestar_auth import LitestarAuthConfig, UserManagerSecurity
54+
from litestar_auth.ratelimit import AccountLockoutConfig
55+
56+
config = LitestarAuthConfig(
57+
user_model=User,
58+
user_manager_class=UserManager,
59+
session_maker=session_maker,
60+
account_lockout_config=AccountLockoutConfig(enabled=True),
61+
user_manager_security=UserManagerSecurity(
62+
login_identifier_telemetry_secret=login_identifier_secret,
63+
reset_password_token_secret=reset_password_secret,
64+
verification_token_secret=verification_token_secret,
65+
),
66+
)
67+
```
68+
69+
The default `InMemoryAccountLockoutStore` is process-local. It is appropriate for single-process
70+
development, tests, and deployments that explicitly accept single-worker state. Production
71+
multi-worker deployments need a shared store such as `RedisAccountLockoutStore`; when
72+
`deployment_worker_count > 1`, plugin startup fails closed if enabled account lockout resolves to a
73+
process-local store.
74+
75+
The in-memory store also has a bounded key capacity. If it reaches `max_keys` after expired counters
76+
are pruned, unknown account keys are treated as locked and the login path fails closed. That protects
77+
existing lockout counters from being dropped under capacity pressure, but a flood of distinct
78+
identifiers can deny new logins until counters expire. For publicly exposed, high-volume, or
79+
multi-tenant deployments, prefer `RedisAccountLockoutStore`; if you intentionally keep the in-memory
80+
store, supply it through `store_factory` with a `max_keys` sized for the expected identifier
81+
cardinality and memory budget:
82+
83+
```python
84+
from litestar_auth.ratelimit import AccountLockoutConfig, InMemoryAccountLockoutStore
85+
86+
account_lockout_config = AccountLockoutConfig(
87+
enabled=True,
88+
failure_threshold=5,
89+
window_seconds=900.0,
90+
store_factory=lambda: InMemoryAccountLockoutStore(
91+
failure_threshold=5,
92+
window_seconds=900.0,
93+
max_keys=500_000,
94+
),
95+
)
96+
```
97+
98+
```python
99+
from litestar_auth.ratelimit import AccountLockoutConfig, RedisAccountLockoutStore
100+
101+
account_lockout_config = AccountLockoutConfig(
102+
enabled=True,
103+
failure_threshold=5,
104+
window_seconds=900.0,
105+
store_factory=lambda: RedisAccountLockoutStore(
106+
redis=redis_client,
107+
failure_threshold=5,
108+
window_seconds=900.0,
109+
),
110+
)
111+
```
112+
113+
Locked accounts intentionally receive the same `LOGIN_BAD_CREDENTIALS` response as a wrong password
114+
or missing user. The response has no lockout-specific status, error code, or `Retry-After` header, so
115+
attackers cannot distinguish account existence or lock state through the login endpoint.
116+
117+
This non-enumerating property is *timing*-safe only while `login_minimum_response_seconds` dominates
118+
the Argon2 verification cost. A locked account short-circuits before password hashing, whereas wrong-password
119+
and unknown-account failures both pay the Argon2 verification; the shared response-time floor pads all three
120+
paths (including the locked short-circuit) to the same duration, masking the difference. The default `0.4s`
121+
floor comfortably exceeds typical Argon2 cost. If you lower it below your Argon2 verification time the locked
122+
path becomes timing-distinguishable, reopening account enumeration — plugin startup emits a `SecurityWarning`
123+
when account lockout is enabled and `login_minimum_response_seconds` is below `0.2s` for this reason.
124+
31125
The plugin-managed JWT/TOTP security surfaces use the same shared posture wording as runtime startup and validation:
32126

33127
--8<-- "docs/snippets/plugin_security_tradeoffs.md"

docs/roadmap.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ What this library aims to cover today, what it deliberately does not include, an
44

55
## In scope today
66

7-
Registration, login/logout, email verification and password reset, Bearer / cookie / API-key transports, JWT / database / Redis / API-key strategies, guards, role-derived permission authorization, optional user CRUD, TOTP, OAuth login and account linking, rate limiting, hooks, configurable login identifier (`email` or `username`). See [Features on the home page](index.md).
7+
Registration, login/logout, email verification and password reset, Bearer / cookie / API-key transports, JWT / database / Redis / API-key strategies, guards, role-derived permission authorization, optional user CRUD, TOTP, OAuth login and account linking, rate limiting, per-account password-login lockout, hooks, configurable login identifier (`email` or `username`). See [Features on the home page](index.md).
88

99
Opt-in **multi-tenant organizations** (roadmap #10) are complete for the library's intended scope:
1010
global `User` plus `OrganizationMembership`, tenant resolution, verified current-organization
@@ -16,6 +16,12 @@ applications deliver the raw token from `on_after_organization_invitation`. Appl
1616
isolation remains the application's responsibility: the library does not add tenant foreign keys,
1717
mutate application queries, or filter application-owned tables.
1818

19+
Opt-in **per-account brute-force protection** is delivered for plugin-owned password login through
20+
`AccountLockoutConfig`. The library ships digest-only account keys, process-local in-memory storage
21+
for single-worker deployments, Redis-backed shared storage for multi-worker deployments, generic
22+
non-enumerating login failures for locked accounts, and independent composition with auth endpoint
23+
rate limiting.
24+
1925
## Explicitly out of scope (library core)
2026

2127
- Built-in **email transport** (use hooks).

docs/security.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ Before production rollout, verify the deployment-owned security contract in
3737
- **Failed-login telemetry** — failed-login logs never include the submitted email/username. Configure
3838
`UserManagerSecurity.login_identifier_telemetry_secret` when you want a stable, non-reversible
3939
`identifier_digest`; the digest is omitted when that dedicated secret is unset.
40+
- **Per-account lockout** — optional password-login brute-force protection through
41+
`AccountLockoutConfig`. It is disabled by default; when enabled, login identifiers are normalized
42+
and stored only as keyed digests, repeated failed password checks lock that account key for the
43+
configured window, and a successful password login before lockout resets the counter. Locked
44+
accounts deliberately return the same `LOGIN_BAD_CREDENTIALS` response as wrong credentials or a
45+
missing user, without a distinct lockout code, status, or `Retry-After` header.
4046
- **Rate limiting** — optional per-endpoint limits; in-memory backend is single-process only and fails closed for new keys when its capacity cap is reached.
4147
- **Route-level role checks**`is_superuser`, `has_any_role(...)`, and `has_all_roles(...)` reuse the same normalized flat-role semantics as persistence and manager writes, and they fail closed if the authenticated user does not expose the documented role-capable contract. Role guard matching uses fixed-work internal comparisons over normalized role strings to avoid role-membership short-circuit predicates; this is a defense-in-depth posture, not a claim of cryptographic constant-time behavior across Python or the network.
4248

@@ -64,8 +70,9 @@ When you assemble `JWTStrategy` or `BaseUserManager` yourself, inspect the runti
6470
`TOTP_STEPUP_REQUIRED` 403 contract, default endpoint policies, recovery-code behavior, and the
6571
API-key transport rationale.
6672
- `BaseUserManager` uses `UserManagerSecurity.login_identifier_telemetry_secret` only for
67-
failed-login identifier digests. It is optional; omitting it keeps logs correlation-safe by
68-
leaving `identifier_digest` out rather than reusing another auth secret.
73+
failed-login identifier digests and plugin-managed account-lockout key derivation. It is optional
74+
while lockout is disabled; enabling `AccountLockoutConfig` without this secret is rejected. Omit it
75+
only when you do not need correlated failed-login telemetry and do not enable account lockout.
6976

7077
## OAuth redirect-host DNS validation
7178

@@ -124,6 +131,7 @@ Additional opt-ins to weaker behavior:
124131
| `CookieTransport(allow_insecure_cookie_auth=True)` | Allows cookie auth without CSRF for controlled non-browser scenarios only. |
125132
| `ApiKeyConfig(signing_enabled=True, secret_encryption_keyring=...)` | Enables request signing, but stores an encrypted copy of signing-required key secrets so signatures can be verified. |
126133
| `InMemoryApiKeyNonceStore` | Process-local API-key signing replay cache; use `RedisApiKeyNonceStore` for multi-worker deployments. |
134+
| `AccountLockoutConfig(enabled=True)` with the default in-memory store | Process-local per-account lockout counters; use `RedisAccountLockoutStore` for multi-worker deployments. |
127135

128136
## Bearer failure-code taxonomy
129137

@@ -157,6 +165,10 @@ expired, timestamp-skewed, or nonce-replayed keys, still consume the limiter bef
157165
- No built-in **email** sending — you must implement hooks.
158166
- No **RBAC** or **WebAuthn** in core — the built-in role guards are flat membership checks only; extend in your application for permission matrices or object-level policy.
159167
- **Durable JWT revocation** requires a shared store — `JWTStrategy(secret=...)` without `denylist_store` or `allow_inmemory_denylist=True` fails closed at construction time. Use Redis (or equivalent) denylist for multi-worker production if you rely on revoke; reserve `allow_inmemory_denylist=True` for single-process development or tests.
168+
- **Per-account lockout** requires a shared store in multi-worker deployments. The default
169+
`InMemoryAccountLockoutStore` is single-process only and is a development/single-worker tool; use
170+
`RedisAccountLockoutStore` or a custom shared `AccountLockoutStore` when multiple workers can
171+
receive login attempts for the same account.
160172
- **API keys** are user-owned delegated credentials only. Service-account-only keys, HKDF child keys,
161173
IP allowlists, per-key audit tables, and mTLS binding are outside this release.
162174
- API-key signing-secret rotation is operator-owned row processing. The library does not provide

litestar_auth/_keyed_digest.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@
44

55
import hashlib
66
import hmac
7+
import unicodedata
8+
from typing import TYPE_CHECKING
9+
10+
from litestar_auth._email import normalize_email
11+
12+
if TYPE_CHECKING:
13+
from litestar_auth.types import LoginIdentifier
714

815

916
def keyed_bytes(key: bytes, payload: bytes) -> bytes:
@@ -19,6 +26,57 @@ def keyed_hex(key: bytes, *parts: bytes) -> str:
1926
return hmac_digest.hexdigest()
2027

2128

29+
def normalize_login_identifier(identifier: str) -> str:
30+
"""Return the canonical login identifier (stripped, casefolded) for keyed digests."""
31+
return identifier.strip().casefold()
32+
33+
34+
def normalize_account_identifier(identifier: str, *, login_identifier: LoginIdentifier) -> str:
35+
"""Return the identifier normalized to mirror user-store identity resolution.
36+
37+
Account-keyed digests (e.g. lockout counters) must map 1:1 to the account the
38+
store would resolve. Email mode applies the account email policy (NFKC +
39+
lowercase); username mode applies the store's stripped + lowercase rule.
40+
Mirroring the store -- rather than an independent ``casefold()`` -- closes two
41+
normalization-drift gaps: an attacker can no longer mint multiple keys for one
42+
account via NFKC/compatibility variants (lockout evasion), nor collide two
43+
distinct accounts onto one key via casefold-vs-lower folding (e.g. German
44+
sharp-s ``ß`` -> ``ss``).
45+
46+
Returns:
47+
The store-equivalent normalized identifier.
48+
"""
49+
if login_identifier == "email":
50+
try:
51+
return normalize_email(identifier)
52+
except ValueError:
53+
# Failed the stricter email policy, so the store lookup will also miss;
54+
# fall back to the store-equivalent form (NFKC + lowercase) for a stable key.
55+
return unicodedata.normalize("NFKC", identifier.strip()).lower()
56+
# Username lookups intentionally skip NFKC to match UserPolicy.normalize_username_lookup.
57+
return identifier.strip().lower()
58+
59+
60+
def keyed_account_identifier_hex(
61+
key: str | bytes,
62+
identifier: str,
63+
*,
64+
login_identifier: LoginIdentifier,
65+
context: bytes,
66+
) -> str:
67+
"""Return a keyed digest for a login identifier under a domain-separation context.
68+
69+
The identifier is normalized to mirror user-store identity resolution (see
70+
:func:`normalize_account_identifier`) so the digest is a faithful 1:1 account key.
71+
72+
Returns:
73+
HMAC-SHA-256 hex digest of the store-normalized identifier under ``context``.
74+
"""
75+
digest_key = key.encode("utf-8") if isinstance(key, str) else key
76+
normalized_identifier = normalize_account_identifier(identifier, login_identifier=login_identifier).encode("utf-8")
77+
return keyed_hex(digest_key, context, b"\x00", normalized_identifier)
78+
79+
2280
def hkdf_sha256_32(key_material: bytes, *, salt: bytes, info: bytes) -> bytes:
2381
"""Derive a 32-byte HKDF-SHA256 output with explicit domain separation.
2482

litestar_auth/_manager/construction.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from datetime import timedelta
99
from typing import TYPE_CHECKING, Any, Protocol, TypedDict
1010

11+
from litestar_auth._keyed_digest import normalize_login_identifier
1112
from litestar_auth._manager.security import validate_user_manager_security_secret_roles_are_distinct
1213
from litestar_auth._manager.user_policy import DEFAULT_CREATABLE_FIELDS, DEFAULT_UPDATABLE_FIELDS, UserFieldPolicy
1314
from litestar_auth._superuser_role import DEFAULT_SUPERUSER_ROLE_NAME
@@ -146,7 +147,7 @@ def resolve_oauth_account_store[UP: UserProtocol[Any], ID](
146147

147148
def login_identifier_digest(identifier: str, *, key: str) -> str:
148149
"""Return a keyed, non-reversible digest for login-failure correlation."""
149-
normalized_identifier = identifier.strip().casefold()
150+
normalized_identifier = normalize_login_identifier(identifier)
150151
digest_key = hashlib.sha256(key.encode()).digest()
151152
return hashlib.blake2b(
152153
normalized_identifier.encode(),

litestar_auth/_plugin/_hooks.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ class FeatureWiring:
9999
feature="core",
100100
before_startup=(
101101
"require_shared_rate_limit_backends_for_multiworker",
102+
"require_shared_account_lockout_store_for_multiworker",
102103
"require_refreshable_strategy_when_enable_refresh",
103104
"warn_insecure_plugin_startup_defaults",
104105
),

litestar_auth/_plugin/auth_controller.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
from collections.abc import Sequence
6-
from dataclasses import dataclass
6+
from dataclasses import dataclass, field
77
from datetime import timedelta
88
from typing import TYPE_CHECKING, Any
99

@@ -41,7 +41,7 @@
4141
from litestar.types import ExceptionHandlersMap
4242

4343
from litestar_auth._plugin.config import StartupBackendInventory, StartupBackendTemplate
44-
from litestar_auth.ratelimit import AuthRateLimitConfig
44+
from litestar_auth.ratelimit import AccountLockoutConfig, AuthRateLimitConfig
4545
from litestar_auth.types import LoginIdentifier
4646

4747

@@ -57,6 +57,8 @@ class PluginAuthControllerSettings[UP: UserProtocol[Any], ID]:
5757
backend_inventory: StartupBackendInventory[UP, ID]
5858
backend_index: int
5959
rate_limit_config: AuthRateLimitConfig | None = None
60+
account_lockout_config: AccountLockoutConfig | None = None
61+
account_lockout_key_secret: str | None = field(default=None, repr=False)
6062
enable_refresh: bool = False
6163
requires_verification: bool = True
6264
login_identifier: LoginIdentifier = "email"
@@ -124,6 +126,8 @@ def _build_runtime_context(request_backend: AuthenticationBackend[UP, ID]) -> ob
124126
_AuthControllerSettings(
125127
backend=request_backend,
126128
rate_limit_config=settings.rate_limit_config,
129+
account_lockout_config=settings.account_lockout_config,
130+
account_lockout_key_secret=settings.account_lockout_key_secret,
127131
enable_refresh=settings.enable_refresh,
128132
requires_verification=settings.requires_verification,
129133
login_identifier=settings.login_identifier,

litestar_auth/_plugin/config/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@
8282
from litestar_auth._plugin.config._core import (
8383
OAUTH_ASSOCIATE_USER_MANAGER_DEPENDENCY_KEY as OAUTH_ASSOCIATE_USER_MANAGER_DEPENDENCY_KEY,
8484
)
85+
from litestar_auth._plugin.config._core import (
86+
AccountLockoutConfig as AccountLockoutConfig,
87+
)
8588
from litestar_auth._plugin.config._core import (
8689
ApiKeyConfig as ApiKeyConfig,
8790
)

0 commit comments

Comments
 (0)