import re from datetime import date from pydantic import BaseModel, ConfigDict, Field, field_validator # Shared email format regex — used by RegisterRequest, ProfileUpdate, and admin.CreateUserRequest _EMAIL_REGEX = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$") def _validate_email_format(v: str | None, *, required: bool = False) -> str | None: """Shared email validation. Returns normalised email or None.""" if v is None: if required: raise ValueError("Email is required") return None v = v.strip().lower() if not v: if required: raise ValueError("Email is required") return None if not _EMAIL_REGEX.match(v): raise ValueError("Invalid email format") return v def _validate_name_field(v: str | None) -> str | None: """Shared name field validation (strips, rejects control chars).""" if v is None: return None v = v.strip() if not v: return None if re.search(r"[\x00-\x1f]", v): raise ValueError("Name must not contain control characters") return v 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 email: str = Field(..., max_length=254) date_of_birth: date preferred_name: str | None = Field(None, max_length=100) @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) @field_validator("email") @classmethod def validate_email(cls, v: str) -> str: result = _validate_email_format(v, required=True) assert result is not None # required=True guarantees non-None return result @field_validator("date_of_birth") @classmethod def validate_date_of_birth(cls, v: date) -> date: if v > date.today(): raise ValueError("Date of birth cannot be in the future") if v.year < 1900: raise ValueError("Date of birth is not valid") return v @field_validator("preferred_name") @classmethod def validate_preferred_name(cls, v: str | None) -> str | None: return _validate_name_field(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 class ProfileUpdate(BaseModel): model_config = ConfigDict(extra="forbid") first_name: str | None = Field(None, max_length=100) last_name: str | None = Field(None, max_length=100) email: str | None = Field(None, max_length=254) date_of_birth: date | None = None umbral_name: str | None = Field(None, min_length=3, max_length=50) @field_validator("umbral_name") @classmethod def validate_umbral_name(cls, v: str | None) -> str | None: if v is None: return v import re if not re.match(r'^[a-zA-Z0-9_-]{3,50}$', v): raise ValueError('Umbral name must be 3-50 alphanumeric characters, hyphens, or underscores') return v @field_validator("email") @classmethod def validate_email(cls, v: str | None) -> str | None: return _validate_email_format(v) @field_validator("date_of_birth") @classmethod def validate_date_of_birth(cls, v: date | None) -> date | None: if v is None: return v if v > date.today(): raise ValueError("Date of birth cannot be in the future") if v.year < 1900: raise ValueError("Date of birth is not valid") return v @field_validator("first_name", "last_name") @classmethod def validate_name_fields(cls, v: str | None) -> str | None: return _validate_name_field(v) class ProfileResponse(BaseModel): model_config = ConfigDict(from_attributes=True) username: str umbral_name: str email: str | None first_name: str | None last_name: str | None date_of_birth: date | None