Functional Weave
Code in Python

auth.password-policy@1.0.0

impl/python/check_password.py

4,443 bytes · the Python implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

from typing import List, Optional

from .auth_password_policy_data import COMMON_PASSWORDS  ← this capability’s own data, compiled from data/policies.json into the same file by fune build
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)