feat: Implement MFA authentication, add ride statistics model, and update various services, APIs, and tests across the application.

This commit is contained in:
pacnpal
2025-12-28 17:32:53 -05:00
parent aa56c46c27
commit c95f99ca10
452 changed files with 7948 additions and 6073 deletions

View File

@@ -12,6 +12,7 @@ which can be configured via DJANGO_SETTINGS_MODULE environment variable.
"""
import os
from celery import Celery
from decouple import config

View File

@@ -15,6 +15,7 @@ Structure:
import sys
from pathlib import Path
from decouple import config
# =============================================================================
@@ -85,10 +86,11 @@ THIRD_PARTY_APPS = [
"django_fsm_log", # FSM transition logging
"allauth",
"allauth.account",
"allauth.mfa", # MFA/TOTP support
"allauth.socialaccount",
"allauth.socialaccount.providers.google",
"allauth.socialaccount.providers.discord",
"django_turnstile", # Cloudflare Turnstile CAPTCHA
"turnstile", # Cloudflare Turnstile CAPTCHA (django-turnstile package)
"django_cleanup",
"django_filters",
"django_htmx",
@@ -239,13 +241,9 @@ TEST_RUNNER = "django.test.runner.DiscoverRunner"
# These imports add/override settings defined above.
# Database configuration (DATABASES, GDAL_LIBRARY_PATH, GEOS_LIBRARY_PATH)
from config.settings.database import * # noqa: F401,F403,E402
# Cache configuration (CACHES, SESSION_*, CACHE_MIDDLEWARE_*)
from config.settings.cache import * # noqa: F401,F403,E402
# Security configuration (SECURE_*, CSRF_*, SESSION_COOKIE_*, AUTH_PASSWORD_VALIDATORS)
from config.settings.security import * # noqa: F401,F403,E402
from config.settings.database import * # noqa: F401,F403,E402
# Email configuration (EMAIL_*, FORWARD_EMAIL_*)
from config.settings.email import * # noqa: F401,F403,E402
@@ -256,12 +254,15 @@ from config.settings.logging import * # noqa: F401,F403,E402
# REST Framework configuration (REST_FRAMEWORK, CORS_*, SIMPLE_JWT, REST_AUTH, SPECTACULAR_SETTINGS)
from config.settings.rest_framework import * # noqa: F401,F403,E402
# Third-party configuration (ACCOUNT_*, SOCIALACCOUNT_*, CLOUDFLARE_IMAGES, etc.)
from config.settings.third_party import * # noqa: F401,F403,E402
# Security configuration (SECURE_*, CSRF_*, SESSION_COOKIE_*, AUTH_PASSWORD_VALIDATORS)
from config.settings.security import * # noqa: F401,F403,E402
# Storage configuration (STATIC_*, MEDIA_*, STORAGES, WHITENOISE_*, FILE_UPLOAD_*)
from config.settings.storage import * # noqa: F401,F403,E402
# Third-party configuration (ACCOUNT_*, SOCIALACCOUNT_*, CLOUDFLARE_IMAGES, etc.)
from config.settings.third_party import * # noqa: F401,F403,E402
# =============================================================================
# Post-Import Overrides
# =============================================================================

View File

@@ -10,6 +10,7 @@ This module extends base.py with development-specific configurations:
"""
import logging
from .base import * # noqa: F401,F403
# =============================================================================

View File

@@ -10,6 +10,7 @@ This module extends base.py with production-specific configurations:
"""
from decouple import config
from .base import * # noqa: F401,F403
# =============================================================================
@@ -244,8 +245,8 @@ SENTRY_DSN = config("SENTRY_DSN", default="")
if SENTRY_DSN:
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.celery import CeleryIntegration
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.redis import RedisIntegration
sentry_sdk.init(

View File

@@ -18,8 +18,8 @@ Database URL Format:
- SpatiaLite: spatialite:///path/to/db.sqlite3
"""
from decouple import config
import dj_database_url
from decouple import config
# =============================================================================
# Database Configuration

View File

@@ -20,6 +20,7 @@ Log Levels (in order of severity):
"""
from pathlib import Path
from decouple import config
# =============================================================================

View File

@@ -12,6 +12,7 @@ Why python-decouple?
"""
from datetime import timedelta
from decouple import config
# =============================================================================

View File

@@ -21,9 +21,8 @@ Why python-decouple?
import logging
import warnings
from datetime import datetime, timedelta
from typing import Optional
from decouple import config, UndefinedValueError
from decouple import UndefinedValueError, config
logger = logging.getLogger("security")
@@ -171,10 +170,10 @@ def validate_secret_key(secret_key: str) -> bool:
def get_secret(
name: str,
default: Optional[str] = None,
default: str | None = None,
required: bool = True,
min_length: int = 0,
) -> Optional[str]:
) -> str | None:
"""
Safely retrieve a secret with validation.
@@ -197,11 +196,10 @@ def get_secret(
raise ValueError(f"Required secret '{name}' is not set")
return default
if value and min_length > 0:
if not validate_secret_strength(name, value, min_length):
if required:
raise ValueError(f"Secret '{name}' does not meet requirements")
return default
if value and min_length > 0 and not validate_secret_strength(name, value, min_length):
if required:
raise ValueError(f"Secret '{name}' does not meet requirements")
return default
return value
@@ -284,7 +282,7 @@ class SecretProvider:
- Azure Key Vault
"""
def get_secret(self, name: str) -> Optional[str]:
def get_secret(self, name: str) -> str | None:
"""Retrieve a secret by name."""
raise NotImplementedError
@@ -308,7 +306,7 @@ class EnvironmentSecretProvider(SecretProvider):
This is the fallback provider for development and simple deployments.
"""
def get_secret(self, name: str) -> Optional[str]:
def get_secret(self, name: str) -> str | None:
"""Retrieve a secret from environment variables."""
try:
return config(name)
@@ -370,7 +368,7 @@ def run_startup_validation() -> None:
if errors:
for error in errors:
if debug_mode:
warnings.warn(f"Secret validation warning: {error}")
warnings.warn(f"Secret validation warning: {error}", stacklevel=2)
else:
logger.error(f"Secret validation error: {error}")
@@ -383,9 +381,8 @@ def run_startup_validation() -> None:
# Validate SECRET_KEY specifically
try:
secret_key = config("SECRET_KEY")
if not validate_secret_key(secret_key):
if not debug_mode:
raise ValueError("SECRET_KEY does not meet security requirements")
if not validate_secret_key(secret_key) and not debug_mode:
raise ValueError("SECRET_KEY does not meet security requirements")
except UndefinedValueError:
if not debug_mode:
raise ValueError("SECRET_KEY is required in production")

View File

@@ -12,6 +12,7 @@ Why python-decouple?
"""
from pathlib import Path
from decouple import config
# =============================================================================

