feat: Implement email change cancellation, location search, and admin anomaly detection endpoints.

This commit is contained in:
pacnpal
2026-01-05 14:31:04 -05:00
parent a801813dcf
commit 2b7bb4dfaa
13 changed files with 2074 additions and 22 deletions

View File

@@ -6,6 +6,8 @@ login, signup, logout, password management, social authentication,
user profiles, and top lists.
"""
import logging
from typing import cast # added 'cast'
from django.contrib.auth import authenticate, get_user_model, login, logout
@@ -71,6 +73,7 @@ except Exception:
TurnstileMixin = FallbackTurnstileMixin
UserModel = get_user_model()
logger = logging.getLogger(__name__)
# Helper: safely obtain underlying HttpRequest (used by Django auth)
@@ -831,7 +834,529 @@ The ThrillWiki Team
# Don't reveal whether email exists
return Response({"detail": "If the email exists, a verification email has been sent", "success": True})
# Note: User Profile, Top List, and Top List Item ViewSets are now handled
# by the dedicated accounts app at backend/apps/api/v1/accounts/views.py
# to avoid duplication and maintain clean separation of concerns.
@extend_schema_view(
post=extend_schema(
summary="Process OAuth profile",
description="Process OAuth profile data during social authentication flow.",
request={
"type": "object",
"properties": {
"provider": {"type": "string", "description": "OAuth provider (e.g., google, discord)"},
"profile": {
"type": "object",
"description": "Profile data from OAuth provider",
"properties": {
"id": {"type": "string"},
"email": {"type": "string", "format": "email"},
"name": {"type": "string"},
"avatar_url": {"type": "string", "format": "uri"},
},
},
"access_token": {"type": "string", "description": "OAuth access token"},
},
"required": ["provider", "profile"],
},
responses={
200: {
"type": "object",
"properties": {
"success": {"type": "boolean"},
"action": {"type": "string", "enum": ["created", "updated", "linked"]},
"user": {"type": "object"},
"profile_synced": {"type": "boolean"},
},
},
400: "Bad Request",
401: "Unauthorized",
403: "Account suspended",
},
tags=["Social Authentication"],
),
)
class ProcessOAuthProfileAPIView(APIView):
"""
API endpoint to process OAuth profile data.
This endpoint is called AFTER the OAuth flow is complete to:
1. Check if user is banned (SECURITY CRITICAL)
2. Extract avatar from OAuth provider
3. Download and upload avatar to Cloudflare Images
4. Sync display name from OAuth provider
5. Update username if it's a generic UUID-based username
Called with an empty body - uses the authenticated session.
Full parity with Supabase Edge Function: process-oauth-profile
BULLETPROOFED: Comprehensive validation, sanitization, and error handling.
"""
permission_classes = [IsAuthenticated]
# Security constants
MAX_AVATAR_SIZE = 10 * 1024 * 1024 # 10MB
AVATAR_DOWNLOAD_TIMEOUT = 10.0 # seconds
AVATAR_UPLOAD_TIMEOUT = 30.0 # seconds
MAX_USERNAME_LENGTH = 150
MIN_USERNAME_LENGTH = 3
ALLOWED_USERNAME_CHARS = set("abcdefghijklmnopqrstuvwxyz0123456789_")
# Rate limiting for avatar uploads (prevent abuse)
AVATAR_UPLOAD_COOLDOWN = 60 # seconds between uploads
def post(self, request: Request) -> Response:
import re
import httpx
from django.db import transaction
from django.core.cache import cache
try:
user = request.user
# ================================================================
# STEP 0: Validate user object exists and is valid
# ================================================================
if not user or not hasattr(user, 'user_id'):
logger.error("ProcessOAuthProfile called with invalid user object")
return Response({
"success": False,
"error": "Invalid user session",
}, status=status.HTTP_401_UNAUTHORIZED)
user_id_str = str(user.user_id)
# ================================================================
# STEP 1: CRITICAL - Check ban status FIRST
# ================================================================
is_banned = getattr(user, 'is_banned', False)
# Also check via profile if applicable
if not is_banned:
try:
from apps.accounts.models import UserProfile
profile_check = UserProfile.objects.filter(user=user).first()
if profile_check and getattr(profile_check, 'is_banned', False):
is_banned = True
except Exception:
pass
if is_banned:
ban_reason = getattr(user, 'ban_reason', None) or "Policy violation"
# Sanitize ban reason for response
safe_ban_reason = str(ban_reason)[:200] if ban_reason else None
logger.warning(
f"Banned user attempted OAuth profile update",
extra={"user_id": user_id_str, "ban_reason": safe_ban_reason}
)
return Response({
"error": "Account suspended",
"message": (
f"Your account has been suspended. Reason: {safe_ban_reason}"
if safe_ban_reason
else "Your account has been suspended. Contact support for assistance."
),
"ban_reason": safe_ban_reason,
}, status=status.HTTP_403_FORBIDDEN)
# ================================================================
# STEP 2: Check rate limiting for avatar uploads
# ================================================================
rate_limit_key = f"oauth_profile:avatar:{user_id_str}"
if cache.get(rate_limit_key):
return Response({
"success": True,
"action": "rate_limited",
"message": "Please wait before updating your profile again",
"avatar_uploaded": False,
"profile_updated": False,
})
# ================================================================
# STEP 3: Get OAuth provider info from social accounts
# ================================================================
try:
from allauth.socialaccount.models import SocialAccount
except ImportError:
logger.error("django-allauth not installed")
return Response({
"success": False,
"error": "Social authentication not configured",
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
social_accounts = SocialAccount.objects.filter(user=user)
if not social_accounts.exists():
return Response({
"success": True,
"action": "skipped",
"message": "No OAuth accounts linked",
})
# Get the most recent social account
social_account = social_accounts.order_by("-date_joined").first()
if not social_account:
return Response({
"success": True,
"action": "skipped",
"message": "No valid OAuth account found",
})
provider = social_account.provider or "unknown"
extra_data = social_account.extra_data or {}
# Validate extra_data is a dict
if not isinstance(extra_data, dict):
logger.warning(f"Invalid extra_data type for user {user_id_str}: {type(extra_data)}")
extra_data = {}
# ================================================================
# STEP 4: Extract profile data based on provider (with sanitization)
# ================================================================
avatar_url = None
display_name = None
username_base = None
if provider == "google":
avatar_url = self._sanitize_url(extra_data.get("picture"))
display_name = self._sanitize_display_name(extra_data.get("name"))
email = extra_data.get("email", "")
if email and isinstance(email, str):
username_base = self._sanitize_username(email.split("@")[0])
elif provider == "discord":
discord_data = extra_data
discord_id = discord_data.get("id") or discord_data.get("sub")
display_name = self._sanitize_display_name(
discord_data.get("global_name")
or discord_data.get("full_name")
or discord_data.get("name")
)
# Discord avatar URL construction with validation
avatar_hash = discord_data.get("avatar")
if discord_id and avatar_hash and isinstance(discord_id, str) and isinstance(avatar_hash, str):
# Validate discord_id is numeric
if discord_id.isdigit():
# Validate avatar_hash is alphanumeric
if re.match(r'^[a-zA-Z0-9_]+$', avatar_hash):
avatar_url = f"https://cdn.discordapp.com/avatars/{discord_id}/{avatar_hash}.png?size=256"
if not avatar_url:
avatar_url = self._sanitize_url(
discord_data.get("avatar_url") or discord_data.get("picture")
)
raw_username = discord_data.get("username") or discord_data.get("name", "")
if raw_username and isinstance(raw_username, str):
username_base = self._sanitize_username(raw_username.split("#")[0])
if not username_base and discord_id:
username_base = f"discord_{str(discord_id)[:8]}"
else:
# Generic provider handling
avatar_url = self._sanitize_url(
extra_data.get("picture")
or extra_data.get("avatar_url")
or extra_data.get("avatar")
)
display_name = self._sanitize_display_name(
extra_data.get("name") or extra_data.get("display_name")
)
# ================================================================
# STEP 5: Get or create user profile (with transaction)
# ================================================================
from apps.accounts.models import UserProfile
with transaction.atomic():
profile, profile_created = UserProfile.objects.select_for_update().get_or_create(
user=user
)
# Check if profile already has an avatar
if profile.avatar_id:
return Response({
"success": True,
"action": "skipped",
"message": "Avatar already exists",
"avatar_uploaded": False,
"profile_updated": False,
})
# ================================================================
# STEP 6: Download and upload avatar to Cloudflare (outside transaction)
# ================================================================
avatar_uploaded = False
if avatar_url:
try:
# Validate URL scheme
if not avatar_url.startswith(('https://', 'http://')):
logger.warning(f"Invalid avatar URL scheme: {avatar_url[:50]}")
else:
# Download avatar from provider
download_response = httpx.get(
avatar_url,
timeout=self.AVATAR_DOWNLOAD_TIMEOUT,
follow_redirects=True,
headers={
"User-Agent": "ThrillWiki/1.0",
"Accept": "image/*",
},
)
if download_response.status_code == 200:
image_data = download_response.content
content_type = download_response.headers.get("content-type", "")
# Validate content type
if not content_type.startswith("image/"):
logger.warning(f"Invalid content type for avatar: {content_type}")
# Validate file size
elif len(image_data) > self.MAX_AVATAR_SIZE:
logger.warning(
f"Avatar too large for user {user_id_str}: {len(image_data)} bytes"
)
# Validate minimum size (avoid empty images)
elif len(image_data) < 100:
logger.warning(f"Avatar too small for user {user_id_str}")
else:
avatar_uploaded = self._upload_to_cloudflare(
image_data, user_id_str, provider, profile
)
else:
logger.warning(
f"Avatar download failed: {download_response.status_code}",
extra={"user_id": user_id_str, "provider": provider}
)
except httpx.TimeoutException:
logger.warning(f"Avatar download timeout for user {user_id_str}")
except httpx.HTTPError as download_error:
logger.warning(f"Failed to download avatar: {download_error}")
except Exception as e:
logger.warning(f"Unexpected avatar error: {e}")
# Set rate limit after successful processing
if avatar_uploaded:
cache.set(rate_limit_key, True, self.AVATAR_UPLOAD_COOLDOWN)
# ================================================================
# STEP 7: Update display name if not set (with validation)
# ================================================================
profile_updated = False
if display_name and not getattr(user, "display_name", None):
try:
user.display_name = display_name
user.save(update_fields=["display_name"])
profile_updated = True
except Exception as e:
logger.warning(f"Failed to update display name: {e}")
# ================================================================
# STEP 8: Update username if it's a generic UUID-based username
# ================================================================
current_username = getattr(user, "username", "") or ""
if username_base and current_username.startswith("user_"):
try:
new_username = self._ensure_unique_username(username_base, user.user_id)
if new_username and new_username != current_username:
user.username = new_username
user.save(update_fields=["username"])
profile_updated = True
logger.info(
f"Username updated from {current_username} to {new_username}",
extra={"user_id": user_id_str}
)
except Exception as e:
logger.warning(f"Failed to update username: {e}")
return Response({
"success": True,
"action": "processed",
"provider": provider,
"avatar_uploaded": avatar_uploaded,
"profile_updated": profile_updated,
"message": "OAuth profile processed successfully",
})
except Exception as e:
capture_and_log(e, "Process OAuth profile", source="api", request=request)
return Response({
"success": False,
"error": "Failed to process OAuth profile",
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def _sanitize_url(self, url) -> str | None:
"""Sanitize and validate URL."""
if not url or not isinstance(url, str):
return None
url = url.strip()[:2000] # Limit length
# Basic URL validation
if not url.startswith(('https://', 'http://')):
return None
# Block obviously malicious patterns
dangerous_patterns = ['javascript:', 'data:', 'file:', '<script', 'onclick']
for pattern in dangerous_patterns:
if pattern.lower() in url.lower():
return None
return url
def _sanitize_display_name(self, name) -> str | None:
"""Sanitize display name."""
if not name or not isinstance(name, str):
return None
import re
# Strip and limit length
name = name.strip()[:100]
# Remove control characters
name = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', name)
# Remove excessive whitespace
name = ' '.join(name.split())
# Must have at least 1 character
if len(name) < 1:
return None
return name
def _sanitize_username(self, username) -> str | None:
"""Sanitize username for use."""
if not username or not isinstance(username, str):
return None
import re
# Lowercase and remove non-allowed characters
username = username.lower().strip()
username = re.sub(r'[^a-z0-9_]', '', username)
# Enforce length limits
if len(username) < self.MIN_USERNAME_LENGTH:
return None
username = username[:self.MAX_USERNAME_LENGTH]
return username
def _upload_to_cloudflare(self, image_data: bytes, user_id: str, provider: str, profile) -> bool:
"""Upload image to Cloudflare Images with error handling."""
import httpx
from django.db import transaction
try:
from django_cloudflareimages_toolkit.models import CloudflareImage
from django_cloudflareimages_toolkit.services import CloudflareImagesService
cf_service = CloudflareImagesService()
# Request direct upload URL
upload_result = cf_service.get_direct_upload_url(
metadata={
"type": "avatar",
"user_id": user_id,
"provider": provider,
}
)
if not upload_result or "upload_url" not in upload_result:
logger.warning("Failed to get Cloudflare upload URL")
return False
upload_url = upload_result["upload_url"]
cloudflare_id = upload_result.get("id") or upload_result.get("cloudflare_id")
if not cloudflare_id:
logger.warning("No Cloudflare ID in upload result")
return False
# Upload image to Cloudflare
files = {"file": ("avatar.png", image_data, "image/png")}
upload_response = httpx.post(
upload_url,
files=files,
timeout=self.AVATAR_UPLOAD_TIMEOUT,
)
if upload_response.status_code not in [200, 201]:
logger.warning(f"Cloudflare upload failed: {upload_response.status_code}")
return False
# Create CloudflareImage record and link to profile
with transaction.atomic():
cf_image = CloudflareImage.objects.create(
cloudflare_id=cloudflare_id,
is_uploaded=True,
metadata={
"type": "avatar",
"user_id": user_id,
"provider": provider,
}
)
profile.avatar = cf_image
profile.save(update_fields=["avatar"])
logger.info(
f"Avatar uploaded successfully",
extra={"user_id": user_id, "provider": provider, "cloudflare_id": cloudflare_id}
)
return True
except ImportError:
logger.warning("django-cloudflareimages-toolkit not available")
return False
except Exception as cf_error:
logger.warning(f"Cloudflare upload error: {cf_error}")
return False
def _ensure_unique_username(self, base_username: str, user_id: str, max_attempts: int = 10) -> str | None:
"""
Ensure username is unique by appending numbers if needed.
Returns None if no valid username can be generated.
"""
if not base_username:
return None
username = base_username.lower()[:self.MAX_USERNAME_LENGTH]
# Validate characters
if not all(c in self.ALLOWED_USERNAME_CHARS for c in username):
return None
attempt = 0
while attempt < max_attempts:
try:
existing = UserModel.objects.filter(username=username).exclude(user_id=user_id).exists()
if not existing:
return username
except Exception:
break
attempt += 1
# Ensure we don't exceed max length with suffix
suffix = f"_{attempt}"
max_base = self.MAX_USERNAME_LENGTH - len(suffix)
username = f"{base_username.lower()[:max_base]}{suffix}"
# Fallback to UUID-based username
return f"user_{str(user_id)[:8]}"