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,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"})

View File

@@ -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,
}
)

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

View File

@@ -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."""

View File

@@ -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

View File

@@ -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(