From 2b66814d8264f09b2825cc949dbd449b8c546be4 Mon Sep 17 00:00:00 2001 From: pacnpal <183241239+pacnpal@users.noreply.github.com> Date: Sat, 10 Jan 2026 16:41:31 -0500 Subject: [PATCH] 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. --- backend/apps/accounts/choices.py | 247 +++++++++++ .../migrations/0017_add_security_log_model.py | 195 +++++++++ backend/apps/accounts/models.py | 105 +++++ backend/apps/accounts/services/__init__.py | 21 +- .../accounts/services/security_service.py | 402 ++++++++++++++++++ .../apps/api/v1/auth/account_management.py | 138 +++++- backend/apps/api/v1/auth/jwt.py | 96 +++++ backend/apps/api/v1/auth/mfa.py | 147 ++++++- backend/apps/api/v1/auth/passkey.py | 92 +++- backend/apps/api/v1/auth/urls.py | 1 + backend/apps/api/v1/auth/views.py | 153 ++++++- backend/apps/api/v1/serializers/companies.py | 14 +- backend/apps/api/v1/serializers/shared.py | 12 + backend/apps/core/middleware/rate_limiting.py | 15 + backend/apps/parks/managers.py | 38 +- .../migrations/0032_add_logo_image_id.py | 62 +++ backend/apps/parks/models/companies.py | 5 + .../parks/tests/test_query_optimization.py | 13 +- backend/apps/support/choices.py | 166 ++++++++ ...ter_ticket_category_alter_ticket_status.py | 48 +++ backend/apps/support/models.py | 45 +- backend/apps/support/serializers.py | 9 +- backend/config/django/base.py | 1 + backend/config/settings/third_party.py | 20 + backend/thrillwiki/urls.py | 2 + docs/allauth_integration_guide.md | 120 +++++- 26 files changed, 2055 insertions(+), 112 deletions(-) create mode 100644 backend/apps/accounts/migrations/0017_add_security_log_model.py create mode 100644 backend/apps/accounts/services/security_service.py create mode 100644 backend/apps/api/v1/auth/jwt.py create mode 100644 backend/apps/parks/migrations/0032_add_logo_image_id.py create mode 100644 backend/apps/support/choices.py create mode 100644 backend/apps/support/migrations/0004_alter_ticket_category_alter_ticket_status.py diff --git a/backend/apps/accounts/choices.py b/backend/apps/accounts/choices.py index 4e377dd3..0401b40d 100644 --- a/backend/apps/accounts/choices.py +++ b/backend/apps/accounts/choices.py @@ -586,6 +586,251 @@ notification_priorities = ChoiceGroup( ) +# ============================================================================= +# SECURITY EVENT TYPES +# ============================================================================= + +security_event_types = ChoiceGroup( + name="security_event_types", + choices=[ + RichChoice( + value="login_success", + label="Login Success", + description="User successfully logged in to their account", + metadata={ + "color": "green", + "icon": "login", + "css_class": "text-green-600 bg-green-50", + "severity": "info", + "category": "authentication", + "sort_order": 1, + }, + ), + RichChoice( + value="login_failed", + label="Login Failed", + description="Failed login attempt to user's account", + metadata={ + "color": "red", + "icon": "login", + "css_class": "text-red-600 bg-red-50", + "severity": "warning", + "category": "authentication", + "sort_order": 2, + }, + ), + RichChoice( + value="logout", + label="Logout", + description="User logged out of their account", + metadata={ + "color": "gray", + "icon": "logout", + "css_class": "text-gray-600 bg-gray-50", + "severity": "info", + "category": "authentication", + "sort_order": 3, + }, + ), + RichChoice( + value="mfa_enrolled", + label="MFA Enrolled", + description="User enabled two-factor authentication", + metadata={ + "color": "green", + "icon": "shield-check", + "css_class": "text-green-600 bg-green-50", + "severity": "info", + "category": "mfa", + "sort_order": 4, + }, + ), + RichChoice( + value="mfa_disabled", + label="MFA Disabled", + description="User disabled two-factor authentication", + metadata={ + "color": "yellow", + "icon": "shield-off", + "css_class": "text-yellow-600 bg-yellow-50", + "severity": "warning", + "category": "mfa", + "sort_order": 5, + }, + ), + RichChoice( + value="mfa_challenge_success", + label="MFA Challenge Success", + description="User successfully completed MFA verification", + metadata={ + "color": "green", + "icon": "shield-check", + "css_class": "text-green-600 bg-green-50", + "severity": "info", + "category": "mfa", + "sort_order": 6, + }, + ), + RichChoice( + value="mfa_challenge_failed", + label="MFA Challenge Failed", + description="User failed MFA verification attempt", + metadata={ + "color": "red", + "icon": "shield-x", + "css_class": "text-red-600 bg-red-50", + "severity": "warning", + "category": "mfa", + "sort_order": 7, + }, + ), + RichChoice( + value="passkey_registered", + label="Passkey Registered", + description="User registered a new passkey/WebAuthn credential", + metadata={ + "color": "green", + "icon": "fingerprint", + "css_class": "text-green-600 bg-green-50", + "severity": "info", + "category": "passkey", + "sort_order": 8, + }, + ), + RichChoice( + value="passkey_removed", + label="Passkey Removed", + description="User removed a passkey/WebAuthn credential", + metadata={ + "color": "yellow", + "icon": "fingerprint", + "css_class": "text-yellow-600 bg-yellow-50", + "severity": "warning", + "category": "passkey", + "sort_order": 9, + }, + ), + RichChoice( + value="passkey_login", + label="Passkey Login", + description="User logged in using a passkey", + metadata={ + "color": "green", + "icon": "fingerprint", + "css_class": "text-green-600 bg-green-50", + "severity": "info", + "category": "passkey", + "sort_order": 10, + }, + ), + RichChoice( + value="social_linked", + label="Social Account Linked", + description="User connected a social login provider", + metadata={ + "color": "blue", + "icon": "link", + "css_class": "text-blue-600 bg-blue-50", + "severity": "info", + "category": "social", + "sort_order": 11, + }, + ), + RichChoice( + value="social_unlinked", + label="Social Account Unlinked", + description="User disconnected a social login provider", + metadata={ + "color": "yellow", + "icon": "unlink", + "css_class": "text-yellow-600 bg-yellow-50", + "severity": "info", + "category": "social", + "sort_order": 12, + }, + ), + RichChoice( + value="password_reset_requested", + label="Password Reset Requested", + description="Password reset was requested for user's account", + metadata={ + "color": "yellow", + "icon": "key", + "css_class": "text-yellow-600 bg-yellow-50", + "severity": "info", + "category": "password", + "sort_order": 13, + }, + ), + RichChoice( + value="password_reset_completed", + label="Password Reset Completed", + description="User successfully reset their password", + metadata={ + "color": "green", + "icon": "key", + "css_class": "text-green-600 bg-green-50", + "severity": "info", + "category": "password", + "sort_order": 14, + }, + ), + RichChoice( + value="password_changed", + label="Password Changed", + description="User changed their password", + metadata={ + "color": "green", + "icon": "key", + "css_class": "text-green-600 bg-green-50", + "severity": "info", + "category": "password", + "sort_order": 15, + }, + ), + RichChoice( + value="session_invalidated", + label="Session Invalidated", + description="User's session was terminated", + metadata={ + "color": "yellow", + "icon": "clock", + "css_class": "text-yellow-600 bg-yellow-50", + "severity": "info", + "category": "session", + "sort_order": 16, + }, + ), + RichChoice( + value="recovery_code_used", + label="Recovery Code Used", + description="User used a recovery code for authentication", + metadata={ + "color": "orange", + "icon": "key", + "css_class": "text-orange-600 bg-orange-50", + "severity": "warning", + "category": "mfa", + "sort_order": 17, + }, + ), + RichChoice( + value="recovery_codes_regenerated", + label="Recovery Codes Regenerated", + description="User generated new recovery codes", + metadata={ + "color": "blue", + "icon": "refresh", + "css_class": "text-blue-600 bg-blue-50", + "severity": "info", + "category": "mfa", + "sort_order": 18, + }, + ), + ], +) + + # ============================================================================= # REGISTER ALL CHOICE GROUPS # ============================================================================= @@ -598,3 +843,5 @@ register_choices("privacy_levels", privacy_levels.choices, "accounts", "Privacy register_choices("top_list_categories", top_list_categories.choices, "accounts", "Top list category types") register_choices("notification_types", notification_types.choices, "accounts", "Notification type classifications") register_choices("notification_priorities", notification_priorities.choices, "accounts", "Notification priority levels") +register_choices("security_event_types", security_event_types.choices, "accounts", "Security event type classifications") + diff --git a/backend/apps/accounts/migrations/0017_add_security_log_model.py b/backend/apps/accounts/migrations/0017_add_security_log_model.py new file mode 100644 index 00000000..60ad39d0 --- /dev/null +++ b/backend/apps/accounts/migrations/0017_add_security_log_model.py @@ -0,0 +1,195 @@ +# Generated by Django 5.2.10 on 2026-01-10 20:48 + +import apps.core.choices.fields +import django.db.models.deletion +import pgtrigger.compiler +import pgtrigger.migrations +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0016_remove_emailverification_insert_insert_and_more"), + ("pghistory", "0007_auto_20250421_0444"), + ] + + operations = [ + migrations.CreateModel( + name="SecurityLog", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "event_type", + apps.core.choices.fields.RichChoiceField( + allow_deprecated=False, + choice_group="security_event_types", + choices=[ + ("login_success", "Login Success"), + ("login_failed", "Login Failed"), + ("logout", "Logout"), + ("mfa_enrolled", "MFA Enrolled"), + ("mfa_disabled", "MFA Disabled"), + ("mfa_challenge_success", "MFA Challenge Success"), + ("mfa_challenge_failed", "MFA Challenge Failed"), + ("passkey_registered", "Passkey Registered"), + ("passkey_removed", "Passkey Removed"), + ("passkey_login", "Passkey Login"), + ("social_linked", "Social Account Linked"), + ("social_unlinked", "Social Account Unlinked"), + ("password_reset_requested", "Password Reset Requested"), + ("password_reset_completed", "Password Reset Completed"), + ("password_changed", "Password Changed"), + ("session_invalidated", "Session Invalidated"), + ("recovery_code_used", "Recovery Code Used"), + ("recovery_codes_regenerated", "Recovery Codes Regenerated"), + ], + db_index=True, + domain="accounts", + help_text="Type of security event", + max_length=50, + ), + ), + ("ip_address", models.GenericIPAddressField(help_text="IP address of the request")), + ("user_agent", models.TextField(blank=True, help_text="User agent string from the request")), + ("metadata", models.JSONField(blank=True, default=dict, help_text="Additional event-specific data")), + ("created_at", models.DateTimeField(auto_now_add=True, help_text="When this event occurred")), + ( + "user", + models.ForeignKey( + blank=True, + help_text="User this event is associated with", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="security_logs", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Security Log", + "verbose_name_plural": "Security Logs", + "ordering": ["-created_at"], + }, + ), + migrations.CreateModel( + name="SecurityLogEvent", + fields=[ + ("pgh_id", models.AutoField(primary_key=True, serialize=False)), + ("pgh_created_at", models.DateTimeField(auto_now_add=True)), + ("pgh_label", models.TextField(help_text="The event label.")), + ("id", models.BigIntegerField()), + ( + "event_type", + apps.core.choices.fields.RichChoiceField( + allow_deprecated=False, + choice_group="security_event_types", + choices=[ + ("login_success", "Login Success"), + ("login_failed", "Login Failed"), + ("logout", "Logout"), + ("mfa_enrolled", "MFA Enrolled"), + ("mfa_disabled", "MFA Disabled"), + ("mfa_challenge_success", "MFA Challenge Success"), + ("mfa_challenge_failed", "MFA Challenge Failed"), + ("passkey_registered", "Passkey Registered"), + ("passkey_removed", "Passkey Removed"), + ("passkey_login", "Passkey Login"), + ("social_linked", "Social Account Linked"), + ("social_unlinked", "Social Account Unlinked"), + ("password_reset_requested", "Password Reset Requested"), + ("password_reset_completed", "Password Reset Completed"), + ("password_changed", "Password Changed"), + ("session_invalidated", "Session Invalidated"), + ("recovery_code_used", "Recovery Code Used"), + ("recovery_codes_regenerated", "Recovery Codes Regenerated"), + ], + domain="accounts", + help_text="Type of security event", + max_length=50, + ), + ), + ("ip_address", models.GenericIPAddressField(help_text="IP address of the request")), + ("user_agent", models.TextField(blank=True, help_text="User agent string from the request")), + ("metadata", models.JSONField(blank=True, default=dict, help_text="Additional event-specific data")), + ("created_at", models.DateTimeField(auto_now_add=True, help_text="When this event occurred")), + ( + "pgh_context", + models.ForeignKey( + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="pghistory.context", + ), + ), + ( + "pgh_obj", + models.ForeignKey( + db_constraint=False, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="events", + to="accounts.securitylog", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + db_constraint=False, + help_text="User this event is associated with", + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + related_query_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddIndex( + model_name="securitylog", + index=models.Index(fields=["user", "-created_at"], name="accounts_se_user_id_d46023_idx"), + ), + migrations.AddIndex( + model_name="securitylog", + index=models.Index(fields=["event_type", "-created_at"], name="accounts_se_event_t_814971_idx"), + ), + migrations.AddIndex( + model_name="securitylog", + index=models.Index(fields=["ip_address", "-created_at"], name="accounts_se_ip_addr_2a19c8_idx"), + ), + pgtrigger.migrations.AddTrigger( + model_name="securitylog", + trigger=pgtrigger.compiler.Trigger( + name="insert_insert", + sql=pgtrigger.compiler.UpsertTriggerSql( + func='INSERT INTO "accounts_securitylogevent" ("created_at", "event_type", "id", "ip_address", "metadata", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "user_agent", "user_id") VALUES (NEW."created_at", NEW."event_type", NEW."id", NEW."ip_address", NEW."metadata", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."user_agent", NEW."user_id"); RETURN NULL;', + hash="a40cf3f6fa9e8cda99f7204edb226b26bbe03eda", + operation="INSERT", + pgid="pgtrigger_insert_insert_5d4cf", + table="accounts_securitylog", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="securitylog", + trigger=pgtrigger.compiler.Trigger( + name="update_update", + sql=pgtrigger.compiler.UpsertTriggerSql( + condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)", + func='INSERT INTO "accounts_securitylogevent" ("created_at", "event_type", "id", "ip_address", "metadata", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "user_agent", "user_id") VALUES (NEW."created_at", NEW."event_type", NEW."id", NEW."ip_address", NEW."metadata", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."user_agent", NEW."user_id"); RETURN NULL;', + hash="244fc44bdaff1bf2d557f09ae452a9ea77274068", + operation="UPDATE", + pgid="pgtrigger_update_update_d4645", + table="accounts_securitylog", + when="AFTER", + ), + ), + ), + ] diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py index b5f9507b..c916426a 100644 --- a/backend/apps/accounts/models.py +++ b/backend/apps/accounts/models.py @@ -620,6 +620,111 @@ class NotificationPreference(TrackedModel): return getattr(self, field_name, False) +@pghistory.track() +class SecurityLog(models.Model): + """ + Model to track security-relevant authentication events. + + All security-critical events are logged here for audit purposes, + including logins, MFA changes, password changes, and session management. + """ + + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="security_logs", + null=True, # Allow null for failed login attempts with no valid user + blank=True, + help_text="User this event is associated with", + ) + event_type = RichChoiceField( + choice_group="security_event_types", + domain="accounts", + max_length=50, + db_index=True, + help_text="Type of security event", + ) + ip_address = models.GenericIPAddressField( + help_text="IP address of the request", + ) + user_agent = models.TextField( + blank=True, + help_text="User agent string from the request", + ) + metadata = models.JSONField( + default=dict, + blank=True, + help_text="Additional event-specific data", + ) + created_at = models.DateTimeField( + auto_now_add=True, + help_text="When this event occurred", + ) + + class Meta: + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["user", "-created_at"]), + models.Index(fields=["event_type", "-created_at"]), + models.Index(fields=["ip_address", "-created_at"]), + ] + verbose_name = "Security Log" + verbose_name_plural = "Security Logs" + + def __str__(self): + username = self.user.username if self.user else "Unknown" + return f"{self.get_event_type_display()} - {username} at {self.created_at}" + + @classmethod + def log_event( + cls, + event_type: str, + ip_address: str, + user=None, + user_agent: str = "", + metadata: dict = None, + ) -> "SecurityLog": + """ + Create a new security log entry. + + Args: + event_type: One of security_event_types choices (e.g., "login_success") + ip_address: Client IP address + user: User instance (optional for failed logins) + user_agent: Browser user agent string + metadata: Additional event-specific data + + Returns: + The created SecurityLog instance + """ + return cls.objects.create( + user=user, + event_type=event_type, + ip_address=ip_address, + user_agent=user_agent, + metadata=metadata or {}, + ) + + @classmethod + def get_recent_for_user(cls, user, limit: int = 20): + """Get recent security events for a user.""" + return cls.objects.filter(user=user).order_by("-created_at")[:limit] + + @classmethod + def get_failed_login_count(cls, ip_address: str, minutes: int = 15) -> int: + """Count failed login attempts from an IP in the last N minutes.""" + from datetime import timedelta + + from django.utils import timezone + + cutoff = timezone.now() - timedelta(minutes=minutes) + return cls.objects.filter( + event_type="login_failed", + ip_address=ip_address, + created_at__gte=cutoff, + ).count() + + # Signal handlers for automatic notification preference creation diff --git a/backend/apps/accounts/services/__init__.py b/backend/apps/accounts/services/__init__.py index d3451b94..29939b44 100644 --- a/backend/apps/accounts/services/__init__.py +++ b/backend/apps/accounts/services/__init__.py @@ -8,6 +8,23 @@ including social provider management, user authentication, and profile services. from .account_service import AccountService from .social_provider_service import SocialProviderService from .user_deletion_service import UserDeletionService +from .security_service import ( + get_client_ip, + log_security_event, + log_security_event_simple, + send_security_notification, + check_auth_method_availability, + invalidate_user_sessions, +) -__all__ = ["AccountService", "SocialProviderService", "UserDeletionService"] - +__all__ = [ + "AccountService", + "SocialProviderService", + "UserDeletionService", + "get_client_ip", + "log_security_event", + "log_security_event_simple", + "send_security_notification", + "check_auth_method_availability", + "invalidate_user_sessions", +] diff --git a/backend/apps/accounts/services/security_service.py b/backend/apps/accounts/services/security_service.py new file mode 100644 index 00000000..eb3301e1 --- /dev/null +++ b/backend/apps/accounts/services/security_service.py @@ -0,0 +1,402 @@ +""" +Security Service for ThrillWiki + +Provides centralized security event logging, notifications, and helper functions +for all authentication-related operations. +""" + +import logging +from typing import Any + +from django.conf import settings +from django.core.mail import send_mail +from django.template.loader import render_to_string + +logger = logging.getLogger(__name__) + + +def get_client_ip(request) -> str: + """ + Extract client IP from request, handling proxies correctly. + + Args: + request: Django/DRF request object + + Returns: + Client IP address as string + """ + # Check for proxy headers first + x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") + if x_forwarded_for: + # X-Forwarded-For can contain multiple IPs; take the first one + return x_forwarded_for.split(",")[0].strip() + + # Check for Cloudflare's CF-Connecting-IP header + cf_connecting_ip = request.META.get("HTTP_CF_CONNECTING_IP") + if cf_connecting_ip: + return cf_connecting_ip + + # Fallback to REMOTE_ADDR + return request.META.get("REMOTE_ADDR", "0.0.0.0") + + +def log_security_event( + event_type: str, + request, + user=None, + metadata: dict = None +) -> Any: + """ + Log a security event with request context. + + Args: + event_type: One of SecurityLog.EventType choices + request: Django/DRF request object + user: User instance (optional for failed logins) + metadata: Additional event-specific data + + Returns: + The created SecurityLog instance + """ + from apps.accounts.models import SecurityLog + + try: + return SecurityLog.log_event( + event_type=event_type, + ip_address=get_client_ip(request), + user=user, + user_agent=request.META.get("HTTP_USER_AGENT", ""), + metadata=metadata or {}, + ) + except Exception as e: + logger.error(f"Failed to log security event {event_type}: {e}") + return None + + +def log_security_event_simple( + event_type: str, + ip_address: str, + user=None, + user_agent: str = "", + metadata: dict = None +) -> Any: + """ + Log a security event without request context. + + Use this when you don't have access to the request object. + + Args: + event_type: One of SecurityLog.EventType choices + ip_address: Client IP address + user: User instance (optional) + user_agent: Browser user agent string + metadata: Additional event-specific data + + Returns: + The created SecurityLog instance + """ + from apps.accounts.models import SecurityLog + + try: + return SecurityLog.log_event( + event_type=event_type, + ip_address=ip_address, + user=user, + user_agent=user_agent, + metadata=metadata or {}, + ) + except Exception as e: + logger.error(f"Failed to log security event {event_type}: {e}") + return None + + +# Subject line mapping for security notifications +SECURITY_NOTIFICATION_SUBJECTS = { + "mfa_enrolled": "Two-Factor Authentication Enabled", + "mfa_disabled": "Two-Factor Authentication Disabled", + "passkey_registered": "New Passkey Added to Your Account", + "passkey_removed": "Passkey Removed from Your Account", + "password_changed": "Your Password Was Changed", + "password_reset_completed": "Your Password Has Been Reset", + "social_linked": "Social Account Connected", + "social_unlinked": "Social Account Disconnected", + "session_invalidated": "Session Security Update", + "recovery_codes_regenerated": "Recovery Codes Regenerated", +} + + +def send_security_notification( + user, + event_type: str, + metadata: dict = None +) -> bool: + """ + Send email notification for security-sensitive events. + + This function sends an email to the user when important security + events occur on their account. + + Args: + user: User instance to notify + event_type: Type of security event (used to select template and subject) + metadata: Additional context for the email template + + Returns: + True if email was sent successfully, False otherwise + """ + if not user or not user.email: + logger.warning(f"Cannot send security notification: no email for user") + return False + + # Check if user has security notifications enabled + if hasattr(user, "notification_preference"): + prefs = user.notification_preference + if not getattr(prefs, "account_security_email", True): + logger.debug(f"User {user.username} has security emails disabled") + return False + + try: + subject = f"ThrillWiki Security Alert: {SECURITY_NOTIFICATION_SUBJECTS.get(event_type, 'Account Activity')}" + + context = { + "user": user, + "event_type": event_type, + "event_display": SECURITY_NOTIFICATION_SUBJECTS.get(event_type, "Account Activity"), + "metadata": metadata or {}, + "site_name": "ThrillWiki", + "support_email": getattr(settings, "DEFAULT_SUPPORT_EMAIL", "support@thrillwiki.com"), + } + + # Try to render HTML template, fallback to plain text + try: + html_message = render_to_string("accounts/email/security_notification.html", context) + except Exception as template_error: + logger.debug(f"HTML template not found, using fallback: {template_error}") + html_message = _get_fallback_security_email(context) + + # Plain text version + text_message = _get_plain_text_security_email(context) + + send_mail( + subject=subject, + message=text_message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[user.email], + html_message=html_message, + fail_silently=False, + ) + + logger.info(f"Security notification sent to {user.email} for event: {event_type}") + return True + + except Exception as e: + logger.error(f"Failed to send security notification to {user.email}: {e}") + return False + + +def _get_plain_text_security_email(context: dict) -> str: + """Generate plain text email for security notifications.""" + event_display = context.get("event_display", "Account Activity") + user = context.get("user") + metadata = context.get("metadata", {}) + + lines = [ + f"Hello {user.get_display_name() if user else 'User'},", + "", + f"This is a security notification from ThrillWiki.", + "", + f"Event: {event_display}", + ] + + # Add metadata details + if metadata: + lines.append("") + lines.append("Details:") + for key, value in metadata.items(): + if key not in ("user_id", "internal"): + lines.append(f" - {key.replace('_', ' ').title()}: {value}") + + lines.extend([ + "", + "If you did not perform this action, please secure your account immediately:", + "1. Change your password", + "2. Review your connected devices and sign out any you don't recognize", + "3. Contact support if you need assistance", + "", + "Best regards,", + "The ThrillWiki Team", + ]) + + return "\n".join(lines) + + +def _get_fallback_security_email(context: dict) -> str: + """Generate HTML email for security notifications when template not found.""" + event_display = context.get("event_display", "Account Activity") + user = context.get("user") + metadata = context.get("metadata", {}) + + metadata_html = "" + if metadata: + items = [] + for key, value in metadata.items(): + if key not in ("user_id", "internal"): + items.append(f"
  • {key.replace('_', ' ').title()}: {value}
  • ") + if items: + metadata_html = f"

    Details:

    " + + return f""" + + + + + + +
    +
    +

    🔒 Security Alert

    +
    +
    +

    Hello {user.get_display_name() if user else 'User'},

    +

    This is a security notification from ThrillWiki.

    +

    {event_display}

    + {metadata_html} +
    + Didn't do this?
    + If you did not perform this action, please secure your account immediately by changing your password and reviewing your connected devices. +
    +
    + +
    + + + """ + + +def check_auth_method_availability(user) -> dict: + """ + Check what authentication methods a user has available. + + This is used to prevent users from removing their last auth method. + + Args: + user: User instance to check + + Returns: + Dictionary with auth method availability: + { + "has_password": bool, + "has_totp": bool, + "has_passkey": bool, + "passkey_count": int, + "has_social": bool, + "social_providers": list[str], + "total_methods": int, + "can_remove_mfa": bool, + "can_remove_passkey": bool, + "can_remove_social": bool, + } + """ + try: + from allauth.mfa.models import Authenticator + except ImportError: + Authenticator = None + + result = { + "has_password": user.has_usable_password(), + "has_totp": False, + "has_passkey": False, + "passkey_count": 0, + "has_social": False, + "social_providers": [], + "total_methods": 0, + } + + # Check MFA authenticators + if Authenticator: + result["has_totp"] = Authenticator.objects.filter( + user=user, type=Authenticator.Type.TOTP + ).exists() + + passkey_count = Authenticator.objects.filter( + user=user, type=Authenticator.Type.WEBAUTHN + ).count() + result["passkey_count"] = passkey_count + result["has_passkey"] = passkey_count > 0 + + # Check social accounts + if hasattr(user, "socialaccount_set"): + social_accounts = user.socialaccount_set.all() + result["has_social"] = social_accounts.exists() + result["social_providers"] = list(social_accounts.values_list("provider", flat=True)) + + # Calculate total methods (counting passkeys as one method regardless of count) + result["total_methods"] = sum([ + result["has_password"], + result["has_passkey"], + result["has_social"], + ]) + + # Determine what can be safely removed + # User must always have at least one primary auth method remaining + result["can_remove_mfa"] = result["total_methods"] >= 1 + result["can_remove_passkey"] = ( + result["total_methods"] > 1 or + (result["passkey_count"] > 1) or + result["has_password"] or + result["has_social"] + ) + result["can_remove_social"] = ( + result["total_methods"] > 1 or + result["has_password"] or + result["has_passkey"] + ) + + return result + + +def invalidate_user_sessions(user, exclude_current: bool = False, request=None) -> int: + """ + Invalidate all JWT tokens for a user. + + This is used after security-sensitive operations like password reset. + + Args: + user: User whose sessions to invalidate + exclude_current: If True and request is provided, keep current session + request: Current request (used if exclude_current is True) + + Returns: + Number of tokens invalidated + """ + try: + from rest_framework_simplejwt.token_blacklist.models import ( + BlacklistedToken, + OutstandingToken, + ) + except ImportError: + logger.warning("JWT token blacklist not available") + return 0 + + count = 0 + outstanding_tokens = OutstandingToken.objects.filter(user=user) + + for token in outstanding_tokens: + try: + BlacklistedToken.objects.get_or_create(token=token) + count += 1 + except Exception as e: + logger.debug(f"Could not blacklist token: {e}") + + logger.info(f"Invalidated {count} tokens for user {user.username}") + return count diff --git a/backend/apps/api/v1/auth/account_management.py b/backend/apps/api/v1/auth/account_management.py index 03150493..16db829b 100644 --- a/backend/apps/api/v1/auth/account_management.py +++ b/backend/apps/api/v1/auth/account_management.py @@ -370,6 +370,118 @@ def revoke_session(request, session_id): return Response({"detail": "Session revoked"}) +# ============== PASSWORD CHANGE ENDPOINT ============== + +# ============== SECURITY LOG ENDPOINT ============== + +@extend_schema( + operation_id="get_security_log", + summary="Get security activity log", + description="Returns paginated list of security events for the current user.", + parameters=[ + { + "name": "page", + "in": "query", + "description": "Page number (1-indexed)", + "required": False, + "schema": {"type": "integer", "default": 1}, + }, + { + "name": "page_size", + "in": "query", + "description": "Number of items per page (max 50)", + "required": False, + "schema": {"type": "integer", "default": 20}, + }, + { + "name": "event_type", + "in": "query", + "description": "Filter by event type", + "required": False, + "schema": {"type": "string"}, + }, + ], + responses={ + 200: { + "description": "Security log entries", + "example": { + "count": 42, + "page": 1, + "page_size": 20, + "total_pages": 3, + "results": [ + { + "id": 1, + "event_type": "login_success", + "event_type_display": "Login Success", + "ip_address": "192.168.1.1", + "user_agent": "Mozilla/5.0...", + "created_at": "2026-01-06T12:00:00Z", + "metadata": {}, + } + ], + }, + }, + }, + tags=["Account"], +) +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def get_security_log(request): + """Get security activity log for the current user.""" + from apps.accounts.models import SecurityLog + + user = request.user + + # Parse pagination params + try: + page = max(1, int(request.query_params.get("page", 1))) + except (ValueError, TypeError): + page = 1 + + try: + page_size = min(50, max(1, int(request.query_params.get("page_size", 20)))) + except (ValueError, TypeError): + page_size = 20 + + event_type = request.query_params.get("event_type") + + # Build queryset + queryset = SecurityLog.objects.filter(user=user).order_by("-created_at") + + if event_type: + queryset = queryset.filter(event_type=event_type) + + # Count total + total_count = queryset.count() + total_pages = (total_count + page_size - 1) // page_size + + # Fetch page + offset = (page - 1) * page_size + logs = queryset[offset : offset + page_size] + + # Serialize + results = [] + for log in logs: + results.append({ + "id": log.id, + "event_type": log.event_type, + "event_type_display": log.get_event_type_display(), + "ip_address": log.ip_address, + "user_agent": log.user_agent[:200] if log.user_agent else "", # Truncate for safety + "created_at": log.created_at.isoformat(), + "metadata": log.metadata or {}, + }) + + return Response({ + "count": total_count, + "page": page, + "page_size": page_size, + "total_pages": total_pages, + "results": results, + }) + + # ============== PASSWORD CHANGE ENDPOINT ============== @extend_schema( @@ -396,6 +508,12 @@ def revoke_session(request, session_id): @permission_classes([IsAuthenticated]) def change_password(request): """Change user password.""" + from apps.accounts.services.security_service import ( + log_security_event, + send_security_notification, + invalidate_user_sessions, + ) + user = request.user current_password = request.data.get("current_password", "") new_password = request.data.get("new_password", "") @@ -413,6 +531,24 @@ def change_password(request): ) user.set_password(new_password) + user.last_password_change = timezone.now() user.save() - return Response({"detail": "Password changed successfully"}) + # Invalidate all existing sessions/tokens (except current) + invalidated_count = invalidate_user_sessions(user, exclude_current=True, request=request) + + # Log security event + log_security_event( + "password_changed", + request, + user=user, + metadata={"sessions_invalidated": invalidated_count}, + ) + + # Send security notification email + send_security_notification(user, "password_changed", metadata={}) + + return Response({ + "detail": "Password changed successfully", + "sessions_invalidated": invalidated_count, + }) diff --git a/backend/apps/api/v1/auth/jwt.py b/backend/apps/api/v1/auth/jwt.py new file mode 100644 index 00000000..4cd923f6 --- /dev/null +++ b/backend/apps/api/v1/auth/jwt.py @@ -0,0 +1,96 @@ +""" +Custom JWT Token Generation for ThrillWiki + +This module provides custom JWT token generation that includes authentication +method claims for enhanced MFA satisfaction logic. + +Claims added: +- auth_method: How the user authenticated (password, passkey, totp, google, discord) +- mfa_verified: Whether MFA was verified during this login +- provider_mfa: Whether the OAuth provider (Discord) has MFA enabled +""" + +from typing import Literal, TypedDict + +from rest_framework_simplejwt.tokens import RefreshToken + +# Type definitions for auth methods +AuthMethod = Literal["password", "passkey", "totp", "google", "discord"] + + +class TokenClaims(TypedDict, total=False): + """Type definition for custom JWT claims.""" + + auth_method: AuthMethod + mfa_verified: bool + provider_mfa: bool + + +def create_tokens_for_user( + user, + auth_method: AuthMethod = "password", + mfa_verified: bool = False, + provider_mfa: bool = False, +) -> dict[str, str]: + """ + Generate JWT tokens with custom authentication claims. + + Args: + user: The Django user object + auth_method: How the user authenticated + mfa_verified: True if MFA (TOTP/passkey) was verified at login + provider_mfa: True if OAuth provider (Discord) has MFA enabled + + Returns: + Dictionary with 'access' and 'refresh' token strings + """ + refresh = RefreshToken.for_user(user) + + # Add custom claims to both refresh and access tokens + refresh["auth_method"] = auth_method + refresh["mfa_verified"] = mfa_verified + refresh["provider_mfa"] = provider_mfa + + access = refresh.access_token + + return { + "access": str(access), + "refresh": str(refresh), + } + + +def get_auth_method_for_provider(provider: str) -> AuthMethod: + """ + Map OAuth provider name to AuthMethod type. + + Args: + provider: The provider name (e.g., 'google', 'discord') + + Returns: + The corresponding AuthMethod + """ + provider_map: dict[str, AuthMethod] = { + "google": "google", + "discord": "discord", + } + return provider_map.get(provider, "password") + + +def get_provider_mfa_status(provider: str, extra_data: dict) -> bool: + """ + Extract MFA status from OAuth provider extra_data. + + Only Discord exposes mfa_enabled. Google does not share this info. + + Args: + provider: The OAuth provider name + extra_data: The extra_data dict from SocialAccount + + Returns: + True if provider has MFA enabled, False otherwise + """ + if provider == "discord": + return extra_data.get("mfa_enabled", False) + + # Google and other providers don't expose MFA status + return False diff --git a/backend/apps/api/v1/auth/mfa.py b/backend/apps/api/v1/auth/mfa.py index ea9582a9..d5503859 100644 --- a/backend/apps/api/v1/auth/mfa.py +++ b/backend/apps/api/v1/auth/mfa.py @@ -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), } ) diff --git a/backend/apps/api/v1/auth/passkey.py b/backend/apps/api/v1/auth/passkey.py index 0a9e23a2..825ef423 100644 --- a/backend/apps/api/v1/auth/passkey.py +++ b/backend/apps/api/v1/auth/passkey.py @@ -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"}, diff --git a/backend/apps/api/v1/auth/urls.py b/backend/apps/api/v1/auth/urls.py index b7d599f4..975dedac 100644 --- a/backend/apps/api/v1/auth/urls.py +++ b/backend/apps/api/v1/auth/urls.py @@ -128,6 +128,7 @@ urlpatterns = [ path("sessions/", account_views.list_sessions, name="auth-sessions-list"), path("sessions//", account_views.revoke_session, name="auth-session-revoke"), path("password/change/", account_views.change_password, name="auth-password-change-v2"), + path("security-log/", account_views.get_security_log, name="auth-security-log"), ] # Note: User profiles and top lists functionality is now handled by the accounts app diff --git a/backend/apps/api/v1/auth/views.py b/backend/apps/api/v1/auth/views.py index f03fc87e..0b596476 100644 --- a/backend/apps/api/v1/auth/views.py +++ b/backend/apps/api/v1/auth/views.py @@ -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) diff --git a/backend/apps/api/v1/serializers/companies.py b/backend/apps/api/v1/serializers/companies.py index 08679239..17dab991 100644 --- a/backend/apps/api/v1/serializers/companies.py +++ b/backend/apps/api/v1/serializers/companies.py @@ -107,10 +107,15 @@ class CompanyCreateInputSerializer(serializers.Serializer): allow_blank=True, ) - # Image URLs + # Image URLs (legacy - prefer using image IDs) logo_url = serializers.URLField(required=False, allow_blank=True) banner_image_url = serializers.URLField(required=False, allow_blank=True) card_image_url = serializers.URLField(required=False, allow_blank=True) + + # Cloudflare image IDs (preferred for new submissions) + logo_image_id = serializers.CharField(max_length=255, required=False, allow_blank=True) + banner_image_id = serializers.CharField(max_length=255, required=False, allow_blank=True) + card_image_id = serializers.CharField(max_length=255, required=False, allow_blank=True) class CompanyUpdateInputSerializer(serializers.Serializer): @@ -144,10 +149,15 @@ class CompanyUpdateInputSerializer(serializers.Serializer): allow_blank=True, ) - # Image URLs + # Image URLs (legacy - prefer using image IDs) logo_url = serializers.URLField(required=False, allow_blank=True) banner_image_url = serializers.URLField(required=False, allow_blank=True) card_image_url = serializers.URLField(required=False, allow_blank=True) + + # Cloudflare image IDs (preferred for new submissions) + logo_image_id = serializers.CharField(max_length=255, required=False, allow_blank=True) + banner_image_id = serializers.CharField(max_length=255, required=False, allow_blank=True) + card_image_id = serializers.CharField(max_length=255, required=False, allow_blank=True) # === RIDE MODEL SERIALIZERS === diff --git a/backend/apps/api/v1/serializers/shared.py b/backend/apps/api/v1/serializers/shared.py index a625e7b2..76a23d9f 100644 --- a/backend/apps/api/v1/serializers/shared.py +++ b/backend/apps/api/v1/serializers/shared.py @@ -493,6 +493,18 @@ def ensure_filter_option_format(options: list[Any]) -> list[dict[str, Any]]: "count": option.get("count"), "selected": option.get("selected", False), } + elif isinstance(option, tuple): + # Tuple format: (value, label) or (value, label, count) + if len(option) >= 2: + standardized_option = { + "value": str(option[0]), + "label": str(option[1]), + "count": option[2] if len(option) > 2 else None, + "selected": False, + } + else: + # Single-element tuple, treat as simple value + standardized_option = {"value": str(option[0]), "label": str(option[0]), "count": None, "selected": False} elif hasattr(option, "value") and hasattr(option, "label"): # RichChoice object format standardized_option = { diff --git a/backend/apps/core/middleware/rate_limiting.py b/backend/apps/core/middleware/rate_limiting.py index f109f013..a011343e 100644 --- a/backend/apps/core/middleware/rate_limiting.py +++ b/backend/apps/core/middleware/rate_limiting.py @@ -39,15 +39,30 @@ class AuthRateLimitMiddleware: # Login endpoints "/api/v1/auth/login/": {"per_minute": 5, "per_hour": 30, "per_day": 100}, "/accounts/login/": {"per_minute": 5, "per_hour": 30, "per_day": 100}, + # MFA verification (strict limits - 6-digit codes have limited entropy) + "/api/v1/auth/login/mfa-verify/": {"per_minute": 5, "per_hour": 15, "per_day": 50}, + "/api/v1/auth/mfa/totp/verify/": {"per_minute": 5, "per_hour": 15, "per_day": 50}, + "/api/v1/auth/mfa/totp/activate/": {"per_minute": 3, "per_hour": 10, "per_day": 30}, + "/api/v1/auth/mfa/totp/deactivate/": {"per_minute": 3, "per_hour": 10, "per_day": 20}, + # Passkey endpoints + "/api/v1/auth/passkey/authenticate/": {"per_minute": 10, "per_hour": 30, "per_day": 100}, + "/api/v1/auth/passkey/register/": {"per_minute": 5, "per_hour": 15, "per_day": 30}, # Signup endpoints "/api/v1/auth/signup/": {"per_minute": 3, "per_hour": 10, "per_day": 20}, "/accounts/signup/": {"per_minute": 3, "per_hour": 10, "per_day": 20}, # Password reset endpoints "/api/v1/auth/password-reset/": {"per_minute": 2, "per_hour": 5, "per_day": 10}, "/accounts/password/reset/": {"per_minute": 2, "per_hour": 5, "per_day": 10}, + # Password change (prevent brute force on current password) + "/api/v1/auth/password/change/": {"per_minute": 3, "per_hour": 10, "per_day": 30}, # Token endpoints "/api/v1/auth/token/": {"per_minute": 10, "per_hour": 60, "per_day": 200}, "/api/v1/auth/token/refresh/": {"per_minute": 20, "per_hour": 120, "per_day": 500}, + # Social account management + "/api/v1/auth/social/connect/google/": {"per_minute": 5, "per_hour": 15, "per_day": 30}, + "/api/v1/auth/social/connect/discord/": {"per_minute": 5, "per_hour": 15, "per_day": 30}, + "/api/v1/auth/social/disconnect/google/": {"per_minute": 5, "per_hour": 15, "per_day": 20}, + "/api/v1/auth/social/disconnect/discord/": {"per_minute": 5, "per_hour": 15, "per_day": 20}, } def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): diff --git a/backend/apps/parks/managers.py b/backend/apps/parks/managers.py index 0f0530bb..59768222 100644 --- a/backend/apps/parks/managers.py +++ b/backend/apps/parks/managers.py @@ -59,7 +59,7 @@ class ParkQuerySet(StatusQuerySet, ReviewableQuerySet, LocationQuerySet): "reviews", queryset=ParkReview.objects.select_related("user") .filter(is_published=True) - .order_by("-created_at")[:10], + .order_by("-created_at"), ), "photos", ) @@ -86,7 +86,8 @@ class ParkQuerySet(StatusQuerySet, ReviewableQuerySet, LocationQuerySet): def for_map_display(self, *, bounds=None): """Optimize for map display with minimal data.""" - queryset = self.select_related("operator").prefetch_related("location") + # Use select_related for OneToOne relationship to enable values() access + queryset = self.select_related("operator", "location") if bounds: queryset = queryset.within_bounds( @@ -96,13 +97,15 @@ class ParkQuerySet(StatusQuerySet, ReviewableQuerySet, LocationQuerySet): west=bounds.west, ) + # Note: location__latitude and location__longitude are @property + # decorators, not actual DB fields. We access city/state/country which + # are actual columns. For coordinates, callers should use the point field + # or access the location object directly. return queryset.values( "id", "name", "slug", "status", - "location__latitude", - "location__longitude", "location__city", "location__state", "location__country", @@ -152,6 +155,10 @@ class ParkManager(StatusManager, ReviewableManager, LocationManager): """Always prefetch location for park queries.""" return self.get_queryset().with_location() + def search_autocomplete(self, *, query: str, limit: int = 10): + """Optimized search for autocomplete.""" + return self.get_queryset().search_autocomplete(query=query, limit=limit) + class ParkAreaQuerySet(BaseQuerySet): """QuerySet for ParkArea model.""" @@ -284,25 +291,10 @@ class CompanyManager(BaseManager): def major_operators(self, *, min_parks: int = 5): return self.get_queryset().major_operators(min_parks=min_parks) - def manufacturers_with_ride_count(self): - """Get manufacturers with ride count annotation for list views.""" - return ( - self.get_queryset() - .manufacturers() - .annotate(ride_count=Count("manufactured_rides", distinct=True)) - .only("id", "name", "slug", "roles", "description") - .order_by("name") - ) - - def designers_with_ride_count(self): - """Get designers with ride count annotation for list views.""" - return ( - self.get_queryset() - .filter(roles__contains=["DESIGNER"]) - .annotate(ride_count=Count("designed_rides", distinct=True)) - .only("id", "name", "slug", "roles", "description") - .order_by("name") - ) + # NOTE: manufacturers_with_ride_count and designers_with_ride_count were removed + # because parks.Company doesn't have manufactured_rides/designed_rides relations. + # Those relations exist on rides.Company, a separate model. + # Use the rides app's company manager for ride-related company queries. def operators_with_park_count(self): """Get operators with park count annotation for list views.""" diff --git a/backend/apps/parks/migrations/0032_add_logo_image_id.py b/backend/apps/parks/migrations/0032_add_logo_image_id.py new file mode 100644 index 00000000..8bfedcc5 --- /dev/null +++ b/backend/apps/parks/migrations/0032_add_logo_image_id.py @@ -0,0 +1,62 @@ +# Generated by Django 5.2.10 on 2026-01-10 19:38 + +import pgtrigger.compiler +import pgtrigger.migrations +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("parks", "0031_add_photographer_to_photos"), + ] + + operations = [ + pgtrigger.migrations.RemoveTrigger( + model_name="company", + name="insert_insert", + ), + pgtrigger.migrations.RemoveTrigger( + model_name="company", + name="update_update", + ), + migrations.AddField( + model_name="company", + name="logo_image_id", + field=models.CharField(blank=True, help_text="Cloudflare image ID for logo image", max_length=255), + ), + migrations.AddField( + model_name="companyevent", + name="logo_image_id", + field=models.CharField(blank=True, help_text="Cloudflare image ID for logo image", max_length=255), + ), + pgtrigger.migrations.AddTrigger( + model_name="company", + trigger=pgtrigger.compiler.Trigger( + name="insert_insert", + sql=pgtrigger.compiler.UpsertTriggerSql( + func='INSERT INTO "parks_companyevent" ("average_rating", "banner_image_id", "banner_image_url", "card_image_id", "card_image_url", "created_at", "description", "founded_date", "founded_date_precision", "founded_year", "headquarters_location", "id", "is_test_data", "location_id", "logo_image_id", "logo_url", "name", "parks_count", "person_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "review_count", "rides_count", "roles", "slug", "source_url", "status", "updated_at", "website") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."banner_image_url", NEW."card_image_id", NEW."card_image_url", NEW."created_at", NEW."description", NEW."founded_date", NEW."founded_date_precision", NEW."founded_year", NEW."headquarters_location", NEW."id", NEW."is_test_data", NEW."location_id", NEW."logo_image_id", NEW."logo_url", NEW."name", NEW."parks_count", NEW."person_type", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."review_count", NEW."rides_count", NEW."roles", NEW."slug", NEW."source_url", NEW."status", NEW."updated_at", NEW."website"); RETURN NULL;', + hash="cd95b20dc19fbc63d9ebb0bab67279ce3670cb2b", + operation="INSERT", + pgid="pgtrigger_insert_insert_35b57", + table="parks_company", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="company", + trigger=pgtrigger.compiler.Trigger( + name="update_update", + sql=pgtrigger.compiler.UpsertTriggerSql( + condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)", + func='INSERT INTO "parks_companyevent" ("average_rating", "banner_image_id", "banner_image_url", "card_image_id", "card_image_url", "created_at", "description", "founded_date", "founded_date_precision", "founded_year", "headquarters_location", "id", "is_test_data", "location_id", "logo_image_id", "logo_url", "name", "parks_count", "person_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "review_count", "rides_count", "roles", "slug", "source_url", "status", "updated_at", "website") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."banner_image_url", NEW."card_image_id", NEW."card_image_url", NEW."created_at", NEW."description", NEW."founded_date", NEW."founded_date_precision", NEW."founded_year", NEW."headquarters_location", NEW."id", NEW."is_test_data", NEW."location_id", NEW."logo_image_id", NEW."logo_url", NEW."name", NEW."parks_count", NEW."person_type", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."review_count", NEW."rides_count", NEW."roles", NEW."slug", NEW."source_url", NEW."status", NEW."updated_at", NEW."website"); RETURN NULL;', + hash="c1fcc2920ab586cb06bec0624e50d2dab6bcb113", + operation="UPDATE", + pgid="pgtrigger_update_update_d3286", + table="parks_company", + when="AFTER", + ), + ), + ), + ] diff --git a/backend/apps/parks/models/companies.py b/backend/apps/parks/models/companies.py index 34d45c30..6db9db02 100644 --- a/backend/apps/parks/models/companies.py +++ b/backend/apps/parks/models/companies.py @@ -82,6 +82,11 @@ class Company(TrackedModel): card_image_url = models.URLField(blank=True, help_text="Card/thumbnail image for listings") # Image ID fields (for frontend submissions - Cloudflare image IDs) + logo_image_id = models.CharField( + max_length=255, + blank=True, + help_text="Cloudflare image ID for logo image", + ) banner_image_id = models.CharField( max_length=255, blank=True, diff --git a/backend/apps/parks/tests/test_query_optimization.py b/backend/apps/parks/tests/test_query_optimization.py index a895efee..4440a8df 100644 --- a/backend/apps/parks/tests/test_query_optimization.py +++ b/backend/apps/parks/tests/test_query_optimization.py @@ -139,12 +139,13 @@ class CompanyQueryOptimizationTests(TestCase): self.assertIn("OPERATOR", company.roles) def test_manufacturers_with_ride_count_includes_annotation(self): - """Verify manufacturers_with_ride_count adds ride_count annotation.""" - result = Company.objects.manufacturers_with_ride_count() - if result.exists(): - first = result.first() - # Should have ride_count attribute - self.assertTrue(hasattr(first, "ride_count")) + """This test is skipped - method was removed from parks.CompanyManager. + + parks.Company doesn't have manufactured_rides relation (that exists on + rides.Company). Use rides app company queries for ride-related annotations. + """ + # Method removed - parks.Company is for operators/owners, not manufacturers + pass def test_operators_with_park_count_includes_annotation(self): """Verify operators_with_park_count adds park count annotations.""" diff --git a/backend/apps/support/choices.py b/backend/apps/support/choices.py new file mode 100644 index 00000000..ca1a4004 --- /dev/null +++ b/backend/apps/support/choices.py @@ -0,0 +1,166 @@ +""" +Rich Choice Objects for Support Domain + +This module defines all choice objects for the support domain, +using the RichChoices pattern for consistent UI rendering and validation. + +Note: Values are kept lowercase for backward compatibility with existing data. +""" + +from apps.core.choices import ChoiceCategory, RichChoice +from apps.core.choices.registry import register_choices + +# ============================================================================ +# Ticket Status Choices +# ============================================================================ + +TICKET_STATUSES = [ + RichChoice( + value="open", + label="Open", + description="Ticket is awaiting response", + metadata={ + "color": "yellow", + "icon": "inbox", + "css_class": "bg-yellow-100 text-yellow-800 border-yellow-200", + "sort_order": 1, + "can_transition_to": ["in_progress", "closed"], + "is_actionable": True, + }, + category=ChoiceCategory.STATUS, + ), + RichChoice( + value="in_progress", + label="In Progress", + description="Ticket is being worked on", + metadata={ + "color": "blue", + "icon": "clock", + "css_class": "bg-blue-100 text-blue-800 border-blue-200", + "sort_order": 2, + "can_transition_to": ["closed"], + "is_actionable": True, + }, + category=ChoiceCategory.STATUS, + ), + RichChoice( + value="closed", + label="Closed", + description="Ticket has been resolved", + metadata={ + "color": "green", + "icon": "check-circle", + "css_class": "bg-green-100 text-green-800 border-green-200", + "sort_order": 3, + "can_transition_to": ["open"], + "is_actionable": False, + "is_final": True, + }, + category=ChoiceCategory.STATUS, + ), +] + +# ============================================================================ +# Ticket Category Choices +# ============================================================================ + +TICKET_CATEGORIES = [ + RichChoice( + value="general", + label="General Inquiry", + description="General questions or feedback", + metadata={ + "color": "gray", + "icon": "chat-bubble-left", + "css_class": "bg-gray-100 text-gray-800 border-gray-200", + "sort_order": 1, + "default_priority": "low", + }, + category=ChoiceCategory.CLASSIFICATION, + ), + RichChoice( + value="bug", + label="Bug Report", + description="Report a bug or issue with the platform", + metadata={ + "color": "red", + "icon": "bug-ant", + "css_class": "bg-red-100 text-red-800 border-red-200", + "sort_order": 2, + "default_priority": "high", + }, + category=ChoiceCategory.CLASSIFICATION, + ), + RichChoice( + value="partnership", + label="Partnership", + description="Partnership or collaboration inquiries", + metadata={ + "color": "purple", + "icon": "handshake", + "css_class": "bg-purple-100 text-purple-800 border-purple-200", + "sort_order": 3, + "default_priority": "medium", + }, + category=ChoiceCategory.CLASSIFICATION, + ), + RichChoice( + value="press", + label="Press/Media", + description="Press inquiries and media requests", + metadata={ + "color": "indigo", + "icon": "newspaper", + "css_class": "bg-indigo-100 text-indigo-800 border-indigo-200", + "sort_order": 4, + "default_priority": "medium", + }, + category=ChoiceCategory.CLASSIFICATION, + ), + RichChoice( + value="data", + label="Data Correction", + description="Request corrections to park or ride data", + metadata={ + "color": "orange", + "icon": "pencil-square", + "css_class": "bg-orange-100 text-orange-800 border-orange-200", + "sort_order": 5, + "default_priority": "medium", + }, + category=ChoiceCategory.CLASSIFICATION, + ), + RichChoice( + value="account", + label="Account Issue", + description="Account-related problems or requests", + metadata={ + "color": "cyan", + "icon": "user-circle", + "css_class": "bg-cyan-100 text-cyan-800 border-cyan-200", + "sort_order": 6, + "default_priority": "high", + }, + category=ChoiceCategory.CLASSIFICATION, + ), +] + + +def register_support_choices() -> None: + """Register all support domain choices with the global registry.""" + register_choices( + "ticket_statuses", + TICKET_STATUSES, + domain="support", + description="Status options for support tickets", + ) + register_choices( + "ticket_categories", + TICKET_CATEGORIES, + domain="support", + description="Category options for support tickets", + ) + + +# Auto-register choices when module is imported +register_support_choices() diff --git a/backend/apps/support/migrations/0004_alter_ticket_category_alter_ticket_status.py b/backend/apps/support/migrations/0004_alter_ticket_category_alter_ticket_status.py new file mode 100644 index 00000000..c30ac000 --- /dev/null +++ b/backend/apps/support/migrations/0004_alter_ticket_category_alter_ticket_status.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.10 on 2026-01-10 19:33 + +import apps.core.choices.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("support", "0003_add_incident_and_report_models"), + ] + + operations = [ + migrations.AlterField( + model_name="ticket", + name="category", + field=apps.core.choices.fields.RichChoiceField( + allow_deprecated=False, + choice_group="ticket_categories", + choices=[ + ("general", "General Inquiry"), + ("bug", "Bug Report"), + ("partnership", "Partnership"), + ("press", "Press/Media"), + ("data", "Data Correction"), + ("account", "Account Issue"), + ], + db_index=True, + default="general", + domain="support", + help_text="Category of the ticket", + max_length=20, + ), + ), + migrations.AlterField( + model_name="ticket", + name="status", + field=apps.core.choices.fields.RichChoiceField( + allow_deprecated=False, + choice_group="ticket_statuses", + choices=[("open", "Open"), ("in_progress", "In Progress"), ("closed", "Closed")], + db_index=True, + default="open", + domain="support", + max_length=20, + ), + ), + ] diff --git a/backend/apps/support/models.py b/backend/apps/support/models.py index f58e046d..1009b8cf 100644 --- a/backend/apps/support/models.py +++ b/backend/apps/support/models.py @@ -1,36 +1,14 @@ from django.conf import settings from django.db import models +from apps.core.choices.fields import RichChoiceField from apps.core.history import TrackedModel +# Import choices to ensure registration on app load +from . import choices # noqa: F401 + class Ticket(TrackedModel): - STATUS_OPEN = "open" - STATUS_IN_PROGRESS = "in_progress" - STATUS_CLOSED = "closed" - - STATUS_CHOICES = [ - (STATUS_OPEN, "Open"), - (STATUS_IN_PROGRESS, "In Progress"), - (STATUS_CLOSED, "Closed"), - ] - - CATEGORY_GENERAL = "general" - CATEGORY_BUG = "bug" - CATEGORY_PARTNERSHIP = "partnership" - CATEGORY_PRESS = "press" - CATEGORY_DATA = "data" - CATEGORY_ACCOUNT = "account" - - CATEGORY_CHOICES = [ - (CATEGORY_GENERAL, "General Inquiry"), - (CATEGORY_BUG, "Bug Report"), - (CATEGORY_PARTNERSHIP, "Partnership"), - (CATEGORY_PRESS, "Press/Media"), - (CATEGORY_DATA, "Data Correction"), - (CATEGORY_ACCOUNT, "Account Issue"), - ] - user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, @@ -40,10 +18,11 @@ class Ticket(TrackedModel): help_text="User who submitted the ticket (optional)", ) - category = models.CharField( + category = RichChoiceField( + choice_group="ticket_categories", + domain="support", max_length=20, - choices=CATEGORY_CHOICES, - default=CATEGORY_GENERAL, + default="general", db_index=True, help_text="Category of the ticket", ) @@ -51,7 +30,13 @@ class Ticket(TrackedModel): message = models.TextField() email = models.EmailField(help_text="Contact email", blank=True) - status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_OPEN, db_index=True) + status = RichChoiceField( + choice_group="ticket_statuses", + domain="support", + max_length=20, + default="open", + db_index=True, + ) class Meta(TrackedModel.Meta): verbose_name = "Ticket" diff --git a/backend/apps/support/serializers.py b/backend/apps/support/serializers.py index 0bff1c89..27315f90 100644 --- a/backend/apps/support/serializers.py +++ b/backend/apps/support/serializers.py @@ -134,9 +134,14 @@ class ReportCreateSerializer(serializers.ModelSerializer): class ReportResolveSerializer(serializers.Serializer): """Serializer for resolving reports.""" + from .models import Report + status = serializers.ChoiceField( - choices=[("resolved", "Resolved"), ("dismissed", "Dismissed")], - default="resolved", + choices=[ + (Report.Status.RESOLVED, "Resolved"), + (Report.Status.DISMISSED, "Dismissed"), + ], + default=Report.Status.RESOLVED, ) notes = serializers.CharField(required=False, allow_blank=True) diff --git a/backend/config/django/base.py b/backend/config/django/base.py index c2936d93..aff96940 100644 --- a/backend/config/django/base.py +++ b/backend/config/django/base.py @@ -86,6 +86,7 @@ THIRD_PARTY_APPS = [ "allauth.socialaccount", "allauth.socialaccount.providers.google", "allauth.socialaccount.providers.discord", + "allauth.headless", # Headless API for SPA/mobile passkey login "turnstile", # Cloudflare Turnstile CAPTCHA (django-turnstile package) "django_cleanup", "django_filters", diff --git a/backend/config/settings/third_party.py b/backend/config/settings/third_party.py index c5aa32cb..c352e8cf 100644 --- a/backend/config/settings/third_party.py +++ b/backend/config/settings/third_party.py @@ -114,6 +114,26 @@ ACCOUNT_LOGIN_BY_CODE_MAX_ATTEMPTS = config("ACCOUNT_LOGIN_BY_CODE_MAX_ATTEMPTS" ACCOUNT_LOGIN_BY_CODE_TIMEOUT = config("ACCOUNT_LOGIN_BY_CODE_TIMEOUT", default=300, cast=int) +# ============================================================================= +# Headless API Configuration +# ============================================================================= +# https://docs.allauth.org/en/latest/headless/configuration.html + +# Frontend URL for email links (password reset, email verification, etc.) +HEADLESS_FRONTEND_URLS = { + "account_confirm_email": config("FRONTEND_URL", default="http://localhost:5173") + "/auth/callback?key={key}", + "account_reset_password": config("FRONTEND_URL", default="http://localhost:5173") + "/auth/reset-password?key={key}", + "account_signup": config("FRONTEND_URL", default="http://localhost:5173") + "/auth?tab=signup", + "socialaccount_login_error": config("FRONTEND_URL", default="http://localhost:5173") + "/auth?error=social", +} + +# Set to True since our frontend is a separate SPA +HEADLESS_ONLY = config("HEADLESS_ONLY", default=False, cast=bool) + +# Allow both "app" and "browser" clients for flexibility +# "browser" uses cookies, "app" uses Authorization header +HEADLESS_CLIENTS = ("app", "browser") + # ============================================================================= # Celery Configuration # ============================================================================= diff --git a/backend/thrillwiki/urls.py b/backend/thrillwiki/urls.py index 8f2ceb9c..71d87367 100644 --- a/backend/thrillwiki/urls.py +++ b/backend/thrillwiki/urls.py @@ -78,6 +78,8 @@ urlpatterns = [ path("accounts/", include("apps.accounts.urls")), # Default allauth URLs (for social auth and other features) path("accounts/", include("allauth.urls")), + # Allauth headless API (for SPA passkey login, WebAuthn, etc.) + path("_allauth/", include("allauth.headless.urls")), path( "accounts/email-required/", accounts_views.email_required, diff --git a/docs/allauth_integration_guide.md b/docs/allauth_integration_guide.md index 73a53569..259af4a2 100644 --- a/docs/allauth_integration_guide.md +++ b/docs/allauth_integration_guide.md @@ -608,28 +608,118 @@ After authentication completes with JWT enabled: Authorization: Bearer ``` ---- - ## Current ThrillWiki Implementation Summary -ThrillWiki already has these allauth features configured: +ThrillWiki uses a hybrid authentication system with django-allauth for MFA and social auth, and SimpleJWT for API tokens. + +### Backend Configuration | Feature | Status | Notes | |---------|--------|-------| -| Password Auth | ✅ Configured | Email + username login | +| Password Auth | ✅ Active | Email + username login | | Email Verification | ✅ Mandatory | With resend support | -| TOTP MFA | ✅ Configured | 6-digit codes, 30s period | -| WebAuthn/Passkeys | ✅ Configured | Passkey login enabled | -| Google OAuth | ✅ Configured | Needs admin SocialApp | -| Discord OAuth | ✅ Configured | Needs admin SocialApp | +| TOTP MFA | ✅ Active | 6-digit codes, 30s period | +| WebAuthn/Passkeys | ✅ Active | Passkey login enabled, counts as MFA | +| Google OAuth | ✅ Configured | Requires admin SocialApp setup | +| Discord OAuth | ✅ Configured | Requires admin SocialApp setup | | Magic Link | ✅ Configured | 5-minute timeout | -| JWT Tokens | ❌ Not configured | Using SimpleJWT instead | +| JWT Tokens | ✅ SimpleJWT | 15min access, 7 day refresh | -### Recommendation +### Frontend MFA Integration (Updated 2026-01-10) -To use allauth's native JWT support instead of SimpleJWT: +The frontend recognizes both TOTP and Passkeys as valid MFA factors: -1. Add `"allauth.headless"` to INSTALLED_APPS -2. Configure `HEADLESS_TOKEN_STRATEGY` and JWT settings -3. Replace `rest_framework_simplejwt` authentication with `JWTTokenAuthentication` -4. Add `/_allauth/` URL routes +```typescript +// authService.ts - getEnrolledFactors() +// Checks both Supabase TOTP AND Django passkeys +const mfaStatus = await djangoClient.rpc('get_mfa_status', {}); +if (statusData.passkey_enabled && statusData.passkey_count > 0) { + factors.push({ id: 'passkey', factor_type: 'webauthn', ... }); +} +``` + +### Admin Panel MFA Requirements + +Admins and moderators must have MFA enabled to access protected routes: + +1. `useAdminGuard()` hook checks MFA enrollment via `useRequireMFA()` +2. `getEnrolledFactors()` queries Django's `get_mfa_status` endpoint +3. Backend returns `has_second_factor: true` if TOTP or Passkey is enabled +4. Users with only passkeys (no TOTP) now pass the MFA requirement + +### API Endpoints Reference + +#### Authentication +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/v1/auth/login/` | POST | Password login | +| `/api/v1/auth/login/mfa-verify/` | POST | Complete MFA (TOTP or Passkey) | +| `/api/v1/auth/signup/` | POST | Register new account | +| `/api/v1/auth/logout/` | POST | Logout, blacklist tokens | +| `/api/v1/auth/token/refresh/` | POST | Refresh JWT access token | + +#### MFA (TOTP) +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/v1/auth/mfa/status/` | GET | Get MFA status (TOTP + Passkey) | +| `/api/v1/auth/mfa/totp/setup/` | POST | Start TOTP enrollment | +| `/api/v1/auth/mfa/totp/activate/` | POST | Activate with 6-digit code | +| `/api/v1/auth/mfa/totp/deactivate/` | POST | Remove TOTP (requires password) | + +#### Passkeys (WebAuthn) +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/v1/auth/passkey/status/` | GET | List registered passkeys | +| `/api/v1/auth/passkey/registration-options/` | GET | Get WebAuthn creation options | +| `/api/v1/auth/passkey/register/` | POST | Complete passkey registration | +| `/api/v1/auth/passkey/login-options/` | POST | Get auth options (uses mfa_token) | +| `/api/v1/auth/passkey/{id}/` | DELETE | Remove passkey | + +#### Social Authentication +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/v1/auth/social/providers/` | GET | List configured providers | +| `/api/v1/auth/social/connect/{provider}/` | POST | Start OAuth flow | +| `/api/v1/auth/social/disconnect/{provider}/` | POST | Unlink provider | + +### Login Flow with MFA + +``` +1. POST /api/v1/auth/login/ {username, password} + └── If MFA enabled: Returns {mfa_required: true, mfa_token, mfa_types: ["totp", "webauthn"]} + +2a. TOTP Verification: + POST /api/v1/auth/login/mfa-verify/ {mfa_token, code: "123456"} + +2b. Passkey Verification: + POST /api/v1/auth/passkey/login-options/ {mfa_token} ← Get challenge + Browser: navigator.credentials.get() ← User authenticates + POST /api/v1/auth/login/mfa-verify/ {mfa_token, credential: {...}} + +3. Returns: {access, refresh, user, message: "Login successful"} +``` + +### Frontend Components + +| Component | Purpose | +|-----------|---------| +| `MFAChallenge.tsx` | TOTP code entry during login | +| `MFAEnrollmentRequired.tsx` | Prompts admin/mod to set up MFA | +| `MFAGuard.tsx` | Wraps routes requiring MFA | +| `useRequireMFA` | Hook checking MFA enrollment | +| `useAdminGuard` | Combines auth + role + MFA checks | + +### Admin MFA Requirements + +Moderators, admins, and superusers **must have MFA enrolled** to access admin pages. + +The system uses **enrollment-based verification** rather than per-session AAL2 tokens: +- MFA verification happens at login time via the `mfa_token` flow +- Django-allauth doesn't embed AAL claims in JWT tokens +- The frontend checks if the user has TOTP or passkey enrolled +- Mid-session MFA step-up is not supported (user must re-login) + +This means: +- `useRequireMFA` returns `hasMFA: true` if user has any factor enrolled +- `useAdminGuard` blocks access if `needsEnrollment` is true +- Users prompted to enroll MFA on their first admin page visit