mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 07:45:18 -05:00
feat: Implement email change cancellation, location search, and admin anomaly detection endpoints.
This commit is contained in:
@@ -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]}"
|
||||
|
||||
Reference in New Issue
Block a user