"""Security blueprint — 2FA, API keys, connected accounts, sessions, login history.
Split from api_proxy.py monolith for maintainability (July 2026).
Imports shared utilities from api_proxy.
"""
from flask import Blueprint, request, jsonify, session as flask_session
from flask_login import current_user
from werkzeug.security import generate_password_hash
from datetime import datetime, timezone
import secrets
import uuid
from app.routes.api_proxy import require_auth_json
from app.utils.csrf import require_csrf
from app.routes.auth_api import _get_user_company
from app.utils.pagination import paginate_query
security_api_bp = Blueprint('security_api', __name__)
@security_api_bp.route('/api/security/2fa/status', methods=['GET'])
@require_auth_json()
def api_security_2fa_status():
"""Return current 2FA status for the authenticated user."""
return jsonify({
'enabled': bool(current_user.totp_enabled),
'totp_enabled': bool(current_user.totp_enabled),
})
@security_api_bp.route('/api/security/2fa/setup', methods=['POST'])
@require_auth_json()
@require_csrf
def api_security_2fa_setup():
"""Generate a fresh TOTP secret and QR code URL."""
import pyotp
import qrcode
import io
import base64
from app.models import db
# Generate secret
secret = pyotp.random_base32()
# Build otpauth URI
issuer = 'Command Sovereignty'
otpauth_url = pyotp.totp.TOTP(secret).provisioning_uri(
name=current_user.email,
issuer_name=issuer,
)
# Generate QR code as base64 PNG
qr = qrcode.make(otpauth_url)
buf = io.BytesIO()
qr.save(buf, format='PNG')
qr_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
qr_url = f'data:image/png;base64,{qr_base64}'
# Temporarily store secret on user (not enabled yet)
current_user.totp_secret = secret
db.session.commit()
return jsonify({
'secret': secret,
'qr_url': qr_url,
'otpauth_url': otpauth_url,
'user_email': current_user.email,
})
@security_api_bp.route('/api/security/2fa/enable', methods=['POST'])
@require_auth_json()
@require_csrf
def api_security_2fa_enable():
"""Verify TOTP code and enable 2FA."""
import pyotp
from app.models import db
data = request.get_json()
if not data:
return jsonify({'error': 'Missing request body'}), 400
code = data.get('code', '').replace(' ', '')
secret = data.get('secret', '')
if not code or not secret:
return jsonify({'error': 'code and secret are required'}), 400
if not current_user.totp_secret:
return jsonify({'error': 'No TOTP secret found — please set up 2FA first'}), 400
# Verify against stored secret (or the provided one)
totp = pyotp.TOTP(secret if secret else current_user.totp_secret)
if not totp.verify(code):
return jsonify({'error': 'Invalid TOTP code'}), 400
current_user.totp_secret = secret if secret else current_user.totp_secret
current_user.totp_enabled = True
db.session.commit()
return jsonify({'success': True, 'message': '2FA enabled successfully'})
@security_api_bp.route('/api/security/2fa/disable', methods=['POST'])
@require_auth_json()
@require_csrf
def api_security_2fa_disable():
"""Disable 2FA after verifying the user's password."""
from app.models import db
data = request.get_json()
if not data:
return jsonify({'error': 'Missing request body'}), 400
current_password = data.get('currentPassword', '')
if not current_password:
return jsonify({'error': 'currentPassword is required'}), 400
if not current_user.check_password(current_password):
return jsonify({'error': 'Incorrect password'}), 403
current_user.totp_enabled = False
current_user.totp_secret = None
db.session.commit()
return jsonify({'success': True, 'message': '2FA disabled successfully'})
# ═══════════════════════════════════════════════════════════════════════
# API Keys
# ═══════════════════════════════════════════════════════════════════════
@security_api_bp.route('/api/security/api-keys', methods=['GET'])
@require_auth_json()
def api_security_api_keys_list():
"""List API keys for the current user (masked keys only)."""
from app.models import ApiKey
keys = ApiKey.query.filter_by(user_id=current_user.id).order_by(
ApiKey.created_at.desc()
).all()
result = []
for key in keys:
result.append({
'id': key.id,
'name': key.name,
'masked_key': (key.prefix or 'csk_') + '••••••••••••••••',
'last_used_at': key.last_used_at.isoformat() if key.last_used_at else None,
'created_at': key.created_at.isoformat() if key.created_at else None,
'expires_at': key.expires_at.isoformat() if key.expires_at else None,
'permissions': key.permissions or {},
})
return jsonify({'success': True, 'data': result})
@security_api_bp.route('/api/security/api-keys', methods=['POST'])
@require_auth_json()
@require_csrf
def api_security_api_keys_create():
"""Create a new API key — returns the full key only once."""
import uuid
from app.models import db, ApiKey
data = request.get_json()
if not data:
return jsonify({'error': 'Missing request body'}), 400
name = data.get('name', '').strip()
if not name:
return jsonify({'error': 'name is required'}), 400
permissions = data.get('permissions', {})
prefix = data.get('prefix', 'csk_')
expires_at_raw = data.get('expires_at')
# Parse optional expires_at
expires_at = None
if expires_at_raw:
from datetime import timezone as tz
expires_at = datetime.fromisoformat(expires_at_raw.replace('Z', '+00:00'))
# Generate the actual API key
raw_key = prefix + secrets.token_hex(24) # 48 hex chars
hashed = generate_password_hash(raw_key)
api_key = ApiKey(
id=str(uuid.uuid4()),
user_id=current_user.id,
name=name,
prefix=prefix,
hashed_key=hashed,
permissions=permissions,
expires_at=expires_at,
)
db.session.add(api_key)
db.session.commit()
return jsonify({
'success': True,
'data': {
'id': api_key.id,
'name': api_key.name,
'key': raw_key, # Only returned once!
'masked_key': prefix + '••••••••••••••••',
'last_used_at': None,
'created_at': api_key.created_at.isoformat() if api_key.created_at else None,
'expires_at': api_key.expires_at.isoformat() if api_key.expires_at else None,
'permissions': api_key.permissions or {},
}
})
@security_api_bp.route('/api/security/api-keys/<key_id>', methods=['PUT'])
@require_auth_json()
@require_csrf
def api_security_api_keys_update(key_id):
"""Update the name of an API key."""
from app.models import db, ApiKey
api_key = ApiKey.query.filter_by(id=key_id, user_id=current_user.id).first()
if not api_key:
return jsonify({'error': 'API key not found'}), 404
data = request.get_json()
if not data:
return jsonify({'error': 'Missing request body'}), 400
name = data.get('name')
if name:
api_key.name = name.strip()
db.session.commit()
return jsonify({
'success': True,
'data': {
'id': api_key.id,
'name': api_key.name,
}
})
@security_api_bp.route('/api/security/api-keys/<key_id>', methods=['DELETE'])
@require_auth_json()
@require_csrf
def api_security_api_keys_revoke(key_id):
"""Revoke (delete) an API key."""
from app.models import db, ApiKey
api_key = ApiKey.query.filter_by(id=key_id, user_id=current_user.id).first()
if not api_key:
return jsonify({'error': 'API key not found'}), 404
db.session.delete(api_key)
db.session.commit()
return jsonify({'success': True, 'message': 'API key revoked'})
# ═══════════════════════════════════════════════════════════════════════
# Connected Accounts (via Connector model)
# ═══════════════════════════════════════════════════════════════════════
@security_api_bp.route('/api/security/connected-accounts', methods=['GET'])
@require_auth_json()
def api_security_connected_accounts():
"""List connected integrations for the user's company."""
from app.models import Connector
company, _ = _get_user_company()
if not company:
return jsonify({'success': True, 'data': []})
connectors = Connector.query.filter_by(company_id=company.id).all()
# Service display map
service_info = {
'google_ads': {
'display_name': 'Google Ads',
'icon': 'google-ads',
'providerUrl': 'https://ads.google.com',
},
'facebook_ads': {
'display_name': 'Facebook Ads',
'icon': 'facebook-ads',
'providerUrl': 'https://business.facebook.com',
},
'google_analytics': {
'display_name': 'Google Analytics',
'icon': 'google-analytics',
'providerUrl': 'https://analytics.google.com',
},
'servicetitan': {
'display_name': 'ServiceTitan',
'icon': 'servicetitan',
'providerUrl': 'https://www.servicetitan.com',
},
'hubspot': {
'display_name': 'HubSpot',
'icon': 'hubspot',
'providerUrl': 'https://www.hubspot.com',
},
'quickbooks': {
'display_name': 'QuickBooks',
'icon': 'quickbooks',
'providerUrl': 'https://quickbooks.intuit.com',
},
}
result = []
for conn in connectors:
info = service_info.get(conn.service, {
'display_name': conn.service.replace('_', ' ').title(),
'icon': conn.service,
'providerUrl': '',
})
result.append({
'service': conn.service,
'display_name': info['display_name'],
'status': conn.status if conn.status else 'inactive',
'connected_at': conn.created_at.isoformat() if conn.created_at else None,
'last_sync': conn.last_sync_at.isoformat() if conn.last_sync_at else None,
'icon': info['icon'],
'providerUrl': info['providerUrl'],
})
return jsonify({'success': True, 'data': result})
@security_api_bp.route('/api/security/connected-accounts/<service>/disconnect', methods=['POST'])
@require_auth_json()
@require_csrf
def api_security_disconnect_account(service):
"""Disconnect (disable) a connector for the user's company."""
from app.models import db, Connector
company, _ = _get_user_company()
if not company:
return jsonify({'error': 'No company found'}), 404
connector = Connector.query.filter_by(
company_id=company.id, service=service
).first()
if not connector:
return jsonify({'error': f'No {service} connection found'}), 404
connector.status = 'inactive'
connector.config_encrypted = None
connector.config_json = None
db.session.commit()
return jsonify({'success': True, 'message': f'{service} disconnected'})
# ═══════════════════════════════════════════════════════════════════════
# Active Sessions
# ═══════════════════════════════════════════════════════════════════════
@security_api_bp.route('/api/security/sessions', methods=['GET'])
@require_auth_json()
def api_security_sessions():
"""List active sessions for the current user."""
from app.models import UserSession
query = UserSession.query.filter_by(
user_id=current_user.id
).order_by(UserSession.created_at.desc())
pagination, meta = paginate_query(query)
sessions = pagination.items
result = []
for s in sessions:
result.append({
'id': s.id,
'session_id': s.session_id,
'device_info': s.device_info or '',
'ip_address': s.ip_address or '',
'is_current': s.is_current,
'last_seen_at': s.last_seen_at.isoformat() if s.last_seen_at else None,
'created_at': s.created_at.isoformat() if s.created_at else None,
})
return jsonify({'success': True, 'data': result, **meta})
@security_api_bp.route('/api/security/sessions/<session_id>/revoke', methods=['POST'])
@require_auth_json()
@require_csrf
def api_security_revoke_session(session_id):
"""Revoke a specific session by ID."""
from app.models import db, UserSession
session_rec = UserSession.query.filter_by(
id=session_id, user_id=current_user.id
).first()
if not session_rec:
return jsonify({'error': 'Session not found'}), 404
# Prevent revoking the current session
if session_rec.is_current:
return jsonify({'error': 'Cannot revoke the current session'}), 400
db.session.delete(session_rec)
db.session.commit()
return jsonify({'success': True, 'message': 'Session revoked'})
@security_api_bp.route('/api/security/sessions/revoke-other', methods=['POST'])
@require_auth_json()
@require_csrf
def api_security_revoke_other_sessions():
"""Revoke all sessions except the current one."""
from app.models import db, UserSession
now = datetime.now(timezone.utc)
# Set is_current=False on all, then set True on current
# First find current session
current = UserSession.query.filter_by(
user_id=current_user.id, is_current=True
).first()
if current:
# Delete all non-current sessions
deleted = UserSession.query.filter(
UserSession.user_id == current_user.id,
UserSession.id != current.id,
).delete(synchronize_session='fetch')
# Update last_seen on current
current.last_seen_at = now
else:
# No current session marked; delete all
deleted = UserSession.query.filter_by(
user_id=current_user.id
).delete(synchronize_session='fetch')
db.session.commit()
return jsonify({
'success': True,
'message': f'Revoked {deleted} session(s)',
'count': deleted,
})
# ═══════════════════════════════════════════════════════════════════════
# Login History
# ═══════════════════════════════════════════════════════════════════════
@security_api_bp.route('/api/security/login-history', methods=['GET'])
@require_auth_json()
def api_security_login_history():
"""Return the last 20 session records (login history) for the user."""
from app.models import UserSession
sessions = UserSession.query.filter_by(
user_id=current_user.id
).order_by(UserSession.created_at.desc()).limit(20).all()
result = []
for s in sessions:
result.append({
'id': s.id,
'session_id': s.session_id,
'device_info': s.device_info or '',
'ip_address': s.ip_address or '',
'is_current': s.is_current,
'last_seen_at': s.last_seen_at.isoformat() if s.last_seen_at else None,
'created_at': s.created_at.isoformat() if s.created_at else None,
})
return jsonify({'success': True, 'data': result})