Phase 1: Add role, mfa_enforce_pending, must_change_password to users table. Create system_config (singleton) and audit_log tables. Migration 026. Phase 2: Add user_id FK to all 8 data tables (todos, reminders, projects, calendars, people, locations, event_templates, ntfy_sent) with 4-step nullable→backfill→FK→NOT NULL pattern. Migrations 027-034. Phase 3: Harden auth schemas (extra="forbid" on RegisterRequest), add MFA enforcement token serializer with distinct salt, rewrite auth router with require_role() factory and registration endpoint. Phase 4: Scope all 12 routers by user_id, fix dependency type bugs, bound weather cache (SEC-15), multi-user ntfy dispatch. Phase 5: Create admin router (14 endpoints), admin schemas, audit service, rate limiting in nginx. SEC-08 CSRF via X-Requested-With. Phase 6: Update frontend types, useAuth hook (role/isAdmin/register), App.tsx (AdminRoute guard), Sidebar (admin link), api.ts (XHR header). Security findings addressed: SEC-01, SEC-02, SEC-03, SEC-04, SEC-05, SEC-06, SEC-07, SEC-08, SEC-12, SEC-13, SEC-15. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
101 lines
2.9 KiB
Python
101 lines
2.9 KiB
Python
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):
|
||
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):
|
||
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):
|
||
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):
|
||
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
|