import re from pydantic import BaseModel, field_validator def _validate_password_strength(v: str) -> str: """ Shared password validation (OWASP ASVS v4 Level 1). - Minimum 12 chars (OWASP minimum) - Maximum 128 chars (prevents DoS via large input to argon2) - Must contain at least one letter and one non-letter - No complexity rules per NIST SP 800-63B """ if len(v) < 12: raise ValueError("Password must be at least 12 characters") if len(v) > 128: raise ValueError("Password must be 128 characters or fewer") if not re.search(r"[A-Za-z]", v): raise ValueError("Password must contain at least one letter") if not re.search(r"[^A-Za-z]", v): raise ValueError("Password must contain at least one non-letter character") return v class SetupRequest(BaseModel): username: str password: str @field_validator("username") @classmethod def validate_username(cls, v: str) -> str: v = v.strip().lower() if not 3 <= len(v) <= 50: raise ValueError("Username must be 3–50 characters") if not re.fullmatch(r"[a-z0-9_\-]+", v): raise ValueError("Username may only contain letters, numbers, _ and -") return v @field_validator("password") @classmethod def validate_password(cls, v: str) -> str: return _validate_password_strength(v) class LoginRequest(BaseModel): username: str password: str @field_validator("username") @classmethod def normalize_username(cls, v: str) -> str: """Normalise to lowercase so 'Admin' and 'admin' resolve to the same user.""" return v.strip().lower() class ChangePasswordRequest(BaseModel): old_password: str new_password: str @field_validator("new_password") @classmethod def validate_new_password(cls, v: str) -> str: return _validate_password_strength(v)