mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 15:15:18 -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
|
||||
# =============================================================================
|
||||
@@ -598,3 +843,5 @@ register_choices("privacy_levels", privacy_levels.choices, "accounts", "Privacy
|
||||
register_choices("top_list_categories", top_list_categories.choices, "accounts", "Top list category types")
|
||||
register_choices("notification_types", notification_types.choices, "accounts", "Notification type classifications")
|
||||
register_choices("notification_priorities", notification_priorities.choices, "accounts", "Notification priority levels")
|
||||
register_choices("security_event_types", security_event_types.choices, "accounts", "Security event type classifications")
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,23 @@ including social provider management, user authentication, and profile services.
|
||||
from .account_service import AccountService
|
||||
from .social_provider_service import SocialProviderService
|
||||
from .user_deletion_service import UserDeletionService
|
||||
from .security_service import (
|
||||
get_client_ip,
|
||||
log_security_event,
|
||||
log_security_event_simple,
|
||||
send_security_notification,
|
||||
check_auth_method_availability,
|
||||
invalidate_user_sessions,
|
||||
)
|
||||
|
||||
__all__ = ["AccountService", "SocialProviderService", "UserDeletionService"]
|
||||
|
||||
__all__ = [
|
||||
"AccountService",
|
||||
"SocialProviderService",
|
||||
"UserDeletionService",
|
||||
"get_client_ip",
|
||||
"log_security_event",
|
||||
"log_security_event_simple",
|
||||
"send_security_notification",
|
||||
"check_auth_method_availability",
|
||||
"invalidate_user_sessions",
|
||||
]
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user