"""Local password accounts and opaque, revocable bearer sessions.""" from __future__ import annotations import base64 import binascii import hashlib import hmac import re import secrets import sqlite3 import time from uuid import uuid4 from app import database PASSWORD_ITERATIONS = 310_000 SESSION_LIFETIME_SECONDS = 7 * 24 * 60 * 60 PASSWORD_RESET_LIFETIME_SECONDS = 30 * 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( "sha256", b"constant-time dummy password", _DUMMY_SALT, PASSWORD_ITERATIONS ) def normalize_email(email: str) -> str: normalized = email.strip().casefold() if len(normalized) > 254 or not _EMAIL_PATTERN.fullmatch(normalized): raise ValueError("أدخل بريدًا إلكترونيًا صالحًا.") 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 password_reset_retry_after( email: str, client_host: str, *, now: int | None = None ) -> int: current = int(time.time()) if now is None else now normalized = normalize_email(email) return max( _retry_after("password-reset-email", normalized, 3, 60 * 60, now=current), _retry_after("password-reset-client", client_host or "unknown", 10, 60 * 60, now=current), ) def record_password_reset_attempt( email: str, client_host: str, *, now: int | None = None ) -> None: current = int(time.time()) if now is None else now normalized = normalize_email(email) _record_attempt("password-reset-email", normalized, 60 * 60, now=current) _record_attempt("password-reset-client", client_host or "unknown", 60 * 60, now=current) 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( "sha256", password.encode("utf-8"), salt, PASSWORD_ITERATIONS ) return "$".join( ( "pbkdf2_sha256", str(PASSWORD_ITERATIONS), base64.urlsafe_b64encode(salt).decode("ascii"), base64.urlsafe_b64encode(digest).decode("ascii"), ) ) def _verify_password(password: str, encoded: str | None) -> bool: if encoded is None: salt, expected, iterations = _DUMMY_SALT, _DUMMY_DIGEST, PASSWORD_ITERATIONS else: try: algorithm, rounds, salt_text, digest_text = encoded.split("$", 3) iterations = int(rounds) if algorithm != "pbkdf2_sha256" or not 100_000 <= iterations <= 1_000_000: return False salt = base64.urlsafe_b64decode(salt_text.encode("ascii")) expected = base64.urlsafe_b64decode(digest_text.encode("ascii")) except (ValueError, UnicodeError, binascii.Error): return False actual = hashlib.pbkdf2_hmac( "sha256", password.encode("utf-8"), salt, iterations ) return hmac.compare_digest(actual, expected) def create_account(email: str, password: str) -> str: normalized = normalize_email(email) if len(password) < 12 or len(password) > 256: raise ValueError("يجب أن تتراوح كلمة المرور بين 12 و256 محرفًا.") user_id = str(uuid4()) password_hash = _hash_password(password) try: with database._connect() as connection: connection.execute("INSERT INTO users(id) VALUES (?)", (user_id,)) connection.execute( """INSERT INTO user_identities(user_id,provider,provider_subject,email,password_hash) VALUES(?, 'password', ?, ?, ?)""", (user_id, normalized, normalized, password_hash), ) except sqlite3.IntegrityError as exc: raise ValueError("يوجد حساب مسجل بهذا البريد الإلكتروني.") from exc return user_id def issue_password_reset(email: str, *, now: int | None = None) -> str | None: normalized = normalize_email(email) current = int(time.time()) if now is None else now token = secrets.token_urlsafe(32) token_hash = hashlib.sha256(token.encode("ascii")).hexdigest() with database._connect() as connection: row = connection.execute( "SELECT user_id FROM user_identities WHERE provider='password' AND provider_subject=?", (normalized,), ).fetchone() connection.execute("DELETE FROM password_reset_tokens WHERE expires_at <= ?", (current,)) if row is None: return None connection.execute( "DELETE FROM password_reset_tokens WHERE user_id=?", (row["user_id"],) ) connection.execute( "INSERT INTO password_reset_tokens(token_hash,user_id,expires_at) VALUES(?,?,?)", (token_hash, row["user_id"], current + PASSWORD_RESET_LIFETIME_SECONDS), ) return token def reset_password(token: str, password: str, *, now: int | None = None) -> bool: if not 12 <= len(password) <= 256 or not token or len(token) > 256: return False current = int(time.time()) if now is None else now token_hash = hashlib.sha256(token.encode("ascii", errors="ignore")).hexdigest() with database._connect() as connection: connection.execute("BEGIN IMMEDIATE") row = connection.execute( "SELECT user_id FROM password_reset_tokens WHERE token_hash=? AND expires_at>?", (token_hash, current), ).fetchone() if row is None: connection.execute("DELETE FROM password_reset_tokens WHERE token_hash=?", (token_hash,)) return False user_id = row["user_id"] password_hash = _hash_password(password) connection.execute( "UPDATE user_identities SET password_hash=? WHERE user_id=? AND provider='password'", (password_hash, user_id), ) connection.execute("DELETE FROM password_reset_tokens WHERE user_id=?", (user_id,)) connection.execute("DELETE FROM auth_sessions WHERE user_id=?", (user_id,)) return True def authenticate(email: str, password: str) -> tuple[str, str] | None: normalized = normalize_email(email) with database._connect() as connection: row = connection.execute( """SELECT user_id,password_hash FROM user_identities WHERE provider='password' AND provider_subject=?""", (normalized,), ).fetchone() password_hash = row["password_hash"] if row is not None else None valid = _verify_password(password, password_hash) if row is None or not valid: return None return row["user_id"], normalized def issue_session(user_id: str) -> tuple[str, int]: token = secrets.token_urlsafe(32) expires_at = int(time.time()) + SESSION_LIFETIME_SECONDS token_hash = hashlib.sha256(token.encode("ascii")).hexdigest() with database._connect() as connection: connection.execute("DELETE FROM auth_sessions WHERE expires_at <= ?", (int(time.time()),)) connection.execute( "INSERT INTO auth_sessions(token_hash,user_id,expires_at) VALUES(?,?,?)", (token_hash, user_id, expires_at), ) return token, expires_at def resolve_session(token: str) -> str | None: if not token or len(token) > 256: return None token_hash = hashlib.sha256(token.encode("ascii", errors="ignore")).hexdigest() now = int(time.time()) with database._connect() as connection: row = connection.execute( "SELECT user_id FROM auth_sessions WHERE token_hash=? AND expires_at>?", (token_hash, now), ).fetchone() return row["user_id"] if row is not None else None def revoke_session(token: str) -> None: token_hash = hashlib.sha256(token.encode("ascii", errors="ignore")).hexdigest() with database._connect() as connection: connection.execute("DELETE FROM auth_sessions WHERE token_hash=?", (token_hash,)) def account_email(user_id: str) -> str | None: with database._connect() as connection: row = connection.execute( "SELECT email FROM user_identities WHERE user_id=? AND provider='password'", (user_id,), ).fetchone() return row["email"] if row is not None else None