""" OTP views for admin sudo authentication. These views handle OTP device setup, verification, and recovery code management. """ from __future__ import annotations import json import logging from typing import Any from django import forms from django.contrib.auth.mixins import UserPassesTestMixin from django.contrib.auth.views import redirect_to_login from django.http import HttpRequest, HttpResponse from django.shortcuts import redirect from django.urls import reverse from django.utils.http import url_has_allowed_host_and_scheme from django.views import View from django.conf import settings from inertia import render from labs.models import AdminOTPDevice from ..otp import ( OTPRateLimitError, OTPVerificationError, totp_service, ) from ..sudo import grant_sudo, is_sudo_active, revoke_sudo from .base import AdminBaseViewMixin logger = logging.getLogger(__name__) # Default redirect URL for admin views DEFAULT_ADMIN_REDIRECT = "labs_admin:dashboard" def get_safe_redirect_url( request: HttpRequest, url: str | None, default_url_name: str = DEFAULT_ADMIN_REDIRECT, ) -> str: """ Validate and return a safe redirect URL. This function prevents open redirect vulnerabilities by validating that the redirect URL is either: 1. A relative path on the same host 2. An absolute URL pointing to an allowed host Args: request: The current HTTP request. url: The URL to validate (from user input). default_url_name: The URL name to redirect to if validation fails. Returns: A safe redirect URL string. """ if url and url_has_allowed_host_and_scheme( url, allowed_hosts={request.get_host()}, require_https=request.is_secure(), ): return url return reverse(default_url_name) class StaffRequiredMixin(UserPassesTestMixin): """ Mixin that requires staff authentication but NOT sudo. Used for OTP setup and verification pages which are needed to obtain sudo privileges. """ login_url = settings.LOGIN_URL redirect_field_name = "redirect_uri" def test_func(self) -> bool: user = self.request.user return bool(user and user.is_active and user.is_staff) def handle_no_permission(self) -> HttpResponse: if not self.request.user.is_authenticated: return redirect_to_login( self.request.get_full_path(), str(self.login_url), self.redirect_field_name, ) # User is authenticated but not staff from django.core.exceptions import PermissionDenied raise PermissionDenied class OTPCodeForm(forms.Form): """Form for OTP code verification.""" code = forms.CharField( min_length=6, max_length=6, strip=True, ) def clean_code(self) -> str: code = self.cleaned_data["code"] # Remove any spaces or dashes and ensure only digits code = code.replace(" ", "").replace("-", "") if not code.isdigit(): raise forms.ValidationError("Code must contain only digits") return code class RecoveryCodeForm(forms.Form): """Form for recovery code verification.""" code = forms.CharField( min_length=8, max_length=12, strip=True, ) class OTPSetupView(StaffRequiredMixin, View): """ View for setting up OTP device. Generates a new TOTP secret and displays a QR code for the user to scan with their authenticator app. """ def get(self, request: HttpRequest) -> HttpResponse: user = request.user # Check if user already has a confirmed OTP device try: device = user.admin_otp_device if device.is_confirmed: # Device already setup, redirect to verify or dashboard if is_sudo_active(request): return redirect("labs_admin:dashboard") return redirect("labs_admin:otp_verify") except AdminOTPDevice.DoesNotExist: device = None # Generate new secret if no device exists or device is not confirmed if device is None: secret = totp_service.generate_secret() recovery_codes = totp_service.generate_recovery_codes() device = AdminOTPDevice.objects.create( user=user, secret_encrypted=secret, recovery_codes_encrypted=json.dumps(recovery_codes), is_confirmed=False, ) else: # Regenerate for unconfirmed device secret = totp_service.generate_secret() recovery_codes = totp_service.generate_recovery_codes() device.secret_encrypted = secret device.recovery_codes_encrypted = json.dumps(recovery_codes) device.save() # Get the secret for display (it's auto-decrypted by EncryptedTextField) secret = device.secret_encrypted provisioning_uri = totp_service.get_provisioning_uri(user, secret) recovery_codes = json.loads(device.recovery_codes_encrypted) return render( request, "otp/Setup", { "secret": secret, "provisioning_uri": provisioning_uri, "recovery_codes": recovery_codes, }, ) def post(self, request: HttpRequest) -> HttpResponse: """Verify the OTP code to confirm device setup.""" user = request.user try: device = user.admin_otp_device except AdminOTPDevice.DoesNotExist: request.session["flash_error"] = "Please set up OTP first" return redirect("labs_admin:otp_setup") form = OTPCodeForm(request.POST) if not form.is_valid(): secret = device.secret_encrypted provisioning_uri = totp_service.get_provisioning_uri(user, secret) recovery_codes = json.loads(device.recovery_codes_encrypted or "[]") return render( request, "otp/Setup", { "secret": secret, "provisioning_uri": provisioning_uri, "recovery_codes": recovery_codes, "errors": {"code": ["Please enter a valid 6-digit code"]}, }, ) code = form.cleaned_data["code"] try: # Verify without rate limiting during setup (we use a different check) totp_service.verify_code(device, code, check_rate_limit=True) device.is_confirmed = True device.save(update_fields=["is_confirmed", "updated_at"]) # Grant sudo after successful setup grant_sudo(request) logger.info( "OTP device confirmed for user %s (id=%d)", user.email, user.id, ) request.session["flash_success"] = ( "Two-factor authentication enabled successfully" ) return redirect("labs_admin:dashboard") except OTPRateLimitError as e: secret = device.secret_encrypted provisioning_uri = totp_service.get_provisioning_uri(user, secret) recovery_codes = json.loads(device.recovery_codes_encrypted or "[]") return render( request, "otp/Setup", { "secret": secret, "provisioning_uri": provisioning_uri, "recovery_codes": recovery_codes, "errors": {"code": [str(e)]}, "rate_limited": True, "rate_limit_seconds": e.remaining_seconds, }, ) except OTPVerificationError: secret = device.secret_encrypted provisioning_uri = totp_service.get_provisioning_uri(user, secret) recovery_codes = json.loads(device.recovery_codes_encrypted or "[]") return render( request, "otp/Setup", { "secret": secret, "provisioning_uri": provisioning_uri, "recovery_codes": recovery_codes, "errors": { "code": ["Invalid verification code. Please try again."] }, }, ) class OTPVerifyView(StaffRequiredMixin, View): """ View for OTP verification (sudo authentication). Prompts the user to enter their OTP code to gain sudo privileges. """ def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: # Check if user has OTP setup if request.user.is_authenticated and request.user.is_staff: try: device = request.user.admin_otp_device if not device.is_confirmed: return redirect("labs_admin:otp_setup") except AdminOTPDevice.DoesNotExist: return redirect("labs_admin:otp_setup") # If already has sudo, redirect to intended destination if is_sudo_active(request): next_url = request.GET.get("next") return redirect(get_safe_redirect_url(request, next_url)) return super().dispatch(request, *args, **kwargs) def get(self, request: HttpRequest) -> HttpResponse: next_url = request.GET.get("next", "") return render( request, "otp/Verify", { "next": next_url, }, ) def post(self, request: HttpRequest) -> HttpResponse: user = request.user next_url = request.POST.get("next", "") or request.GET.get("next", "") try: device = user.admin_otp_device except AdminOTPDevice.DoesNotExist: return redirect("labs_admin:otp_setup") form = OTPCodeForm(request.POST) if not form.is_valid(): return render( request, "otp/Verify", { "next": next_url, "errors": {"code": ["Please enter a valid 6-digit code"]}, }, ) code = form.cleaned_data["code"] try: totp_service.verify_code(device, code) grant_sudo(request) logger.info( "Sudo granted via OTP for user %s (id=%d)", user.email, user.id, ) return redirect(get_safe_redirect_url(request, next_url)) except OTPRateLimitError as e: return render( request, "otp/Verify", { "next": next_url, "errors": {"code": [str(e)]}, "rate_limited": True, "rate_limit_seconds": e.remaining_seconds, }, ) except OTPVerificationError: return render( request, "otp/Verify", { "next": next_url, "errors": { "code": ["Invalid verification code. Please try again."] }, }, ) class OTPRecoveryView(StaffRequiredMixin, View): """ View for recovery code verification. Allows users to use a recovery code when they don't have access to their authenticator app. """ def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: if request.user.is_authenticated and request.user.is_staff: try: device = request.user.admin_otp_device if not device.is_confirmed: return redirect("labs_admin:otp_setup") except AdminOTPDevice.DoesNotExist: return redirect("labs_admin:otp_setup") if is_sudo_active(request): return redirect("labs_admin:dashboard") return super().dispatch(request, *args, **kwargs) def get(self, request: HttpRequest) -> HttpResponse: next_url = request.GET.get("next", "") return render( request, "otp/Recovery", { "next": next_url, }, ) def post(self, request: HttpRequest) -> HttpResponse: user = request.user next_url = request.POST.get("next", "") or request.GET.get("next", "") try: device = user.admin_otp_device except AdminOTPDevice.DoesNotExist: return redirect("labs_admin:otp_setup") form = RecoveryCodeForm(request.POST) if not form.is_valid(): return render( request, "otp/Recovery", { "next": next_url, "errors": {"code": ["Please enter a valid recovery code"]}, }, ) code = form.cleaned_data["code"] try: totp_service.verify_recovery_code(device, code) grant_sudo(request) remaining = totp_service.get_remaining_recovery_codes_count(device) logger.info( "Sudo granted via recovery code for user %s (id=%d). Remaining codes: %d", user.email, user.id, remaining, ) if remaining <= 2: request.session["flash_success"] = ( f"Recovery code accepted. Warning: Only {remaining} recovery codes remaining. " "Please generate new codes." ) else: request.session["flash_success"] = "Recovery code accepted" return redirect(get_safe_redirect_url(request, next_url)) except OTPRateLimitError as e: return render( request, "otp/Recovery", { "next": next_url, "errors": {"code": [str(e)]}, "rate_limited": True, "rate_limit_seconds": e.remaining_seconds, }, ) except OTPVerificationError: return render( request, "otp/Recovery", { "next": next_url, "errors": {"code": ["Invalid recovery code. Please try again."]}, }, ) class OTPRegenerateRecoveryView(AdminBaseViewMixin, View): """ View for regenerating recovery codes. This requires sudo authentication since it's a sensitive operation. """ def get(self, request: HttpRequest) -> HttpResponse: user = request.user try: device = user.admin_otp_device remaining = totp_service.get_remaining_recovery_codes_count(device) except AdminOTPDevice.DoesNotExist: return redirect("labs_admin:otp_setup") return render( request, "otp/RegenerateRecovery", { "remaining_codes": remaining, }, ) def post(self, request: HttpRequest) -> HttpResponse: user = request.user try: device = user.admin_otp_device except AdminOTPDevice.DoesNotExist: return redirect("labs_admin:otp_setup") # Generate new recovery codes recovery_codes = totp_service.generate_recovery_codes() device.recovery_codes_encrypted = json.dumps(recovery_codes) device.save(update_fields=["recovery_codes_encrypted", "updated_at"]) logger.info( "Recovery codes regenerated for user %s (id=%d)", user.email, user.id, ) return render( request, "otp/RegenerateRecovery", { "recovery_codes": recovery_codes, "regenerated": True, }, ) class ExitSudoView(StaffRequiredMixin, View): """ View for exiting sudo mode. Revokes the current sudo session and redirects to OTP verification. """ def post(self, request: HttpRequest) -> HttpResponse: user = request.user revoke_sudo(request) logger.info( "User %s (id=%d) exited sudo mode", user.email, user.id, ) return redirect("labs_admin:otp_verify") class LogoutView(StaffRequiredMixin, View): """ View for logging out from admin. Revokes sudo and logs out the user completely. """ def post(self, request: HttpRequest) -> HttpResponse: from django.contrib.auth import logout user = request.user revoke_sudo(request) logger.info( "User %s (id=%d) logged out from admin", user.email, user.id, ) logout(request) return redirect(settings.LOGIN_URL)