feat: Implement passkey authentication, account management features, and a dedicated MFA login verification flow.

This commit is contained in:
pacnpal
2026-01-06 10:08:44 -05:00
parent b80654952d
commit 4da7e52fb0
14 changed files with 1566 additions and 20 deletions

View 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,
)