project initialization
Some checks failed
System Monitoring / Health Checks (push) Has been cancelled
System Monitoring / Performance Monitoring (push) Has been cancelled
System Monitoring / Database Monitoring (push) Has been cancelled
System Monitoring / Cache Monitoring (push) Has been cancelled
System Monitoring / Log Monitoring (push) Has been cancelled
System Monitoring / Resource Monitoring (push) Has been cancelled
System Monitoring / Uptime Monitoring (push) Has been cancelled
System Monitoring / Backup Monitoring (push) Has been cancelled
System Monitoring / Security Monitoring (push) Has been cancelled
System Monitoring / Monitoring Dashboard (push) Has been cancelled
System Monitoring / Alerting (push) Has been cancelled
Security Scanning / Dependency Scanning (push) Has been cancelled
Security Scanning / Code Security Scanning (push) Has been cancelled
Security Scanning / Secrets Scanning (push) Has been cancelled
Security Scanning / Container Security Scanning (push) Has been cancelled
Security Scanning / Compliance Checking (push) Has been cancelled
Security Scanning / Security Dashboard (push) Has been cancelled
Security Scanning / Security Remediation (push) Has been cancelled
Some checks failed
System Monitoring / Health Checks (push) Has been cancelled
System Monitoring / Performance Monitoring (push) Has been cancelled
System Monitoring / Database Monitoring (push) Has been cancelled
System Monitoring / Cache Monitoring (push) Has been cancelled
System Monitoring / Log Monitoring (push) Has been cancelled
System Monitoring / Resource Monitoring (push) Has been cancelled
System Monitoring / Uptime Monitoring (push) Has been cancelled
System Monitoring / Backup Monitoring (push) Has been cancelled
System Monitoring / Security Monitoring (push) Has been cancelled
System Monitoring / Monitoring Dashboard (push) Has been cancelled
System Monitoring / Alerting (push) Has been cancelled
Security Scanning / Dependency Scanning (push) Has been cancelled
Security Scanning / Code Security Scanning (push) Has been cancelled
Security Scanning / Secrets Scanning (push) Has been cancelled
Security Scanning / Container Security Scanning (push) Has been cancelled
Security Scanning / Compliance Checking (push) Has been cancelled
Security Scanning / Security Dashboard (push) Has been cancelled
Security Scanning / Security Remediation (push) Has been cancelled
This commit is contained in:
995
backend/src/core/api/auth_views.py
Normal file
995
backend/src/core/api/auth_views.py
Normal file
@@ -0,0 +1,995 @@
|
||||
"""
|
||||
Authentication API endpoints for multi-tenant SaaS platform.
|
||||
|
||||
Provides REST API endpoints for:
|
||||
- User registration and login
|
||||
- Multi-method authentication
|
||||
- MFA verification and management
|
||||
- Token management and refresh
|
||||
- Password management
|
||||
- Social authentication
|
||||
- Magic link authentication
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Any, Optional
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.utils import timezone
|
||||
from django.core.cache import cache
|
||||
from rest_framework import status, generics, viewsets
|
||||
from rest_framework.decorators import action, api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.request import Request
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from logging import getLogger
|
||||
from ..models.tenant import Tenant
|
||||
from ..auth.jwt_service import jwt_service
|
||||
from ..auth.authentication import auth_backend
|
||||
from ..auth.mfa import mfa_service
|
||||
from ..auth.permissions import TenantPermission, HasPermission
|
||||
from ..serializers.auth_serializers import (
|
||||
LoginSerializer,
|
||||
RegisterSerializer,
|
||||
MFASerializer,
|
||||
MFASetupSerializer,
|
||||
TokenRefreshSerializer,
|
||||
PasswordChangeSerializer,
|
||||
PasswordResetSerializer,
|
||||
PasswordResetConfirmSerializer,
|
||||
MagicLinkSerializer,
|
||||
SocialAuthSerializer,
|
||||
BiometricAuthSerializer,
|
||||
BackupCodeSerializer,
|
||||
AuthStatusSerializer,
|
||||
)
|
||||
from ..services.user_service import user_service
|
||||
from ..exceptions import AuthenticationError, ValidationError
|
||||
|
||||
User = get_user_model()
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class AuthViewSet(viewsets.GenericViewSet):
|
||||
"""
|
||||
Authentication API endpoints.
|
||||
|
||||
Provides comprehensive authentication functionality including:
|
||||
- Multi-method login/logout
|
||||
- User registration
|
||||
- MFA setup and verification
|
||||
- Token management
|
||||
- Password management
|
||||
- Social authentication
|
||||
- Magic link authentication
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
serializer_class = AuthStatusSerializer
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Return appropriate serializer based on action."""
|
||||
if self.action == 'login':
|
||||
return LoginSerializer
|
||||
elif self.action == 'register':
|
||||
return RegisterSerializer
|
||||
elif self.action == 'mfa_verify':
|
||||
return MFASerializer
|
||||
elif self.action == 'mfa_setup':
|
||||
return MFASetupSerializer
|
||||
elif self.action == 'refresh_token':
|
||||
return TokenRefreshSerializer
|
||||
elif self.action == 'change_password':
|
||||
return PasswordChangeSerializer
|
||||
elif self.action == 'reset_password':
|
||||
return PasswordResetSerializer
|
||||
elif self.action == 'confirm_password_reset':
|
||||
return PasswordResetConfirmSerializer
|
||||
elif self.action == 'magic_link':
|
||||
return MagicLinkSerializer
|
||||
elif self.action == 'social_auth':
|
||||
return SocialAuthSerializer
|
||||
elif self.action == 'biometric_auth':
|
||||
return BiometricAuthSerializer
|
||||
elif self.action == 'backup_codes':
|
||||
return BackupCodeSerializer
|
||||
return super().get_serializer_class()
|
||||
|
||||
@extend_schema(
|
||||
summary="User Login",
|
||||
description="Authenticate user with multiple methods",
|
||||
responses={200: AuthStatusSerializer},
|
||||
)
|
||||
@action(detail=False, methods=['post'])
|
||||
def login(self, request: Request) -> Response:
|
||||
"""
|
||||
Login user with specified authentication method.
|
||||
|
||||
Supported methods:
|
||||
- password: Email/username + password
|
||||
- ic: Malaysian IC number + password
|
||||
- company: Company registration + password
|
||||
- phone: Phone number + SMS code
|
||||
- magic: Magic link token
|
||||
- biometric: Biometric authentication
|
||||
- google: Google OAuth
|
||||
- facebook: Facebook OAuth
|
||||
"""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
try:
|
||||
method = serializer.validated_data['method']
|
||||
credentials = serializer.validated_data['credentials']
|
||||
|
||||
# Perform authentication
|
||||
auth_result = auth_backend.authenticate(request, method=method, **credentials)
|
||||
|
||||
# Handle MFA requirement
|
||||
if isinstance(auth_result, dict) and auth_result.get('requires_mfa'):
|
||||
user = auth_result['user']
|
||||
return Response({
|
||||
'requires_mfa': True,
|
||||
'user_id': str(user.id),
|
||||
'email': user.email,
|
||||
'mfa_methods': user.get_available_mfa_methods(),
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
# Generate JWT tokens
|
||||
user = auth_result
|
||||
tenant = getattr(request, 'tenant', None) or getattr(user, 'tenant', None)
|
||||
|
||||
device_info = {
|
||||
'user_agent': request.META.get('HTTP_USER_AGENT', ''),
|
||||
'ip_address': request.META.get('REMOTE_ADDR', ''),
|
||||
'device_type': self._get_device_type(request),
|
||||
}
|
||||
|
||||
tokens = jwt_service.generate_token_pair(user, tenant, device_info)
|
||||
|
||||
# Update user last login
|
||||
user.last_login = timezone.now()
|
||||
user.save(update_fields=['last_login'])
|
||||
|
||||
logger.info(f"User {user.id} logged in successfully via {method}")
|
||||
|
||||
return Response({
|
||||
'user': self._serialize_user(user),
|
||||
'tokens': tokens,
|
||||
'mfa_status': mfa_service.get_mfa_status(user),
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
except AuthenticationError as e:
|
||||
logger.warning(f"Login failed: {str(e)}")
|
||||
return Response(
|
||||
{'error': str(e)},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Login error: {str(e)}")
|
||||
return Response(
|
||||
{'error': 'Authentication failed'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
summary="User Registration",
|
||||
description="Register new user with tenant context",
|
||||
responses={201: AuthStatusSerializer},
|
||||
)
|
||||
@action(detail=False, methods=['post'])
|
||||
def register(self, request: Request) -> Response:
|
||||
"""
|
||||
Register new user account.
|
||||
|
||||
Supports individual and tenant-based registration with:
|
||||
- Email verification
|
||||
- Phone verification (optional)
|
||||
- Malaysian IC validation
|
||||
- Company registration validation
|
||||
"""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
try:
|
||||
# Create user
|
||||
user = user_service.create_user(
|
||||
email=serializer.validated_data['email'],
|
||||
password=serializer.validated_data['password'],
|
||||
first_name=serializer.validated_data.get('first_name', ''),
|
||||
last_name=serializer.validated_data.get('last_name', ''),
|
||||
phone_number=serializer.validated_data.get('phone_number'),
|
||||
malaysian_ic=serializer.validated_data.get('malaysian_ic'),
|
||||
tenant_id=serializer.validated_data.get('tenant_id'),
|
||||
role=serializer.validated_data.get('role', 'user'),
|
||||
)
|
||||
|
||||
# Generate verification codes if required
|
||||
verification_codes = {}
|
||||
if user.email and not user.email_verified:
|
||||
email_code = auth_backend.generate_registration_otp(user.email)
|
||||
verification_codes['email_otp'] = email_code['email_otp']
|
||||
|
||||
if user.phone_number and not user.phone_verified:
|
||||
phone_code = auth_backend.generate_phone_verification_code(user.phone_number)
|
||||
verification_codes['phone_otp'] = phone_code
|
||||
|
||||
logger.info(f"User {user.id} registered successfully")
|
||||
|
||||
return Response({
|
||||
'user': self._serialize_user(user),
|
||||
'verification_codes': verification_codes,
|
||||
'message': 'User registered successfully. Please verify your email.',
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
||||
except ValidationError as e:
|
||||
logger.warning(f"Registration validation error: {str(e)}")
|
||||
return Response(
|
||||
{'error': str(e)},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Registration error: {str(e)}")
|
||||
return Response(
|
||||
{'error': 'Registration failed'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
summary="MFA Verification",
|
||||
description="Verify MFA code for authentication",
|
||||
responses={200: AuthStatusSerializer},
|
||||
)
|
||||
@action(detail=False, methods=['post'])
|
||||
def mfa_verify(self, request: Request) -> Response:
|
||||
"""
|
||||
Verify MFA code to complete authentication.
|
||||
|
||||
Supported MFA methods:
|
||||
- totp: Time-based One-Time Password
|
||||
- sms: SMS verification code
|
||||
- email: Email verification code
|
||||
- backup: Backup code
|
||||
"""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
try:
|
||||
user_id = serializer.validated_data['user_id']
|
||||
method = serializer.validated_data['method']
|
||||
code = serializer.validated_data['code']
|
||||
|
||||
user = User.objects.get(id=user_id)
|
||||
|
||||
# Verify MFA code
|
||||
if not mfa_service.validate_mfa_attempt(user, method, code):
|
||||
return Response(
|
||||
{'error': 'Invalid MFA code'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Generate JWT tokens
|
||||
tenant = getattr(request, 'tenant', None) or getattr(user, 'tenant', None)
|
||||
device_info = {
|
||||
'user_agent': request.META.get('HTTP_USER_AGENT', ''),
|
||||
'ip_address': request.META.get('REMOTE_ADDR', ''),
|
||||
'device_type': self._get_device_type(request),
|
||||
}
|
||||
|
||||
tokens = jwt_service.generate_token_pair(user, tenant, device_info)
|
||||
|
||||
# Update user last login
|
||||
user.last_login = timezone.now()
|
||||
user.save(update_fields=['last_login'])
|
||||
|
||||
logger.info(f"User {user.id} MFA verification successful")
|
||||
|
||||
return Response({
|
||||
'user': self._serialize_user(user),
|
||||
'tokens': tokens,
|
||||
'mfa_status': mfa_service.get_mfa_status(user),
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
except User.DoesNotExist:
|
||||
return Response(
|
||||
{'error': 'User not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
except AuthenticationError as e:
|
||||
return Response(
|
||||
{'error': str(e)},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"MFA verification error: {str(e)}")
|
||||
return Response(
|
||||
{'error': 'MFA verification failed'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
summary="MFA Setup",
|
||||
description="Set up MFA for user account",
|
||||
responses={200: MFASetupSerializer},
|
||||
)
|
||||
@action(detail=False, methods=['post'])
|
||||
def mfa_setup(self, request: Request) -> Response:
|
||||
"""
|
||||
Set up MFA for user account.
|
||||
|
||||
Supports TOTP setup with QR code generation and verification.
|
||||
"""
|
||||
if not request.user.is_authenticated:
|
||||
return Response(
|
||||
{'error': 'Authentication required'},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
try:
|
||||
user = request.user
|
||||
method = serializer.validated_data.get('method', 'totp')
|
||||
|
||||
if method == 'totp':
|
||||
# Generate TOTP setup
|
||||
totp_data = mfa_service.setup_totp(user)
|
||||
|
||||
if 'verification_code' in serializer.validated_data:
|
||||
# Verify TOTP setup
|
||||
secret = serializer.validated_data['secret']
|
||||
code = serializer.validated_data['verification_code']
|
||||
|
||||
if mfa_service.verify_totp_setup(user, secret, code):
|
||||
return Response({
|
||||
'message': 'MFA enabled successfully',
|
||||
'backup_codes': mfa_service.generate_backup_codes(user),
|
||||
})
|
||||
else:
|
||||
return Response(
|
||||
{'error': 'Invalid verification code'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
return Response(totp_data)
|
||||
|
||||
else:
|
||||
return Response(
|
||||
{'error': f'MFA method {method} not supported'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"MFA setup error: {str(e)}")
|
||||
return Response(
|
||||
{'error': 'MFA setup failed'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
summary="Token Refresh",
|
||||
description="Refresh access token using refresh token",
|
||||
responses={200: TokenRefreshSerializer},
|
||||
)
|
||||
@action(detail=False, methods=['post'])
|
||||
def refresh_token(self, request: Request) -> Response:
|
||||
"""
|
||||
Refresh access token using refresh token.
|
||||
"""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
try:
|
||||
refresh_token = serializer.validated_data['refresh_token']
|
||||
new_tokens = jwt_service.refresh_access_token(refresh_token)
|
||||
|
||||
return Response(new_tokens, status=status.HTTP_200_OK)
|
||||
|
||||
except AuthenticationError as e:
|
||||
return Response(
|
||||
{'error': str(e)},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Token refresh error: {str(e)}")
|
||||
return Response(
|
||||
{'error': 'Token refresh failed'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
summary="Logout",
|
||||
description="Logout user and blacklist tokens",
|
||||
responses={200: {'type': 'object', 'properties': {'message': {'type': 'string'}}}},
|
||||
)
|
||||
@action(detail=False, methods=['post'])
|
||||
def logout(self, request: Request) -> Response:
|
||||
"""
|
||||
Logout user and blacklist current tokens.
|
||||
"""
|
||||
if not request.user.is_authenticated:
|
||||
return Response(
|
||||
{'error': 'Authentication required'},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
|
||||
try:
|
||||
# Get authorization header
|
||||
auth_header = request.META.get('HTTP_AUTHORIZATION', '')
|
||||
if auth_header.startswith('Bearer '):
|
||||
token = auth_header.split(' ')[1]
|
||||
jwt_service.blacklist_token(token)
|
||||
|
||||
# Blacklist all user sessions if requested
|
||||
blacklist_all = request.data.get('blacklist_all_sessions', False)
|
||||
if blacklist_all:
|
||||
jwt_service.blacklist_token(token, blacklist_all_sessions=True)
|
||||
|
||||
logger.info(f"User {request.user.id} logged out successfully")
|
||||
|
||||
return Response({'message': 'Logged out successfully'}, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Logout error: {str(e)}")
|
||||
return Response(
|
||||
{'error': 'Logout failed'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
summary="Change Password",
|
||||
description="Change user password",
|
||||
responses={200: {'type': 'object', 'properties': {'message': {'type': 'string'}}}},
|
||||
)
|
||||
@action(detail=False, methods=['post'])
|
||||
def change_password(self, request: Request) -> Response:
|
||||
"""
|
||||
Change user password.
|
||||
"""
|
||||
if not request.user.is_authenticated:
|
||||
return Response(
|
||||
{'error': 'Authentication required'},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
try:
|
||||
user = request.user
|
||||
current_password = serializer.validated_data['current_password']
|
||||
new_password = serializer.validated_data['new_password']
|
||||
|
||||
if not user.check_password(current_password):
|
||||
return Response(
|
||||
{'error': 'Current password is incorrect'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
user.set_password(new_password)
|
||||
user.save(update_fields=['password'])
|
||||
|
||||
# Blacklist all existing tokens
|
||||
jwt_service.blacklist_token('', blacklist_all_sessions=True)
|
||||
|
||||
logger.info(f"Password changed for user {user.id}")
|
||||
|
||||
return Response({'message': 'Password changed successfully'}, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Password change error: {str(e)}")
|
||||
return Response(
|
||||
{'error': 'Password change failed'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
summary="Reset Password",
|
||||
description="Request password reset",
|
||||
responses={200: {'type': 'object', 'properties': {'message': {'type': 'string'}}}},
|
||||
)
|
||||
@action(detail=False, methods=['post'])
|
||||
def reset_password(self, request: Request) -> Response:
|
||||
"""
|
||||
Request password reset email.
|
||||
"""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
try:
|
||||
email = serializer.validated_data['email']
|
||||
|
||||
try:
|
||||
user = User.objects.get(email__iexact=email)
|
||||
except User.DoesNotExist:
|
||||
# Don't reveal whether email exists
|
||||
return Response({'message': 'Password reset email sent if email exists'})
|
||||
|
||||
# Generate password reset token
|
||||
token = secrets.token_urlsafe(32)
|
||||
reset_key = f"password_reset:{token}"
|
||||
cache.set(reset_key, str(user.id), timeout=3600) # 1 hour
|
||||
|
||||
# Send password reset email
|
||||
# This would integrate with email service
|
||||
logger.info(f"Password reset requested for user {user.id}")
|
||||
|
||||
return Response({'message': 'Password reset email sent if email exists'})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Password reset request error: {str(e)}")
|
||||
return Response(
|
||||
{'error': 'Password reset request failed'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
summary="Confirm Password Reset",
|
||||
description="Confirm password reset with token",
|
||||
responses={200: {'type': 'object', 'properties': {'message': {'type': 'string'}}}},
|
||||
)
|
||||
@action(detail=False, methods=['post'])
|
||||
def confirm_password_reset(self, request: Request) -> Response:
|
||||
"""
|
||||
Confirm password reset with token.
|
||||
"""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
try:
|
||||
token = serializer.validated_data['token']
|
||||
new_password = serializer.validated_data['new_password']
|
||||
|
||||
# Verify token
|
||||
reset_key = f"password_reset:{token}"
|
||||
user_id = cache.get(reset_key)
|
||||
|
||||
if not user_id:
|
||||
return Response(
|
||||
{'error': 'Invalid or expired token'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
except User.DoesNotExist:
|
||||
return Response(
|
||||
{'error': 'User not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
# Update password
|
||||
user.set_password(new_password)
|
||||
user.save(update_fields=['password'])
|
||||
|
||||
# Clear reset token
|
||||
cache.delete(reset_key)
|
||||
|
||||
# Blacklist all existing tokens
|
||||
jwt_service.blacklist_token('', blacklist_all_sessions=True)
|
||||
|
||||
logger.info(f"Password reset completed for user {user.id}")
|
||||
|
||||
return Response({'message': 'Password reset successfully'}, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Password reset confirmation error: {str(e)}")
|
||||
return Response(
|
||||
{'error': 'Password reset failed'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
summary="Magic Link",
|
||||
description="Generate magic link for email authentication",
|
||||
responses={200: {'type': 'object', 'properties': {'token': {'type': 'string'}}}},
|
||||
)
|
||||
@action(detail=False, methods=['post'])
|
||||
def magic_link(self, request: Request) -> Response:
|
||||
"""
|
||||
Generate magic link for email authentication.
|
||||
"""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
try:
|
||||
email = serializer.validated_data['email']
|
||||
tenant = getattr(request, 'tenant', None)
|
||||
|
||||
token = auth_backend.generate_magic_link(email, tenant)
|
||||
|
||||
# Send magic link email
|
||||
# This would integrate with email service
|
||||
logger.info(f"Magic link generated for {email}")
|
||||
|
||||
return Response({'token': token}, status=status.HTTP_200_OK)
|
||||
|
||||
except AuthenticationError as e:
|
||||
return Response(
|
||||
{'error': str(e)},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Magic link generation error: {str(e)}")
|
||||
return Response(
|
||||
{'error': 'Magic link generation failed'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
summary="Social Authentication",
|
||||
description="Authenticate with social providers",
|
||||
responses={200: AuthStatusSerializer},
|
||||
)
|
||||
@action(detail=False, methods=['post'])
|
||||
def social_auth(self, request: Request) -> Response:
|
||||
"""
|
||||
Authenticate with social providers (Google, Facebook).
|
||||
"""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
try:
|
||||
provider = serializer.validated_data['provider']
|
||||
access_token = serializer.validated_data['access_token']
|
||||
|
||||
# Map provider to authentication method
|
||||
method_map = {
|
||||
'google': 'google',
|
||||
'facebook': 'facebook',
|
||||
}
|
||||
|
||||
method = method_map.get(provider)
|
||||
if not method:
|
||||
return Response(
|
||||
{'error': f'Provider {provider} not supported'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Authenticate with social provider
|
||||
credentials = {'access_token': access_token}
|
||||
if 'id_token' in serializer.validated_data:
|
||||
credentials['id_token'] = serializer.validated_data['id_token']
|
||||
|
||||
user = auth_backend.authenticate(request, method=method, **credentials)
|
||||
|
||||
if not user:
|
||||
return Response(
|
||||
{'error': 'Social authentication failed'},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
|
||||
# Generate JWT tokens
|
||||
tenant = getattr(request, 'tenant', None) or getattr(user, 'tenant', None)
|
||||
device_info = {
|
||||
'user_agent': request.META.get('HTTP_USER_AGENT', ''),
|
||||
'ip_address': request.META.get('REMOTE_ADDR', ''),
|
||||
'device_type': self._get_device_type(request),
|
||||
}
|
||||
|
||||
tokens = jwt_service.generate_token_pair(user, tenant, device_info)
|
||||
|
||||
# Update user last login
|
||||
user.last_login = timezone.now()
|
||||
user.save(update_fields=['last_login'])
|
||||
|
||||
logger.info(f"User {user.id} logged in via {provider}")
|
||||
|
||||
return Response({
|
||||
'user': self._serialize_user(user),
|
||||
'tokens': tokens,
|
||||
'mfa_status': mfa_service.get_mfa_status(user),
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
except AuthenticationError as e:
|
||||
return Response(
|
||||
{'error': str(e)},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Social authentication error: {str(e)}")
|
||||
return Response(
|
||||
{'error': 'Social authentication failed'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
summary="Biometric Authentication",
|
||||
description="Authenticate with biometric data",
|
||||
responses={200: AuthStatusSerializer},
|
||||
)
|
||||
@action(detail=False, methods=['post'])
|
||||
def biometric_auth(self, request: Request) -> Response:
|
||||
"""
|
||||
Authenticate with biometric data.
|
||||
"""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
try:
|
||||
user_id = serializer.validated_data['user_id']
|
||||
biometric_token = serializer.validated_data['biometric_token']
|
||||
|
||||
user = auth_backend.authenticate(
|
||||
request,
|
||||
method='biometric',
|
||||
user_id=user_id,
|
||||
biometric_token=biometric_token
|
||||
)
|
||||
|
||||
if not user:
|
||||
return Response(
|
||||
{'error': 'Biometric authentication failed'},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
|
||||
# Generate JWT tokens
|
||||
tenant = getattr(request, 'tenant', None) or getattr(user, 'tenant', None)
|
||||
device_info = {
|
||||
'user_agent': request.META.get('HTTP_USER_AGENT', ''),
|
||||
'ip_address': request.META.get('REMOTE_ADDR', ''),
|
||||
'device_type': self._get_device_type(request),
|
||||
}
|
||||
|
||||
tokens = jwt_service.generate_token_pair(user, tenant, device_info)
|
||||
|
||||
# Update user last login
|
||||
user.last_login = timezone.now()
|
||||
user.save(update_fields=['last_login'])
|
||||
|
||||
logger.info(f"User {user.id} logged in via biometric authentication")
|
||||
|
||||
return Response({
|
||||
'user': self._serialize_user(user),
|
||||
'tokens': tokens,
|
||||
'mfa_status': mfa_service.get_mfa_status(user),
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
except AuthenticationError as e:
|
||||
return Response(
|
||||
{'error': str(e)},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Biometric authentication error: {str(e)}")
|
||||
return Response(
|
||||
{'error': 'Biometric authentication failed'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
summary="MFA Status",
|
||||
description="Get MFA status for current user",
|
||||
responses={200: {'type': 'object'}},
|
||||
)
|
||||
@action(detail=False, methods=['get'])
|
||||
def mfa_status(self, request: Request) -> Response:
|
||||
"""
|
||||
Get MFA status for current user.
|
||||
"""
|
||||
if not request.user.is_authenticated:
|
||||
return Response(
|
||||
{'error': 'Authentication required'},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
|
||||
try:
|
||||
mfa_status = mfa_service.get_mfa_status(request.user)
|
||||
return Response(mfa_status, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"MFA status error: {str(e)}")
|
||||
return Response(
|
||||
{'error': 'Failed to get MFA status'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
summary="Backup Codes",
|
||||
description="Generate new backup codes",
|
||||
responses={200: BackupCodeSerializer},
|
||||
)
|
||||
@action(detail=False, methods=['post'])
|
||||
def backup_codes(self, request: Request) -> Response:
|
||||
"""
|
||||
Generate new backup codes.
|
||||
"""
|
||||
if not request.user.is_authenticated:
|
||||
return Response(
|
||||
{'error': 'Authentication required'},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
|
||||
try:
|
||||
backup_codes = mfa_service.generate_backup_codes(request.user)
|
||||
return Response({'backup_codes': backup_codes}, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Backup codes generation error: {str(e)}")
|
||||
return Response(
|
||||
{'error': 'Failed to generate backup codes'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
summary="Disable MFA",
|
||||
description="Disable MFA for current user",
|
||||
responses={200: {'type': 'object', 'properties': {'message': {'type': 'string'}}}},
|
||||
)
|
||||
@action(detail=False, methods=['post'])
|
||||
def disable_mfa(self, request: Request) -> Response:
|
||||
"""
|
||||
Disable MFA for current user.
|
||||
"""
|
||||
if not request.user.is_authenticated:
|
||||
return Response(
|
||||
{'error': 'Authentication required'},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
|
||||
try:
|
||||
if mfa_service.disable_mfa(request.user):
|
||||
return Response({'message': 'MFA disabled successfully'}, status=status.HTTP_200_OK)
|
||||
else:
|
||||
return Response(
|
||||
{'error': 'Failed to disable MFA'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"MFA disable error: {str(e)}")
|
||||
return Response(
|
||||
{'error': 'Failed to disable MFA'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
# Helper methods
|
||||
|
||||
def _serialize_user(self, user: User) -> Dict[str, Any]:
|
||||
"""Serialize user data for API response."""
|
||||
return {
|
||||
'id': str(user.id),
|
||||
'email': user.email,
|
||||
'first_name': user.first_name,
|
||||
'last_name': user.last_name,
|
||||
'role': user.role,
|
||||
'is_active': user.is_active,
|
||||
'email_verified': user.email_verified,
|
||||
'phone_number': user.phone_number,
|
||||
'phone_verified': user.phone_verified,
|
||||
'malaysian_ic': user.malaysian_ic,
|
||||
'tenant_id': str(user.tenant.id) if user.tenant else None,
|
||||
'last_login': user.last_login,
|
||||
'created_at': user.created_at,
|
||||
'updated_at': user.updated_at,
|
||||
}
|
||||
|
||||
def _get_device_type(self, request: Request) -> str:
|
||||
"""Detect device type from user agent."""
|
||||
user_agent = request.META.get('HTTP_USER_AGENT', '').lower()
|
||||
|
||||
if 'mobile' in user_agent:
|
||||
return 'mobile'
|
||||
elif 'tablet' in user_agent:
|
||||
return 'tablet'
|
||||
elif 'desktop' in user_agent:
|
||||
return 'desktop'
|
||||
else:
|
||||
return 'unknown'
|
||||
|
||||
|
||||
# View functions for specific endpoints
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def auth_status(request: Request) -> Response:
|
||||
"""
|
||||
Get authentication status for current user.
|
||||
"""
|
||||
try:
|
||||
user = request.user
|
||||
return Response({
|
||||
'authenticated': True,
|
||||
'user': {
|
||||
'id': str(user.id),
|
||||
'email': user.email,
|
||||
'first_name': user.first_name,
|
||||
'last_name': user.last_name,
|
||||
'role': user.role,
|
||||
'tenant_id': str(user.tenant.id) if user.tenant else None,
|
||||
},
|
||||
'mfa_status': mfa_service.get_mfa_status(user),
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Auth status error: {str(e)}")
|
||||
return Response(
|
||||
{'error': 'Failed to get auth status'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([AllowAny])
|
||||
def verify_email(request: Request) -> Response:
|
||||
"""
|
||||
Verify email address with OTP code.
|
||||
"""
|
||||
try:
|
||||
email = request.data.get('email')
|
||||
code = request.data.get('code')
|
||||
|
||||
if not email or not code:
|
||||
return Response(
|
||||
{'error': 'Email and code are required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Verify email OTP
|
||||
if not auth_backend.verify_registration_otp(email, email_otp=code):
|
||||
return Response(
|
||||
{'error': 'Invalid verification code'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Update user email verification status
|
||||
try:
|
||||
user = User.objects.get(email__iexact=email)
|
||||
user.email_verified = True
|
||||
user.save(update_fields=['email_verified'])
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
|
||||
return Response({'message': 'Email verified successfully'}, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Email verification error: {str(e)}")
|
||||
return Response(
|
||||
{'error': 'Email verification failed'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([AllowAny])
|
||||
def verify_phone(request: Request) -> Response:
|
||||
"""
|
||||
Verify phone number with OTP code.
|
||||
"""
|
||||
try:
|
||||
phone_number = request.data.get('phone_number')
|
||||
code = request.data.get('code')
|
||||
|
||||
if not phone_number or not code:
|
||||
return Response(
|
||||
{'error': 'Phone number and code are required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Verify phone OTP
|
||||
if not auth_backend.verify_registration_otp('', phone_otp=code, phone_number=phone_number):
|
||||
return Response(
|
||||
{'error': 'Invalid verification code'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Update user phone verification status
|
||||
try:
|
||||
user = User.objects.get(phone_number=phone_number)
|
||||
user.phone_verified = True
|
||||
user.save(update_fields=['phone_verified'])
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
|
||||
return Response({'message': 'Phone number verified successfully'}, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Phone verification error: {str(e)}")
|
||||
return Response(
|
||||
{'error': 'Phone verification failed'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
Reference in New Issue
Block a user