| """ |
| 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 |
|
|
|
|
| |
|
|
|
|
| @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"]) |
| |
| 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") |
|
|
| |
| |
| |
| 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): |
| |
| 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, |
| 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: |
| |
| form.add_error(None, str(e)) |
| else: |
| form = MFAVerifyForm() |
|
|
| return render(request, "pages/security/mfa_verify_login.html", {"form": form}) |
|
|
|
|
| |
|
|
|
|
| @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}) |
|
|
|
|
| |
|
|
|
|
| @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") |
|
|
|
|
| |
|
|
|
|
| @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") |
|
|
|
|
| |
|
|
|
|
| @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) |
|
|