146 lines
4.4 KiB
Python
146 lines
4.4 KiB
Python
"""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",
|
|||
|
|
)
|