Rate limit account authentication attempts

This commit is contained in:
Hamza Ayed
2026-10-03 00:59:47 +03:00
parent f745bed0f7
commit 5430d4ac4b
6 changed files with 165 additions and 4 deletions
+114
View File
@@ -16,6 +16,10 @@ from app import database
PASSWORD_ITERATIONS = 310_000
SESSION_LIFETIME_SECONDS = 7 * 24 * 60 * 60
LOGIN_FAILURE_LIMIT = 5
LOGIN_WINDOW_SECONDS = 15 * 60
REGISTRATION_LIMIT = 30
REGISTRATION_WINDOW_SECONDS = 60 * 60
_EMAIL_PATTERN = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
_DUMMY_SALT = bytes.fromhex("4f2bc994cb56a7a98b253a615fb57d2c")
_DUMMY_DIGEST = hashlib.pbkdf2_hmac(
@@ -30,6 +34,116 @@ def normalize_email(email: str) -> str:
return normalized
def _fingerprint(value: str) -> str:
return hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest()
def _rate_limit_keys(email: str, client_host: str) -> tuple[tuple[str, str], ...]:
normalized = normalize_email(email)
return (
("login-email", _fingerprint(normalized)),
("login-client", _fingerprint(client_host or "unknown")),
)
def login_retry_after(email: str, client_host: str, *, now: int | None = None) -> int:
"""Return seconds until a login attempt is allowed, or zero when clear."""
current = int(time.time()) if now is None else now
retry = 0
with database._connect() as connection:
for scope, fingerprint in _rate_limit_keys(email, client_host):
row = connection.execute(
"SELECT window_started_at, attempts FROM auth_rate_limits WHERE scope=? AND fingerprint=?",
(scope, fingerprint),
).fetchone()
if row is None or row["attempts"] < LOGIN_FAILURE_LIMIT:
continue
remaining = LOGIN_WINDOW_SECONDS - (current - row["window_started_at"])
retry = max(retry, remaining)
connection.execute(
"DELETE FROM auth_rate_limits WHERE window_started_at + ? <= ?",
(LOGIN_WINDOW_SECONDS, current),
)
return max(0, retry)
def record_login_failure(email: str, client_host: str, *, now: int | None = None) -> None:
current = int(time.time()) if now is None else now
with database._connect() as connection:
for scope, fingerprint in _rate_limit_keys(email, client_host):
connection.execute(
"""INSERT INTO auth_rate_limits(scope,fingerprint,window_started_at,attempts)
VALUES(?,?,?,1)
ON CONFLICT(scope,fingerprint) DO UPDATE SET
window_started_at=CASE
WHEN window_started_at + ? <= ? THEN ? ELSE window_started_at END,
attempts=CASE
WHEN window_started_at + ? <= ? THEN 1 ELSE attempts + 1 END""",
(
scope, fingerprint, current,
LOGIN_WINDOW_SECONDS, current, current,
LOGIN_WINDOW_SECONDS, current,
),
)
def clear_login_failures(email: str, client_host: str) -> None:
with database._connect() as connection:
connection.executemany(
"DELETE FROM auth_rate_limits WHERE scope=? AND fingerprint=?",
_rate_limit_keys(email, client_host),
)
def registration_retry_after(client_host: str, *, now: int | None = None) -> int:
return _retry_after(
"register-client", client_host or "unknown", REGISTRATION_LIMIT,
REGISTRATION_WINDOW_SECONDS, now=now,
)
def record_registration_attempt(client_host: str, *, now: int | None = None) -> None:
_record_attempt(
"register-client", client_host or "unknown", REGISTRATION_WINDOW_SECONDS,
now=now,
)
def _retry_after(
scope: str, value: str, limit: int, duration: int, *, now: int | None = None
) -> int:
current = int(time.time()) if now is None else now
fingerprint = _fingerprint(value)
with database._connect() as connection:
row = connection.execute(
"SELECT window_started_at,attempts FROM auth_rate_limits WHERE scope=? AND fingerprint=?",
(scope, fingerprint),
).fetchone()
if row is None or row["attempts"] < limit:
return 0
return max(0, duration - (current - row["window_started_at"]))
def _record_attempt(
scope: str, value: str, duration: int, *, now: int | None = None
) -> None:
current = int(time.time()) if now is None else now
fingerprint = _fingerprint(value)
with database._connect() as connection:
connection.execute(
"""INSERT INTO auth_rate_limits(scope,fingerprint,window_started_at,attempts)
VALUES(?,?,?,1)
ON CONFLICT(scope,fingerprint) DO UPDATE SET
window_started_at=CASE WHEN window_started_at + ? <= ? THEN ? ELSE window_started_at END,
attempts=CASE WHEN window_started_at + ? <= ? THEN 1 ELSE attempts + 1 END""",
(
scope, fingerprint, current,
duration, current, current,
duration, current,
),
)
def _hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac(