import re from pydantic import BaseModel, ConfigDict, 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 def _validate_username(v: str) -> str: """Shared username validation.""" 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 class SetupRequest(BaseModel): model_config = ConfigDict(extra="forbid") username: str password: str @field_validator("username") @classmethod def validate_username(cls, v: str) -> str: return _validate_username(v) @field_validator("password") @classmethod def validate_password(cls, v: str) -> str: return _validate_password_strength(v) class RegisterRequest(BaseModel): """ Public registration schema — SEC-01: extra="forbid" prevents role injection. An attacker sending {"username": "...", "password": "...", "role": "admin"} will get a 422 Validation Error instead of silent acceptance. """ model_config = ConfigDict(extra="forbid") username: str password: str @field_validator("username") @classmethod def validate_username(cls, v: str) -> str: return _validate_username(v) @field_validator("password") @classmethod def validate_password(cls, v: str) -> str: return _validate_password_strength(v) class LoginRequest(BaseModel): model_config = ConfigDict(extra="forbid") 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): model_config = ConfigDict(extra="forbid") old_password: str new_password: str @field_validator("new_password") @classmethod def validate_new_password(cls, v: str) -> str: return _validate_password_strength(v) class VerifyPasswordRequest(BaseModel): model_config = ConfigDict(extra="forbid") password: str @field_validator("password") @classmethod def validate_length(cls, v: str) -> str: if len(v) > 128: raise ValueError("Password must be 128 characters or fewer") return v