from typing import List, Optional from .auth_password_policy_data import COMMON_PASSWORDS from .auth_password_policy_types import PasswordCheck, PasswordFailure, PasswordPolicy #: Words shorter than this in a name or email are not refused inside a #: password: "al" or "jo" would refuse half of all passwords. MIN_PERSONAL = 3 _COMMON = frozenset(row.password for row in COMMON_PASSWORDS) def _ascii_lower(text: str) -> str: # str.lower() folds non-ASCII letters, differently from the other languages. return "".join(chr(ord(c) + 32) if "A" <= c <= "Z" else c for c in text) def _is_ascii_alnum(ch: str) -> bool: return ("a" <= ch <= "z") or ("A" <= ch <= "Z") or ("0" <= ch <= "9") def _words(text: str) -> List[str]: """Pieces between ASCII punctuation and spaces, lower-cased, 3+ characters.""" out = [] current = "" for ch in text: if ord(ch) < 128 and not _is_ascii_alnum(ch): if len(current) >= MIN_PERSONAL: out.append(_ascii_lower(current)) current = "" else: current += ch if len(current) >= MIN_PERSONAL: out.append(_ascii_lower(current)) return out def _is_whole(value: object) -> bool: return isinstance(value, int) and not isinstance(value, bool) def _check_policy(policy: PasswordPolicy) -> None: if not _is_whole(policy.min_length) or policy.min_length < 1: raise ValueError("policy minLength must be a whole number of at least 1") if not _is_whole(policy.max_length) or policy.max_length < policy.min_length: raise ValueError("policy maxLength must be a whole number no smaller than minLength") if not _is_whole(policy.min_character_classes) or not 0 <= policy.min_character_classes <= 4: raise ValueError("policy minCharacterClasses must be a whole number from 0 to 4") def check_password(password: str, email: Optional[str], name: Optional[str], policy: PasswordPolicy) -> PasswordCheck: """Every rule of the policy the password breaks, in a fixed order, each with a sentence to show beside the field. The same function runs in the browser and on the server.""" if not isinstance(password, str): raise TypeError("password must be a string") _check_policy(policy) failures = [] length = len(password) # code points, as TypeScript's Array.from counts them if length < policy.min_length: failures.append(PasswordFailure(code="too_short", message="Use at least %d characters." % policy.min_length)) if length > policy.max_length: failures.append(PasswordFailure(code="too_long", message="Use no more than %d characters." % policy.max_length)) if policy.min_character_classes > 0: lower = upper = digit = other = 0 for ch in password: if "a" <= ch <= "z": lower = 1 elif "A" <= ch <= "Z": upper = 1 elif "0" <= ch <= "9": digit = 1 else: other = 1 if lower + upper + digit + other < policy.min_character_classes: failures.append( PasswordFailure( code="too_few_character_classes", message="Use at least %d of these: lower-case letters, capital letters, digits, symbols." % policy.min_character_classes, ) ) folded = _ascii_lower(password) if policy.block_common and folded in _COMMON: failures.append( PasswordFailure(code="too_common", message="This password is too common. Choose something harder to guess.") ) if policy.block_personal: if isinstance(email, str): at = email.rfind("@") local = email[:at] if at >= 0 else email candidates = _words(local) if len(local) >= MIN_PERSONAL: candidates.append(_ascii_lower(local)) if any(word in folded for word in candidates): failures.append( PasswordFailure(code="contains_email", message="Do not include your email address in your password.") ) if isinstance(name, str) and any(word in folded for word in _words(name)): failures.append(PasswordFailure(code="contains_name", message="Do not include your name in your password.")) return PasswordCheck(valid=len(failures) == 0, failures=failures)