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

@@ -212,16 +212,29 @@ class LoginAPIView(APIView):
# pass a real HttpRequest to Django login with backend specified
login(_get_underlying_request(request), user, backend="django.contrib.auth.backends.ModelBackend")
# Generate JWT tokens
from rest_framework_simplejwt.tokens import RefreshToken
# Generate JWT tokens with auth method claims
from .jwt import create_tokens_for_user
refresh = RefreshToken.for_user(user)
access_token = refresh.access_token
tokens = create_tokens_for_user(
user,
auth_method="password",
mfa_verified=False,
provider_mfa=False,
)
# Log successful login
from apps.accounts.services.security_service import log_security_event
log_security_event(
"login_success",
request,
user=user,
metadata={"auth_method": "password", "mfa_required": False},
)
response_serializer = LoginOutputSerializer(
{
"access": str(access_token),
"refresh": str(refresh),
"access": tokens["access"],
"refresh": tokens["refresh"],
"user": user,
"message": "Login successful",
}
@@ -237,6 +250,14 @@ class LoginAPIView(APIView):
status=status.HTTP_400_BAD_REQUEST,
)
else:
# Log failed login attempt
from apps.accounts.services.security_service import log_security_event
log_security_event(
"login_failed",
request,
user=None,
metadata={"username_attempted": email_or_username},
)
return Response(
{"detail": "Invalid credentials"},
status=status.HTTP_400_BAD_REQUEST,
@@ -331,8 +352,17 @@ class MFALoginVerifyAPIView(APIView):
)
# Verify MFA - either TOTP or Passkey
from apps.accounts.services.security_service import log_security_event
if totp_code:
if not self._verify_totp(user, totp_code):
# Log failed MFA attempt
log_security_event(
"mfa_challenge_failed",
request,
user=user,
metadata={"method": "totp"},
)
return Response(
{"detail": "Invalid verification code"},
status=status.HTTP_400_BAD_REQUEST,
@@ -341,6 +371,13 @@ class MFALoginVerifyAPIView(APIView):
# Verify passkey/WebAuthn credential
passkey_result = self._verify_passkey(request, user, credential)
if not passkey_result["success"]:
# Log failed MFA attempt
log_security_event(
"mfa_challenge_failed",
request,
user=user,
metadata={"method": "passkey", "error": passkey_result.get("error")},
)
return Response(
{"detail": passkey_result.get("error", "Passkey verification failed")},
status=status.HTTP_400_BAD_REQUEST,
@@ -357,16 +394,41 @@ class MFALoginVerifyAPIView(APIView):
# Complete login
login(_get_underlying_request(request), user, backend="django.contrib.auth.backends.ModelBackend")
# Generate JWT tokens
from rest_framework_simplejwt.tokens import RefreshToken
# Determine auth method based on what was verified
from .jwt import create_tokens_for_user
refresh = RefreshToken.for_user(user)
access_token = refresh.access_token
if credential:
# Passkey verification - inherently MFA
auth_method = "passkey"
else:
# TOTP verification
auth_method = "totp"
# Log successful MFA challenge and login
log_security_event(
"mfa_challenge_success",
request,
user=user,
metadata={"method": auth_method},
)
log_security_event(
"login_success",
request,
user=user,
metadata={"auth_method": auth_method, "mfa_verified": True},
)
tokens = create_tokens_for_user(
user,
auth_method=auth_method,
mfa_verified=True,
provider_mfa=False,
)
response_serializer = LoginOutputSerializer(
{
"access": str(access_token),
"refresh": str(refresh),
"access": tokens["access"],
"refresh": tokens["refresh"],
"user": user,
"message": "Login successful",
}
@@ -516,6 +578,8 @@ class LogoutAPIView(APIView):
def post(self, request: Request) -> Response:
try:
user = request.user
# Get refresh token from request data with proper type handling
refresh_token = None
if hasattr(request, "data") and request.data is not None:
@@ -539,6 +603,15 @@ class LogoutAPIView(APIView):
if hasattr(request.user, "auth_token"):
request.user.auth_token.delete()
# Log security event
from apps.accounts.services.security_service import log_security_event
log_security_event(
"logout",
request,
user=user,
metadata={},
)
# Logout from session using the underlying HttpRequest
logout(_get_underlying_request(request))
@@ -804,6 +877,11 @@ class ConnectProviderAPIView(APIView):
serializer_class = ConnectProviderInputSerializer
def post(self, request: Request, provider: str) -> Response:
from apps.accounts.services.security_service import (
log_security_event,
send_security_notification,
)
# Validate provider
if provider not in ["google", "discord"]:
return Response(
@@ -815,6 +893,30 @@ class ConnectProviderAPIView(APIView):
status=status.HTTP_400_BAD_REQUEST,
)
# Check if user's email is verified before allowing social account linking
# This prevents attackers from linking a social account to an unverified email
user = request.user
# Check allauth email verification status
try:
from allauth.account.models import EmailAddress
primary_email = EmailAddress.objects.filter(user=user, primary=True).first()
if primary_email and not primary_email.verified:
return Response(
{
"detail": "Please verify your email address before connecting social accounts",
"code": "EMAIL_NOT_VERIFIED",
"suggestions": [
"Check your email for a verification link",
"Request a new verification email from your account settings",
],
},
status=status.HTTP_400_BAD_REQUEST,
)
except ImportError:
# If allauth.account is not available, skip check
pass
serializer = ConnectProviderInputSerializer(data=request.data)
if not serializer.is_valid():
return Response(
@@ -833,6 +935,17 @@ class ConnectProviderAPIView(APIView):
service = SocialProviderService()
result = service.connect_provider(request.user, provider, access_token)
# Log security event
log_security_event(
"social_linked",
request,
user=request.user,
metadata={"provider": provider},
)
# Send security notification
send_security_notification(request.user, "social_linked", {"provider": provider.title()})
response_serializer = ConnectProviderOutputSerializer(result)
return Response(response_serializer.data)
@@ -882,6 +995,11 @@ class DisconnectProviderAPIView(APIView):
)
try:
from apps.accounts.services.security_service import (
log_security_event,
send_security_notification,
)
service = SocialProviderService()
# Check if disconnection is safe
@@ -903,6 +1021,17 @@ class DisconnectProviderAPIView(APIView):
# Perform disconnection
result = service.disconnect_provider(request.user, provider)
# Log security event
log_security_event(
"social_unlinked",
request,
user=request.user,
metadata={"provider": provider},
)
# Send security notification
send_security_notification(request.user, "social_unlinked", {"provider": provider.title()})
response_serializer = DisconnectProviderOutputSerializer(result)
return Response(response_serializer.data)