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)