""" Views for the security app — MFA management, sessions, security dashboard. """ from django.contrib import messages from django.contrib.auth.decorators import login_required from django.shortcuts import redirect, render from django.views.decorators.http import require_GET, require_http_methods from apps.security.forms import BackupCodeForm, MFASetupForm from apps.security.services import ( disable_totp, generate_backup_codes, get_active_sessions, get_recent_events, get_remaining_backup_codes_count, global_logout, is_mfa_enabled, list_trusted_devices, revoke_session, revoke_trusted_device, setup_totp, verify_and_enable_totp, verify_backup_code, ) from core.exceptions import AuthenticationError, ValidationError # ─── MFA Setup ─────────────────────────────────────────────────────────────── @login_required @require_http_methods(["GET", "POST"]) def mfa_setup_view(request): """Set up TOTP MFA — shows QR code data and verifies initial code.""" if request.method == "POST": form = MFASetupForm(request.POST) if form.is_valid(): try: verify_and_enable_totp(request.user, form.cleaned_data["otp_code"]) # Auto-generate backup codes on first MFA enable codes = generate_backup_codes(request.user) request.session["backup_codes"] = codes messages.success(request, "MFA enabled successfully!") return redirect("security:backup-codes") except (AuthenticationError, ValidationError) as e: messages.error(request, str(e)) else: form = MFASetupForm() setup_data = setup_totp(request.user) return render(request, "pages/security/mfa_setup.html", { "form": form, "setup_data": setup_data, }) @login_required @require_http_methods(["GET", "POST"]) def mfa_disable_view(request): """Disable TOTP MFA.""" if request.method == "POST": disable_totp(request.user) messages.success(request, "MFA has been disabled.") return redirect("security:dashboard") return render(request, "pages/security/mfa_disable.html") @require_http_methods(["GET", "POST"]) def mfa_verify_login_view(request): """ Verify TOTP during login (2nd factor). User ID is stored in session['mfa_user_id'] from the login view. """ user_id = request.session.get("mfa_user_id") if not user_id: return redirect("accounts:login") # Import User locally to avoid circular imports if needed, # but apps.accounts.models is already imported in clean project structure? # Actually, verify_totp expects a user object. from django.contrib.auth import get_user_model, login User = get_user_model() try: user = User.objects.get(pk=user_id) except User.DoesNotExist: request.session.pop("mfa_user_id", None) return redirect("accounts:login") from apps.security.forms import MFAVerifyForm from apps.security.services import verify_totp if request.method == "POST": form = MFAVerifyForm(request.POST) if form.is_valid(): code = form.cleaned_data["otp_code"] trust_device = form.cleaned_data.get("trust_device") try: if verify_totp(user, code): # Success login(request, user) request.session.pop("mfa_user_id", None) next_url = request.session.pop("mfa_next", "accounts:profile") response = redirect(next_url) if trust_device: import uuid from apps.security.services import register_trusted_device device_id = str(uuid.uuid4()) fingerprint = getattr(request, "device_fingerprint", "") register_trusted_device(user, device_id, fingerprint=fingerprint) response.set_signed_cookie( "trusted_device", device_id, salt="mfa", max_age=2592000, # 30 days httponly=True, secure=request.is_secure(), samesite="Lax", ) messages.success(request, "Two-factor authentication successful. Device trusted.") else: messages.success(request, "Two-factor authentication successful.") return response else: form.add_error("otp_code", "Invalid authentication code.") except Exception as e: # ValidationError or others form.add_error(None, str(e)) else: form = MFAVerifyForm() return render(request, "pages/security/mfa_verify_login.html", {"form": form}) # ─── Backup Codes ──────────────────────────────────────────────────────────── @login_required @require_GET def backup_codes_view(request): """Display backup codes (only after initial generation).""" codes = request.session.pop("backup_codes", None) remaining = get_remaining_backup_codes_count(request.user) return render(request, "pages/security/backup_codes.html", { "codes": codes, "remaining": remaining, }) @login_required @require_http_methods(["GET", "POST"]) def regenerate_backup_codes_view(request): """Regenerate backup codes (invalidates old ones).""" if request.method == "POST": codes = generate_backup_codes(request.user) request.session["backup_codes"] = codes messages.success(request, "New backup codes generated.") return redirect("security:backup-codes") return render(request, "pages/security/regenerate_backup_codes.html") @login_required @require_http_methods(["GET", "POST"]) def verify_backup_code_view(request): """Verify a backup code for MFA recovery.""" if request.method == "POST": form = BackupCodeForm(request.POST) if form.is_valid(): if verify_backup_code(request.user, form.cleaned_data["backup_code"]): messages.success(request, "Backup code accepted.") return redirect("accounts:profile") else: messages.error(request, "Invalid or already used backup code.") else: form = BackupCodeForm() return render(request, "pages/security/verify_backup_code.html", {"form": form}) # ─── Sessions Management ──────────────────────────────────────────────────── @login_required @require_GET def sessions_view(request): """View all active sessions.""" sessions = get_active_sessions(request.user) return render(request, "pages/security/sessions.html", {"sessions": sessions}) @login_required @require_http_methods(["POST"]) def revoke_session_view(request, session_id: str): """Revoke a specific session.""" try: revoke_session(session_id, request.user) messages.success(request, "Session revoked.") except ValueError as e: messages.error(request, str(e)) return redirect("security:sessions") @login_required @require_http_methods(["POST"]) def global_logout_view(request): """Revoke ALL sessions (global logout).""" count = global_logout(request.user) messages.success(request, f"Logged out from {count} session(s).") return redirect("accounts:login") # ─── Trusted Devices ──────────────────────────────────────────────────────── @login_required @require_GET def trusted_devices_view(request): """View all trusted devices.""" devices = list_trusted_devices(request.user) return render(request, "pages/security/trusted_devices.html", {"devices": devices}) @login_required @require_http_methods(["POST"]) def revoke_device_view(request, device_id: str): """Remove a trusted device.""" revoke_trusted_device(request.user, device_id) messages.success(request, "Device removed from trusted list.") return redirect("security:trusted-devices") # ─── Security Dashboard ───────────────────────────────────────────────────── @login_required @require_GET def security_dashboard_view(request): """Security overview — MFA status, sessions, recent events.""" context = { "mfa_enabled": is_mfa_enabled(request.user), "active_sessions": get_active_sessions(request.user)[:5], "recent_events": get_recent_events(request.user, limit=10), "backup_codes_remaining": get_remaining_backup_codes_count(request.user), "trusted_devices": list_trusted_devices(request.user)[:5], } return render(request, "pages/security/index.html", context)