Rate limit account authentication attempts
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user