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(
+8
View File
@@ -71,6 +71,14 @@ def initialize_database() -> None:
CREATE INDEX IF NOT EXISTS idx_auth_sessions_user
ON auth_sessions(user_id, expires_at);
CREATE TABLE IF NOT EXISTS auth_rate_limits (
scope TEXT NOT NULL,
fingerprint TEXT NOT NULL,
window_started_at INTEGER NOT NULL,
attempts INTEGER NOT NULL CHECK(attempts >= 0),
PRIMARY KEY(scope, fingerprint)
);
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+26 -3
View File
@@ -1179,7 +1179,16 @@ def create_local_session(request: Request) -> dict[str, Any]:
@app.post("/v1/auth/register", status_code=201)
def register_account(credentials: PasswordCredentials) -> dict[str, Any]:
def register_account(credentials: PasswordCredentials, request: Request) -> dict[str, Any]:
client_host = request.client.host if request.client is not None else "unknown"
retry_after = auth.registration_retry_after(client_host)
if retry_after:
raise HTTPException(
status_code=429,
detail="تم بلوغ حد إنشاء الحسابات من هذا الاتصال؛ حاول لاحقًا.",
headers={"Retry-After": str(retry_after)},
)
auth.record_registration_attempt(client_host)
try:
user_id = auth.create_account(credentials.email, credentials.password)
email = auth.normalize_email(credentials.email)
@@ -1190,13 +1199,27 @@ def register_account(credentials: PasswordCredentials) -> dict[str, Any]:
@app.post("/v1/auth/login")
def login_account(credentials: PasswordCredentials) -> dict[str, Any]:
def login_account(credentials: PasswordCredentials, request: Request) -> dict[str, Any]:
try:
account = auth.authenticate(credentials.email, credentials.password)
email = auth.normalize_email(credentials.email)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
client_host = request.client.host if request.client is not None else "unknown"
retry_after = auth.login_retry_after(email, client_host)
if retry_after:
raise HTTPException(
status_code=429,
detail="محاولات الدخول كثيرة؛ انتظر انتهاء المهلة ثم أعد المحاولة.",
headers={"Retry-After": str(retry_after)},
)
try:
account = auth.authenticate(email, credentials.password)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
if account is None:
auth.record_login_failure(email, client_host)
raise HTTPException(status_code=401, detail="البريد الإلكتروني أو كلمة المرور غير صحيحة.")
auth.clear_login_failures(email, client_host)
user_id, email = account
return _auth_response(user_id, email, "account")