Initial commit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Todd
2026-03-29 22:42:55 -04:00
commit 0d7b2b1aab
389 changed files with 280296 additions and 0 deletions

565
web/backend/auth_manager.py Normal file
View File

@@ -0,0 +1,565 @@
#!/usr/bin/env python3
"""
Authentication Manager for Media Downloader
Handles user authentication and sessions
"""
import os
import sqlite3
import secrets
import hashlib
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, List, Tuple
from passlib.context import CryptContext
from jose import jwt, JWTError
from pathlib import Path
# Password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# JWT Configuration
def _load_jwt_secret():
"""Load JWT secret from file, environment, or generate new one"""
# Try to load from file first
secret_file = Path(__file__).parent.parent.parent / '.jwt_secret'
if secret_file.exists():
with open(secret_file, 'r') as f:
secret = f.read().strip()
# Validate secret has minimum length (at least 32 chars)
if len(secret) >= 32:
return secret
# If too short, regenerate
# Fallback to environment variable
if "JWT_SECRET_KEY" in os.environ:
secret = os.environ["JWT_SECRET_KEY"]
if len(secret) >= 32:
return secret
# Generate a cryptographically strong secret (384 bits / 48 bytes)
new_secret = secrets.token_urlsafe(48)
try:
with open(secret_file, 'w') as f:
f.write(new_secret)
os.chmod(secret_file, 0o600)
except Exception:
# Log warning but continue - in-memory secret will invalidate on restart
import logging
logging.getLogger(__name__).warning(
"Could not save JWT secret to file. Tokens will be invalidated on restart."
)
return new_secret
SECRET_KEY = _load_jwt_secret()
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 hours (default)
ACCESS_TOKEN_REMEMBER_MINUTES = 60 * 24 * 30 # 30 days (remember me)
class AuthManager:
def __init__(self, db_path: str = None):
if db_path is None:
db_path = str(Path(__file__).parent.parent.parent / 'database' / 'auth.db')
self.db_path = db_path
# Rate limiting
self.login_attempts = {}
self.max_attempts = 5
self.lockout_duration = timedelta(minutes=15)
self._init_database()
self._create_default_user()
def _init_database(self):
"""Initialize the authentication database schema"""
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(self.db_path, timeout=30.0) as conn:
cursor = conn.cursor()
# Users table
cursor.execute("""
CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer',
email TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
totp_secret TEXT,
totp_enabled INTEGER NOT NULL DEFAULT 0,
duo_enabled INTEGER NOT NULL DEFAULT 0,
duo_username TEXT,
created_at TEXT NOT NULL,
last_login TEXT,
preferences TEXT
)
""")
# Add duo_enabled column to existing table if it doesn't exist
try:
cursor.execute("ALTER TABLE users ADD COLUMN duo_enabled INTEGER NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass # Column already exists
try:
cursor.execute("ALTER TABLE users ADD COLUMN duo_username TEXT")
except sqlite3.OperationalError:
pass # Column already exists
# Backup codes table
cursor.execute("""
CREATE TABLE IF NOT EXISTS backup_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
code_hash TEXT NOT NULL,
used INTEGER NOT NULL DEFAULT 0,
used_at TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
)
""")
# Sessions table
cursor.execute("""
CREATE TABLE IF NOT EXISTS sessions (
session_token TEXT PRIMARY KEY,
username TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
ip_address TEXT,
user_agent TEXT,
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
)
""")
# Login attempts table (for rate limiting)
cursor.execute("""
CREATE TABLE IF NOT EXISTS login_attempts (
username TEXT PRIMARY KEY,
attempts INTEGER NOT NULL DEFAULT 0,
last_attempt TEXT NOT NULL
)
""")
# Audit log
cursor.execute("""
CREATE TABLE IF NOT EXISTS auth_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
action TEXT NOT NULL,
success INTEGER NOT NULL,
ip_address TEXT,
details TEXT,
timestamp TEXT NOT NULL
)
""")
conn.commit()
def _create_default_user(self):
"""Create default admin user if no users exist"""
import logging
_logger = logging.getLogger(__name__)
with sqlite3.connect(self.db_path, timeout=30.0) as conn:
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM users")
if cursor.fetchone()[0] == 0:
# Get password from environment or generate secure random one
default_password = os.environ.get("ADMIN_PASSWORD")
password_generated = False
if not default_password:
# Generate a secure random password (16 chars, URL-safe)
default_password = secrets.token_urlsafe(16)
password_generated = True
password_hash = pwd_context.hash(default_password)
cursor.execute("""
INSERT INTO users (username, password_hash, role, email, created_at, preferences)
VALUES (?, ?, ?, ?, ?, ?)
""", (
'admin',
password_hash,
'admin',
'admin@localhost',
datetime.now().isoformat(),
'{"theme": "dark"}'
))
conn.commit()
# Security: Never log the password - write to secure file instead
if password_generated:
password_file = Path(__file__).parent.parent.parent / '.admin_password'
try:
with open(password_file, 'w') as f:
f.write(f"Admin password (delete after first login):\n{default_password}\n")
os.chmod(password_file, 0o600)
_logger.info("Created default admin user. Password saved to .admin_password")
except Exception:
# If we can't write file, show masked hint only
_logger.info("Created default admin user. Set ADMIN_PASSWORD env var to control password.")
else:
_logger.info("Created default admin user with password from ADMIN_PASSWORD")
def verify_password(self, plain_password: str, hashed_password: str) -> bool:
"""Verify a password against its hash"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(self, password: str) -> str:
"""Hash a password"""
return pwd_context.hash(password)
def create_access_token(self, data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""Create a JWT access token"""
to_encode = data.copy()
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def verify_token(self, token: str) -> Optional[Dict]:
"""Verify and decode a JWT token"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except JWTError:
return None
def check_rate_limit(self, username: str) -> Tuple[bool, Optional[str]]:
"""Check if user is rate limited"""
with sqlite3.connect(self.db_path, timeout=30.0) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT attempts, last_attempt FROM login_attempts
WHERE username = ?
""", (username,))
row = cursor.fetchone()
if not row:
return True, None
attempts, last_attempt_str = row
last_attempt = datetime.fromisoformat(last_attempt_str)
if attempts >= self.max_attempts:
time_since = datetime.now() - last_attempt
if time_since < self.lockout_duration:
remaining = self.lockout_duration - time_since
return False, f"Account locked. Try again in {int(remaining.total_seconds() / 60)} minutes"
else:
# Reset after lockout period
self._reset_login_attempts(username)
return True, None
return True, None
def _reset_login_attempts(self, username: str):
"""Reset login attempts for a user"""
with sqlite3.connect(self.db_path, timeout=30.0) as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM login_attempts WHERE username = ?", (username,))
conn.commit()
def _record_login_attempt(self, username: str, success: bool):
"""Record a login attempt"""
with sqlite3.connect(self.db_path, timeout=30.0) as conn:
cursor = conn.cursor()
if success:
# Reset attempts on successful login
cursor.execute("DELETE FROM login_attempts WHERE username = ?", (username,))
else:
# Increment attempts
now = datetime.now().isoformat()
cursor.execute("""
INSERT INTO login_attempts (username, attempts, last_attempt)
VALUES (?, 1, ?)
ON CONFLICT(username) DO UPDATE SET
attempts = login_attempts.attempts + 1,
last_attempt = EXCLUDED.last_attempt
""", (username, now))
conn.commit()
def authenticate(self, username: str, password: str, ip_address: str = None, remember_me: bool = False) -> Dict:
"""Authenticate a user with username and password"""
# Check rate limiting
allowed, error_msg = self.check_rate_limit(username)
if not allowed:
return {'success': False, 'error': error_msg, 'requires_2fa': False}
with sqlite3.connect(self.db_path, timeout=30.0) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT password_hash, role, is_active
FROM users WHERE username = ?
""", (username,))
row = cursor.fetchone()
if not row:
self._record_login_attempt(username, False)
self._log_audit(username, 'login_failed', False, ip_address, 'Invalid username')
return {'success': False, 'error': 'Invalid credentials'}
password_hash, role, is_active = row
if not is_active:
self._log_audit(username, 'login_failed', False, ip_address, 'Account inactive')
return {'success': False, 'error': 'Account is inactive'}
if not self.verify_password(password, password_hash):
self._record_login_attempt(username, False)
self._log_audit(username, 'login_failed', False, ip_address, 'Invalid password')
return {'success': False, 'error': 'Invalid credentials'}
# Password is correct, create session (2FA removed)
return self._create_session(username, role, ip_address, remember_me)
def _create_session(self, username: str, role: str, ip_address: str = None, remember_me: bool = False) -> Dict:
"""Create a new session and return token - matches backup-central format"""
import json
import uuid
# Create JWT token
with sqlite3.connect(self.db_path, timeout=30.0) as conn:
cursor = conn.cursor()
# Get user email and preferences
cursor.execute("SELECT email, preferences FROM users WHERE username = ?", (username,))
row = cursor.fetchone()
email = row[0] if row and row[0] else None
# Parse preferences JSON
preferences = {}
if row and row[1]:
try:
preferences = json.loads(row[1])
except Exception:
pass
# Use longer expiration if remember_me is enabled
expire_minutes = ACCESS_TOKEN_REMEMBER_MINUTES if remember_me else ACCESS_TOKEN_EXPIRE_MINUTES
token_data = {"sub": username, "role": role, "email": email}
token = self.create_access_token(token_data, expires_delta=timedelta(minutes=expire_minutes))
# Generate session ID
sessionId = str(uuid.uuid4())
now = datetime.now().isoformat()
expires_at = (datetime.now() + timedelta(minutes=expire_minutes)).isoformat()
cursor.execute("""
INSERT INTO sessions (session_token, username, created_at, expires_at, ip_address)
VALUES (?, ?, ?, ?, ?)
""", (token, username, now, expires_at, ip_address))
# Update last login
cursor.execute("""
UPDATE users SET last_login = ? WHERE username = ?
""", (now, username))
conn.commit()
self._log_audit(username, 'login_success', True, ip_address)
# Return structure matching backup-central (with preferences)
return {
'success': True,
'token': token,
'sessionId': sessionId,
'user': {
'username': username,
'role': role,
'email': email,
'preferences': preferences
}
}
def _log_audit(self, username: str, action: str, success: bool, ip_address: str = None, details: str = None):
"""Log an audit event"""
with sqlite3.connect(self.db_path, timeout=30.0) as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO auth_audit (username, action, success, ip_address, details, timestamp)
VALUES (?, ?, ?, ?, ?, ?)
""", (username, action, int(success), ip_address, details, datetime.now().isoformat()))
conn.commit()
def get_user(self, username: str) -> Optional[Dict]:
"""Get user information"""
import json
with sqlite3.connect(self.db_path, timeout=30.0) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT username, role, email, is_active, totp_enabled, duo_enabled, duo_username, created_at, last_login, preferences
FROM users WHERE username = ?
""", (username,))
row = cursor.fetchone()
if not row:
return None
# Parse preferences JSON
preferences = {}
if row[9]:
try:
preferences = json.loads(row[9])
except Exception:
pass
return {
'username': row[0],
'role': row[1],
'email': row[2],
'is_active': bool(row[3]),
'totp_enabled': bool(row[4]),
'duo_enabled': bool(row[5]),
'duo_username': row[6],
'created_at': row[7],
'last_login': row[8],
'preferences': preferences
}
def verify_session(self, token: str) -> Optional[Dict]:
"""Verify a session token"""
payload = self.verify_token(token)
if not payload:
return None
username = payload.get("sub")
if not username:
return None
# Check if session exists and is valid
with sqlite3.connect(self.db_path, timeout=30.0) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT expires_at FROM sessions
WHERE session_token = ? AND username = ?
""", (token, username))
row = cursor.fetchone()
if not row:
return None
expires_at = datetime.fromisoformat(row[0])
if expires_at < datetime.now():
# Session expired
return None
return payload
def logout(self, token: str):
"""Logout and invalidate session"""
with sqlite3.connect(self.db_path, timeout=30.0) as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM sessions WHERE session_token = ?", (token,))
conn.commit()
# ============================================================================
# DUO 2FA METHODS (Duo Universal Prompt)
# ============================================================================
# Duo authentication is now handled via DuoManager using Universal Prompt
# The flow is: login → redirect to Duo → callback → complete login
# All Duo operations are delegated to self.duo_manager
def change_password(self, username: str, current_password: str, new_password: str, ip_address: str = None) -> Dict:
"""Change user password"""
with sqlite3.connect(self.db_path, timeout=30.0) as conn:
cursor = conn.cursor()
# Get current password hash
cursor.execute("SELECT password_hash FROM users WHERE username = ?", (username,))
row = cursor.fetchone()
if not row:
return {'success': False, 'error': 'User not found'}
current_hash = row[0]
# Verify current password
if not self.verify_password(current_password, current_hash):
self._log_audit(username, 'password_change_failed', False, ip_address, 'Invalid current password')
return {'success': False, 'error': 'Current password is incorrect'}
# Validate new password (minimum 8 characters)
if len(new_password) < 8:
return {'success': False, 'error': 'New password must be at least 8 characters'}
# Hash new password
new_hash = self.get_password_hash(new_password)
# Update password
cursor.execute("""
UPDATE users SET password_hash = ?
WHERE username = ?
""", (new_hash, username))
# Security: Invalidate ALL existing sessions for this user
# This ensures compromised sessions are revoked when password changes
cursor.execute("""
DELETE FROM sessions WHERE username = ?
""", (username,))
sessions_invalidated = cursor.rowcount
conn.commit()
self._log_audit(username, 'password_changed', True, ip_address,
f'Invalidated {sessions_invalidated} sessions')
return {'success': True, 'sessions_invalidated': sessions_invalidated}
def update_preferences(self, username: str, preferences: dict) -> Dict:
"""Update user preferences (theme, etc.)"""
import json
with sqlite3.connect(self.db_path, timeout=30.0) as conn:
cursor = conn.cursor()
# Get current preferences
cursor.execute("SELECT preferences FROM users WHERE username = ?", (username,))
row = cursor.fetchone()
if not row:
return {'success': False, 'error': 'User not found'}
# Merge with existing preferences
current_prefs = {}
if row[0]:
try:
current_prefs = json.loads(row[0])
except Exception:
pass
current_prefs.update(preferences)
# Update preferences
cursor.execute("""
UPDATE users SET preferences = ?
WHERE username = ?
""", (json.dumps(current_prefs), username))
conn.commit()
return {'success': True, 'preferences': current_prefs}