Kyle Pope 416f616457 Allow dots in umbral name validation (matches username regex)
Username validation allows dots ([a-z0-9_.\-]+) but the connection
search and umbral name validators used [a-zA-Z0-9_-] which rejected
dots. This caused a 422 on any search for users with dots in their
username (e.g. rca.account01), silently showing "User not found".

Fixed regex in both connection.py schema and auth.py ProfileUpdate
to include dots: [a-zA-Z0-9_.-]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 20:30:27 +08:00

220 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 350 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 ' ' in v:
raise ValueError('Umbral name must be a single word with no spaces')
if not re.match(r'^[a-zA-Z0-9_.-]{3,50}$', v):
raise ValueError('Umbral name must be 3-50 alphanumeric characters, dots, 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