- 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>
63 lines
1.9 KiB
Python
63 lines
1.9 KiB
Python
import re
|
||
from pydantic import BaseModel, 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
|
||
|
||
|
||
class SetupRequest(BaseModel):
|
||
username: str
|
||
password: str
|
||
|
||
@field_validator("username")
|
||
@classmethod
|
||
def validate_username(cls, v: str) -> str:
|
||
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
|
||
|
||
@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)
|