565
web/backend/auth_manager.py
Normal file
565
web/backend/auth_manager.py
Normal 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}
|
||||
Reference in New Issue
Block a user