Based on the git diff provided, here's a concise and descriptive commit message:

feat: add security event taxonomy and optimize park queryset

- Add comprehensive security_event_types ChoiceGroup with categories for authentication, MFA, password, account, session, and API key events
- Include severity levels, icons, and CSS classes for each event type
- Fix park queryset optimization by using select_related for OneToOne location relationship
- Remove location property fields (latitude/longitude) from values() call as they are not actual DB columns
- Add proper location fields (city, state, country) to values() for map display

This change enhances security event tracking capabilities and resolves a queryset optimization issue where property decorators were incorrectly used in values() queries.
This commit is contained in:
pacnpal
2026-01-10 16:41:31 -05:00
parent 96df23242e
commit 2b66814d82
26 changed files with 2055 additions and 112 deletions

View File

@@ -93,6 +93,7 @@ def get_passkey_status(request):
def get_registration_options(request):
"""Get WebAuthn registration options for passkey setup."""
try:
from django.utils import timezone
from allauth.mfa.webauthn.internal import auth as webauthn_auth
# Use the correct allauth API: begin_registration
@@ -101,8 +102,17 @@ def get_registration_options(request):
# State is stored internally by begin_registration via set_state()
# Store registration timeout in session (5 minutes)
request.session["pending_passkey_expires"] = timezone.now().timestamp() + 300 # 5 minutes
# Debug log the structure
logger.debug(f"WebAuthn registration options type: {type(creation_options)}")
logger.debug(f"WebAuthn registration options keys: {creation_options.keys() if isinstance(creation_options, dict) else 'not a dict'}")
logger.info(f"WebAuthn registration options: {creation_options}")
return Response({
"options": creation_options,
"expires_in_seconds": 300,
})
except ImportError as e:
logger.error(f"WebAuthn module import error: {e}")
@@ -143,8 +153,14 @@ def get_registration_options(request):
def register_passkey(request):
"""Complete passkey registration with WebAuthn response."""
try:
from django.utils import timezone
from allauth.mfa.webauthn.internal import auth as webauthn_auth
from apps.accounts.services.security_service import (
log_security_event,
send_security_notification,
)
credential = request.data.get("credential")
name = request.data.get("name", "Passkey")
@@ -154,6 +170,17 @@ def register_passkey(request):
status=status.HTTP_400_BAD_REQUEST,
)
# Check if registration has expired (5 minute timeout)
expires_at = request.session.get("pending_passkey_expires")
if expires_at and timezone.now().timestamp() > expires_at:
# Clear expired session data
if "pending_passkey_expires" in request.session:
del request.session["pending_passkey_expires"]
return Response(
{"detail": "Passkey registration session expired. Please start registration again."},
status=status.HTTP_400_BAD_REQUEST,
)
# Get stored state from session (no request needed, uses context)
state = webauthn_auth.get_state()
if not state:
@@ -164,24 +191,33 @@ def register_passkey(request):
# Use the correct allauth API: complete_registration
try:
from allauth.mfa.models import Authenticator
from allauth.mfa.webauthn.internal.auth import WebAuthn
# Parse the credential response
# Parse the credential response to validate it
credential_data = webauthn_auth.parse_registration_response(credential)
# Complete registration - returns AuthenticatorData (binding)
authenticator_data = webauthn_auth.complete_registration(credential_data)
# Complete registration to validate and clear state
webauthn_auth.complete_registration(credential_data)
# Create the Authenticator record ourselves
authenticator = Authenticator.objects.create(
user=request.user,
type=Authenticator.Type.WEBAUTHN,
data={
"name": name,
"credential": authenticator_data.credential_data.aaguid.hex if authenticator_data.credential_data else None,
},
# Use allauth's WebAuthn.add() to create the Authenticator properly
# It stores the raw credential dict and name in the data field
webauthn_wrapper = WebAuthn.add(
request.user,
name,
credential, # Pass raw credential dict, not parsed data
)
# State is cleared internally by complete_registration
authenticator = webauthn_wrapper.instance
# Log security event
log_security_event(
"passkey_registered",
request,
user=request.user,
metadata={"passkey_name": name, "passkey_id": str(authenticator.id) if authenticator else None},
)
# Send security notification email
send_security_notification(request.user, "passkey_registered", {"passkey_name": name})
return Response({
"detail": "Passkey registered successfully",
@@ -345,6 +381,12 @@ def delete_passkey(request, passkey_id):
try:
from allauth.mfa.models import Authenticator
from apps.accounts.services.security_service import (
check_auth_method_availability,
log_security_event,
send_security_notification,
)
user = request.user
password = request.data.get("password", "")
@@ -355,6 +397,17 @@ def delete_passkey(request, passkey_id):
status=status.HTTP_400_BAD_REQUEST,
)
# Check if user has other auth methods before removing passkey
auth_methods = check_auth_method_availability(user)
# If this is the last passkey and user has no other auth method, block removal
if auth_methods["passkey_count"] == 1:
if not auth_methods["has_password"] and not auth_methods["has_social"] and not auth_methods["has_totp"]:
return Response(
{"detail": "Cannot remove last passkey: you must have at least one authentication method. Please set a password or connect a social account first."},
status=status.HTTP_400_BAD_REQUEST,
)
# Find and delete the passkey
try:
authenticator = Authenticator.objects.get(
@@ -362,7 +415,20 @@ def delete_passkey(request, passkey_id):
user=user,
type=Authenticator.Type.WEBAUTHN,
)
passkey_name = authenticator.data.get("name", "Passkey") if authenticator.data else "Passkey"
authenticator.delete()
# Log security event
log_security_event(
"passkey_removed",
request,
user=user,
metadata={"passkey_name": passkey_name, "passkey_id": str(passkey_id)},
)
# Send security notification email
send_security_notification(user, "passkey_removed", {"passkey_name": passkey_name})
except Authenticator.DoesNotExist:
return Response(
{"detail": "Passkey not found"},