Kyle Pope 45f3788fb0 Add preferred name + email to registration, profile card to settings
Registration form now collects optional preferred_name and email fields.
Settings page Profile card expanded with first name, last name, and email
(editable via new GET/PUT /api/auth/profile endpoints). Email uniqueness
enforced on both registration and profile update. No migrations needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 19:02:42 +08:00

176 lines
5.0 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 pydantic import BaseModel, ConfigDict, Field, 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 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 | None = Field(None, max_length=254)
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 | None) -> str | None:
if v is None:
return None
v = v.strip().lower()
if not v:
return None
if not re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", v):
raise ValueError("Invalid email format")
return v
@field_validator("preferred_name")
@classmethod
def validate_preferred_name(cls, v: str | None) -> str | None:
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
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)
@field_validator("email")
@classmethod
def validate_email(cls, v: str | None) -> str | None:
if v is None:
return None
v = v.strip().lower()
if not v:
return None
if not re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", v):
raise ValueError("Invalid email format")
return v
@field_validator("first_name", "last_name")
@classmethod
def validate_name_fields(cls, v: str | None) -> str | None:
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
class ProfileResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
username: str
email: str | None
first_name: str | None
last_name: str | None