""" User authentication and management services. Provides business logic for: - User registration and authentication - OAuth integration - MFA/2FA management - Permission and role management """ from typing import Optional, Dict, Any from django.contrib.auth import authenticate from django.contrib.auth.password_validation import validate_password from django.core.exceptions import ValidationError from django.db import transaction from django.utils import timezone from django_otp.plugins.otp_totp.models import TOTPDevice from allauth.socialaccount.models import SocialAccount import logging from .models import User, UserRole, UserProfile logger = logging.getLogger(__name__) class AuthenticationService: """Service for handling user authentication operations""" @staticmethod @transaction.atomic def register_user( email: str, password: str, username: Optional[str] = None, first_name: str = '', last_name: str = '' ) -> User: """ Register a new user with email and password. Args: email: User's email address password: User's password (will be validated and hashed) username: Optional username (defaults to email prefix) first_name: User's first name last_name: User's last name Returns: Created User instance Raises: ValidationError: If email exists or password is invalid """ # Normalize email email = email.lower().strip() # Check if user exists if User.objects.filter(email=email).exists(): raise ValidationError({'email': 'A user with this email already exists.'}) # Set username if not provided if not username: username = email.split('@')[0] # Make unique if needed base_username = username counter = 1 while User.objects.filter(username=username).exists(): username = f"{base_username}{counter}" counter += 1 # Validate password try: validate_password(password) except ValidationError as e: raise ValidationError({'password': e.messages}) # Create user user = User.objects.create_user( email=email, username=username, password=password, first_name=first_name, last_name=last_name ) # Create role (default: user) UserRole.objects.create(user=user, role='user') # Create profile UserProfile.objects.create(user=user) logger.info(f"New user registered: {user.email}") return user @staticmethod def authenticate_user(email: str, password: str) -> Optional[User]: """ Authenticate user with email and password. Args: email: User's email address password: User's password Returns: User instance if authentication successful, None otherwise """ email = email.lower().strip() user = authenticate(username=email, password=password) if user and user.banned: logger.warning(f"Banned user attempted login: {email}") raise ValidationError("This account has been banned.") if user: user.last_login = timezone.now() user.save(update_fields=['last_login']) logger.info(f"User authenticated: {email}") return user @staticmethod @transaction.atomic def create_oauth_user( email: str, provider: str, oauth_sub: str, username: Optional[str] = None, first_name: str = '', last_name: str = '', avatar_url: str = '' ) -> User: """ Create or get user from OAuth provider. Args: email: User's email from OAuth provider provider: OAuth provider name (google, discord) oauth_sub: OAuth subject identifier username: Optional username first_name: User's first name last_name: User's last name avatar_url: URL to user's avatar Returns: User instance """ email = email.lower().strip() # Check if user exists with this email try: user = User.objects.get(email=email) # Update OAuth info if not set if not user.oauth_provider: user.oauth_provider = provider user.oauth_sub = oauth_sub user.save(update_fields=['oauth_provider', 'oauth_sub']) return user except User.DoesNotExist: pass # Create new user if not username: username = email.split('@')[0] base_username = username counter = 1 while User.objects.filter(username=username).exists(): username = f"{base_username}{counter}" counter += 1 user = User.objects.create( email=email, username=username, first_name=first_name, last_name=last_name, avatar_url=avatar_url, oauth_provider=provider, oauth_sub=oauth_sub ) # No password needed for OAuth users user.set_unusable_password() user.save() # Create role and profile UserRole.objects.create(user=user, role='user') UserProfile.objects.create(user=user) logger.info(f"OAuth user created: {email} via {provider}") return user @staticmethod def change_password(user: User, old_password: str, new_password: str) -> bool: """ Change user's password. Args: user: User instance old_password: Current password new_password: New password Returns: True if successful Raises: ValidationError: If old password is incorrect or new password is invalid """ # Check old password if not user.check_password(old_password): raise ValidationError({'old_password': 'Incorrect password.'}) # Validate new password try: validate_password(new_password, user=user) except ValidationError as e: raise ValidationError({'new_password': e.messages}) # Set new password user.set_password(new_password) user.save() logger.info(f"Password changed for user: {user.email}") return True @staticmethod def reset_password(user: User, new_password: str) -> bool: """ Reset user's password (admin/forgot password flow). Args: user: User instance new_password: New password Returns: True if successful Raises: ValidationError: If new password is invalid """ # Validate new password try: validate_password(new_password, user=user) except ValidationError as e: raise ValidationError({'password': e.messages}) # Set new password user.set_password(new_password) user.save() logger.info(f"Password reset for user: {user.email}") return True class MFAService: """Service for handling multi-factor authentication""" @staticmethod def enable_totp(user: User, device_name: str = 'default') -> TOTPDevice: """ Enable TOTP-based MFA for user. Args: user: User instance device_name: Name for the TOTP device Returns: TOTPDevice instance with QR code data """ # Check if device already exists device = TOTPDevice.objects.filter( user=user, name=device_name ).first() if not device: device = TOTPDevice.objects.create( user=user, name=device_name, confirmed=False ) return device @staticmethod @transaction.atomic def confirm_totp(user: User, token: str, device_name: str = 'default') -> bool: """ Confirm TOTP device with verification token. Args: user: User instance token: 6-digit TOTP token device_name: Name of the TOTP device Returns: True if successful Raises: ValidationError: If token is invalid """ device = TOTPDevice.objects.filter( user=user, name=device_name ).first() if not device: raise ValidationError("TOTP device not found.") # Verify token if not device.verify_token(token): raise ValidationError("Invalid verification code.") # Confirm device device.confirmed = True device.save() # Enable MFA on user user.mfa_enabled = True user.save(update_fields=['mfa_enabled']) logger.info(f"MFA enabled for user: {user.email}") return True @staticmethod def verify_totp(user: User, token: str) -> bool: """ Verify TOTP token for authentication. Args: user: User instance token: 6-digit TOTP token Returns: True if valid """ device = TOTPDevice.objects.filter( user=user, confirmed=True ).first() if not device: return False return device.verify_token(token) @staticmethod @transaction.atomic def disable_totp(user: User) -> bool: """ Disable TOTP-based MFA for user. Args: user: User instance Returns: True if successful """ # Delete all TOTP devices TOTPDevice.objects.filter(user=user).delete() # Disable MFA on user user.mfa_enabled = False user.save(update_fields=['mfa_enabled']) logger.info(f"MFA disabled for user: {user.email}") return True class RoleService: """Service for managing user roles and permissions""" @staticmethod @transaction.atomic def assign_role( user: User, role: str, granted_by: Optional[User] = None ) -> UserRole: """ Assign role to user. Args: user: User to assign role to role: Role name (user, moderator, admin) granted_by: User granting the role Returns: UserRole instance Raises: ValidationError: If role is invalid """ valid_roles = ['user', 'moderator', 'admin'] if role not in valid_roles: raise ValidationError(f"Invalid role. Must be one of: {', '.join(valid_roles)}") # Get or create role user_role, created = UserRole.objects.get_or_create( user=user, defaults={'role': role, 'granted_by': granted_by} ) if not created and user_role.role != role: user_role.role = role user_role.granted_by = granted_by user_role.granted_at = timezone.now() user_role.save() logger.info(f"Role '{role}' assigned to user: {user.email}") return user_role @staticmethod def has_role(user: User, role: str) -> bool: """ Check if user has specific role. Args: user: User instance role: Role name to check Returns: True if user has the role """ try: user_role = user.role if role == 'moderator': return user_role.is_moderator elif role == 'admin': return user_role.is_admin return user_role.role == role except UserRole.DoesNotExist: return False @staticmethod def get_user_permissions(user: User) -> Dict[str, bool]: """ Get user's permission summary. Args: user: User instance Returns: Dictionary of permissions """ try: user_role = user.role is_moderator = user_role.is_moderator is_admin = user_role.is_admin except UserRole.DoesNotExist: is_moderator = False is_admin = False return { 'can_submit': not user.banned, 'can_moderate': is_moderator and not user.banned, 'can_admin': is_admin and not user.banned, 'can_edit_own': not user.banned, 'can_delete_own': not user.banned, } class UserManagementService: """Service for user profile and account management""" @staticmethod @transaction.atomic def update_profile( user: User, **kwargs ) -> User: """ Update user profile information. Args: user: User instance **kwargs: Fields to update Returns: Updated User instance """ allowed_fields = [ 'first_name', 'last_name', 'username', 'avatar_url', 'bio' ] updated_fields = [] for field, value in kwargs.items(): if field in allowed_fields and hasattr(user, field): setattr(user, field, value) updated_fields.append(field) if updated_fields: user.save(update_fields=updated_fields) logger.info(f"Profile updated for user: {user.email}") return user @staticmethod @transaction.atomic def update_preferences( user: User, **kwargs ) -> UserProfile: """ Update user preferences. Args: user: User instance **kwargs: Preference fields to update Returns: Updated UserProfile instance """ profile = user.profile allowed_fields = [ 'email_notifications', 'email_on_submission_approved', 'email_on_submission_rejected', 'profile_public', 'show_email' ] updated_fields = [] for field, value in kwargs.items(): if field in allowed_fields and hasattr(profile, field): setattr(profile, field, value) updated_fields.append(field) if updated_fields: profile.save(update_fields=updated_fields) logger.info(f"Preferences updated for user: {user.email}") return profile @staticmethod @transaction.atomic def ban_user( user: User, reason: str, banned_by: User ) -> User: """ Ban a user. Args: user: User to ban reason: Reason for ban banned_by: User performing the ban Returns: Updated User instance """ user.ban(reason=reason, banned_by=banned_by) logger.warning(f"User banned: {user.email} by {banned_by.email}. Reason: {reason}") return user @staticmethod @transaction.atomic def unban_user(user: User) -> User: """ Unban a user. Args: user: User to unban Returns: Updated User instance """ user.unban() logger.info(f"User unbanned: {user.email}") return user @staticmethod def get_user_stats(user: User) -> Dict[str, Any]: """ Get user statistics. Args: user: User instance Returns: Dictionary of user stats """ profile = user.profile return { 'total_submissions': profile.total_submissions, 'approved_submissions': profile.approved_submissions, 'reputation_score': user.reputation_score, 'member_since': user.date_joined, 'last_active': user.last_login, }