295 lines
12 KiB
Python
295 lines
12 KiB
Python
"""Durable throttling state for operator sign-in attempts."""
|
|
|
|
import hashlib
|
|
import ipaddress
|
|
import math
|
|
import sqlite3
|
|
from pathlib import Path
|
|
from typing import Callable
|
|
|
|
|
|
class LoginAttemptStoreError(RuntimeError):
|
|
"""Raised when sign-in throttling state cannot be accessed safely."""
|
|
|
|
|
|
def client_source(peer_host: str, forwarded_for: str, trusted_proxy_cidrs: str) -> str:
|
|
"""Resolve a canonical client IP without trusting arbitrary forwarding headers."""
|
|
try:
|
|
peer = ipaddress.ip_address(peer_host)
|
|
trusted = [
|
|
ipaddress.ip_network(value.strip())
|
|
for value in trusted_proxy_cidrs.split(",")
|
|
if value.strip()
|
|
]
|
|
except ValueError:
|
|
return peer_host
|
|
if not any(peer in network for network in trusted):
|
|
return str(peer)
|
|
try:
|
|
forwarded = [
|
|
ipaddress.ip_address(value.strip())
|
|
for value in forwarded_for.split(",")
|
|
if value.strip()
|
|
]
|
|
except ValueError:
|
|
return str(peer)
|
|
for address in reversed(forwarded):
|
|
if not any(address in network for network in trusted):
|
|
return str(address)
|
|
return str(forwarded[0]) if forwarded else str(peer)
|
|
|
|
|
|
class LoginAttemptStore:
|
|
def __init__(
|
|
self,
|
|
path: str | Path,
|
|
*,
|
|
clock: Callable[[], float],
|
|
max_failures: int,
|
|
window_seconds: int,
|
|
max_entries: int = 10_000,
|
|
lock_timeout_seconds: float = 0.1,
|
|
alert_bucket_seconds: int = 60 * 60,
|
|
alert_retention_seconds: int = 30 * 24 * 60 * 60,
|
|
max_alert_buckets: int = 720,
|
|
) -> None:
|
|
self.path = Path(path)
|
|
self.clock = clock
|
|
self.max_failures = max(1, max_failures)
|
|
self.window_seconds = max(1, window_seconds)
|
|
self.max_entries = max(1, max_entries)
|
|
self.lock_timeout_seconds = lock_timeout_seconds
|
|
self.alert_bucket_seconds = max(1, alert_bucket_seconds)
|
|
self.alert_retention_seconds = max(1, alert_retention_seconds)
|
|
self.max_alert_buckets = max(1, max_alert_buckets)
|
|
|
|
@staticmethod
|
|
def _digest(source: str) -> str:
|
|
return hashlib.sha256(source.encode()).hexdigest()
|
|
|
|
def _connect(self) -> sqlite3.Connection:
|
|
try:
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
connection = sqlite3.connect(self.path, timeout=self.lock_timeout_seconds)
|
|
connection.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS login_attempts (
|
|
source_hash TEXT PRIMARY KEY,
|
|
failures INTEGER NOT NULL,
|
|
window_started_at REAL NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
connection.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS login_admissions (
|
|
bucket TEXT NOT NULL,
|
|
source_hash TEXT NOT NULL,
|
|
attempts INTEGER NOT NULL,
|
|
window_started_at REAL NOT NULL,
|
|
PRIMARY KEY (bucket, source_hash)
|
|
)
|
|
"""
|
|
)
|
|
connection.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS login_alerts (
|
|
method TEXT NOT NULL,
|
|
bucket_started_at INTEGER NOT NULL,
|
|
failed_count INTEGER NOT NULL DEFAULT 0,
|
|
blocked_count INTEGER NOT NULL DEFAULT 0,
|
|
first_at INTEGER NOT NULL,
|
|
last_at INTEGER NOT NULL,
|
|
PRIMARY KEY (method, bucket_started_at)
|
|
)
|
|
"""
|
|
)
|
|
return connection
|
|
except (OSError, sqlite3.Error) as exc:
|
|
raise LoginAttemptStoreError(
|
|
"Sign-in throttling is temporarily unavailable"
|
|
) from exc
|
|
|
|
def retry_after(self, source: str) -> int:
|
|
now = self.clock()
|
|
try:
|
|
with self._connect() as connection:
|
|
row = connection.execute(
|
|
"SELECT failures, window_started_at FROM login_attempts WHERE source_hash = ?",
|
|
(self._digest(source),),
|
|
).fetchone()
|
|
except (OSError, sqlite3.Error) as exc:
|
|
raise LoginAttemptStoreError(
|
|
"Sign-in throttling is temporarily unavailable"
|
|
) from exc
|
|
if row is None or row[0] < self.max_failures:
|
|
return 0
|
|
remaining = row[1] + self.window_seconds - now
|
|
return max(0, math.ceil(remaining))
|
|
|
|
def admit(self, bucket: str, source: str, *, limit: int) -> int:
|
|
"""Consume one fixed-window admission or return seconds until retry."""
|
|
now = self.clock()
|
|
source_hash = self._digest(source)
|
|
limit = max(1, limit)
|
|
try:
|
|
with self._connect() as connection:
|
|
connection.execute("BEGIN IMMEDIATE")
|
|
connection.execute(
|
|
"DELETE FROM login_admissions WHERE window_started_at + ? <= ?",
|
|
(self.window_seconds, now),
|
|
)
|
|
row = connection.execute(
|
|
"SELECT attempts, window_started_at FROM login_admissions "
|
|
"WHERE bucket = ? AND source_hash = ?",
|
|
(bucket, source_hash),
|
|
).fetchone()
|
|
if row is not None and row[0] >= limit:
|
|
return max(1, math.ceil(row[1] + self.window_seconds - now))
|
|
if row is None:
|
|
connection.execute(
|
|
"INSERT INTO login_admissions VALUES (?, ?, 1, ?)",
|
|
(bucket, source_hash, now),
|
|
)
|
|
else:
|
|
connection.execute(
|
|
"UPDATE login_admissions SET attempts = attempts + 1 "
|
|
"WHERE bucket = ? AND source_hash = ?",
|
|
(bucket, source_hash),
|
|
)
|
|
connection.execute(
|
|
"""
|
|
DELETE FROM login_admissions
|
|
WHERE (bucket, source_hash) NOT IN (
|
|
SELECT bucket, source_hash FROM login_admissions
|
|
ORDER BY window_started_at DESC, rowid DESC LIMIT ?
|
|
)
|
|
""",
|
|
(self.max_entries,),
|
|
)
|
|
return 0
|
|
except (OSError, sqlite3.Error) as exc:
|
|
raise LoginAttemptStoreError(
|
|
"Sign-in throttling is temporarily unavailable"
|
|
) from exc
|
|
|
|
def _record_alert(
|
|
self, connection: sqlite3.Connection, method: str, *, blocked: bool, now: float
|
|
) -> None:
|
|
safe_method = method if method in {"token", "passkey"} else "unknown"
|
|
occurred_at = int(now)
|
|
bucket = occurred_at - (occurred_at % self.alert_bucket_seconds)
|
|
failed = 0 if blocked else 1
|
|
blocked_count = 1 if blocked else 0
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO login_alerts(
|
|
method, bucket_started_at, failed_count, blocked_count, first_at, last_at
|
|
) VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(method, bucket_started_at) DO UPDATE SET
|
|
failed_count = failed_count + excluded.failed_count,
|
|
blocked_count = blocked_count + excluded.blocked_count,
|
|
first_at = MIN(first_at, excluded.first_at),
|
|
last_at = MAX(last_at, excluded.last_at)
|
|
""",
|
|
(safe_method, bucket, failed, blocked_count, occurred_at, occurred_at),
|
|
)
|
|
connection.execute(
|
|
"DELETE FROM login_alerts WHERE (method, bucket_started_at) NOT IN "
|
|
"(SELECT method, bucket_started_at FROM login_alerts "
|
|
"ORDER BY bucket_started_at DESC LIMIT ?)",
|
|
(self.max_alert_buckets,),
|
|
)
|
|
|
|
def record_failure(self, source: str, *, method: str = "token") -> None:
|
|
now = self.clock()
|
|
source_hash = self._digest(source)
|
|
try:
|
|
with self._connect() as connection:
|
|
connection.execute("BEGIN IMMEDIATE")
|
|
connection.execute(
|
|
"DELETE FROM login_attempts WHERE window_started_at + ? <= ?",
|
|
(self.window_seconds, now),
|
|
)
|
|
row = connection.execute(
|
|
"SELECT failures, window_started_at FROM login_attempts WHERE source_hash = ?",
|
|
(source_hash,),
|
|
).fetchone()
|
|
if row is None or row[1] + self.window_seconds <= now:
|
|
connection.execute(
|
|
"INSERT OR REPLACE INTO login_attempts VALUES (?, 1, ?)",
|
|
(source_hash, now),
|
|
)
|
|
else:
|
|
connection.execute(
|
|
"UPDATE login_attempts SET failures = failures + 1 WHERE source_hash = ?",
|
|
(source_hash,),
|
|
)
|
|
connection.execute(
|
|
"""
|
|
DELETE FROM login_attempts
|
|
WHERE source_hash NOT IN (
|
|
SELECT source_hash FROM login_attempts
|
|
ORDER BY window_started_at DESC, rowid DESC LIMIT ?
|
|
)
|
|
""",
|
|
(self.max_entries,),
|
|
)
|
|
self._record_alert(connection, method, blocked=False, now=now)
|
|
except (OSError, sqlite3.Error) as exc:
|
|
raise LoginAttemptStoreError(
|
|
"Sign-in throttling is temporarily unavailable"
|
|
) from exc
|
|
|
|
def record_blocked(self, *, method: str = "token") -> None:
|
|
now = self.clock()
|
|
try:
|
|
with self._connect() as connection:
|
|
connection.execute("BEGIN IMMEDIATE")
|
|
self._record_alert(connection, method, blocked=True, now=now)
|
|
except (OSError, sqlite3.Error) as exc:
|
|
raise LoginAttemptStoreError(
|
|
"Sign-in throttling is temporarily unavailable"
|
|
) from exc
|
|
|
|
def list_alerts(self, *, limit: int = 24) -> list[dict[str, int | str]]:
|
|
bounded_limit = min(100, max(1, limit))
|
|
now = int(self.clock())
|
|
try:
|
|
with self._connect() as connection:
|
|
connection.execute(
|
|
"DELETE FROM login_alerts WHERE last_at < ?",
|
|
(now - self.alert_retention_seconds,),
|
|
)
|
|
rows = connection.execute(
|
|
"SELECT method, failed_count, blocked_count, first_at, last_at "
|
|
"FROM login_alerts ORDER BY bucket_started_at DESC LIMIT ?",
|
|
(bounded_limit,),
|
|
).fetchall()
|
|
except (OSError, sqlite3.Error) as exc:
|
|
raise LoginAttemptStoreError(
|
|
"Sign-in throttling is temporarily unavailable"
|
|
) from exc
|
|
return [
|
|
{
|
|
"method": row[0],
|
|
"failed_count": row[1],
|
|
"blocked_count": row[2],
|
|
"first_at": row[3],
|
|
"last_at": row[4],
|
|
}
|
|
for row in rows
|
|
]
|
|
|
|
def clear(self, source: str) -> None:
|
|
try:
|
|
with self._connect() as connection:
|
|
connection.execute(
|
|
"DELETE FROM login_attempts WHERE source_hash = ?",
|
|
(self._digest(source),),
|
|
)
|
|
except (OSError, sqlite3.Error) as exc:
|
|
raise LoginAttemptStoreError(
|
|
"Sign-in throttling is temporarily unavailable"
|
|
) from exc
|