Saas-Base / apps /security /views.py
rsnarsna
fourth commit
63a63e2
Raw
History Blame Contribute Delete
9.56 kB
"""
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)