Critical fixes: - C-01: Add receiver_umbral_name/receiver_preferred_name to frontend ConnectionRequest type - C-02: Flush connection request before notification to populate source_id - C-03: Add umbral_name to ProfileResponse/UserProfile, use in Settings Social card - C-04: Remove dead code in sharing-overrides endpoint, merge instead of replace Warning fixes: - W-01/W-02: Batch-fetch settings in incoming/outgoing/list connection endpoints (N+1 fix) - W-04: Add _purge_resolved_requests job for rejected/cancelled requests (30-day retention) - W-10: Add e.stopPropagation() to notification mark-read and delete buttons Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
207 lines
6.1 KiB
Python
207 lines
6.1 KiB
Python
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
|
||
|
||
@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
|