UMBRA/backend/app/schemas/settings.py
Kyle Pope fbc452a004 Implement Stage 6 Track A: PIN → Username/Password auth migration
- New User model (username, argon2id password_hash, totp fields, lockout)
- New UserSession model (DB-backed revocation, replaces in-memory set)
- New services/auth.py: Argon2id hashing, bcrypt→Argon2id upgrade path, URLSafeTimedSerializer session/MFA tokens
- New schemas/auth.py: SetupRequest, LoginRequest, ChangePasswordRequest with OWASP password strength validation
- Full rewrite of routers/auth.py: setup/login/logout/status/change-password with account lockout (10 failures → 30-min, HTTP 423), IP rate limiting retained as outer layer, get_current_user + get_current_settings dependencies replacing get_current_session
- Settings model: drop pin_hash, add user_id FK (nullable for migration)
- Schemas/settings.py: remove SettingsCreate, ChangePinRequest, _validate_pin_length
- Settings router: rewrite to use get_current_user + get_current_settings, preserve ntfy test endpoint
- All 11 consumer routers updated: auth-gate-only routers use get_current_user, routers reading Settings fields use get_current_settings
- config.py: add SESSION_MAX_AGE_DAYS, MFA_TOKEN_MAX_AGE_SECONDS, TOTP_ISSUER
- main.py: import User and UserSession models for Alembic discovery
- requirements.txt: add argon2-cffi>=23.1.0
- Migration 023: create users + user_sessions tables, migrate pin_hash → User row (admin), backfill settings.user_id, drop pin_hash

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 04:12:37 +08:00

140 lines
5.0 KiB
Python

import re
from pydantic import BaseModel, ConfigDict, field_validator
from datetime import datetime
from typing import Literal, Optional
AccentColor = Literal["cyan", "blue", "green", "purple", "red", "orange", "pink", "yellow"]
_NTFY_TOPIC_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
class SettingsUpdate(BaseModel):
accent_color: Optional[AccentColor] = None
upcoming_days: int | None = None
preferred_name: str | None = None
weather_city: str | None = None
weather_lat: float | None = None
weather_lon: float | None = None
first_day_of_week: int | None = None
# ntfy configuration fields
ntfy_server_url: Optional[str] = None
ntfy_topic: Optional[str] = None
# Empty string means "clear the token"; None means "leave unchanged"
ntfy_auth_token: Optional[str] = None
ntfy_enabled: Optional[bool] = None
ntfy_events_enabled: Optional[bool] = None
ntfy_reminders_enabled: Optional[bool] = None
ntfy_todos_enabled: Optional[bool] = None
ntfy_projects_enabled: Optional[bool] = None
ntfy_event_lead_minutes: Optional[int] = None
ntfy_todo_lead_days: Optional[int] = None
ntfy_project_lead_days: Optional[int] = None
@field_validator('first_day_of_week')
@classmethod
def validate_first_day(cls, v: int | None) -> int | None:
if v is not None and v not in (0, 1):
raise ValueError('first_day_of_week must be 0 (Sunday) or 1 (Monday)')
return v
@field_validator('weather_lat')
@classmethod
def validate_lat(cls, v: float | None) -> float | None:
if v is not None and (v < -90 or v > 90):
raise ValueError('Latitude must be between -90 and 90')
return v
@field_validator('weather_lon')
@classmethod
def validate_lon(cls, v: float | None) -> float | None:
if v is not None and (v < -180 or v > 180):
raise ValueError('Longitude must be between -180 and 180')
return v
@field_validator('ntfy_server_url')
@classmethod
def validate_ntfy_url(cls, v: Optional[str]) -> Optional[str]:
if v is None or v == "":
return None
from urllib.parse import urlparse
parsed = urlparse(v)
if parsed.scheme not in ("http", "https"):
raise ValueError("ntfy server URL must use http or https")
if not parsed.netloc:
raise ValueError("ntfy server URL must include a hostname")
# Strip trailing slash — ntfy base URL must not have one
return v.rstrip("/")
@field_validator('ntfy_topic')
@classmethod
def validate_ntfy_topic(cls, v: Optional[str]) -> Optional[str]:
if v is None or v == "":
return None
if not _NTFY_TOPIC_RE.match(v):
raise ValueError(
"ntfy topic must be 1-64 alphanumeric characters, hyphens, or underscores"
)
return v
@field_validator('ntfy_auth_token')
@classmethod
def validate_ntfy_token(cls, v: Optional[str]) -> Optional[str]:
# Empty string signals "clear the token" — normalise to None for storage
if v == "":
return None
if v is not None and len(v) > 500:
raise ValueError("ntfy auth token must be at most 500 characters")
return v
@field_validator('ntfy_event_lead_minutes')
@classmethod
def validate_event_lead(cls, v: Optional[int]) -> Optional[int]:
if v is not None and not (1 <= v <= 1440):
raise ValueError("ntfy_event_lead_minutes must be between 1 and 1440")
return v
@field_validator('ntfy_todo_lead_days')
@classmethod
def validate_todo_lead(cls, v: Optional[int]) -> Optional[int]:
if v is not None and not (0 <= v <= 30):
raise ValueError("ntfy_todo_lead_days must be between 0 and 30")
return v
@field_validator('ntfy_project_lead_days')
@classmethod
def validate_project_lead(cls, v: Optional[int]) -> Optional[int]:
if v is not None and not (0 <= v <= 30):
raise ValueError("ntfy_project_lead_days must be between 0 and 30")
return v
class SettingsResponse(BaseModel):
id: int
accent_color: str
upcoming_days: int
preferred_name: str | None = None
weather_city: str | None = None
weather_lat: float | None = None
weather_lon: float | None = None
first_day_of_week: int = 0
# ntfy fields — ntfy_auth_token is NEVER included here (security requirement 6.2)
ntfy_server_url: Optional[str] = None
ntfy_topic: Optional[str] = None
ntfy_enabled: bool = False
ntfy_events_enabled: bool = True
ntfy_reminders_enabled: bool = True
ntfy_todos_enabled: bool = True
ntfy_projects_enabled: bool = True
ntfy_event_lead_minutes: int = 15
ntfy_todo_lead_days: int = 1
ntfy_project_lead_days: int = 2
# Derived field: True if a token is stored, never exposes the value itself
ntfy_has_token: bool = False
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)