Files
media-downloader/web/backend/duo_manager.py
Todd 0d7b2b1aab Initial commit
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 22:42:55 -04:00

439 lines
15 KiB
Python

#!/usr/bin/env python3
"""
Duo Manager for Media Downloader
Handles Duo Security 2FA Operations
Based on backup-central's implementation
Uses Duo Universal Prompt
"""
import sys
import sqlite3
import secrets
from datetime import datetime, timedelta
from typing import Optional, Dict
from pathlib import Path
import os
# Add parent path to allow imports from modules
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from modules.universal_logger import get_logger
logger = get_logger('DuoManager')
class DuoManager:
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
# Duo Configuration (from environment variables)
self.client_id = os.getenv('DUO_CLIENT_ID')
self.client_secret = os.getenv('DUO_CLIENT_SECRET')
self.api_host = os.getenv('DUO_API_HOSTNAME')
self.redirect_url = os.getenv('DUO_REDIRECT_URL', 'https://md.lic.ad/api/auth/2fa/duo/callback')
# Check if Duo is configured
self.is_configured = bool(self.client_id and self.client_secret and self.api_host)
# State store (for OAuth flow)
self.state_store = {} # username → state mapping
# Initialize database
self._init_database()
# Initialize Duo client if configured
self.duo_client = None
if self.is_configured:
self._init_duo_client()
def _init_database(self):
"""Initialize Duo-related database tables"""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
# Ensure Duo columns exist in users table
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
try:
cursor.execute("ALTER TABLE users ADD COLUMN duo_enrolled_at TEXT")
except sqlite3.OperationalError:
pass
# Duo audit log
cursor.execute("""
CREATE TABLE IF NOT EXISTS duo_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
action TEXT NOT NULL,
success INTEGER NOT NULL,
ip_address TEXT,
user_agent TEXT,
details TEXT,
timestamp TEXT NOT NULL
)
""")
conn.commit()
def _init_duo_client(self):
"""Initialize Duo Universal Prompt client"""
try:
from duo_universal import Client
self.duo_client = Client(
client_id=self.client_id,
client_secret=self.client_secret,
host=self.api_host,
redirect_uri=self.redirect_url
)
logger.info("Duo Universal Prompt client initialized successfully", module="Duo")
except Exception as e:
logger.error(f"Error initializing Duo client: {e}", module="Duo")
self.is_configured = False
def is_duo_configured(self) -> bool:
"""Check if Duo is properly configured"""
return self.is_configured
def get_configuration_status(self) -> Dict:
"""Get Duo configuration status"""
return {
'configured': self.is_configured,
'hasClientId': bool(self.client_id),
'hasClientSecret': bool(self.client_secret),
'hasApiHostname': bool(self.api_host),
'hasRedirectUrl': bool(self.redirect_url),
'apiHostname': self.api_host if self.api_host else None
}
def generate_state(self, username: str, remember_me: bool = False) -> str:
"""
Generate a state parameter for Duo OAuth flow
Args:
username: Username to associate with this state
remember_me: Whether to remember the user for 30 days
Returns:
Random state string
"""
state = secrets.token_urlsafe(32)
self.state_store[state] = {
'username': username,
'remember_me': remember_me,
'created_at': datetime.now(),
'expires_at': datetime.now() + timedelta(minutes=10)
}
return state
def verify_state(self, state: str) -> Optional[tuple]:
"""
Verify state parameter and return associated username and remember_me
Args:
state: State parameter from Duo callback
Returns:
Tuple of (username, remember_me) if valid, None otherwise
"""
if state not in self.state_store:
return None
state_data = self.state_store[state]
# Check if expired
if datetime.now() > state_data['expires_at']:
del self.state_store[state]
return None
username = state_data['username']
remember_me = state_data.get('remember_me', False)
del self.state_store[state] # One-time use
return (username, remember_me)
def create_auth_url(self, username: str, remember_me: bool = False) -> Dict:
"""
Create Duo authentication URL for user
Args:
username: Username to authenticate
remember_me: Whether to remember the user for 30 days
Returns:
Dict with authUrl and state
"""
if not self.is_configured or not self.duo_client:
raise Exception("Duo is not configured")
try:
# Generate state
state = self.generate_state(username, remember_me)
# Get Duo username for this user
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute("SELECT duo_username FROM users WHERE username = ?", (username,))
result = cursor.fetchone()
duo_username = result[0] if result and result[0] else username
# Create Duo Universal Prompt auth URL
auth_url = self.duo_client.create_auth_url(duo_username, state)
self._log_audit(username, 'duo_auth_url_created', True, None, None,
'Duo authentication URL created')
return {
'authUrl': auth_url,
'state': state
}
except Exception as e:
self._log_audit(username, 'duo_auth_url_failed', False, None, None,
f'Error: {str(e)}')
raise
def verify_duo_response(self, duo_code: str, username: str) -> bool:
"""
Verify Duo authentication response (Universal Prompt)
Args:
duo_code: Authorization code from Duo
username: Expected username
Returns:
True if authentication successful
"""
if not self.is_configured or not self.duo_client:
return False
try:
# Exchange authorization code for 2FA result
decoded_token = self.duo_client.exchange_authorization_code_for_2fa_result(
duo_code,
username
)
# Check authentication result
if decoded_token and decoded_token.get('auth_result', {}).get('result') == 'allow':
authenticated_username = decoded_token.get('preferred_username')
# Get Duo username for this user
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute("SELECT duo_username FROM users WHERE username = ?", (username,))
result = cursor.fetchone()
duo_username = result[0] if result and result[0] else username
# Check if username matches
if authenticated_username == duo_username:
self._log_audit(username, 'duo_verify_success', True, None, None,
'Duo authentication successful')
return True
else:
self._log_audit(username, 'duo_verify_failed', False, None, None,
f'Username mismatch: expected {duo_username}, got {authenticated_username}')
return False
else:
self._log_audit(username, 'duo_verify_failed', False, None, None,
'Duo authentication denied')
return False
except Exception as e:
self._log_audit(username, 'duo_verify_failed', False, None, None,
f'Error: {str(e)}')
logger.error(f"Duo verification error: {e}", module="Duo")
return False
def _get_application_key(self) -> str:
"""Get or generate application key for Duo"""
key_file = Path(__file__).parent.parent.parent / '.duo_application_key'
if key_file.exists():
with open(key_file, 'r') as f:
return f.read().strip()
# Generate new key (at least 40 characters)
key = secrets.token_urlsafe(40)
try:
with open(key_file, 'w') as f:
f.write(key)
os.chmod(key_file, 0o600)
except Exception as e:
logger.warning(f"Could not save Duo application key: {e}", module="Duo")
return key
def enroll_user(self, username: str, duo_username: Optional[str] = None) -> bool:
"""
Enroll a user in Duo 2FA
Args:
username: Username
duo_username: Optional Duo username (defaults to username)
Returns:
True if successful
"""
if duo_username is None:
duo_username = username
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE users
SET duo_enabled = 1, duo_username = ?, duo_enrolled_at = ?
WHERE username = ?
""", (duo_username, datetime.now().isoformat(), username))
conn.commit()
self._log_audit(username, 'duo_enrolled', True, None, None,
f'Duo enrolled with username: {duo_username}')
return True
except Exception as e:
logger.error(f"Error enrolling user in Duo: {e}", module="Duo")
return False
def unenroll_user(self, username: str) -> bool:
"""
Unenroll a user from Duo 2FA
Args:
username: Username
Returns:
True if successful
"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE users
SET duo_enabled = 0, duo_username = NULL, duo_enrolled_at = NULL
WHERE username = ?
""", (username,))
conn.commit()
self._log_audit(username, 'duo_unenrolled', True, None, None,
'Duo unenrolled')
return True
except Exception as e:
logger.error(f"Error unenrolling user from Duo: {e}", module="Duo")
return False
def get_duo_status(self, username: str) -> Dict:
"""
Get Duo status for a user
Args:
username: Username
Returns:
Dict with enabled, duoUsername, enrolledAt
"""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT duo_enabled, duo_username, duo_enrolled_at
FROM users
WHERE username = ?
""", (username,))
row = cursor.fetchone()
if not row:
return {'enabled': False, 'duoUsername': None, 'enrolledAt': None}
return {
'enabled': bool(row[0]),
'duoUsername': row[1],
'enrolledAt': row[2]
}
def preauth_user(self, username: str) -> Dict:
"""
Perform Duo preauth to check enrollment status
Args:
username: Username
Returns:
Dict with result and status_msg
"""
if not self.is_configured or not self.duo_client:
return {'result': 'error', 'status_msg': 'Duo not configured'}
try:
# Get Duo username
user_info = self.get_duo_status(username)
duo_username = user_info.get('duoUsername', username)
# Perform preauth
preauth_result = self.duo_client.preauth(username=duo_username)
self._log_audit(username, 'duo_preauth', True, None, None,
f'Preauth result: {preauth_result.get("result")}')
return preauth_result
except Exception as e:
self._log_audit(username, 'duo_preauth_failed', False, None, None,
f'Error: {str(e)}')
return {'result': 'error', 'status_msg': 'Failed to check Duo enrollment status'}
def _log_audit(self, username: str, action: str, success: bool,
ip_address: Optional[str], user_agent: Optional[str],
details: Optional[str] = None):
"""Log Duo audit event"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO duo_audit_log
(username, action, success, ip_address, user_agent, details, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (username, action, int(success), ip_address, user_agent, details,
datetime.now().isoformat()))
conn.commit()
except Exception as e:
logger.error(f"Error logging Duo audit: {e}", module="Duo")
def health_check(self) -> Dict:
"""
Check Duo service health
Returns:
Dict with healthy status and details
"""
if not self.is_configured:
return {'healthy': False, 'error': 'Duo not configured'}
try:
# Simple ping to Duo API
response = self.duo_client.ping()
return {'healthy': True, 'response': response}
except Exception as e:
logger.error(f"Duo health check failed: {e}", module="Duo")
return {'healthy': False, 'error': 'Duo service unavailable'}