mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 11:25:19 -05:00
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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user