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

@@ -64,6 +64,23 @@ def get_mfa_status(request):
except Authenticator.DoesNotExist:
pass
# Check for Discord social account with MFA enabled
discord_mfa_enabled = False
connected_provider = None
try:
social_accounts = user.socialaccount_set.all()
for social_account in social_accounts:
if social_account.provider == "discord":
connected_provider = "discord"
discord_mfa_enabled = social_account.extra_data.get("mfa_enabled", False)
break
elif social_account.provider == "google":
connected_provider = "google"
# Google doesn't expose MFA status
except Exception:
pass
# has_second_factor is True if user has either TOTP or Passkey configured
has_second_factor = totp_enabled or passkey_enabled
@@ -76,6 +93,9 @@ def get_mfa_status(request):
"recovery_codes_enabled": recovery_enabled,
"recovery_codes_count": recovery_count,
"has_second_factor": has_second_factor,
# New fields for enhanced MFA satisfaction
"discord_mfa_enabled": discord_mfa_enabled,
"connected_provider": connected_provider,
}
)
@@ -100,6 +120,8 @@ def get_mfa_status(request):
@permission_classes([IsAuthenticated])
def setup_totp(request):
"""Generate TOTP secret and QR code for setup."""
from django.utils import timezone
from allauth.mfa.totp.internal import auth as totp_auth
user = request.user
@@ -120,14 +142,16 @@ def setup_totp(request):
qr.save(buffer, format="PNG")
qr_code_base64 = f"data:image/png;base64,{base64.b64encode(buffer.getvalue()).decode()}"
# Store secret in session for later verification
# Store secret in session for later verification with 15-minute expiry
request.session["pending_totp_secret"] = secret
request.session["pending_totp_expires"] = (timezone.now().timestamp() + 900) # 15 minutes
return Response(
{
"secret": secret,
"provisioning_uri": uri,
"qr_code_base64": qr_code_base64,
"expires_in_seconds": 900,
}
)
@@ -165,10 +189,17 @@ def setup_totp(request):
@permission_classes([IsAuthenticated])
def activate_totp(request):
"""Verify TOTP code and activate MFA."""
from django.utils import timezone
from allauth.mfa.models import Authenticator
from allauth.mfa.recovery_codes.internal.auth import RecoveryCodes
from allauth.mfa.totp.internal import auth as totp_auth
from apps.accounts.services.security_service import (
log_security_event,
send_security_notification,
)
user = request.user
code = request.data.get("code", "").strip()
@@ -187,6 +218,19 @@ def activate_totp(request):
status=status.HTTP_400_BAD_REQUEST,
)
# Check if setup has expired (15 minute timeout)
expires_at = request.session.get("pending_totp_expires")
if expires_at and timezone.now().timestamp() > expires_at:
# Clear expired session data
if "pending_totp_secret" in request.session:
del request.session["pending_totp_secret"]
if "pending_totp_expires" in request.session:
del request.session["pending_totp_expires"]
return Response(
{"detail": "TOTP setup session expired. Please start setup again."},
status=status.HTTP_400_BAD_REQUEST,
)
# Verify the code
if not totp_auth.validate_totp_code(secret, code):
return Response(
@@ -215,11 +259,25 @@ def activate_totp(request):
# Clear session (only if it exists - won't exist with JWT auth + secret from body)
if "pending_totp_secret" in request.session:
del request.session["pending_totp_secret"]
if "pending_totp_expires" in request.session:
del request.session["pending_totp_expires"]
# Log security event
log_security_event(
"mfa_enrolled",
request,
user=user,
metadata={"method": "totp"},
)
# Send security notification email
send_security_notification(user, "mfa_enrolled", {"method": "TOTP Authenticator"})
return Response(
{
"detail": "Two-factor authentication enabled",
"recovery_codes": codes,
"recovery_codes_count": len(codes),
}
)
@@ -255,13 +313,59 @@ def deactivate_totp(request):
"""Disable TOTP authentication."""
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", "")
recovery_code = request.data.get("recovery_code", "")
# Verify password
if not user.check_password(password):
# Check if user has other auth methods before we allow disabling MFA
auth_methods = check_auth_method_availability(user)
# If TOTP is their only way in alongside passkeys, we need to ensure they have
# at least password or social login to fall back on
if not auth_methods["has_password"] and not auth_methods["has_social"] and not auth_methods["has_passkey"]:
return Response(
{"detail": "Invalid password"},
{"detail": "Cannot disable MFA: you must have at least one authentication method. Please set a password or connect a social account first."},
status=status.HTTP_400_BAD_REQUEST,
)
# Verify password OR recovery code
verified = False
verification_method = None
if password and user.check_password(password):
verified = True
verification_method = "password"
elif recovery_code:
# Try to verify with recovery code
try:
recovery_auth = Authenticator.objects.get(
user=user, type=Authenticator.Type.RECOVERY_CODES
)
unused_codes = recovery_auth.data.get("codes", [])
if recovery_code.upper().replace("-", "").replace(" ", "") in [
c.upper().replace("-", "").replace(" ", "") for c in unused_codes
]:
verified = True
verification_method = "recovery_code"
# Remove the used code
unused_codes = [
c for c in unused_codes
if c.upper().replace("-", "").replace(" ", "") != recovery_code.upper().replace("-", "").replace(" ", "")
]
recovery_auth.data["codes"] = unused_codes
recovery_auth.save()
except Authenticator.DoesNotExist:
pass
if not verified:
return Response(
{"detail": "Invalid password or recovery code"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -276,6 +380,17 @@ def deactivate_totp(request):
status=status.HTTP_400_BAD_REQUEST,
)
# Log security event
log_security_event(
"mfa_disabled",
request,
user=user,
metadata={"method": "totp", "verified_via": verification_method},
)
# Send security notification email
send_security_notification(user, "mfa_disabled", {"method": "TOTP Authenticator"})
return Response(
{
"detail": "Two-factor authentication disabled",
@@ -361,6 +476,11 @@ def regenerate_recovery_codes(request):
from allauth.mfa.models import Authenticator
from allauth.mfa.recovery_codes.internal.auth import RecoveryCodes
from apps.accounts.services.security_service import (
log_security_event,
send_security_notification,
)
user = request.user
password = request.data.get("password", "")
@@ -371,8 +491,11 @@ def regenerate_recovery_codes(request):
status=status.HTTP_400_BAD_REQUEST,
)
# Check if TOTP is enabled
if not Authenticator.objects.filter(user=user, type=Authenticator.Type.TOTP).exists():
# Check if MFA is enabled (TOTP or Passkey)
has_totp = Authenticator.objects.filter(user=user, type=Authenticator.Type.TOTP).exists()
has_passkey = Authenticator.objects.filter(user=user, type=Authenticator.Type.WEBAUTHN).exists()
if not has_totp and not has_passkey:
return Response(
{"detail": "Two-factor authentication is not enabled"},
status=status.HTTP_400_BAD_REQUEST,
@@ -387,9 +510,21 @@ def regenerate_recovery_codes(request):
recovery_instance = RecoveryCodes.activate(user)
codes = recovery_instance.get_unused_codes()
# Log security event
log_security_event(
"recovery_codes_regenerated",
request,
user=user,
metadata={"codes_generated": len(codes)},
)
# Send security notification email
send_security_notification(user, "recovery_codes_regenerated", {"codes_generated": len(codes)})
return Response(
{
"success": True,
"recovery_codes": codes,
"recovery_codes_count": len(codes),
}
)