Coverage for src/local_deep_research/security/account_lockout.py: 99%
72 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""Per-user account lockout after repeated failed login attempts.
3Complements the per-IP rate limiting in ``security/rate_limiter.py`` by
4tracking failures at the *username* level. A user who exceeds the
5configured threshold is temporarily locked out regardless of the source IP.
7Note: lockout state is in-memory and per-process. In multi-worker
8deployments (e.g. gunicorn), each worker maintains separate state.
9"""
11import threading
12from collections import OrderedDict
13from datetime import datetime, timedelta, timezone
15from loguru import logger
17from .security_settings import get_security_default
20class AccountLockoutManager:
21 """Track failed login attempts and lock accounts after a threshold.
23 A failure counter lasts one lockout window from its last failure, the
24 same as a lockout. Within its window an entry is kept, with one
25 exception that bounds memory: counters holding a single failure, the
26 only kind a spray of throwaway usernames creates for one request each,
27 are capped at ``_MAX_SINGLE_FAILURES``, and a new one past that evicts
28 the oldest. A counter that has reached two failures, and an active
29 lockout, stay until their window ends or the user logs in, so flooding
30 the table cannot reset them.
31 """
33 _MAX_SINGLE_FAILURES = 10_000
35 def __init__(
36 self,
37 threshold: int | None = None,
38 lockout_minutes: int | None = None,
39 ) -> None:
40 if threshold is None:
41 threshold = get_security_default(
42 "security.account_lockout_threshold", 10
43 )
44 if lockout_minutes is None:
45 lockout_minutes = get_security_default(
46 "security.account_lockout_duration_minutes", 15
47 )
49 self.threshold: int = threshold
50 self.lockout_minutes: int = lockout_minutes
52 # {username: {"count": int, "locked_until": datetime | None,
53 # "last_failure": datetime}}, ordered by last failure
54 self._state: OrderedDict[str, dict] = OrderedDict()
55 # The usernames in _state whose counter holds a single failure and
56 # no lockout, in the order of that failure.
57 self._singles: OrderedDict[str, None] = OrderedDict()
58 self._lock = threading.Lock()
60 # ------------------------------------------------------------------
61 # Public API
62 # ------------------------------------------------------------------
64 def is_locked(self, username: str) -> bool:
65 """Return ``True`` if *username* is currently locked out."""
66 with self._lock:
67 entry = self._state.get(username)
68 if entry is None:
69 return False
70 locked_until = entry.get("locked_until")
71 if locked_until is None:
72 return False
73 if datetime.now(timezone.utc) >= locked_until:
74 # Lockout expired — reset automatically
75 self._state.pop(username, None)
76 logger.info("Account lockout expired")
77 return False
78 return True
80 def _drop_expired(self, now: datetime) -> None:
81 """Drop the entries whose last failure is a full window old.
83 The table is ordered by last failure, so they are all at its front.
84 A lockout ends one window after the failure that set it, so this
85 covers expired lockouts as well as counters whose window has passed.
87 Must be called while ``self._lock`` is held.
88 """
89 cutoff = now - timedelta(minutes=self.lockout_minutes)
90 while self._state:
91 username, entry = next(iter(self._state.items()))
92 if entry["last_failure"] > cutoff:
93 break
94 del self._state[username]
95 self._singles.pop(username, None)
97 def record_failure(self, username: str) -> None:
98 """Record a failed login attempt for *username*."""
99 with self._lock:
100 # Read under the lock, so the table's order by last failure is
101 # the order in which failures take the lock — assuming the
102 # wall clock doesn't step backwards (#6943). A backward step
103 # of d (NTP correction, manual clock change) can leave an older
104 # stamp after a newer one. _drop_expired stops at the first
105 # unexpired entry, so an expired counter behind it can linger
106 # up to d past its window; repeated steps add up, and nothing
107 # caps the linger at the lockout window. While it lingers, its
108 # count carries into that user's next failure. The direction
109 # is conservative: a backward step drops nothing early, and
110 # is_locked compares locked_until with the clock directly, so
111 # no lockout ends early (one set before the step lasts up to d
112 # longer). If that ever needs hardening, clamp stamps
113 # (max(latest_seen, now)) or order on a monotonic clock instead.
114 now = datetime.now(timezone.utc)
115 self._drop_expired(now)
116 entry = self._state.get(username)
117 if entry is None:
118 entry = {"count": 0, "locked_until": None}
119 self._state[username] = entry
120 else:
121 self._state.move_to_end(username)
122 entry["last_failure"] = now
123 entry["count"] += 1
124 if entry["count"] >= self.threshold:
125 entry["locked_until"] = now + timedelta(
126 minutes=self.lockout_minutes
127 )
128 logger.warning(
129 "Account locked after {} failed attempts",
130 self.threshold,
131 )
133 if entry["count"] == 1 and entry["locked_until"] is None:
134 self._singles[username] = None
135 if len(self._singles) > self._MAX_SINGLE_FAILURES:
136 oldest, _ = self._singles.popitem(last=False)
137 del self._state[oldest]
138 else:
139 self._singles.pop(username, None)
141 def record_success(self, username: str) -> None:
142 """Clear the failure counter for *username* after a successful login."""
143 with self._lock:
144 removed = self._state.pop(username, None)
145 self._singles.pop(username, None)
146 if removed is not None:
147 logger.info("Account lockout cleared after successful login")
150# ------------------------------------------------------------------
151# Module-level singleton
152# ------------------------------------------------------------------
154_manager: AccountLockoutManager | None = None
155_singleton_lock = threading.Lock()
158def get_account_lockout_manager() -> AccountLockoutManager:
159 """Return the module-level singleton ``AccountLockoutManager``."""
160 global _manager
161 if _manager is None:
162 with _singleton_lock:
163 if _manager is None: 163 ↛ 165line 163 didn't jump to line 165
164 _manager = AccountLockoutManager()
165 return _manager