View File

@@ -73,6 +73,38 @@ SOCIALACCOUNT_LOGIN_ON_GET = True
SOCIALACCOUNT_AUTO_SIGNUP = False
SOCIALACCOUNT_STORE_TOKENS = True
# =============================================================================
# MFA (Multi-Factor Authentication) Configuration
# =============================================================================
# https://docs.allauth.org/en/latest/mfa/index.html
# Supported authenticator types
MFA_SUPPORTED_TYPES = ["totp"]
# TOTP settings
MFA_TOTP_ISSUER = config("MFA_TOTP_ISSUER", default="ThrillWiki")
# Number of digits for TOTP codes (default is 6)
MFA_TOTP_DIGITS = 6
# Interval in seconds for TOTP code generation (default 30)
MFA_TOTP_PERIOD = 30
# =============================================================================
# Login By Code (Magic Link) Configuration
# =============================================================================
# https://docs.allauth.org/en/latest/account/configuration.html#login-by-code
# Enable magic link / login by code feature
ACCOUNT_LOGIN_BY_CODE_ENABLED = config("ACCOUNT_LOGIN_BY_CODE_ENABLED", default=True, cast=bool)
# Maximum attempts to enter the code
ACCOUNT_LOGIN_BY_CODE_MAX_ATTEMPTS = config("ACCOUNT_LOGIN_BY_CODE_MAX_ATTEMPTS", default=3, cast=int)
# Code expiration timeout in seconds (5 minutes default)
ACCOUNT_LOGIN_BY_CODE_TIMEOUT = config("ACCOUNT_LOGIN_BY_CODE_TIMEOUT", default=300, cast=int)
# =============================================================================
# Celery Configuration
# =============================================================================
@@ -194,7 +226,7 @@ TURNSTILE_SECRET = config("TURNSTILE_SECRET", default="")
# Skip Turnstile validation in development if keys not set
TURNSTILE_SKIP_VALIDATION = config(
"TURNSTILE_SKIP_VALIDATION",
"TURNSTILE_SKIP_VALIDATION",
default=not TURNSTILE_SECRET, # Skip if no secret
cast=bool
)

View File

@@ -18,10 +18,10 @@ Why python-decouple?
import logging
import re
import warnings
from typing import Any, Callable, Optional
from typing import Any
from urllib.parse import urlparse
from decouple import config, UndefinedValueError
from decouple import UndefinedValueError, config
logger = logging.getLogger("thrillwiki")
@@ -170,24 +170,20 @@ def validate_type(value: Any, expected_type: type) -> bool:
def validate_range(
value: Any,
min_value: Optional[Any] = None,
max_value: Optional[Any] = None
min_value: Any | None = None,
max_value: Any | None = None
) -> bool:
"""Validate that a value is within a specified range."""
if min_value is not None and value < min_value:
return False
if max_value is not None and value > max_value:
return False
return True
return not (max_value is not None and value > max_value)
def validate_length(value: str, min_length: int = 0, max_length: int = None) -> bool:
"""Validate that a string value meets length requirements."""
if len(value) < min_length:
return False
if max_length is not None and len(value) > max_length:
return False
return True
return not (max_length is not None and len(value) > max_length)
VALIDATORS = {
@@ -217,7 +213,7 @@ def validate_variable(name: str, rules: dict) -> list[str]:
try:
# Get the value with appropriate type casting
var_type = rules.get("type", str)
default = rules.get("default", None)
default = rules.get("default")
if var_type == bool:
value = config(name, default=default, cast=bool)
@@ -263,9 +259,8 @@ def validate_variable(name: str, rules: dict) -> list[str]:
# Custom validator
validator_name = rules.get("validator")
if validator_name and validator_name in VALIDATORS:
if not VALIDATORS[validator_name](value):
errors.append(f"{name}: Failed {validator_name} validation")
if validator_name and validator_name in VALIDATORS and not VALIDATORS[validator_name](value):
errors.append(f"{name}: Failed {validator_name} validation")
return errors
@@ -375,7 +370,7 @@ def run_startup_validation() -> None:
else:
if debug_mode:
for error in result["errors"]:
warnings.warn(f"Configuration error: {error}")
warnings.warn(f"Configuration error: {error}", stacklevel=2)
else:
raise ValueError(
"Configuration validation failed. Check logs for details."