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

63 lines
1.9 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, 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 350 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)