"""Password hashing, sign-up and login with brute-force lockout. Passwords are pre-hashed with SHA-256 before bcrypt so that (a) inputs longer than bcrypt's 72-byte limit are not silently truncated, and (b) NUL bytes are handled. bcrypt then salts and stretches. Verification is constant-time (bcrypt). """ from __future__ import annotations import base64 import hashlib import re import time from typing import Any, Dict import bcrypt from .store import AccountStore, DuplicateUserError # Policy knobs. USERNAME_RE = re.compile(r"^[A-Za-z0-9_.-]{3,32}$") MIN_PASSWORD_LEN = 8 MAX_PASSWORD_LEN = 256 MAX_FAILED_LOGINS = 5 LOCKOUT_SECONDS = 15 * 60 # 15 minutes class AuthError(Exception): """User-facing authentication/validation error (safe to display).""" def _prehash(password: str) -> bytes: """SHA-256 -> base64 so bcrypt sees a fixed-length, NUL-free input.""" digest = hashlib.sha256(password.encode("utf-8")).digest() return base64.b64encode(digest) def hash_password(password: str) -> str: return bcrypt.hashpw(_prehash(password), bcrypt.gensalt()).decode("ascii") def verify_password(password: str, password_hash: str) -> bool: try: return bcrypt.checkpw(_prehash(password), password_hash.encode("ascii")) except (ValueError, TypeError): return False def validate_username(username: str) -> str: username = (username or "").strip() if not USERNAME_RE.match(username): raise AuthError( "Username must be 3–32 characters: letters, numbers, '.', '_' or '-'." ) return username def validate_password(password: str) -> None: if not password or len(password) < MIN_PASSWORD_LEN: raise AuthError(f"Password must be at least {MIN_PASSWORD_LEN} characters.") if len(password) > MAX_PASSWORD_LEN: raise AuthError(f"Password must be at most {MAX_PASSWORD_LEN} characters.") def signup(store: AccountStore, username: str, password: str) -> Dict[str, Any]: """Create a new account. Raises AuthError on validation/duplicate.""" username = validate_username(username) validate_password(password) try: user_id = store.create_user(username, hash_password(password)) except DuplicateUserError: raise AuthError("That username is already taken.") return {"id": user_id, "username": username} def login(store: AccountStore, username: str, password: str) -> Dict[str, Any]: """Authenticate a user. Raises AuthError on bad credentials or lockout. Uses a generic "invalid username or password" message for both unknown user and wrong password to avoid username enumeration. """ username = (username or "").strip() user = store.get_user(username) if not user: raise AuthError("Invalid username or password.") if user["locked_until"] and user["locked_until"] > time.time(): wait = int((user["locked_until"] - time.time()) / 60) + 1 raise AuthError( f"Account temporarily locked after too many attempts. Try again in ~{wait} min." ) if not verify_password(password, user["password_hash"]): # Lock the account once the threshold is crossed. will_be = user["failed_logins"] + 1 lock = LOCKOUT_SECONDS if will_be >= MAX_FAILED_LOGINS else 0 store.record_login_result(user["id"], success=False, lock_seconds=lock) raise AuthError("Invalid username or password.") store.record_login_result(user["id"], success=True) return {"id": user["id"], "username": user["username"]}