"""What a new account password must not be (sign-up and reset). Length is bounded by the request schemas (8–128). On top of that a password is refused when it is one of the passwords every guessing attack tries first, is made of the user's own address, is one character repeated, or has turned up in a known data breach. The breach check asks Have I Been Pwned's range API with the first five hex digits of the password's SHA-1 only (k-anonymity: neither the password nor its hash leaves the server) and is skipped, not failed, when that service cannot be reached — a sign-up never hangs on someone else's outage. Set ``PASSWORD_BREACH_CHECK=false`` to never ask it. """ from __future__ import annotations import hashlib import logging import httpx from app.config import get_settings logger = logging.getLogger(__name__) MIN_LENGTH = 8 MAX_LENGTH = 128 _RANGE_API = "https://api.pwnedpasswords.com/range/" _TIMEOUT = 3.0 # The front of every guessing list (and of every breach), lower-cased. _COMMON = frozenset( { "password", "password1", "password12", "password123", "password1234", "passw0rd", "p@ssw0rd", "p@ssword", "12345678", "123456789", "1234567890", "0123456789", "87654321", "11111111", "00000000", "12341234", "qwertyui", "qwerty123", "qwerty1234", "qwertyuiop", "1q2w3e4r", "1q2w3e4r5t", "1qaz2wsx", "zaq12wsx", "asdfghjk", "asdfasdf", "iloveyou", "iloveyou1", "sunshine", "princess", "football", "baseball", "welcome1", "welcome123", "letmein1", "trustno1", "superman", "batman123", "dragon123", "monkey123", "abc12345", "abcd1234", "aa123456", "qazwsxedc", "changeme", "changeme1", "administrator", "admin123", "adminadmin", "computer", "internet", "whatever", "starwars", "michelle", "jennifer", "charlie1", "zaq1zaq1", "haslo123", "polska123", "kochamcie", "zaq1@wsx", "qwerty12", "1234qwer", "q1w2e3r4", } ) class PasswordRejected(Exception): """The password may not be used; ``code`` and ``message`` say why.""" def __init__(self, code: str, message: str) -> None: super().__init__(message) self.code = code self.message = message async def _breached(password: str) -> bool: digest = hashlib.sha1(password.encode("utf-8")).hexdigest().upper() # noqa: S324 - HIBP's format prefix, suffix = digest[:5], digest[5:] try: async with httpx.AsyncClient(timeout=_TIMEOUT) as client: resp = await client.get(_RANGE_API + prefix, headers={"Add-Padding": "true"}) resp.raise_for_status() except httpx.HTTPError as exc: logger.warning("breached-password check skipped: %s", exc) return False for line in resp.text.splitlines(): candidate, _, count = line.partition(":") if candidate.strip() == suffix and count.strip() not in ("", "0"): # padding rows say 0 return True return False async def check_new_password(password: str, handle: str) -> None: """Raise :class:`PasswordRejected` if ``password`` may not be used by ``handle``.""" lowered = password.lower() if len(set(password)) <= 2: raise PasswordRejected("weak_password", "this password is too easy to guess") if lowered in _COMMON: raise PasswordRejected("weak_password", "this is one of the most common passwords") localpart = handle.split("@", 1)[0].lower() if len(localpart) >= 3 and localpart in lowered: # The name with a year or a few characters added ("jsmith2026") is the # first thing anyone who knows the address will try. rest = lowered.replace(localpart, "", 1) if len(rest) < 4 or all(not c.isalpha() for c in rest): raise PasswordRejected("weak_password", "the password must not be your address") if get_settings().password_breach_check and await _breached(password): raise PasswordRejected( "breached_password", "this password has appeared in a data breach — please choose another", )