Files
thrilltrack-explorer/django/apps/users/services.py
pacnpal d6ff4cc3a3 Add email templates for user notifications and account management
- Created a base email template (base.html) for consistent styling across all emails.
- Added moderation approval email template (moderation_approved.html) to notify users of approved submissions.
- Added moderation rejection email template (moderation_rejected.html) to inform users of required changes for their submissions.
- Created password reset email template (password_reset.html) for users requesting to reset their passwords.
- Developed a welcome email template (welcome.html) to greet new users and provide account details and tips for using ThrillWiki.
2025-11-08 15:34:04 -05:00

593 lines
16 KiB
Python

"""
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,
}