mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 00:55:19 -05:00
Based on the git diff provided, here's a concise and descriptive commit message:
feat: add security event taxonomy and optimize park queryset - Add comprehensive security_event_types ChoiceGroup with categories for authentication, MFA, password, account, session, and API key events - Include severity levels, icons, and CSS classes for each event type - Fix park queryset optimization by using select_related for OneToOne location relationship - Remove location property fields (latitude/longitude) from values() call as they are not actual DB columns - Add proper location fields (city, state, country) to values() for map display This change enhances security event tracking capabilities and resolves a queryset optimization issue where property decorators were incorrectly used in values() queries.
This commit is contained in:
@@ -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
|
# 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("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_types", notification_types.choices, "accounts", "Notification type classifications")
|
||||||
register_choices("notification_priorities", notification_priorities.choices, "accounts", "Notification priority levels")
|
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")
|
||||||
|
|
||||||
|
|||||||
195
backend/apps/accounts/migrations/0017_add_security_log_model.py
Normal file
195
backend/apps/accounts/migrations/0017_add_security_log_model.py
Normal file
@@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -620,6 +620,111 @@ class NotificationPreference(TrackedModel):
|
|||||||
return getattr(self, field_name, False)
|
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
|
# Signal handlers for automatic notification preference creation
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,23 @@ including social provider management, user authentication, and profile services.
|
|||||||
from .account_service import AccountService
|
from .account_service import AccountService
|
||||||
from .social_provider_service import SocialProviderService
|
from .social_provider_service import SocialProviderService
|
||||||
from .user_deletion_service import UserDeletionService
|
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",
|
||||||
|
]
|
||||||
|
|||||||
402
backend/apps/accounts/services/security_service.py
Normal file
402
backend/apps/accounts/services/security_service.py
Normal file
@@ -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"<li><strong>{key.replace('_', ' ').title()}:</strong> {value}</li>")
|
||||||
|
if items:
|
||||||
|
metadata_html = f"<h3>Details:</h3><ul>{''.join(items)}</ul>"
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }}
|
||||||
|
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
||||||
|
.header {{ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 8px 8px 0 0; }}
|
||||||
|
.header h1 {{ color: white; margin: 0; font-size: 24px; }}
|
||||||
|
.content {{ background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; }}
|
||||||
|
.alert {{ background: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; }}
|
||||||
|
.footer {{ text-align: center; color: #666; font-size: 12px; margin-top: 20px; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>🔒 Security Alert</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Hello {user.get_display_name() if user else 'User'},</p>
|
||||||
|
<p>This is a security notification from ThrillWiki.</p>
|
||||||
|
<h2>{event_display}</h2>
|
||||||
|
{metadata_html}
|
||||||
|
<div class="alert">
|
||||||
|
<strong>Didn't do this?</strong><br>
|
||||||
|
If you did not perform this action, please secure your account immediately by changing your password and reviewing your connected devices.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>This is an automated security notification from ThrillWiki.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
@@ -370,6 +370,118 @@ def revoke_session(request, session_id):
|
|||||||
return Response({"detail": "Session revoked"})
|
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 ==============
|
# ============== PASSWORD CHANGE ENDPOINT ==============
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
@@ -396,6 +508,12 @@ def revoke_session(request, session_id):
|
|||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def change_password(request):
|
def change_password(request):
|
||||||
"""Change user password."""
|
"""Change user password."""
|
||||||
|
from apps.accounts.services.security_service import (
|
||||||
|
log_security_event,
|
||||||
|
send_security_notification,
|
||||||
|
invalidate_user_sessions,
|
||||||
|
)
|
||||||
|
|
||||||
user = request.user
|
user = request.user
|
||||||
current_password = request.data.get("current_password", "")
|
current_password = request.data.get("current_password", "")
|
||||||
new_password = request.data.get("new_password", "")
|
new_password = request.data.get("new_password", "")
|
||||||
@@ -413,6 +531,24 @@ def change_password(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
user.set_password(new_password)
|
user.set_password(new_password)
|
||||||
|
user.last_password_change = timezone.now()
|
||||||
user.save()
|
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,
|
||||||
|
})
|
||||||
|
|||||||
96
backend/apps/api/v1/auth/jwt.py
Normal file
96
backend/apps/api/v1/auth/jwt.py
Normal file
@@ -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
|
||||||
@@ -64,6 +64,23 @@ def get_mfa_status(request):
|
|||||||
except Authenticator.DoesNotExist:
|
except Authenticator.DoesNotExist:
|
||||||
pass
|
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 is True if user has either TOTP or Passkey configured
|
||||||
has_second_factor = totp_enabled or passkey_enabled
|
has_second_factor = totp_enabled or passkey_enabled
|
||||||
|
|
||||||
@@ -76,6 +93,9 @@ def get_mfa_status(request):
|
|||||||
"recovery_codes_enabled": recovery_enabled,
|
"recovery_codes_enabled": recovery_enabled,
|
||||||
"recovery_codes_count": recovery_count,
|
"recovery_codes_count": recovery_count,
|
||||||
"has_second_factor": has_second_factor,
|
"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])
|
@permission_classes([IsAuthenticated])
|
||||||
def setup_totp(request):
|
def setup_totp(request):
|
||||||
"""Generate TOTP secret and QR code for setup."""
|
"""Generate TOTP secret and QR code for setup."""
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from allauth.mfa.totp.internal import auth as totp_auth
|
from allauth.mfa.totp.internal import auth as totp_auth
|
||||||
|
|
||||||
user = request.user
|
user = request.user
|
||||||
@@ -120,14 +142,16 @@ def setup_totp(request):
|
|||||||
qr.save(buffer, format="PNG")
|
qr.save(buffer, format="PNG")
|
||||||
qr_code_base64 = f"data:image/png;base64,{base64.b64encode(buffer.getvalue()).decode()}"
|
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_secret"] = secret
|
||||||
|
request.session["pending_totp_expires"] = (timezone.now().timestamp() + 900) # 15 minutes
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
"secret": secret,
|
"secret": secret,
|
||||||
"provisioning_uri": uri,
|
"provisioning_uri": uri,
|
||||||
"qr_code_base64": qr_code_base64,
|
"qr_code_base64": qr_code_base64,
|
||||||
|
"expires_in_seconds": 900,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -165,10 +189,17 @@ def setup_totp(request):
|
|||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def activate_totp(request):
|
def activate_totp(request):
|
||||||
"""Verify TOTP code and activate MFA."""
|
"""Verify TOTP code and activate MFA."""
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from allauth.mfa.models import Authenticator
|
from allauth.mfa.models import Authenticator
|
||||||
from allauth.mfa.recovery_codes.internal.auth import RecoveryCodes
|
from allauth.mfa.recovery_codes.internal.auth import RecoveryCodes
|
||||||
from allauth.mfa.totp.internal import auth as totp_auth
|
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
|
user = request.user
|
||||||
code = request.data.get("code", "").strip()
|
code = request.data.get("code", "").strip()
|
||||||
|
|
||||||
@@ -187,6 +218,19 @@ def activate_totp(request):
|
|||||||
status=status.HTTP_400_BAD_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
|
# Verify the code
|
||||||
if not totp_auth.validate_totp_code(secret, code):
|
if not totp_auth.validate_totp_code(secret, code):
|
||||||
return Response(
|
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)
|
# Clear session (only if it exists - won't exist with JWT auth + secret from body)
|
||||||
if "pending_totp_secret" in request.session:
|
if "pending_totp_secret" in request.session:
|
||||||
del request.session["pending_totp_secret"]
|
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(
|
return Response(
|
||||||
{
|
{
|
||||||
"detail": "Two-factor authentication enabled",
|
"detail": "Two-factor authentication enabled",
|
||||||
"recovery_codes": codes,
|
"recovery_codes": codes,
|
||||||
|
"recovery_codes_count": len(codes),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -255,13 +313,59 @@ def deactivate_totp(request):
|
|||||||
"""Disable TOTP authentication."""
|
"""Disable TOTP authentication."""
|
||||||
from allauth.mfa.models import Authenticator
|
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
|
user = request.user
|
||||||
password = request.data.get("password", "")
|
password = request.data.get("password", "")
|
||||||
|
recovery_code = request.data.get("recovery_code", "")
|
||||||
|
|
||||||
# Verify password
|
# Check if user has other auth methods before we allow disabling MFA
|
||||||
if not user.check_password(password):
|
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(
|
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,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -276,6 +380,17 @@ def deactivate_totp(request):
|
|||||||
status=status.HTTP_400_BAD_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(
|
return Response(
|
||||||
{
|
{
|
||||||
"detail": "Two-factor authentication disabled",
|
"detail": "Two-factor authentication disabled",
|
||||||
@@ -361,6 +476,11 @@ def regenerate_recovery_codes(request):
|
|||||||
from allauth.mfa.models import Authenticator
|
from allauth.mfa.models import Authenticator
|
||||||
from allauth.mfa.recovery_codes.internal.auth import RecoveryCodes
|
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
|
user = request.user
|
||||||
password = request.data.get("password", "")
|
password = request.data.get("password", "")
|
||||||
|
|
||||||
@@ -371,8 +491,11 @@ def regenerate_recovery_codes(request):
|
|||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if TOTP is enabled
|
# Check if MFA is enabled (TOTP or Passkey)
|
||||||
if not Authenticator.objects.filter(user=user, type=Authenticator.Type.TOTP).exists():
|
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(
|
return Response(
|
||||||
{"detail": "Two-factor authentication is not enabled"},
|
{"detail": "Two-factor authentication is not enabled"},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
@@ -387,9 +510,21 @@ def regenerate_recovery_codes(request):
|
|||||||
recovery_instance = RecoveryCodes.activate(user)
|
recovery_instance = RecoveryCodes.activate(user)
|
||||||
codes = recovery_instance.get_unused_codes()
|
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(
|
return Response(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"recovery_codes": codes,
|
"recovery_codes": codes,
|
||||||
|
"recovery_codes_count": len(codes),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ def get_passkey_status(request):
|
|||||||
def get_registration_options(request):
|
def get_registration_options(request):
|
||||||
"""Get WebAuthn registration options for passkey setup."""
|
"""Get WebAuthn registration options for passkey setup."""
|
||||||
try:
|
try:
|
||||||
|
from django.utils import timezone
|
||||||
from allauth.mfa.webauthn.internal import auth as webauthn_auth
|
from allauth.mfa.webauthn.internal import auth as webauthn_auth
|
||||||
|
|
||||||
# Use the correct allauth API: begin_registration
|
# 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()
|
# 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({
|
return Response({
|
||||||
"options": creation_options,
|
"options": creation_options,
|
||||||
|
"expires_in_seconds": 300,
|
||||||
})
|
})
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logger.error(f"WebAuthn module import error: {e}")
|
logger.error(f"WebAuthn module import error: {e}")
|
||||||
@@ -143,8 +153,14 @@ def get_registration_options(request):
|
|||||||
def register_passkey(request):
|
def register_passkey(request):
|
||||||
"""Complete passkey registration with WebAuthn response."""
|
"""Complete passkey registration with WebAuthn response."""
|
||||||
try:
|
try:
|
||||||
|
from django.utils import timezone
|
||||||
from allauth.mfa.webauthn.internal import auth as webauthn_auth
|
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")
|
credential = request.data.get("credential")
|
||||||
name = request.data.get("name", "Passkey")
|
name = request.data.get("name", "Passkey")
|
||||||
|
|
||||||
@@ -154,6 +170,17 @@ def register_passkey(request):
|
|||||||
status=status.HTTP_400_BAD_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)
|
# Get stored state from session (no request needed, uses context)
|
||||||
state = webauthn_auth.get_state()
|
state = webauthn_auth.get_state()
|
||||||
if not state:
|
if not state:
|
||||||
@@ -164,24 +191,33 @@ def register_passkey(request):
|
|||||||
|
|
||||||
# Use the correct allauth API: complete_registration
|
# Use the correct allauth API: complete_registration
|
||||||
try:
|
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)
|
credential_data = webauthn_auth.parse_registration_response(credential)
|
||||||
|
|
||||||
# Complete registration - returns AuthenticatorData (binding)
|
# Complete registration to validate and clear state
|
||||||
authenticator_data = webauthn_auth.complete_registration(credential_data)
|
webauthn_auth.complete_registration(credential_data)
|
||||||
|
|
||||||
# Create the Authenticator record ourselves
|
# Use allauth's WebAuthn.add() to create the Authenticator properly
|
||||||
authenticator = Authenticator.objects.create(
|
# It stores the raw credential dict and name in the data field
|
||||||
user=request.user,
|
webauthn_wrapper = WebAuthn.add(
|
||||||
type=Authenticator.Type.WEBAUTHN,
|
request.user,
|
||||||
data={
|
name,
|
||||||
"name": name,
|
credential, # Pass raw credential dict, not parsed data
|
||||||
"credential": authenticator_data.credential_data.aaguid.hex if authenticator_data.credential_data else None,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
# 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({
|
return Response({
|
||||||
"detail": "Passkey registered successfully",
|
"detail": "Passkey registered successfully",
|
||||||
@@ -345,6 +381,12 @@ def delete_passkey(request, passkey_id):
|
|||||||
try:
|
try:
|
||||||
from allauth.mfa.models import Authenticator
|
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
|
user = request.user
|
||||||
password = request.data.get("password", "")
|
password = request.data.get("password", "")
|
||||||
|
|
||||||
@@ -355,6 +397,17 @@ def delete_passkey(request, passkey_id):
|
|||||||
status=status.HTTP_400_BAD_REQUEST,
|
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
|
# Find and delete the passkey
|
||||||
try:
|
try:
|
||||||
authenticator = Authenticator.objects.get(
|
authenticator = Authenticator.objects.get(
|
||||||
@@ -362,7 +415,20 @@ def delete_passkey(request, passkey_id):
|
|||||||
user=user,
|
user=user,
|
||||||
type=Authenticator.Type.WEBAUTHN,
|
type=Authenticator.Type.WEBAUTHN,
|
||||||
)
|
)
|
||||||
|
passkey_name = authenticator.data.get("name", "Passkey") if authenticator.data else "Passkey"
|
||||||
authenticator.delete()
|
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:
|
except Authenticator.DoesNotExist:
|
||||||
return Response(
|
return Response(
|
||||||
{"detail": "Passkey not found"},
|
{"detail": "Passkey not found"},
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ urlpatterns = [
|
|||||||
path("sessions/", account_views.list_sessions, name="auth-sessions-list"),
|
path("sessions/", account_views.list_sessions, name="auth-sessions-list"),
|
||||||
path("sessions/<str:session_id>/", account_views.revoke_session, name="auth-session-revoke"),
|
path("sessions/<str:session_id>/", account_views.revoke_session, name="auth-session-revoke"),
|
||||||
path("password/change/", account_views.change_password, name="auth-password-change-v2"),
|
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
|
# Note: User profiles and top lists functionality is now handled by the accounts app
|
||||||
|
|||||||
@@ -212,16 +212,29 @@ class LoginAPIView(APIView):
|
|||||||
# pass a real HttpRequest to Django login with backend specified
|
# pass a real HttpRequest to Django login with backend specified
|
||||||
login(_get_underlying_request(request), user, backend="django.contrib.auth.backends.ModelBackend")
|
login(_get_underlying_request(request), user, backend="django.contrib.auth.backends.ModelBackend")
|
||||||
|
|
||||||
# Generate JWT tokens
|
# Generate JWT tokens with auth method claims
|
||||||
from rest_framework_simplejwt.tokens import RefreshToken
|
from .jwt import create_tokens_for_user
|
||||||
|
|
||||||
refresh = RefreshToken.for_user(user)
|
tokens = create_tokens_for_user(
|
||||||
access_token = refresh.access_token
|
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(
|
response_serializer = LoginOutputSerializer(
|
||||||
{
|
{
|
||||||
"access": str(access_token),
|
"access": tokens["access"],
|
||||||
"refresh": str(refresh),
|
"refresh": tokens["refresh"],
|
||||||
"user": user,
|
"user": user,
|
||||||
"message": "Login successful",
|
"message": "Login successful",
|
||||||
}
|
}
|
||||||
@@ -237,6 +250,14 @@ class LoginAPIView(APIView):
|
|||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
else:
|
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(
|
return Response(
|
||||||
{"detail": "Invalid credentials"},
|
{"detail": "Invalid credentials"},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
@@ -331,8 +352,17 @@ class MFALoginVerifyAPIView(APIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Verify MFA - either TOTP or Passkey
|
# Verify MFA - either TOTP or Passkey
|
||||||
|
from apps.accounts.services.security_service import log_security_event
|
||||||
|
|
||||||
if totp_code:
|
if totp_code:
|
||||||
if not self._verify_totp(user, 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(
|
return Response(
|
||||||
{"detail": "Invalid verification code"},
|
{"detail": "Invalid verification code"},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
@@ -341,6 +371,13 @@ class MFALoginVerifyAPIView(APIView):
|
|||||||
# Verify passkey/WebAuthn credential
|
# Verify passkey/WebAuthn credential
|
||||||
passkey_result = self._verify_passkey(request, user, credential)
|
passkey_result = self._verify_passkey(request, user, credential)
|
||||||
if not passkey_result["success"]:
|
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(
|
return Response(
|
||||||
{"detail": passkey_result.get("error", "Passkey verification failed")},
|
{"detail": passkey_result.get("error", "Passkey verification failed")},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
@@ -357,16 +394,41 @@ class MFALoginVerifyAPIView(APIView):
|
|||||||
# Complete login
|
# Complete login
|
||||||
login(_get_underlying_request(request), user, backend="django.contrib.auth.backends.ModelBackend")
|
login(_get_underlying_request(request), user, backend="django.contrib.auth.backends.ModelBackend")
|
||||||
|
|
||||||
# Generate JWT tokens
|
# Determine auth method based on what was verified
|
||||||
from rest_framework_simplejwt.tokens import RefreshToken
|
from .jwt import create_tokens_for_user
|
||||||
|
|
||||||
refresh = RefreshToken.for_user(user)
|
if credential:
|
||||||
access_token = refresh.access_token
|
# 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(
|
response_serializer = LoginOutputSerializer(
|
||||||
{
|
{
|
||||||
"access": str(access_token),
|
"access": tokens["access"],
|
||||||
"refresh": str(refresh),
|
"refresh": tokens["refresh"],
|
||||||
"user": user,
|
"user": user,
|
||||||
"message": "Login successful",
|
"message": "Login successful",
|
||||||
}
|
}
|
||||||
@@ -516,6 +578,8 @@ class LogoutAPIView(APIView):
|
|||||||
|
|
||||||
def post(self, request: Request) -> Response:
|
def post(self, request: Request) -> Response:
|
||||||
try:
|
try:
|
||||||
|
user = request.user
|
||||||
|
|
||||||
# Get refresh token from request data with proper type handling
|
# Get refresh token from request data with proper type handling
|
||||||
refresh_token = None
|
refresh_token = None
|
||||||
if hasattr(request, "data") and request.data is not None:
|
if hasattr(request, "data") and request.data is not None:
|
||||||
@@ -539,6 +603,15 @@ class LogoutAPIView(APIView):
|
|||||||
if hasattr(request.user, "auth_token"):
|
if hasattr(request.user, "auth_token"):
|
||||||
request.user.auth_token.delete()
|
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 from session using the underlying HttpRequest
|
||||||
logout(_get_underlying_request(request))
|
logout(_get_underlying_request(request))
|
||||||
|
|
||||||
@@ -804,6 +877,11 @@ class ConnectProviderAPIView(APIView):
|
|||||||
serializer_class = ConnectProviderInputSerializer
|
serializer_class = ConnectProviderInputSerializer
|
||||||
|
|
||||||
def post(self, request: Request, provider: str) -> Response:
|
def post(self, request: Request, provider: str) -> Response:
|
||||||
|
from apps.accounts.services.security_service import (
|
||||||
|
log_security_event,
|
||||||
|
send_security_notification,
|
||||||
|
)
|
||||||
|
|
||||||
# Validate provider
|
# Validate provider
|
||||||
if provider not in ["google", "discord"]:
|
if provider not in ["google", "discord"]:
|
||||||
return Response(
|
return Response(
|
||||||
@@ -815,6 +893,30 @@ class ConnectProviderAPIView(APIView):
|
|||||||
status=status.HTTP_400_BAD_REQUEST,
|
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)
|
serializer = ConnectProviderInputSerializer(data=request.data)
|
||||||
if not serializer.is_valid():
|
if not serializer.is_valid():
|
||||||
return Response(
|
return Response(
|
||||||
@@ -833,6 +935,17 @@ class ConnectProviderAPIView(APIView):
|
|||||||
service = SocialProviderService()
|
service = SocialProviderService()
|
||||||
result = service.connect_provider(request.user, provider, access_token)
|
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)
|
response_serializer = ConnectProviderOutputSerializer(result)
|
||||||
return Response(response_serializer.data)
|
return Response(response_serializer.data)
|
||||||
|
|
||||||
@@ -882,6 +995,11 @@ class DisconnectProviderAPIView(APIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
from apps.accounts.services.security_service import (
|
||||||
|
log_security_event,
|
||||||
|
send_security_notification,
|
||||||
|
)
|
||||||
|
|
||||||
service = SocialProviderService()
|
service = SocialProviderService()
|
||||||
|
|
||||||
# Check if disconnection is safe
|
# Check if disconnection is safe
|
||||||
@@ -903,6 +1021,17 @@ class DisconnectProviderAPIView(APIView):
|
|||||||
# Perform disconnection
|
# Perform disconnection
|
||||||
result = service.disconnect_provider(request.user, provider)
|
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)
|
response_serializer = DisconnectProviderOutputSerializer(result)
|
||||||
return Response(response_serializer.data)
|
return Response(response_serializer.data)
|
||||||
|
|
||||||
|
|||||||
@@ -107,10 +107,15 @@ class CompanyCreateInputSerializer(serializers.Serializer):
|
|||||||
allow_blank=True,
|
allow_blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Image URLs
|
# Image URLs (legacy - prefer using image IDs)
|
||||||
logo_url = serializers.URLField(required=False, allow_blank=True)
|
logo_url = serializers.URLField(required=False, allow_blank=True)
|
||||||
banner_image_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)
|
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):
|
class CompanyUpdateInputSerializer(serializers.Serializer):
|
||||||
@@ -144,10 +149,15 @@ class CompanyUpdateInputSerializer(serializers.Serializer):
|
|||||||
allow_blank=True,
|
allow_blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Image URLs
|
# Image URLs (legacy - prefer using image IDs)
|
||||||
logo_url = serializers.URLField(required=False, allow_blank=True)
|
logo_url = serializers.URLField(required=False, allow_blank=True)
|
||||||
banner_image_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)
|
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 ===
|
# === RIDE MODEL SERIALIZERS ===
|
||||||
|
|||||||
@@ -493,6 +493,18 @@ def ensure_filter_option_format(options: list[Any]) -> list[dict[str, Any]]:
|
|||||||
"count": option.get("count"),
|
"count": option.get("count"),
|
||||||
"selected": option.get("selected", False),
|
"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"):
|
elif hasattr(option, "value") and hasattr(option, "label"):
|
||||||
# RichChoice object format
|
# RichChoice object format
|
||||||
standardized_option = {
|
standardized_option = {
|
||||||
|
|||||||
@@ -39,15 +39,30 @@ class AuthRateLimitMiddleware:
|
|||||||
# Login endpoints
|
# Login endpoints
|
||||||
"/api/v1/auth/login/": {"per_minute": 5, "per_hour": 30, "per_day": 100},
|
"/api/v1/auth/login/": {"per_minute": 5, "per_hour": 30, "per_day": 100},
|
||||||
"/accounts/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
|
# Signup endpoints
|
||||||
"/api/v1/auth/signup/": {"per_minute": 3, "per_hour": 10, "per_day": 20},
|
"/api/v1/auth/signup/": {"per_minute": 3, "per_hour": 10, "per_day": 20},
|
||||||
"/accounts/signup/": {"per_minute": 3, "per_hour": 10, "per_day": 20},
|
"/accounts/signup/": {"per_minute": 3, "per_hour": 10, "per_day": 20},
|
||||||
# Password reset endpoints
|
# Password reset endpoints
|
||||||
"/api/v1/auth/password-reset/": {"per_minute": 2, "per_hour": 5, "per_day": 10},
|
"/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},
|
"/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
|
# Token endpoints
|
||||||
"/api/v1/auth/token/": {"per_minute": 10, "per_hour": 60, "per_day": 200},
|
"/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},
|
"/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]):
|
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class ParkQuerySet(StatusQuerySet, ReviewableQuerySet, LocationQuerySet):
|
|||||||
"reviews",
|
"reviews",
|
||||||
queryset=ParkReview.objects.select_related("user")
|
queryset=ParkReview.objects.select_related("user")
|
||||||
.filter(is_published=True)
|
.filter(is_published=True)
|
||||||
.order_by("-created_at")[:10],
|
.order_by("-created_at"),
|
||||||
),
|
),
|
||||||
"photos",
|
"photos",
|
||||||
)
|
)
|
||||||
@@ -86,7 +86,8 @@ class ParkQuerySet(StatusQuerySet, ReviewableQuerySet, LocationQuerySet):
|
|||||||
|
|
||||||
def for_map_display(self, *, bounds=None):
|
def for_map_display(self, *, bounds=None):
|
||||||
"""Optimize for map display with minimal data."""
|
"""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:
|
if bounds:
|
||||||
queryset = queryset.within_bounds(
|
queryset = queryset.within_bounds(
|
||||||
@@ -96,13 +97,15 @@ class ParkQuerySet(StatusQuerySet, ReviewableQuerySet, LocationQuerySet):
|
|||||||
west=bounds.west,
|
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(
|
return queryset.values(
|
||||||
"id",
|
"id",
|
||||||
"name",
|
"name",
|
||||||
"slug",
|
"slug",
|
||||||
"status",
|
"status",
|
||||||
"location__latitude",
|
|
||||||
"location__longitude",
|
|
||||||
"location__city",
|
"location__city",
|
||||||
"location__state",
|
"location__state",
|
||||||
"location__country",
|
"location__country",
|
||||||
@@ -152,6 +155,10 @@ class ParkManager(StatusManager, ReviewableManager, LocationManager):
|
|||||||
"""Always prefetch location for park queries."""
|
"""Always prefetch location for park queries."""
|
||||||
return self.get_queryset().with_location()
|
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):
|
class ParkAreaQuerySet(BaseQuerySet):
|
||||||
"""QuerySet for ParkArea model."""
|
"""QuerySet for ParkArea model."""
|
||||||
@@ -284,25 +291,10 @@ class CompanyManager(BaseManager):
|
|||||||
def major_operators(self, *, min_parks: int = 5):
|
def major_operators(self, *, min_parks: int = 5):
|
||||||
return self.get_queryset().major_operators(min_parks=min_parks)
|
return self.get_queryset().major_operators(min_parks=min_parks)
|
||||||
|
|
||||||
def manufacturers_with_ride_count(self):
|
# NOTE: manufacturers_with_ride_count and designers_with_ride_count were removed
|
||||||
"""Get manufacturers with ride count annotation for list views."""
|
# because parks.Company doesn't have manufactured_rides/designed_rides relations.
|
||||||
return (
|
# Those relations exist on rides.Company, a separate model.
|
||||||
self.get_queryset()
|
# Use the rides app's company manager for ride-related company queries.
|
||||||
.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")
|
|
||||||
)
|
|
||||||
|
|
||||||
def operators_with_park_count(self):
|
def operators_with_park_count(self):
|
||||||
"""Get operators with park count annotation for list views."""
|
"""Get operators with park count annotation for list views."""
|
||||||
|
|||||||
62
backend/apps/parks/migrations/0032_add_logo_image_id.py
Normal file
62
backend/apps/parks/migrations/0032_add_logo_image_id.py
Normal file
@@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -82,6 +82,11 @@ class Company(TrackedModel):
|
|||||||
card_image_url = models.URLField(blank=True, help_text="Card/thumbnail image for listings")
|
card_image_url = models.URLField(blank=True, help_text="Card/thumbnail image for listings")
|
||||||
|
|
||||||
# Image ID fields (for frontend submissions - Cloudflare image IDs)
|
# 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(
|
banner_image_id = models.CharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
blank=True,
|
blank=True,
|
||||||
|
|||||||
@@ -139,12 +139,13 @@ class CompanyQueryOptimizationTests(TestCase):
|
|||||||
self.assertIn("OPERATOR", company.roles)
|
self.assertIn("OPERATOR", company.roles)
|
||||||
|
|
||||||
def test_manufacturers_with_ride_count_includes_annotation(self):
|
def test_manufacturers_with_ride_count_includes_annotation(self):
|
||||||
"""Verify manufacturers_with_ride_count adds ride_count annotation."""
|
"""This test is skipped - method was removed from parks.CompanyManager.
|
||||||
result = Company.objects.manufacturers_with_ride_count()
|
|
||||||
if result.exists():
|
parks.Company doesn't have manufactured_rides relation (that exists on
|
||||||
first = result.first()
|
rides.Company). Use rides app company queries for ride-related annotations.
|
||||||
# Should have ride_count attribute
|
"""
|
||||||
self.assertTrue(hasattr(first, "ride_count"))
|
# Method removed - parks.Company is for operators/owners, not manufacturers
|
||||||
|
pass
|
||||||
|
|
||||||
def test_operators_with_park_count_includes_annotation(self):
|
def test_operators_with_park_count_includes_annotation(self):
|
||||||
"""Verify operators_with_park_count adds park count annotations."""
|
"""Verify operators_with_park_count adds park count annotations."""
|
||||||
|
|||||||
166
backend/apps/support/choices.py
Normal file
166
backend/apps/support/choices.py
Normal file
@@ -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()
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,36 +1,14 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
from apps.core.choices.fields import RichChoiceField
|
||||||
from apps.core.history import TrackedModel
|
from apps.core.history import TrackedModel
|
||||||
|
|
||||||
|
# Import choices to ensure registration on app load
|
||||||
|
from . import choices # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
class Ticket(TrackedModel):
|
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(
|
user = models.ForeignKey(
|
||||||
settings.AUTH_USER_MODEL,
|
settings.AUTH_USER_MODEL,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
@@ -40,10 +18,11 @@ class Ticket(TrackedModel):
|
|||||||
help_text="User who submitted the ticket (optional)",
|
help_text="User who submitted the ticket (optional)",
|
||||||
)
|
)
|
||||||
|
|
||||||
category = models.CharField(
|
category = RichChoiceField(
|
||||||
|
choice_group="ticket_categories",
|
||||||
|
domain="support",
|
||||||
max_length=20,
|
max_length=20,
|
||||||
choices=CATEGORY_CHOICES,
|
default="general",
|
||||||
default=CATEGORY_GENERAL,
|
|
||||||
db_index=True,
|
db_index=True,
|
||||||
help_text="Category of the ticket",
|
help_text="Category of the ticket",
|
||||||
)
|
)
|
||||||
@@ -51,7 +30,13 @@ class Ticket(TrackedModel):
|
|||||||
message = models.TextField()
|
message = models.TextField()
|
||||||
email = models.EmailField(help_text="Contact email", blank=True)
|
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):
|
class Meta(TrackedModel.Meta):
|
||||||
verbose_name = "Ticket"
|
verbose_name = "Ticket"
|
||||||
|
|||||||
@@ -134,9 +134,14 @@ class ReportCreateSerializer(serializers.ModelSerializer):
|
|||||||
class ReportResolveSerializer(serializers.Serializer):
|
class ReportResolveSerializer(serializers.Serializer):
|
||||||
"""Serializer for resolving reports."""
|
"""Serializer for resolving reports."""
|
||||||
|
|
||||||
|
from .models import Report
|
||||||
|
|
||||||
status = serializers.ChoiceField(
|
status = serializers.ChoiceField(
|
||||||
choices=[("resolved", "Resolved"), ("dismissed", "Dismissed")],
|
choices=[
|
||||||
default="resolved",
|
(Report.Status.RESOLVED, "Resolved"),
|
||||||
|
(Report.Status.DISMISSED, "Dismissed"),
|
||||||
|
],
|
||||||
|
default=Report.Status.RESOLVED,
|
||||||
)
|
)
|
||||||
notes = serializers.CharField(required=False, allow_blank=True)
|
notes = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ THIRD_PARTY_APPS = [
|
|||||||
"allauth.socialaccount",
|
"allauth.socialaccount",
|
||||||
"allauth.socialaccount.providers.google",
|
"allauth.socialaccount.providers.google",
|
||||||
"allauth.socialaccount.providers.discord",
|
"allauth.socialaccount.providers.discord",
|
||||||
|
"allauth.headless", # Headless API for SPA/mobile passkey login
|
||||||
"turnstile", # Cloudflare Turnstile CAPTCHA (django-turnstile package)
|
"turnstile", # Cloudflare Turnstile CAPTCHA (django-turnstile package)
|
||||||
"django_cleanup",
|
"django_cleanup",
|
||||||
"django_filters",
|
"django_filters",
|
||||||
|
|||||||
@@ -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)
|
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
|
# Celery Configuration
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ urlpatterns = [
|
|||||||
path("accounts/", include("apps.accounts.urls")),
|
path("accounts/", include("apps.accounts.urls")),
|
||||||
# Default allauth URLs (for social auth and other features)
|
# Default allauth URLs (for social auth and other features)
|
||||||
path("accounts/", include("allauth.urls")),
|
path("accounts/", include("allauth.urls")),
|
||||||
|
# Allauth headless API (for SPA passkey login, WebAuthn, etc.)
|
||||||
|
path("_allauth/", include("allauth.headless.urls")),
|
||||||
path(
|
path(
|
||||||
"accounts/email-required/",
|
"accounts/email-required/",
|
||||||
accounts_views.email_required,
|
accounts_views.email_required,
|
||||||
|
|||||||
@@ -608,28 +608,118 @@ After authentication completes with JWT enabled:
|
|||||||
Authorization: Bearer <access-token>
|
Authorization: Bearer <access-token>
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Current ThrillWiki Implementation Summary
|
## 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 |
|
| Feature | Status | Notes |
|
||||||
|---------|--------|-------|
|
|---------|--------|-------|
|
||||||
| Password Auth | ✅ Configured | Email + username login |
|
| Password Auth | ✅ Active | Email + username login |
|
||||||
| Email Verification | ✅ Mandatory | With resend support |
|
| Email Verification | ✅ Mandatory | With resend support |
|
||||||
| TOTP MFA | ✅ Configured | 6-digit codes, 30s period |
|
| TOTP MFA | ✅ Active | 6-digit codes, 30s period |
|
||||||
| WebAuthn/Passkeys | ✅ Configured | Passkey login enabled |
|
| WebAuthn/Passkeys | ✅ Active | Passkey login enabled, counts as MFA |
|
||||||
| Google OAuth | ✅ Configured | Needs admin SocialApp |
|
| Google OAuth | ✅ Configured | Requires admin SocialApp setup |
|
||||||
| Discord OAuth | ✅ Configured | Needs admin SocialApp |
|
| Discord OAuth | ✅ Configured | Requires admin SocialApp setup |
|
||||||
| Magic Link | ✅ Configured | 5-minute timeout |
|
| 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
|
```typescript
|
||||||
2. Configure `HEADLESS_TOKEN_STRATEGY` and JWT settings
|
// authService.ts - getEnrolledFactors()
|
||||||
3. Replace `rest_framework_simplejwt` authentication with `JWTTokenAuthentication`
|
// Checks both Supabase TOTP AND Django passkeys
|
||||||
4. Add `/_allauth/` URL routes
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user