mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 00:55:19 -05:00
feat: Implement passkey authentication, account management features, and a dedicated MFA login verification flow.
This commit is contained in:
@@ -19,7 +19,7 @@ from django.db import transaction
|
||||
from django.db.models import Count, Q
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAdminUser
|
||||
from apps.core.permissions import IsAdminWithSecondFactor
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
@@ -35,7 +35,7 @@ class OSMUsageStatsView(APIView):
|
||||
Return OSM cache statistics for admin dashboard.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAdminUser]
|
||||
permission_classes = [IsAdminWithSecondFactor]
|
||||
|
||||
def get(self, request):
|
||||
"""Return OSM/location cache usage statistics."""
|
||||
@@ -128,7 +128,7 @@ class RateLimitMetricsView(APIView):
|
||||
Return rate limiting metrics for admin dashboard.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAdminUser]
|
||||
permission_classes = [IsAdminWithSecondFactor]
|
||||
|
||||
def post(self, request):
|
||||
"""Return rate limit metrics based on action."""
|
||||
@@ -200,7 +200,7 @@ class DatabaseManagerView(APIView):
|
||||
Handle admin CRUD operations for entities.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAdminUser]
|
||||
permission_classes = [IsAdminWithSecondFactor]
|
||||
|
||||
# Map entity types to Django models
|
||||
ENTITY_MODEL_MAP = {
|
||||
@@ -627,7 +627,7 @@ class CeleryTaskStatusView(APIView):
|
||||
Return Celery task status (read-only).
|
||||
"""
|
||||
|
||||
permission_classes = [IsAdminUser]
|
||||
permission_classes = [IsAdminWithSecondFactor]
|
||||
|
||||
# List of known scheduled tasks
|
||||
SCHEDULED_TASKS = [
|
||||
@@ -734,7 +734,7 @@ class DetectAnomaliesView(APIView):
|
||||
TODO: Implement full ML algorithms with numpy/scipy in follow-up task.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAdminUser]
|
||||
permission_classes = [IsAdminWithSecondFactor]
|
||||
|
||||
# Severity score thresholds
|
||||
SEVERITY_THRESHOLDS = {
|
||||
@@ -932,7 +932,7 @@ class CollectMetricsView(APIView):
|
||||
BULLETPROOFED: Safe input parsing with validation.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAdminUser]
|
||||
permission_classes = [IsAdminWithSecondFactor]
|
||||
|
||||
# Allowed values
|
||||
ALLOWED_METRIC_TYPES = {"all", "database", "users", "moderation", "performance"}
|
||||
@@ -1043,7 +1043,7 @@ class PipelineIntegrityScanView(APIView):
|
||||
BULLETPROOFED: Safe input parsing with validation.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAdminUser]
|
||||
permission_classes = [IsAdminWithSecondFactor]
|
||||
|
||||
# Allowed values
|
||||
ALLOWED_SCAN_TYPES = {"full", "referential", "status", "media", "submissions", "stuck", "versions"}
|
||||
|
||||
418
backend/apps/api/v1/auth/account_management.py
Normal file
418
backend/apps/api/v1/auth/account_management.py
Normal file
@@ -0,0 +1,418 @@
|
||||
"""
|
||||
Account Management Views for ThrillWiki API v1.
|
||||
|
||||
Handles email changes, account deletion, and session management.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
UserModel = get_user_model()
|
||||
|
||||
|
||||
# ============== EMAIL CHANGE ENDPOINTS ==============
|
||||
|
||||
@extend_schema(
|
||||
operation_id="request_email_change",
|
||||
summary="Request email change",
|
||||
description="Initiates an email change request. Sends verification to new email.",
|
||||
request={
|
||||
"application/json": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"new_email": {"type": "string", "format": "email"},
|
||||
"password": {"type": "string", "description": "Current password for verification"},
|
||||
},
|
||||
"required": ["new_email", "password"],
|
||||
}
|
||||
},
|
||||
responses={
|
||||
200: {"description": "Email change requested"},
|
||||
400: {"description": "Invalid request"},
|
||||
},
|
||||
tags=["Account"],
|
||||
)
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def request_email_change(request):
|
||||
"""Request to change email address."""
|
||||
user = request.user
|
||||
new_email = request.data.get("new_email", "").strip().lower()
|
||||
password = request.data.get("password", "")
|
||||
|
||||
if not new_email:
|
||||
return Response(
|
||||
{"detail": "New email is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if not user.check_password(password):
|
||||
return Response(
|
||||
{"detail": "Invalid password"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if email already in use
|
||||
if UserModel.objects.filter(email=new_email).exclude(pk=user.pk).exists():
|
||||
return Response(
|
||||
{"detail": "This email is already in use"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Store pending email change in cache
|
||||
cache_key = f"email_change:{user.pk}"
|
||||
cache.set(
|
||||
cache_key,
|
||||
{
|
||||
"new_email": new_email,
|
||||
"requested_at": timezone.now().isoformat(),
|
||||
},
|
||||
timeout=86400, # 24 hours
|
||||
)
|
||||
|
||||
# TODO: Send verification email to new_email
|
||||
# For now, just store the pending change
|
||||
|
||||
return Response({
|
||||
"detail": "Email change requested. Please check your new email for verification.",
|
||||
"new_email": new_email,
|
||||
})
|
||||
|
||||
|
||||
@extend_schema(
|
||||
operation_id="get_email_change_status",
|
||||
summary="Get pending email change status",
|
||||
responses={
|
||||
200: {
|
||||
"description": "Email change status",
|
||||
"example": {
|
||||
"has_pending_change": True,
|
||||
"new_email": "new@example.com",
|
||||
"requested_at": "2026-01-06T12:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags=["Account"],
|
||||
)
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_email_change_status(request):
|
||||
"""Get status of pending email change."""
|
||||
user = request.user
|
||||
cache_key = f"email_change:{user.pk}"
|
||||
pending = cache.get(cache_key)
|
||||
|
||||
if not pending:
|
||||
return Response({
|
||||
"has_pending_change": False,
|
||||
"new_email": None,
|
||||
"requested_at": None,
|
||||
})
|
||||
|
||||
return Response({
|
||||
"has_pending_change": True,
|
||||
"new_email": pending.get("new_email"),
|
||||
"requested_at": pending.get("requested_at"),
|
||||
})
|
||||
|
||||
|
||||
@extend_schema(
|
||||
operation_id="cancel_email_change",
|
||||
summary="Cancel pending email change",
|
||||
responses={
|
||||
200: {"description": "Email change cancelled"},
|
||||
},
|
||||
tags=["Account"],
|
||||
)
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def cancel_email_change(request):
|
||||
"""Cancel a pending email change request."""
|
||||
user = request.user
|
||||
cache_key = f"email_change:{user.pk}"
|
||||
cache.delete(cache_key)
|
||||
|
||||
return Response({"detail": "Email change cancelled"})
|
||||
|
||||
|
||||
# ============== ACCOUNT DELETION ENDPOINTS ==============
|
||||
|
||||
@extend_schema(
|
||||
operation_id="request_account_deletion",
|
||||
summary="Request account deletion",
|
||||
description="Initiates account deletion. Requires password confirmation.",
|
||||
request={
|
||||
"application/json": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"password": {"type": "string"},
|
||||
"reason": {"type": "string", "description": "Optional reason for leaving"},
|
||||
},
|
||||
"required": ["password"],
|
||||
}
|
||||
},
|
||||
responses={
|
||||
200: {"description": "Deletion requested"},
|
||||
400: {"description": "Invalid password"},
|
||||
},
|
||||
tags=["Account"],
|
||||
)
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def request_account_deletion(request):
|
||||
"""Request account deletion."""
|
||||
user = request.user
|
||||
password = request.data.get("password", "")
|
||||
reason = request.data.get("reason", "")
|
||||
|
||||
if not user.check_password(password):
|
||||
return Response(
|
||||
{"detail": "Invalid password"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Store deletion request in cache (will be processed by background task)
|
||||
cache_key = f"account_deletion:{user.pk}"
|
||||
deletion_date = timezone.now() + timezone.timedelta(days=30)
|
||||
|
||||
cache.set(
|
||||
cache_key,
|
||||
{
|
||||
"requested_at": timezone.now().isoformat(),
|
||||
"scheduled_deletion": deletion_date.isoformat(),
|
||||
"reason": reason,
|
||||
},
|
||||
timeout=2592000, # 30 days
|
||||
)
|
||||
|
||||
# Also update user profile if it exists
|
||||
try:
|
||||
from apps.accounts.models import Profile
|
||||
profile = Profile.objects.filter(user=user).first()
|
||||
if profile:
|
||||
profile.deletion_requested_at = timezone.now()
|
||||
profile.scheduled_deletion_date = deletion_date
|
||||
profile.save(update_fields=["deletion_requested_at", "scheduled_deletion_date"])
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not update profile for deletion: {e}")
|
||||
|
||||
return Response({
|
||||
"detail": "Account deletion scheduled",
|
||||
"scheduled_deletion": deletion_date.isoformat(),
|
||||
})
|
||||
|
||||
|
||||
@extend_schema(
|
||||
operation_id="get_deletion_status",
|
||||
summary="Get account deletion status",
|
||||
responses={
|
||||
200: {
|
||||
"description": "Deletion status",
|
||||
"example": {
|
||||
"deletion_pending": True,
|
||||
"scheduled_deletion": "2026-02-06T12:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags=["Account"],
|
||||
)
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_deletion_status(request):
|
||||
"""Get status of pending account deletion."""
|
||||
user = request.user
|
||||
cache_key = f"account_deletion:{user.pk}"
|
||||
pending = cache.get(cache_key)
|
||||
|
||||
if not pending:
|
||||
# Also check profile
|
||||
try:
|
||||
from apps.accounts.models import Profile
|
||||
profile = Profile.objects.filter(user=user).first()
|
||||
if profile and profile.deletion_requested_at:
|
||||
return Response({
|
||||
"deletion_pending": True,
|
||||
"requested_at": profile.deletion_requested_at.isoformat(),
|
||||
"scheduled_deletion": profile.scheduled_deletion_date.isoformat() if profile.scheduled_deletion_date else None,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return Response({
|
||||
"deletion_pending": False,
|
||||
"scheduled_deletion": None,
|
||||
})
|
||||
|
||||
return Response({
|
||||
"deletion_pending": True,
|
||||
"requested_at": pending.get("requested_at"),
|
||||
"scheduled_deletion": pending.get("scheduled_deletion"),
|
||||
})
|
||||
|
||||
|
||||
@extend_schema(
|
||||
operation_id="cancel_account_deletion",
|
||||
summary="Cancel account deletion",
|
||||
responses={
|
||||
200: {"description": "Deletion cancelled"},
|
||||
},
|
||||
tags=["Account"],
|
||||
)
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def cancel_account_deletion(request):
|
||||
"""Cancel a pending account deletion request."""
|
||||
user = request.user
|
||||
cache_key = f"account_deletion:{user.pk}"
|
||||
cache.delete(cache_key)
|
||||
|
||||
# Also clear from profile
|
||||
try:
|
||||
from apps.accounts.models import Profile
|
||||
Profile.objects.filter(user=user).update(
|
||||
deletion_requested_at=None,
|
||||
scheduled_deletion_date=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not clear deletion from profile: {e}")
|
||||
|
||||
return Response({"detail": "Account deletion cancelled"})
|
||||
|
||||
|
||||
# ============== SESSION MANAGEMENT ENDPOINTS ==============
|
||||
|
||||
@extend_schema(
|
||||
operation_id="list_sessions",
|
||||
summary="List active sessions",
|
||||
description="Returns list of active sessions for the current user.",
|
||||
responses={
|
||||
200: {
|
||||
"description": "List of sessions",
|
||||
"example": {
|
||||
"sessions": [
|
||||
{
|
||||
"id": "session_123",
|
||||
"created_at": "2026-01-06T12:00:00Z",
|
||||
"last_activity": "2026-01-06T14:00:00Z",
|
||||
"ip_address": "192.168.1.1",
|
||||
"user_agent": "Mozilla/5.0...",
|
||||
"is_current": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
},
|
||||
tags=["Account"],
|
||||
)
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def list_sessions(request):
|
||||
"""List all active sessions for the user."""
|
||||
# For JWT-based auth, we track sessions differently
|
||||
# This is a simplified implementation - in production you'd track tokens
|
||||
# For now, return the current session info
|
||||
|
||||
current_session = {
|
||||
"id": "current",
|
||||
"created_at": timezone.now().isoformat(),
|
||||
"last_activity": timezone.now().isoformat(),
|
||||
"ip_address": request.META.get("REMOTE_ADDR", "unknown"),
|
||||
"user_agent": request.META.get("HTTP_USER_AGENT", "unknown"),
|
||||
"is_current": True,
|
||||
}
|
||||
|
||||
return Response({
|
||||
"sessions": [current_session],
|
||||
"count": 1,
|
||||
})
|
||||
|
||||
|
||||
@extend_schema(
|
||||
operation_id="revoke_session",
|
||||
summary="Revoke a session",
|
||||
description="Revokes a specific session. If revoking current session, user will be logged out.",
|
||||
responses={
|
||||
200: {"description": "Session revoked"},
|
||||
404: {"description": "Session not found"},
|
||||
},
|
||||
tags=["Account"],
|
||||
)
|
||||
@api_view(["DELETE"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def revoke_session(request, session_id):
|
||||
"""Revoke a specific session."""
|
||||
# For JWT auth, we'd need to implement token blacklisting
|
||||
# This is a placeholder that returns success
|
||||
|
||||
if session_id == "current":
|
||||
# Blacklist the current refresh token if using SimpleJWT
|
||||
try:
|
||||
from rest_framework_simplejwt.token_blacklist.models import BlacklistedToken
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
# Get refresh token from request if available
|
||||
refresh_token = request.data.get("refresh_token")
|
||||
if refresh_token:
|
||||
token = RefreshToken(refresh_token)
|
||||
token.blacklist()
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not blacklist token: {e}")
|
||||
|
||||
return Response({"detail": "Session revoked"})
|
||||
|
||||
|
||||
# ============== PASSWORD CHANGE ENDPOINT ==============
|
||||
|
||||
@extend_schema(
|
||||
operation_id="change_password",
|
||||
summary="Change password",
|
||||
description="Changes the user's password. Requires current password.",
|
||||
request={
|
||||
"application/json": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"current_password": {"type": "string"},
|
||||
"new_password": {"type": "string"},
|
||||
},
|
||||
"required": ["current_password", "new_password"],
|
||||
}
|
||||
},
|
||||
responses={
|
||||
200: {"description": "Password changed"},
|
||||
400: {"description": "Invalid current password or weak new password"},
|
||||
},
|
||||
tags=["Account"],
|
||||
)
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def change_password(request):
|
||||
"""Change user password."""
|
||||
user = request.user
|
||||
current_password = request.data.get("current_password", "")
|
||||
new_password = request.data.get("new_password", "")
|
||||
|
||||
if not user.check_password(current_password):
|
||||
return Response(
|
||||
{"detail": "Current password is incorrect"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if len(new_password) < 8:
|
||||
return Response(
|
||||
{"detail": "New password must be at least 8 characters"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
|
||||
return Response({"detail": "Password changed successfully"})
|
||||
@@ -51,6 +51,10 @@ def get_mfa_status(request):
|
||||
totp_enabled = authenticators.filter(type=Authenticator.Type.TOTP).exists()
|
||||
recovery_enabled = authenticators.filter(type=Authenticator.Type.RECOVERY_CODES).exists()
|
||||
|
||||
# Check for WebAuthn/Passkey authenticators
|
||||
passkey_enabled = authenticators.filter(type=Authenticator.Type.WEBAUTHN).exists()
|
||||
passkey_count = authenticators.filter(type=Authenticator.Type.WEBAUTHN).count()
|
||||
|
||||
# Count recovery codes if any
|
||||
recovery_count = 0
|
||||
if recovery_enabled:
|
||||
@@ -60,12 +64,18 @@ def get_mfa_status(request):
|
||||
except Authenticator.DoesNotExist:
|
||||
pass
|
||||
|
||||
# has_second_factor is True if user has either TOTP or Passkey configured
|
||||
has_second_factor = totp_enabled or passkey_enabled
|
||||
|
||||
return Response(
|
||||
{
|
||||
"mfa_enabled": totp_enabled,
|
||||
"mfa_enabled": totp_enabled, # Backward compatibility
|
||||
"totp_enabled": totp_enabled,
|
||||
"passkey_enabled": passkey_enabled,
|
||||
"passkey_count": passkey_count,
|
||||
"recovery_codes_enabled": recovery_enabled,
|
||||
"recovery_codes_count": recovery_count,
|
||||
"has_second_factor": has_second_factor,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
536
backend/apps/api/v1/auth/passkey.py
Normal file
536
backend/apps/api/v1/auth/passkey.py
Normal file
@@ -0,0 +1,536 @@
|
||||
"""
|
||||
Passkey (WebAuthn) API Views
|
||||
|
||||
Provides REST API endpoints for WebAuthn/Passkey operations using django-allauth's
|
||||
mfa.webauthn module. Supports passkey registration, authentication, and management.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
operation_id="get_passkey_status",
|
||||
summary="Get passkey status for current user",
|
||||
description="Returns whether passkeys are enabled and lists registered passkeys.",
|
||||
responses={
|
||||
200: {
|
||||
"description": "Passkey status",
|
||||
"example": {
|
||||
"passkey_enabled": True,
|
||||
"passkeys": [
|
||||
{"id": "abc123", "name": "MacBook Pro", "created_at": "2026-01-06T12:00:00Z"}
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
tags=["Passkey"],
|
||||
)
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_passkey_status(request):
|
||||
"""Get passkey status for current user."""
|
||||
try:
|
||||
from allauth.mfa.models import Authenticator
|
||||
|
||||
user = request.user
|
||||
passkeys = Authenticator.objects.filter(
|
||||
user=user, type=Authenticator.Type.WEBAUTHN
|
||||
)
|
||||
|
||||
passkey_list = []
|
||||
for pk in passkeys:
|
||||
passkey_data = pk.data or {}
|
||||
passkey_list.append({
|
||||
"id": str(pk.id),
|
||||
"name": passkey_data.get("name", "Passkey"),
|
||||
"created_at": pk.created_at.isoformat() if hasattr(pk, "created_at") else None,
|
||||
})
|
||||
|
||||
return Response({
|
||||
"passkey_enabled": passkeys.exists(),
|
||||
"passkey_count": passkeys.count(),
|
||||
"passkeys": passkey_list,
|
||||
})
|
||||
except ImportError:
|
||||
return Response({
|
||||
"passkey_enabled": False,
|
||||
"passkey_count": 0,
|
||||
"passkeys": [],
|
||||
"error": "WebAuthn module not available",
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting passkey status: {e}")
|
||||
return Response(
|
||||
{"detail": "Failed to get passkey status"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
operation_id="get_registration_options",
|
||||
summary="Get WebAuthn registration options",
|
||||
description="Returns options for registering a new passkey. Start the registration flow.",
|
||||
responses={
|
||||
200: {
|
||||
"description": "WebAuthn registration options",
|
||||
"example": {
|
||||
"options": {"challenge": "...", "rp": {"name": "ThrillWiki"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
tags=["Passkey"],
|
||||
)
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_registration_options(request):
|
||||
"""Get WebAuthn registration options for passkey setup."""
|
||||
try:
|
||||
from allauth.mfa.webauthn.internal import auth as webauthn_auth
|
||||
|
||||
# Use the correct allauth API: begin_registration
|
||||
creation_options, state = webauthn_auth.begin_registration(request)
|
||||
|
||||
# Store state in session for verification
|
||||
webauthn_auth.set_state(request, state)
|
||||
|
||||
return Response({
|
||||
"options": creation_options,
|
||||
})
|
||||
except ImportError as e:
|
||||
logger.error(f"WebAuthn module import error: {e}")
|
||||
return Response(
|
||||
{"detail": "WebAuthn module not available"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting registration options: {e}")
|
||||
return Response(
|
||||
{"detail": f"Failed to get registration options: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
operation_id="register_passkey",
|
||||
summary="Complete passkey registration",
|
||||
description="Verifies the WebAuthn response and registers the new passkey.",
|
||||
request={
|
||||
"application/json": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"credential": {"type": "object", "description": "WebAuthn credential response"},
|
||||
"name": {"type": "string", "description": "Name for this passkey"},
|
||||
},
|
||||
"required": ["credential"],
|
||||
}
|
||||
},
|
||||
responses={
|
||||
200: {"description": "Passkey registered successfully"},
|
||||
400: {"description": "Invalid credential or registration failed"},
|
||||
},
|
||||
tags=["Passkey"],
|
||||
)
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def register_passkey(request):
|
||||
"""Complete passkey registration with WebAuthn response."""
|
||||
try:
|
||||
from allauth.mfa.webauthn.internal import auth as webauthn_auth
|
||||
|
||||
credential = request.data.get("credential")
|
||||
name = request.data.get("name", "Passkey")
|
||||
|
||||
if not credential:
|
||||
return Response(
|
||||
{"detail": "Credential is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get stored state from session
|
||||
state = webauthn_auth.get_state(request)
|
||||
if not state:
|
||||
return Response(
|
||||
{"detail": "No pending registration. Please start registration again."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Use the correct allauth API: complete_registration
|
||||
try:
|
||||
# Parse the credential response
|
||||
credential_data = webauthn_auth.parse_registration_response(credential)
|
||||
|
||||
# Complete registration - this creates the Authenticator
|
||||
authenticator = webauthn_auth.complete_registration(
|
||||
request,
|
||||
credential_data,
|
||||
state,
|
||||
name=name,
|
||||
)
|
||||
|
||||
# Clear session state
|
||||
webauthn_auth.clear_state(request)
|
||||
|
||||
return Response({
|
||||
"detail": "Passkey registered successfully",
|
||||
"name": name,
|
||||
"id": str(authenticator.id) if authenticator else None,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"WebAuthn registration failed: {e}")
|
||||
return Response(
|
||||
{"detail": f"Registration failed: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except ImportError as e:
|
||||
logger.error(f"WebAuthn module import error: {e}")
|
||||
return Response(
|
||||
{"detail": "WebAuthn module not available"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error registering passkey: {e}")
|
||||
return Response(
|
||||
{"detail": f"Failed to register passkey: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
operation_id="get_authentication_options",
|
||||
summary="Get WebAuthn authentication options",
|
||||
description="Returns options for authenticating with a passkey.",
|
||||
responses={
|
||||
200: {
|
||||
"description": "WebAuthn authentication options",
|
||||
"example": {
|
||||
"options": {"challenge": "...", "allowCredentials": []},
|
||||
},
|
||||
},
|
||||
},
|
||||
tags=["Passkey"],
|
||||
)
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_authentication_options(request):
|
||||
"""Get WebAuthn authentication options for passkey verification."""
|
||||
try:
|
||||
from allauth.mfa.webauthn.internal import auth as webauthn_auth
|
||||
|
||||
# Use the correct allauth API: begin_authentication
|
||||
request_options, state = webauthn_auth.begin_authentication(request)
|
||||
|
||||
# Store state in session for verification
|
||||
webauthn_auth.set_state(request, state)
|
||||
|
||||
return Response({
|
||||
"options": request_options,
|
||||
})
|
||||
except ImportError as e:
|
||||
logger.error(f"WebAuthn module import error: {e}")
|
||||
return Response(
|
||||
{"detail": "WebAuthn module not available"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting authentication options: {e}")
|
||||
return Response(
|
||||
{"detail": f"Failed to get authentication options: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
operation_id="authenticate_passkey",
|
||||
summary="Authenticate with passkey",
|
||||
description="Verifies the WebAuthn response for authentication.",
|
||||
request={
|
||||
"application/json": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"credential": {"type": "object", "description": "WebAuthn credential response"},
|
||||
},
|
||||
"required": ["credential"],
|
||||
}
|
||||
},
|
||||
responses={
|
||||
200: {"description": "Authentication successful"},
|
||||
400: {"description": "Invalid credential or authentication failed"},
|
||||
},
|
||||
tags=["Passkey"],
|
||||
)
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def authenticate_passkey(request):
|
||||
"""Verify passkey authentication."""
|
||||
try:
|
||||
from allauth.mfa.webauthn.internal import auth as webauthn_auth
|
||||
|
||||
credential = request.data.get("credential")
|
||||
|
||||
if not credential:
|
||||
return Response(
|
||||
{"detail": "Credential is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get stored state from session
|
||||
state = webauthn_auth.get_state(request)
|
||||
if not state:
|
||||
return Response(
|
||||
{"detail": "No pending authentication. Please start authentication again."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Use the correct allauth API: complete_authentication
|
||||
try:
|
||||
# Parse the credential response
|
||||
credential_data = webauthn_auth.parse_authentication_response(credential)
|
||||
|
||||
# Complete authentication
|
||||
webauthn_auth.complete_authentication(request, credential_data, state)
|
||||
|
||||
# Clear session state
|
||||
webauthn_auth.clear_state(request)
|
||||
|
||||
return Response({"success": True})
|
||||
except Exception as e:
|
||||
logger.error(f"WebAuthn authentication failed: {e}")
|
||||
return Response(
|
||||
{"detail": f"Authentication failed: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except ImportError as e:
|
||||
logger.error(f"WebAuthn module import error: {e}")
|
||||
return Response(
|
||||
{"detail": "WebAuthn module not available"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error authenticating passkey: {e}")
|
||||
return Response(
|
||||
{"detail": f"Failed to authenticate: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
operation_id="delete_passkey",
|
||||
summary="Delete a passkey",
|
||||
description="Removes a registered passkey from the user's account.",
|
||||
request={
|
||||
"application/json": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"password": {"type": "string", "description": "Current password for confirmation"},
|
||||
},
|
||||
"required": ["password"],
|
||||
}
|
||||
},
|
||||
responses={
|
||||
200: {"description": "Passkey deleted successfully"},
|
||||
400: {"description": "Invalid password or passkey not found"},
|
||||
},
|
||||
tags=["Passkey"],
|
||||
)
|
||||
@api_view(["DELETE"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def delete_passkey(request, passkey_id):
|
||||
"""Delete a passkey."""
|
||||
try:
|
||||
from allauth.mfa.models import Authenticator
|
||||
|
||||
user = request.user
|
||||
password = request.data.get("password", "")
|
||||
|
||||
# Verify password
|
||||
if not user.check_password(password):
|
||||
return Response(
|
||||
{"detail": "Invalid password"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Find and delete the passkey
|
||||
try:
|
||||
authenticator = Authenticator.objects.get(
|
||||
id=passkey_id,
|
||||
user=user,
|
||||
type=Authenticator.Type.WEBAUTHN,
|
||||
)
|
||||
authenticator.delete()
|
||||
except Authenticator.DoesNotExist:
|
||||
return Response(
|
||||
{"detail": "Passkey not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
return Response({"detail": "Passkey deleted successfully"})
|
||||
except ImportError:
|
||||
return Response(
|
||||
{"detail": "WebAuthn module not available"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting passkey: {e}")
|
||||
return Response(
|
||||
{"detail": f"Failed to delete passkey: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
operation_id="rename_passkey",
|
||||
summary="Rename a passkey",
|
||||
description="Updates the name of a registered passkey.",
|
||||
request={
|
||||
"application/json": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "New name for the passkey"},
|
||||
},
|
||||
"required": ["name"],
|
||||
}
|
||||
},
|
||||
responses={
|
||||
200: {"description": "Passkey renamed successfully"},
|
||||
404: {"description": "Passkey not found"},
|
||||
},
|
||||
tags=["Passkey"],
|
||||
)
|
||||
@api_view(["PATCH"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def rename_passkey(request, passkey_id):
|
||||
"""Rename a passkey."""
|
||||
try:
|
||||
from allauth.mfa.models import Authenticator
|
||||
|
||||
user = request.user
|
||||
new_name = request.data.get("name", "").strip()
|
||||
|
||||
if not new_name:
|
||||
return Response(
|
||||
{"detail": "Name is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
authenticator = Authenticator.objects.get(
|
||||
id=passkey_id, user=user, type=Authenticator.Type.WEBAUTHN,
|
||||
)
|
||||
data = authenticator.data or {}
|
||||
data["name"] = new_name
|
||||
authenticator.data = data
|
||||
authenticator.save()
|
||||
except Authenticator.DoesNotExist:
|
||||
return Response(
|
||||
{"detail": "Passkey not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
return Response({"detail": "Passkey renamed successfully", "name": new_name})
|
||||
except ImportError:
|
||||
return Response(
|
||||
{"detail": "WebAuthn module not available"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error renaming passkey: {e}")
|
||||
return Response(
|
||||
{"detail": f"Failed to rename passkey: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
operation_id="get_login_passkey_options",
|
||||
summary="Get WebAuthn options for MFA login",
|
||||
description="Returns passkey auth options using MFA token (unauthenticated).",
|
||||
request={
|
||||
"application/json": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mfa_token": {"type": "string", "description": "MFA token from login"},
|
||||
},
|
||||
"required": ["mfa_token"],
|
||||
}
|
||||
},
|
||||
responses={
|
||||
200: {"description": "WebAuthn authentication options"},
|
||||
400: {"description": "Invalid or expired MFA token"},
|
||||
},
|
||||
tags=["Passkey"],
|
||||
)
|
||||
@api_view(["POST"])
|
||||
def get_login_passkey_options(request):
|
||||
"""Get WebAuthn authentication options for MFA login flow (unauthenticated)."""
|
||||
from django.core.cache import cache
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
mfa_token = request.data.get("mfa_token")
|
||||
|
||||
if not mfa_token:
|
||||
return Response(
|
||||
{"detail": "MFA token is required"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
cache_key = f"mfa_login:{mfa_token}"
|
||||
cached_data = cache.get(cache_key)
|
||||
|
||||
if not cached_data:
|
||||
return Response(
|
||||
{"detail": "MFA session expired or invalid"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
user_id = cached_data.get("user_id")
|
||||
|
||||
try:
|
||||
user = User.objects.get(pk=user_id)
|
||||
except User.DoesNotExist:
|
||||
return Response({"detail": "User not found"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
from allauth.mfa.models import Authenticator
|
||||
from allauth.mfa.webauthn.internal import auth as webauthn_auth
|
||||
|
||||
passkeys = Authenticator.objects.filter(
|
||||
user=user, type=Authenticator.Type.WEBAUTHN
|
||||
)
|
||||
|
||||
if not passkeys.exists():
|
||||
return Response(
|
||||
{"detail": "No passkeys registered"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
original_user = getattr(request, "user", None)
|
||||
request.user = user
|
||||
|
||||
try:
|
||||
request_options, state = webauthn_auth.begin_authentication(request)
|
||||
passkey_state_key = f"mfa_passkey_state:{mfa_token}"
|
||||
cache.set(passkey_state_key, state, timeout=300)
|
||||
return Response({"options": request_options})
|
||||
finally:
|
||||
if original_user is not None:
|
||||
request.user = original_user
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"WebAuthn module import error: {e}")
|
||||
return Response(
|
||||
{"detail": "WebAuthn module not available"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting login passkey options: {e}")
|
||||
return Response(
|
||||
{"detail": f"Failed to get passkey options: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
@@ -105,19 +105,36 @@ class UserOutputSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class LoginInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for user login."""
|
||||
"""Input serializer for user login.
|
||||
|
||||
username = serializers.CharField(max_length=254, help_text="Username or email address")
|
||||
Accepts either 'email' or 'username' field for backward compatibility.
|
||||
The view will use whichever is provided.
|
||||
"""
|
||||
|
||||
# Accept both email and username - frontend sends "email", but we also support "username"
|
||||
email = serializers.CharField(max_length=254, required=False, help_text="Email address")
|
||||
username = serializers.CharField(max_length=254, required=False, help_text="Username (alternative to email)")
|
||||
password = serializers.CharField(max_length=128, style={"input_type": "password"}, trim_whitespace=False)
|
||||
|
||||
def validate(self, attrs):
|
||||
email = attrs.get("email")
|
||||
username = attrs.get("username")
|
||||
password = attrs.get("password")
|
||||
|
||||
if username and password:
|
||||
return attrs
|
||||
# Use email if provided, fallback to username
|
||||
identifier = email or username
|
||||
|
||||
if not identifier:
|
||||
raise serializers.ValidationError("Either email or username is required.")
|
||||
|
||||
if not password:
|
||||
raise serializers.ValidationError("Password is required.")
|
||||
|
||||
# Store the identifier in a standard field for the view to consume
|
||||
attrs["username"] = identifier
|
||||
return attrs
|
||||
|
||||
|
||||
raise serializers.ValidationError("Must include username/email and password.")
|
||||
|
||||
|
||||
class LoginOutputSerializer(serializers.Serializer):
|
||||
@@ -129,6 +146,53 @@ class LoginOutputSerializer(serializers.Serializer):
|
||||
message = serializers.CharField()
|
||||
|
||||
|
||||
class MFARequiredOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer when MFA verification is required after password auth."""
|
||||
|
||||
mfa_required = serializers.BooleanField(default=True)
|
||||
mfa_token = serializers.CharField(help_text="Temporary token for MFA verification")
|
||||
mfa_types = serializers.ListField(
|
||||
child=serializers.CharField(),
|
||||
help_text="Available MFA types: 'totp', 'webauthn'",
|
||||
)
|
||||
user_id = serializers.IntegerField(help_text="User ID for reference")
|
||||
message = serializers.CharField(default="MFA verification required")
|
||||
|
||||
|
||||
class MFALoginVerifyInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for MFA login verification."""
|
||||
|
||||
mfa_token = serializers.CharField(help_text="Temporary MFA token from login response")
|
||||
code = serializers.CharField(
|
||||
max_length=6,
|
||||
min_length=6,
|
||||
required=False,
|
||||
help_text="6-digit TOTP code from authenticator app",
|
||||
)
|
||||
# For passkey/webauthn - credential will be a complex object
|
||||
credential = serializers.JSONField(required=False, help_text="WebAuthn credential response")
|
||||
|
||||
def validate(self, attrs):
|
||||
code = attrs.get("code")
|
||||
credential = attrs.get("credential")
|
||||
|
||||
if not code and not credential:
|
||||
raise serializers.ValidationError(
|
||||
"Either 'code' (TOTP) or 'credential' (passkey) is required."
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class MFALoginVerifyOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for successful MFA verification."""
|
||||
|
||||
access = serializers.CharField()
|
||||
refresh = serializers.CharField()
|
||||
user = UserOutputSerializer()
|
||||
message = serializers.CharField(default="Login successful")
|
||||
|
||||
|
||||
class SignupInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for user registration."""
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ from django.urls import include, path
|
||||
from rest_framework_simplejwt.views import TokenRefreshView
|
||||
|
||||
from . import mfa as mfa_views
|
||||
from . import passkey as passkey_views
|
||||
from . import account_management as account_views
|
||||
from .views import (
|
||||
AuthStatusAPIView,
|
||||
# Social provider management views
|
||||
@@ -22,6 +24,7 @@ from .views import (
|
||||
# Main auth views
|
||||
LoginAPIView,
|
||||
LogoutAPIView,
|
||||
MFALoginVerifyAPIView,
|
||||
PasswordChangeAPIView,
|
||||
PasswordResetAPIView,
|
||||
ProcessOAuthProfileAPIView,
|
||||
@@ -34,6 +37,7 @@ from .views import (
|
||||
urlpatterns = [
|
||||
# Core authentication endpoints
|
||||
path("login/", LoginAPIView.as_view(), name="auth-login"),
|
||||
path("login/mfa-verify/", MFALoginVerifyAPIView.as_view(), name="auth-login-mfa-verify"),
|
||||
path("signup/", SignupAPIView.as_view(), name="auth-signup"),
|
||||
path("logout/", LogoutAPIView.as_view(), name="auth-logout"),
|
||||
path("user/", CurrentUserAPIView.as_view(), name="auth-current-user"),
|
||||
@@ -105,6 +109,25 @@ urlpatterns = [
|
||||
path("mfa/totp/deactivate/", mfa_views.deactivate_totp, name="auth-mfa-totp-deactivate"),
|
||||
path("mfa/totp/verify/", mfa_views.verify_totp, name="auth-mfa-totp-verify"),
|
||||
path("mfa/recovery-codes/regenerate/", mfa_views.regenerate_recovery_codes, name="auth-mfa-recovery-regenerate"),
|
||||
# Passkey (WebAuthn) endpoints
|
||||
path("passkey/status/", passkey_views.get_passkey_status, name="auth-passkey-status"),
|
||||
path("passkey/registration-options/", passkey_views.get_registration_options, name="auth-passkey-registration-options"),
|
||||
path("passkey/register/", passkey_views.register_passkey, name="auth-passkey-register"),
|
||||
path("passkey/authentication-options/", passkey_views.get_authentication_options, name="auth-passkey-authentication-options"),
|
||||
path("passkey/authenticate/", passkey_views.authenticate_passkey, name="auth-passkey-authenticate"),
|
||||
path("passkey/<int:passkey_id>/", passkey_views.delete_passkey, name="auth-passkey-delete"),
|
||||
path("passkey/<int:passkey_id>/rename/", passkey_views.rename_passkey, name="auth-passkey-rename"),
|
||||
path("passkey/login-options/", passkey_views.get_login_passkey_options, name="auth-passkey-login-options"),
|
||||
# Account management endpoints
|
||||
path("email/change/", account_views.request_email_change, name="auth-email-change"),
|
||||
path("email/change/status/", account_views.get_email_change_status, name="auth-email-change-status"),
|
||||
path("email/change/cancel/", account_views.cancel_email_change, name="auth-email-change-cancel"),
|
||||
path("account/delete/", account_views.request_account_deletion, name="auth-account-delete"),
|
||||
path("account/delete/status/", account_views.get_deletion_status, name="auth-deletion-status"),
|
||||
path("account/delete/cancel/", account_views.cancel_account_deletion, name="auth-deletion-cancel"),
|
||||
path("sessions/", account_views.list_sessions, name="auth-sessions-list"),
|
||||
path("sessions/<str:session_id>/", account_views.revoke_session, name="auth-session-revoke"),
|
||||
path("password/change/", account_views.change_password, name="auth-password-change-v2"),
|
||||
]
|
||||
|
||||
# Note: User profiles and top lists functionality is now handled by the accounts app
|
||||
|
||||
@@ -178,6 +178,37 @@ class LoginAPIView(APIView):
|
||||
|
||||
if user:
|
||||
if getattr(user, "is_active", False):
|
||||
# Check if user has MFA enabled
|
||||
mfa_info = self._check_user_mfa(user)
|
||||
|
||||
if mfa_info["has_mfa"]:
|
||||
# MFA required - generate temp token and return mfa_required response
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.core.cache import cache
|
||||
|
||||
# Generate secure temp token
|
||||
mfa_token = get_random_string(64)
|
||||
|
||||
# Store user ID in cache with token (expires in 5 minutes)
|
||||
cache_key = f"mfa_login:{mfa_token}"
|
||||
cache.set(cache_key, {
|
||||
"user_id": user.pk,
|
||||
"username": user.username,
|
||||
}, timeout=300) # 5 minutes
|
||||
|
||||
from .serializers import MFARequiredOutputSerializer
|
||||
|
||||
response_data = {
|
||||
"mfa_required": True,
|
||||
"mfa_token": mfa_token,
|
||||
"mfa_types": mfa_info["mfa_types"],
|
||||
"user_id": user.pk,
|
||||
"message": "MFA verification required",
|
||||
}
|
||||
response_serializer = MFARequiredOutputSerializer(response_data)
|
||||
return Response(response_serializer.data)
|
||||
|
||||
# No MFA - proceed with normal login
|
||||
# pass a real HttpRequest to Django login with backend specified
|
||||
login(_get_underlying_request(request), user, backend="django.contrib.auth.backends.ModelBackend")
|
||||
|
||||
@@ -213,6 +244,207 @@ class LoginAPIView(APIView):
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def _check_user_mfa(self, user) -> dict:
|
||||
"""Check if user has MFA (TOTP or WebAuthn) configured."""
|
||||
try:
|
||||
from allauth.mfa.models import Authenticator
|
||||
|
||||
authenticators = Authenticator.objects.filter(user=user)
|
||||
|
||||
has_totp = authenticators.filter(type=Authenticator.Type.TOTP).exists()
|
||||
has_webauthn = authenticators.filter(type=Authenticator.Type.WEBAUTHN).exists()
|
||||
|
||||
mfa_types = []
|
||||
if has_totp:
|
||||
mfa_types.append("totp")
|
||||
if has_webauthn:
|
||||
mfa_types.append("webauthn")
|
||||
|
||||
return {
|
||||
"has_mfa": has_totp or has_webauthn,
|
||||
"has_totp": has_totp,
|
||||
"has_webauthn": has_webauthn,
|
||||
"mfa_types": mfa_types,
|
||||
}
|
||||
except ImportError:
|
||||
return {"has_mfa": False, "has_totp": False, "has_webauthn": False, "mfa_types": []}
|
||||
except Exception:
|
||||
return {"has_mfa": False, "has_totp": False, "has_webauthn": False, "mfa_types": []}
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="Verify MFA for login",
|
||||
description="Complete MFA verification after password authentication. Submit TOTP code to receive JWT tokens.",
|
||||
request={"application/json": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mfa_token": {"type": "string", "description": "Temporary token from login response"},
|
||||
"code": {"type": "string", "description": "6-digit TOTP code"},
|
||||
},
|
||||
"required": ["mfa_token", "code"],
|
||||
}},
|
||||
responses={
|
||||
200: LoginOutputSerializer,
|
||||
400: "Bad Request - Invalid code or expired token",
|
||||
},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class MFALoginVerifyAPIView(APIView):
|
||||
"""API endpoint to verify MFA code and complete login."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
from django.core.cache import cache
|
||||
from .serializers import MFALoginVerifyInputSerializer
|
||||
|
||||
serializer = MFALoginVerifyInputSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
validated = serializer.validated_data
|
||||
mfa_token = validated.get("mfa_token")
|
||||
totp_code = validated.get("code")
|
||||
credential = validated.get("credential") # WebAuthn/Passkey credential
|
||||
|
||||
# Retrieve user from cache
|
||||
cache_key = f"mfa_login:{mfa_token}"
|
||||
cached_data = cache.get(cache_key)
|
||||
|
||||
if not cached_data:
|
||||
return Response(
|
||||
{"detail": "MFA session expired or invalid. Please login again."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
user_id = cached_data.get("user_id")
|
||||
|
||||
try:
|
||||
user = UserModel.objects.get(pk=user_id)
|
||||
except UserModel.DoesNotExist:
|
||||
return Response(
|
||||
{"detail": "User not found"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Verify MFA - either TOTP or Passkey
|
||||
if totp_code:
|
||||
if not self._verify_totp(user, totp_code):
|
||||
return Response(
|
||||
{"detail": "Invalid verification code"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
elif credential:
|
||||
# Verify passkey/WebAuthn credential
|
||||
passkey_result = self._verify_passkey(request, user, credential)
|
||||
if not passkey_result["success"]:
|
||||
return Response(
|
||||
{"detail": passkey_result.get("error", "Passkey verification failed")},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
{"detail": "Either TOTP code or passkey credential is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Clear the MFA token from cache
|
||||
cache.delete(cache_key)
|
||||
|
||||
# Complete login
|
||||
login(_get_underlying_request(request), user, backend="django.contrib.auth.backends.ModelBackend")
|
||||
|
||||
# Generate JWT tokens
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
refresh = RefreshToken.for_user(user)
|
||||
access_token = refresh.access_token
|
||||
|
||||
response_serializer = LoginOutputSerializer(
|
||||
{
|
||||
"access": str(access_token),
|
||||
"refresh": str(refresh),
|
||||
"user": user,
|
||||
"message": "Login successful",
|
||||
}
|
||||
)
|
||||
return Response(response_serializer.data)
|
||||
|
||||
def _verify_totp(self, user, code: str) -> bool:
|
||||
"""Verify TOTP code against user's authenticator."""
|
||||
try:
|
||||
from allauth.mfa.models import Authenticator
|
||||
from allauth.mfa.totp import TOTP
|
||||
|
||||
try:
|
||||
authenticator = Authenticator.objects.get(
|
||||
user=user,
|
||||
type=Authenticator.Type.TOTP,
|
||||
)
|
||||
except Authenticator.DoesNotExist:
|
||||
return False
|
||||
|
||||
# Get the TOTP instance and verify
|
||||
totp = TOTP(authenticator)
|
||||
return totp.validate_code(code)
|
||||
|
||||
except ImportError:
|
||||
logger.error("allauth.mfa not available for TOTP verification")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"TOTP verification error: {e}")
|
||||
return False
|
||||
|
||||
def _verify_passkey(self, request, user, credential: dict) -> dict:
|
||||
"""Verify WebAuthn/Passkey credential."""
|
||||
try:
|
||||
from allauth.mfa.models import Authenticator
|
||||
from allauth.mfa.webauthn.internal import auth as webauthn_auth
|
||||
|
||||
# Check if user has any WebAuthn authenticators
|
||||
has_passkey = Authenticator.objects.filter(
|
||||
user=user,
|
||||
type=Authenticator.Type.WEBAUTHN,
|
||||
).exists()
|
||||
|
||||
if not has_passkey:
|
||||
return {"success": False, "error": "No passkey registered for this user"}
|
||||
|
||||
try:
|
||||
# Parse the authentication response
|
||||
credential_data = webauthn_auth.parse_authentication_response(credential)
|
||||
|
||||
# Get or create authentication state
|
||||
# For login flow, we need to set up the state first
|
||||
state = webauthn_auth.get_state(request)
|
||||
|
||||
if not state:
|
||||
# If no state, generate one for this user
|
||||
_, state = webauthn_auth.begin_authentication(request)
|
||||
webauthn_auth.set_state(request, state)
|
||||
|
||||
# Complete authentication
|
||||
webauthn_auth.complete_authentication(request, credential_data, state)
|
||||
|
||||
# Clear the state
|
||||
webauthn_auth.clear_state(request)
|
||||
|
||||
return {"success": True}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"WebAuthn authentication failed: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"WebAuthn module not available: {e}")
|
||||
return {"success": False, "error": "Passkey authentication not available"}
|
||||
except Exception as e:
|
||||
logger.error(f"Passkey verification error: {e}")
|
||||
return {"success": False, "error": "Passkey verification failed"}
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
|
||||
@@ -333,6 +333,11 @@ class ParkListCreateAPIView(APIView):
|
||||
|
||||
def _apply_park_attribute_filters(self, qs: QuerySet, params: dict) -> QuerySet:
|
||||
"""Apply park attribute filtering to the queryset."""
|
||||
# Slug filter - exact match for single park lookup
|
||||
slug = params.get("slug")
|
||||
if slug:
|
||||
qs = qs.filter(slug=slug)
|
||||
|
||||
park_type = params.get("park_type")
|
||||
if park_type:
|
||||
qs = qs.filter(park_type=park_type)
|
||||
|
||||
@@ -7,7 +7,7 @@ entity completeness, and system health.
|
||||
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAdminUser
|
||||
from apps.core.permissions import IsAdminWithSecondFactor
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
@@ -89,7 +89,7 @@ class DataCompletenessAPIView(APIView):
|
||||
companies, and ride models.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAdminUser]
|
||||
permission_classes = [IsAdminWithSecondFactor]
|
||||
|
||||
@extend_schema(
|
||||
tags=["Admin"],
|
||||
|
||||
@@ -28,3 +28,65 @@ class IsStaffOrReadOnly(permissions.BasePermission):
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
return request.user and request.user.is_staff
|
||||
|
||||
|
||||
class IsAdminWithSecondFactor(permissions.BasePermission):
|
||||
"""
|
||||
Requires admin status AND at least one configured second factor.
|
||||
|
||||
Accepts either:
|
||||
- TOTP (MFA/Authenticator app)
|
||||
- WebAuthn (Passkey/Security key)
|
||||
|
||||
This permission ensures that admin users have a second factor configured
|
||||
before they can access sensitive admin endpoints.
|
||||
"""
|
||||
|
||||
message = "Admin access requires MFA or Passkey to be configured."
|
||||
|
||||
def has_permission(self, request, view):
|
||||
user = request.user
|
||||
|
||||
# Must be authenticated
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Must be admin (staff, superuser, or ADMIN role)
|
||||
if not self._is_admin(user):
|
||||
self.message = "You do not have admin privileges."
|
||||
return False
|
||||
|
||||
# Must have at least one second factor configured
|
||||
if not self._has_second_factor(user):
|
||||
self.message = "Admin access requires MFA or Passkey to be configured."
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _is_admin(self, user) -> bool:
|
||||
"""Check if user has admin privileges."""
|
||||
if user.is_superuser:
|
||||
return True
|
||||
if user.is_staff:
|
||||
return True
|
||||
# Check custom role field if it exists
|
||||
if hasattr(user, "role") and user.role in ("ADMIN", "SUPERUSER"):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _has_second_factor(self, user) -> bool:
|
||||
"""Check if user has at least one second factor configured."""
|
||||
try:
|
||||
from allauth.mfa.models import Authenticator
|
||||
|
||||
# Check for TOTP or WebAuthn authenticators
|
||||
return Authenticator.objects.filter(
|
||||
user=user,
|
||||
type__in=[Authenticator.Type.TOTP, Authenticator.Type.WEBAUTHN]
|
||||
).exists()
|
||||
except ImportError:
|
||||
# allauth.mfa not installed
|
||||
return False
|
||||
except Exception:
|
||||
# Any other error, fail closed (deny access)
|
||||
return False
|
||||
|
||||
137
backend/apps/core/tests/test_permissions.py
Normal file
137
backend/apps/core/tests/test_permissions.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Tests for custom permissions, particularly IsAdminWithSecondFactor.
|
||||
|
||||
Tests that admin users must have MFA or Passkey configured before
|
||||
accessing sensitive admin endpoints.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import RequestFactory, TestCase
|
||||
|
||||
from apps.core.permissions import IsAdminWithSecondFactor
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class TestIsAdminWithSecondFactor(TestCase):
|
||||
"""Tests for IsAdminWithSecondFactor permission class."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.factory = RequestFactory()
|
||||
self.permission = IsAdminWithSecondFactor()
|
||||
|
||||
def _make_request(self, user=None):
|
||||
"""Create a mock request with the given user."""
|
||||
request = self.factory.get("/api/v1/admin/test/")
|
||||
request.user = user if user else MagicMock(is_authenticated=False)
|
||||
return request
|
||||
|
||||
def test_anonymous_user_denied(self):
|
||||
"""Anonymous users should be denied access."""
|
||||
request = self._make_request()
|
||||
request.user.is_authenticated = False
|
||||
|
||||
self.assertFalse(self.permission.has_permission(request, None))
|
||||
|
||||
def test_non_admin_user_denied(self):
|
||||
"""Non-admin users should be denied access."""
|
||||
user = MagicMock()
|
||||
user.is_authenticated = True
|
||||
user.is_superuser = False
|
||||
user.is_staff = False
|
||||
user.role = "USER"
|
||||
|
||||
request = self._make_request(user)
|
||||
|
||||
self.assertFalse(self.permission.has_permission(request, None))
|
||||
self.assertIn("admin privileges", self.permission.message)
|
||||
|
||||
@patch("apps.core.permissions.IsAdminWithSecondFactor._has_second_factor")
|
||||
def test_admin_without_mfa_denied(self, mock_has_second_factor):
|
||||
"""Admin without MFA or Passkey should be denied access."""
|
||||
mock_has_second_factor.return_value = False
|
||||
|
||||
user = MagicMock()
|
||||
user.is_authenticated = True
|
||||
user.is_superuser = True
|
||||
user.is_staff = True
|
||||
user.role = "ADMIN"
|
||||
|
||||
request = self._make_request(user)
|
||||
|
||||
self.assertFalse(self.permission.has_permission(request, None))
|
||||
self.assertIn("MFA or Passkey", self.permission.message)
|
||||
|
||||
@patch("apps.core.permissions.IsAdminWithSecondFactor._has_second_factor")
|
||||
def test_superuser_with_mfa_allowed(self, mock_has_second_factor):
|
||||
"""Superuser with MFA configured should be allowed access."""
|
||||
mock_has_second_factor.return_value = True
|
||||
|
||||
user = MagicMock()
|
||||
user.is_authenticated = True
|
||||
user.is_superuser = True
|
||||
user.is_staff = True
|
||||
|
||||
request = self._make_request(user)
|
||||
|
||||
self.assertTrue(self.permission.has_permission(request, None))
|
||||
|
||||
@patch("apps.core.permissions.IsAdminWithSecondFactor._has_second_factor")
|
||||
def test_staff_with_passkey_allowed(self, mock_has_second_factor):
|
||||
"""Staff user with Passkey configured should be allowed access."""
|
||||
mock_has_second_factor.return_value = True
|
||||
|
||||
user = MagicMock()
|
||||
user.is_authenticated = True
|
||||
user.is_superuser = False
|
||||
user.is_staff = True
|
||||
|
||||
request = self._make_request(user)
|
||||
|
||||
self.assertTrue(self.permission.has_permission(request, None))
|
||||
|
||||
@patch("apps.core.permissions.IsAdminWithSecondFactor._has_second_factor")
|
||||
def test_admin_role_with_mfa_allowed(self, mock_has_second_factor):
|
||||
"""User with ADMIN role and MFA should be allowed access."""
|
||||
mock_has_second_factor.return_value = True
|
||||
|
||||
user = MagicMock()
|
||||
user.is_authenticated = True
|
||||
user.is_superuser = False
|
||||
user.is_staff = False
|
||||
user.role = "ADMIN"
|
||||
|
||||
request = self._make_request(user)
|
||||
|
||||
self.assertTrue(self.permission.has_permission(request, None))
|
||||
|
||||
def test_has_second_factor_with_totp(self):
|
||||
"""Test _has_second_factor detects TOTP authenticator."""
|
||||
user = MagicMock()
|
||||
|
||||
with patch("apps.core.permissions.Authenticator") as MockAuth:
|
||||
# Mock the queryset to return True for TOTP
|
||||
mock_qs = MagicMock()
|
||||
mock_qs.filter.return_value.exists.return_value = True
|
||||
MockAuth.objects.filter.return_value = mock_qs
|
||||
MockAuth.Type.TOTP = "totp"
|
||||
MockAuth.Type.WEBAUTHN = "webauthn"
|
||||
|
||||
# Need to patch the import inside the method
|
||||
with patch.dict("sys.modules", {"allauth.mfa.models": MagicMock(Authenticator=MockAuth)}):
|
||||
result = self.permission._has_second_factor(user)
|
||||
# This tests the exception path since import is mocked at module level
|
||||
# The actual integration test would require a full database setup
|
||||
|
||||
def test_has_second_factor_import_error(self):
|
||||
"""Test _has_second_factor handles ImportError gracefully."""
|
||||
user = MagicMock()
|
||||
|
||||
with patch.dict("sys.modules", {"allauth.mfa.models": None}):
|
||||
with patch("builtins.__import__", side_effect=ImportError):
|
||||
# Should return False, not raise exception
|
||||
result = self.permission._has_second_factor(user)
|
||||
self.assertFalse(result)
|
||||
@@ -173,6 +173,10 @@ class IsModeratorOrAdmin(GuardMixin, permissions.BasePermission):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Django superusers always have access
|
||||
if getattr(request.user, "is_superuser", False):
|
||||
return True
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
return user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]
|
||||
|
||||
@@ -193,6 +197,10 @@ class IsAdminOrSuperuser(GuardMixin, permissions.BasePermission):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Django superusers always have access
|
||||
if getattr(request.user, "is_superuser", False):
|
||||
return True
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
return user_role in ["ADMIN", "SUPERUSER"]
|
||||
|
||||
@@ -220,6 +228,10 @@ class CanViewModerationData(GuardMixin, permissions.BasePermission):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Django superusers can view all data
|
||||
if getattr(request.user, "is_superuser", False):
|
||||
return True
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
|
||||
# Moderators and above can view all data
|
||||
@@ -249,6 +261,10 @@ class CanModerateContent(GuardMixin, permissions.BasePermission):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Django superusers always have access
|
||||
if getattr(request.user, "is_superuser", False):
|
||||
return True
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
return user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]
|
||||
|
||||
@@ -257,6 +273,10 @@ class CanModerateContent(GuardMixin, permissions.BasePermission):
|
||||
if not self.has_permission(request, view):
|
||||
return False
|
||||
|
||||
# Django superusers can do everything
|
||||
if getattr(request.user, "is_superuser", False):
|
||||
return True
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
|
||||
# Superusers can do everything
|
||||
@@ -297,6 +317,10 @@ class CanAssignModerationTasks(GuardMixin, permissions.BasePermission):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Django superusers always have access
|
||||
if getattr(request.user, "is_superuser", False):
|
||||
return True
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
return user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]
|
||||
|
||||
@@ -341,6 +365,10 @@ class CanPerformBulkOperations(GuardMixin, permissions.BasePermission):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Django superusers always have access
|
||||
if getattr(request.user, "is_superuser", False):
|
||||
return True
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
return user_role in ["ADMIN", "SUPERUSER"]
|
||||
|
||||
@@ -349,6 +377,10 @@ class CanPerformBulkOperations(GuardMixin, permissions.BasePermission):
|
||||
if not self.has_permission(request, view):
|
||||
return False
|
||||
|
||||
# Django superusers can perform all bulk operations
|
||||
if getattr(request.user, "is_superuser", False):
|
||||
return True
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
|
||||
# Superusers can perform all bulk operations
|
||||
@@ -386,6 +418,10 @@ class IsOwnerOrModerator(GuardMixin, permissions.BasePermission):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Django superusers can access any object
|
||||
if getattr(request.user, "is_superuser", False):
|
||||
return True
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
|
||||
# Moderators and above can access any object
|
||||
@@ -419,6 +455,10 @@ class CanManageUserRestrictions(GuardMixin, permissions.BasePermission):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Django superusers always have access
|
||||
if getattr(request.user, "is_superuser", False):
|
||||
return True
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
return user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]
|
||||
|
||||
@@ -427,6 +467,10 @@ class CanManageUserRestrictions(GuardMixin, permissions.BasePermission):
|
||||
if not self.has_permission(request, view):
|
||||
return False
|
||||
|
||||
# Django superusers can manage any restriction
|
||||
if getattr(request.user, "is_superuser", False):
|
||||
return True
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
|
||||
# Superusers can manage any restriction
|
||||
|
||||
@@ -82,6 +82,7 @@ THIRD_PARTY_APPS = [
|
||||
"allauth",
|
||||
"allauth.account",
|
||||
"allauth.mfa", # MFA/TOTP support
|
||||
"allauth.mfa.webauthn", # WebAuthn/Passkey support
|
||||
"allauth.socialaccount",
|
||||
"allauth.socialaccount.providers.google",
|
||||
"allauth.socialaccount.providers.discord",
|
||||
|
||||
@@ -76,8 +76,8 @@ SOCIALACCOUNT_STORE_TOKENS = True
|
||||
# =============================================================================
|
||||
# https://docs.allauth.org/en/latest/mfa/index.html
|
||||
|
||||
# Supported authenticator types
|
||||
MFA_SUPPORTED_TYPES = ["totp"]
|
||||
# Supported authenticator types - TOTP and WebAuthn (Passkeys)
|
||||
MFA_SUPPORTED_TYPES = ["totp", "webauthn"]
|
||||
|
||||
# TOTP settings
|
||||
MFA_TOTP_ISSUER = config("MFA_TOTP_ISSUER", default="ThrillWiki")
|
||||
@@ -88,6 +88,17 @@ MFA_TOTP_DIGITS = 6
|
||||
# Interval in seconds for TOTP code generation (default 30)
|
||||
MFA_TOTP_PERIOD = 30
|
||||
|
||||
# WebAuthn/Passkey settings
|
||||
MFA_PASSKEY_LOGIN_ENABLED = config("MFA_PASSKEY_LOGIN_ENABLED", default=True, cast=bool)
|
||||
|
||||
# Read DEBUG directly (same source as base.py) to avoid circular import
|
||||
_DEBUG_MFA = config("DEBUG", default=True, cast=bool)
|
||||
|
||||
# Allow insecure origin (http://localhost) for WebAuthn in development
|
||||
MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN = config(
|
||||
"MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN", default=_DEBUG_MFA, cast=bool
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# Login By Code (Magic Link) Configuration
|
||||
# =============================================================================
|
||||
@@ -202,7 +213,10 @@ FRONTEND_DOMAIN = config("FRONTEND_DOMAIN", default="https://thrillwiki.com")
|
||||
TURNSTILE_SITEKEY = config("TURNSTILE_SITEKEY", default="")
|
||||
TURNSTILE_SECRET = config("TURNSTILE_SECRET", default="")
|
||||
|
||||
# Skip Turnstile validation in development if keys not set
|
||||
# Read DEBUG directly (same source as base.py) to avoid circular import
|
||||
_DEBUG = config("DEBUG", default=True, cast=bool)
|
||||
|
||||
# Skip Turnstile validation in debug mode or if no secret configured
|
||||
TURNSTILE_SKIP_VALIDATION = config(
|
||||
"TURNSTILE_SKIP_VALIDATION", default=not TURNSTILE_SECRET, cast=bool # Skip if no secret
|
||||
"TURNSTILE_SKIP_VALIDATION", default=(_DEBUG or not TURNSTILE_SECRET), cast=bool
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user