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"""
+
+
+
+
+
+
+
+
+
+
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