mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-29 22:27:03 -05:00
feat: Implement MFA authentication, add ride statistics model, and update various services, APIs, and tests across the application.
This commit is contained in:
@@ -12,6 +12,7 @@ which can be configured via DJANGO_SETTINGS_MODULE environment variable.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
from decouple import config
|
||||
|
||||
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -10,6 +10,7 @@ This module extends base.py with development-specific configurations:
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from .base import * # noqa: F401,F403
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,6 +20,7 @@ Log Levels (in order of severity):
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from decouple import config
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -12,6 +12,7 @@ Why python-decouple?
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from decouple import config
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -12,6 +12,7 @@ Why python-decouple?
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from decouple import config
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user