| """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 |
|
|
| |
| 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 |
|
|
|
|
| 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"]): |
| |
| 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"]} |
|
|