439 lines
15 KiB
Python
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'}
|