soundwave/backend/user/views.py

592 lines
19 KiB
Python
Raw Normal View History

"""User API views"""
import os
import mimetypes
from pathlib import Path
from django.contrib.auth import authenticate, login, logout
from django.http import HttpResponse, FileResponse
from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.parsers import MultiPartParser, FormParser
from user.models import Account
from user.serializers import (
AccountSerializer,
LoginSerializer,
UserConfigSerializer,
TwoFactorSetupSerializer,
TwoFactorVerifySerializer,
TwoFactorStatusSerializer,
)
from user.two_factor import (
generate_totp_secret,
get_totp_uri,
generate_qr_code,
verify_totp,
generate_backup_codes,
generate_backup_codes_pdf,
)
from datetime import datetime
class UserAccountView(APIView):
"""User account endpoint"""
def get(self, request):
"""Get current user account"""
user = request.user
# Calculate current storage usage
user.calculate_storage_usage()
serializer = AccountSerializer(user)
return Response(serializer.data)
class UserProfileView(APIView):
"""User profile management"""
def patch(self, request):
"""Update user profile (username, email, first_name, last_name)"""
user = request.user
username = request.data.get('username')
email = request.data.get('email')
first_name = request.data.get('first_name')
last_name = request.data.get('last_name')
current_password = request.data.get('current_password', '').strip()
# At least one field must be provided
if not username and not email and first_name is None and last_name is None:
return Response(
{'error': 'At least one field (username, email, first_name, last_name) must be provided'},
status=status.HTTP_400_BAD_REQUEST
)
# Password is required to change username or email (security critical fields)
if (username or email) and not current_password:
return Response(
{'error': 'Current password is required to change username or email'},
status=status.HTTP_400_BAD_REQUEST
)
# Verify current password only if it's provided (for username/email changes)
if current_password and not user.check_password(current_password):
return Response(
{'error': 'Current password is incorrect'},
status=status.HTTP_401_UNAUTHORIZED
)
# Validate username
if username:
username = username.strip()
if len(username) < 3:
return Response(
{'error': 'Username must be at least 3 characters long'},
status=status.HTTP_400_BAD_REQUEST
)
if not username.isalnum() and '_' not in username:
return Response(
{'error': 'Username can only contain letters, numbers, and underscores'},
status=status.HTTP_400_BAD_REQUEST
)
# Check if username is already taken
if Account.objects.exclude(id=user.id).filter(username=username).exists():
return Response(
{'error': 'Username already taken'},
status=status.HTTP_400_BAD_REQUEST
)
# Check if email is already taken
if email and Account.objects.exclude(id=user.id).filter(email=email).exists():
return Response(
{'error': 'Email already in use'},
status=status.HTTP_400_BAD_REQUEST
)
# Update fields
updated_fields = []
if username:
user.username = username
updated_fields.append('username')
if email:
user.email = email
updated_fields.append('email')
if first_name is not None:
user.first_name = first_name
updated_fields.append('name')
if last_name is not None:
user.last_name = last_name
if 'name' not in updated_fields:
updated_fields.append('name')
user.save()
return Response({
'message': f'{" and ".join(updated_fields).capitalize()} updated successfully',
'user': AccountSerializer(user).data
})
class ChangePasswordView(APIView):
"""Change user password"""
def post(self, request):
"""Change password"""
user = request.user
current_password = request.data.get('current_password')
new_password = request.data.get('new_password')
if not current_password or not new_password:
return Response(
{'error': 'Current and new password are required'},
status=status.HTTP_400_BAD_REQUEST
)
# Verify current password
if not user.check_password(current_password):
return Response(
{'error': 'Current password is incorrect'},
status=status.HTTP_401_UNAUTHORIZED
)
# Validate new password length
if len(new_password) < 8:
return Response(
{'error': 'Password must be at least 8 characters long'},
status=status.HTTP_400_BAD_REQUEST
)
# Set new password
user.set_password(new_password)
user.save()
# Delete old token and create new one for security
Token.objects.filter(user=user).delete()
new_token = Token.objects.create(user=user)
return Response({
'message': 'Password changed successfully',
'token': new_token.key # Return new token so user stays logged in
})
class LoginView(APIView):
"""Login endpoint"""
permission_classes = [AllowAny]
def post(self, request):
"""Authenticate user"""
serializer = LoginSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = authenticate(
username=serializer.validated_data['username'],
password=serializer.validated_data['password']
)
if not user:
return Response(
{'error': 'Invalid credentials'},
status=status.HTTP_401_UNAUTHORIZED
)
# Check if 2FA is enabled
if user.two_factor_enabled:
two_factor_code = serializer.validated_data.get('two_factor_code')
if not two_factor_code:
return Response({
'requires_2fa': True,
'message': 'Two-factor authentication required'
}, status=status.HTTP_200_OK)
# Verify TOTP code
if user.two_factor_secret and verify_totp(user.two_factor_secret, two_factor_code):
pass # Code is valid, continue login
# Check backup codes
elif two_factor_code in user.backup_codes:
# Remove used backup code
user.backup_codes.remove(two_factor_code)
user.save()
else:
return Response(
{'error': 'Invalid two-factor code'},
status=status.HTTP_401_UNAUTHORIZED
)
login(request, user)
token, _ = Token.objects.get_or_create(user=user)
return Response({
'token': token.key,
'user': AccountSerializer(user).data
})
class LogoutView(APIView):
"""Logout endpoint"""
def post(self, request):
"""Logout user and delete token"""
# Delete the user's token for security
if request.user.is_authenticated:
try:
Token.objects.filter(user=request.user).delete()
except Token.DoesNotExist:
pass
logout(request)
return Response({'message': 'Logged out successfully'})
class RegisterView(APIView):
"""
Registration endpoint - DISABLED
Public registration is not allowed. Only admins can create new users.
"""
permission_classes = [AllowAny]
def post(self, request):
"""Block public registration"""
from config.user_settings import ALLOW_PUBLIC_REGISTRATION
if not ALLOW_PUBLIC_REGISTRATION:
return Response(
{
'error': 'Public registration is disabled',
'message': 'New users can only be created by administrators. Please contact your system administrator for account creation.'
},
status=status.HTTP_403_FORBIDDEN
)
# If registration is enabled in settings, this would handle it
# This code is kept for potential future use
return Response(
{'error': 'Registration endpoint not implemented'},
status=status.HTTP_501_NOT_IMPLEMENTED
)
class UserConfigView(APIView):
"""User configuration endpoint"""
def get(self, request):
"""Get user configuration"""
# TODO: Implement user config storage
config = {
'theme': 'dark',
'items_per_page': 50,
'audio_quality': 'best'
}
serializer = UserConfigSerializer(config)
return Response(serializer.data)
def post(self, request):
"""Update user configuration"""
serializer = UserConfigSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
# TODO: Store user config
return Response(serializer.data)
class TwoFactorStatusView(APIView):
"""Get 2FA status"""
def get(self, request):
"""Get 2FA status for current user"""
user = request.user
serializer = TwoFactorStatusSerializer({
'enabled': user.two_factor_enabled,
'backup_codes_count': len(user.backup_codes) if user.backup_codes else 0
})
return Response(serializer.data)
class TwoFactorSetupView(APIView):
"""Setup 2FA"""
def post(self, request):
"""Generate 2FA secret and QR code"""
user = request.user
# Generate new secret
secret = generate_totp_secret()
uri = get_totp_uri(secret, user.username)
qr_code = generate_qr_code(uri)
# Generate backup codes
backup_codes = generate_backup_codes()
# Store secret temporarily (not enabled yet)
user.two_factor_secret = secret
user.backup_codes = backup_codes
user.save()
serializer = TwoFactorSetupSerializer({
'secret': secret,
'qr_code': qr_code,
'backup_codes': backup_codes
})
return Response(serializer.data)
class TwoFactorVerifyView(APIView):
"""Verify and enable 2FA"""
def post(self, request):
"""Verify 2FA code and enable"""
serializer = TwoFactorVerifySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = request.user
code = serializer.validated_data['code']
if not user.two_factor_secret:
return Response(
{'error': 'No 2FA setup found. Please setup 2FA first.'},
status=status.HTTP_400_BAD_REQUEST
)
if verify_totp(user.two_factor_secret, code):
user.two_factor_enabled = True
user.save()
return Response({
'message': 'Two-factor authentication enabled successfully',
'enabled': True
})
return Response(
{'error': 'Invalid verification code'},
status=status.HTTP_400_BAD_REQUEST
)
class TwoFactorDisableView(APIView):
"""Disable 2FA"""
def post(self, request):
"""Disable 2FA for user"""
serializer = TwoFactorVerifySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = request.user
code = serializer.validated_data['code']
if not user.two_factor_enabled:
return Response(
{'error': 'Two-factor authentication is not enabled'},
status=status.HTTP_400_BAD_REQUEST
)
# Verify code before disabling
if verify_totp(user.two_factor_secret, code) or code in user.backup_codes:
user.two_factor_enabled = False
user.two_factor_secret = None
user.backup_codes = []
user.save()
return Response({
'message': 'Two-factor authentication disabled successfully',
'enabled': False
})
return Response(
{'error': 'Invalid verification code'},
status=status.HTTP_400_BAD_REQUEST
)
class TwoFactorRegenerateCodesView(APIView):
"""Regenerate backup codes"""
def post(self, request):
"""Generate new backup codes"""
user = request.user
if not user.two_factor_enabled:
return Response(
{'error': 'Two-factor authentication is not enabled'},
status=status.HTTP_400_BAD_REQUEST
)
# Generate new backup codes
backup_codes = generate_backup_codes()
user.backup_codes = backup_codes
user.save()
return Response({
'backup_codes': backup_codes,
'message': 'Backup codes regenerated successfully'
})
class TwoFactorDownloadCodesView(APIView):
"""Download backup codes as PDF"""
def get(self, request):
"""Generate and download backup codes PDF"""
user = request.user
if not user.two_factor_enabled or not user.backup_codes:
return Response(
{'error': 'No backup codes available'},
status=status.HTTP_400_BAD_REQUEST
)
# Generate PDF
pdf_buffer = generate_backup_codes_pdf(user.username, user.backup_codes)
# Create filename: username_SoundWave_BackupCodes_YYYY-MM-DD.pdf
filename = f"{user.username}_SoundWave_BackupCodes_{datetime.now().strftime('%Y-%m-%d')}.pdf"
response = HttpResponse(pdf_buffer, content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
class AvatarUploadView(APIView):
"""Upload user avatar"""
parser_classes = [MultiPartParser, FormParser]
# Avatar directory - persistent storage
AVATAR_DIR = Path('/app/data/avatars')
MAX_SIZE = 20 * 1024 * 1024 # 20MB
ALLOWED_TYPES = {'image/jpeg', 'image/png', 'image/gif', 'image/webp'}
def post(self, request):
"""Upload custom avatar image"""
if 'avatar' not in request.FILES:
return Response(
{'error': 'No avatar file provided'},
status=status.HTTP_400_BAD_REQUEST
)
avatar_file = request.FILES['avatar']
# Validate file size
if avatar_file.size > self.MAX_SIZE:
return Response(
{'error': f'File too large. Maximum size is {self.MAX_SIZE // (1024*1024)}MB'},
status=status.HTTP_400_BAD_REQUEST
)
# Validate content type
content_type = avatar_file.content_type
if content_type not in self.ALLOWED_TYPES:
return Response(
{'error': f'Invalid file type. Allowed types: {", ".join(self.ALLOWED_TYPES)}'},
status=status.HTTP_400_BAD_REQUEST
)
# Create avatars directory if it doesn't exist
self.AVATAR_DIR.mkdir(parents=True, exist_ok=True)
# Generate safe filename: username_timestamp.ext
ext = Path(avatar_file.name).suffix or '.jpg'
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"{request.user.username}_{timestamp}{ext}"
filepath = self.AVATAR_DIR / filename
# Remove old avatar file if it exists and is not a preset
if request.user.avatar and not request.user.avatar.startswith('preset_'):
old_path = self.AVATAR_DIR / request.user.avatar.split('/')[-1]
if old_path.exists():
old_path.unlink()
# Save file
with open(filepath, 'wb+') as destination:
for chunk in avatar_file.chunks():
destination.write(chunk)
# Update user model
request.user.avatar = f"avatars/{filename}"
request.user.save()
return Response({
'message': 'Avatar uploaded successfully',
'avatar': request.user.avatar
})
def delete(self, request):
"""Remove custom avatar and reset to default"""
user = request.user
# Remove file if it exists and is not a preset
if user.avatar and not user.avatar.startswith('preset_'):
filepath = self.AVATAR_DIR / user.avatar.split('/')[-1]
if filepath.exists():
filepath.unlink()
user.avatar = None
user.save()
return Response({
'message': 'Avatar removed successfully'
})
class AvatarPresetView(APIView):
"""Set preset avatar"""
def post(self, request):
"""Set preset avatar (1-5)"""
preset = request.data.get('preset')
if not preset or not str(preset).isdigit():
return Response(
{'error': 'Invalid preset number'},
status=status.HTTP_400_BAD_REQUEST
)
preset_num = int(preset)
if preset_num < 1 or preset_num > 5:
return Response(
{'error': 'Preset must be between 1 and 5'},
status=status.HTTP_400_BAD_REQUEST
)
# Remove old custom avatar file if exists
user = request.user
if user.avatar and not user.avatar.startswith('preset_'):
avatar_dir = Path('/app/data/avatars')
filepath = avatar_dir / user.avatar.split('/')[-1]
if filepath.exists():
filepath.unlink()
# Set preset
user.avatar = f"preset_{preset_num}"
user.save()
return Response({
'message': 'Preset avatar set successfully',
'avatar': user.avatar
})
class AvatarFileView(APIView):
"""Serve avatar files"""
def get(self, request, filename):
"""Serve avatar file"""
avatar_dir = Path('/app/data/avatars')
filepath = avatar_dir / filename
# Security: validate path
if not filepath.resolve().is_relative_to(avatar_dir.resolve()):
return Response(
{'error': 'Invalid path'},
status=status.HTTP_403_FORBIDDEN
)
if not filepath.exists():
return Response(
{'error': 'Avatar not found'},
status=status.HTTP_404_NOT_FOUND
)
# Determine content type
content_type, _ = mimetypes.guess_type(str(filepath))
if not content_type:
content_type = 'application/octet-stream'
return FileResponse(open(filepath, 'rb'), content_type=content_type)