- 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>
140 lines
5.0 KiB
Python
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)
|