mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 05:45:17 -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"})
|
||||
@@ -50,6 +50,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
|
||||
@@ -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.
|
||||
|
||||
Accepts either 'email' or 'username' field for backward compatibility.
|
||||
The view will use whichever is provided.
|
||||
"""
|
||||
|
||||
username = serializers.CharField(max_length=254, help_text="Username or email address")
|
||||
# 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")
|
||||
|
||||
@@ -212,6 +243,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(
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user