Files

146 lines
4.4 KiB
Python
Raw Permalink Normal View History

"""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",
)