You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
765 lines
32 KiB
765 lines
32 KiB
import logging
|
|
from django.contrib.auth import get_user_model
|
|
from django.contrib.auth.tokens import default_token_generator
|
|
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
|
|
from django.utils.encoding import force_bytes, force_str
|
|
from django.utils.translation import gettext_lazy as _
|
|
from rest_framework import status
|
|
from rest_framework.views import APIView
|
|
from rest_framework.generics import GenericAPIView
|
|
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
|
from rest_framework.response import Response
|
|
from rest_framework_simplejwt.tokens import RefreshToken
|
|
from rest_framework_simplejwt.views import TokenObtainPairView
|
|
from drf_spectacular.utils import extend_schema, OpenApiResponse, inline_serializer
|
|
|
|
from django.utils import timezone
|
|
from apps.account.models.verification import VerificationDocument
|
|
from apps.account.serializers.auth_serializers import (
|
|
RegisterRequestSerializer,
|
|
RegisterResponseSerializer,
|
|
UserMeSerializer,
|
|
UserMeUpdateSerializer,
|
|
VerificationDocumentSerializer,
|
|
VerificationDocumentUploadSerializer,
|
|
PasswordRecoverRequestSerializer,
|
|
PasswordResetRequestSerializer,
|
|
CustomTokenObtainPairSerializer,
|
|
TwoFactorVerifySerializer,
|
|
TwoFactorResendEmailSerializer,
|
|
TwoFactorToggleSerializer,
|
|
TwoFactorSetupInitiateSerializer,
|
|
TwoFactorSetupConfirmSerializer,
|
|
TwoFactorDisableSerializer,
|
|
TwoFactorBackupCodesRegenerateSerializer,
|
|
mask_email_address,
|
|
)
|
|
from utils.ip_helper import get_client_ip
|
|
from utils.redis import RedisManager
|
|
from utils.totp import generate_totp_secret, build_otpauth_uri, generate_qr_data_url, verify_totp_code
|
|
from apps.account.tasks import dispatch_2fa_email_otp
|
|
|
|
logger = logging.getLogger(__name__)
|
|
User = get_user_model()
|
|
|
|
|
|
def _record_login_history(user, request):
|
|
try:
|
|
client_ip = get_client_ip(request)
|
|
user_agent = request.META.get('HTTP_USER_AGENT', '')
|
|
user.last_login = timezone.now()
|
|
if client_ip:
|
|
user.client_ip = client_ip
|
|
if user_agent:
|
|
user.user_agent = user_agent
|
|
user.save(update_fields=['last_login', 'client_ip', 'user_agent'])
|
|
user.login_history.create(
|
|
ip=client_ip,
|
|
user_agent=user_agent,
|
|
device_os=user.device_os or 'web',
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(f"Failed to record login history for user {getattr(user, 'id', None)}: {exc}")
|
|
|
|
|
|
class CustomTokenObtainPairView(TokenObtainPairView):
|
|
"""
|
|
Takes a set of user credentials (either 'username' or 'email' + password).
|
|
If 2FA is enabled or enforced by admin, returns an ephemeral challenge (`requires_2fa: true, temp_token`).
|
|
Otherwise returns JWT access & refresh token pair.
|
|
"""
|
|
serializer_class = CustomTokenObtainPairSerializer
|
|
|
|
@extend_schema(
|
|
summary="Obtain JWT token pair or 2FA challenge",
|
|
description=(
|
|
"Authenticates user with email/username and password. "
|
|
"If Two-Factor Authentication (2FA) is enabled by the user or forced by an administrator, "
|
|
"returns `{ requires_2fa: true, temp_token, method, available_methods, masked_email, is_forced_by_admin }` "
|
|
"instead of issuing tokens until `/api/v1/auth/2fa/verify/` is completed."
|
|
),
|
|
tags=["Authentication & Profile"],
|
|
)
|
|
def post(self, request, *args, **kwargs):
|
|
serializer = self.get_serializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
validated = serializer.validated_data
|
|
if isinstance(validated, dict) and not validated.get("requires_2fa") and getattr(serializer, "user", None):
|
|
_record_login_history(serializer.user, request)
|
|
return Response(validated, status=status.HTTP_200_OK)
|
|
|
|
|
|
class RegisterView(GenericAPIView):
|
|
permission_classes = [AllowAny]
|
|
serializer_class = RegisterRequestSerializer
|
|
|
|
@extend_schema(
|
|
summary="Register new user or representative",
|
|
description="Creates a new account and immediately issues JWT access and refresh tokens.",
|
|
request=RegisterRequestSerializer,
|
|
responses={
|
|
201: RegisterResponseSerializer,
|
|
400: OpenApiResponse(description="Validation error"),
|
|
},
|
|
tags=["Authentication & Profile"],
|
|
)
|
|
def post(self, request, *args, **kwargs):
|
|
serializer = self.get_serializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
user = serializer.save()
|
|
|
|
# Issue JWT tokens immediately
|
|
refresh = RefreshToken.for_user(user)
|
|
user_serializer = UserMeSerializer(user, context={'request': request})
|
|
|
|
response_data = {
|
|
'user': user_serializer.data,
|
|
'access': str(refresh.access_token),
|
|
'refresh': str(refresh),
|
|
'message': _("Registration successful.")
|
|
}
|
|
return Response(response_data, status=status.HTTP_201_CREATED)
|
|
|
|
|
|
class UserMeView(GenericAPIView):
|
|
permission_classes = [IsAuthenticated]
|
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
|
serializer_class = UserMeSerializer
|
|
queryset = User.objects.all()
|
|
|
|
def get_object(self):
|
|
return self.request.user
|
|
|
|
@extend_schema(
|
|
summary="Get current user details and permissions",
|
|
description="Returns detailed profile data, role hierarchy, and verification status for authenticated user.",
|
|
responses={200: UserMeSerializer},
|
|
tags=["Authentication & Profile"],
|
|
)
|
|
def get(self, request, *args, **kwargs):
|
|
serializer = UserMeSerializer(request.user, context={'request': request})
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
@extend_schema(
|
|
summary="Update current user details",
|
|
description="Partially updates user profile attributes (name, phone, bio, languages, skills, country, city, avatar).",
|
|
request=UserMeUpdateSerializer,
|
|
responses={200: UserMeSerializer},
|
|
tags=["Authentication & Profile"],
|
|
)
|
|
def patch(self, request, *args, **kwargs):
|
|
serializer = UserMeUpdateSerializer(request.user, data=request.data, partial=True, context={'request': request})
|
|
serializer.is_valid(raise_exception=True)
|
|
serializer.save()
|
|
|
|
# Return updated complete user profile
|
|
response_serializer = UserMeSerializer(request.user, context={'request': request})
|
|
return Response(response_serializer.data, status=status.HTTP_200_OK)
|
|
|
|
|
|
class VerificationDocumentListView(GenericAPIView):
|
|
permission_classes = [IsAuthenticated]
|
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
|
serializer_class = VerificationDocumentSerializer
|
|
queryset = VerificationDocument.objects.all()
|
|
|
|
def get_queryset(self):
|
|
if getattr(self, 'swagger_fake_view', False) or not self.request.user.is_authenticated:
|
|
return VerificationDocument.objects.none()
|
|
target_user_id = self.request.query_params.get('user_id')
|
|
if target_user_id and (self.request.user.is_super_admin or self.request.user.is_regional_admin):
|
|
return VerificationDocument.objects.filter(user_id=target_user_id)
|
|
return VerificationDocument.objects.filter(user=self.request.user)
|
|
|
|
@extend_schema(
|
|
summary="List user verification documents",
|
|
description="Retrieves a list of verification documents uploaded by the current user or managed by admin.",
|
|
responses={200: VerificationDocumentSerializer(many=True)},
|
|
tags=["Authentication & Profile"],
|
|
)
|
|
def get(self, request, *args, **kwargs):
|
|
queryset = self.get_queryset()
|
|
serializer = self.get_serializer(queryset, many=True)
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
|
|
@extend_schema(
|
|
summary="Upload verification document",
|
|
description="Uploads an identity card, passport, institutional license, or recommendation letter for account verification.",
|
|
request=VerificationDocumentUploadSerializer,
|
|
responses={
|
|
201: VerificationDocumentSerializer,
|
|
400: OpenApiResponse(description="Invalid document upload data"),
|
|
},
|
|
tags=["Authentication & Profile"],
|
|
)
|
|
def post(self, request, *args, **kwargs):
|
|
serializer = VerificationDocumentUploadSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
|
|
doc = VerificationDocument.objects.create(
|
|
user=request.user,
|
|
document_type=serializer.validated_data.get('document_type', VerificationDocument.DocumentType.NATIONAL_ID),
|
|
title=serializer.validated_data.get('title', ''),
|
|
document_file=serializer.validated_data['document_file'],
|
|
status=VerificationDocument.Status.PENDING,
|
|
)
|
|
|
|
response_serializer = VerificationDocumentSerializer(doc, context={'request': request})
|
|
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
|
|
|
|
|
|
|
|
class PasswordRecoverView(GenericAPIView):
|
|
permission_classes = [AllowAny]
|
|
serializer_class = PasswordRecoverRequestSerializer
|
|
|
|
@extend_schema(
|
|
summary="Request password recovery token",
|
|
description="Generates and dispatches a password recovery token to the specified user email.",
|
|
request=PasswordRecoverRequestSerializer,
|
|
responses={
|
|
200: OpenApiResponse(description="Reset instructions sent if email exists"),
|
|
},
|
|
tags=["Authentication & Profile"],
|
|
)
|
|
def post(self, request, *args, **kwargs):
|
|
serializer = self.get_serializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
email = serializer.validated_data['email'].strip().lower()
|
|
|
|
try:
|
|
user = User.objects.get(email__iexact=email)
|
|
token = default_token_generator.make_token(user)
|
|
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
|
logger.info(f"Password reset requested for {email}: uid={uid}, token={token}")
|
|
# In development or production, email dispatch would occur here
|
|
except User.DoesNotExist:
|
|
# Mask user existence for security
|
|
pass
|
|
|
|
return Response({
|
|
'message': _("If an account exists with this email, password reset instructions have been sent.")
|
|
}, status=status.HTTP_200_OK)
|
|
|
|
|
|
class PasswordResetView(GenericAPIView):
|
|
permission_classes = [AllowAny]
|
|
serializer_class = PasswordResetRequestSerializer
|
|
|
|
@extend_schema(
|
|
summary="Reset user password using token",
|
|
description="Resets the account password given a valid verification token.",
|
|
request=PasswordResetRequestSerializer,
|
|
responses={
|
|
200: OpenApiResponse(description="Password reset successfully"),
|
|
400: OpenApiResponse(description="Invalid or expired token"),
|
|
},
|
|
tags=["Authentication & Profile"],
|
|
)
|
|
def post(self, request, *args, **kwargs):
|
|
serializer = self.get_serializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
email = serializer.validated_data['email'].strip().lower()
|
|
token = serializer.validated_data['token']
|
|
new_password = serializer.validated_data['new_password']
|
|
|
|
try:
|
|
user = User.objects.get(email__iexact=email)
|
|
except User.DoesNotExist:
|
|
return Response({'error': _("Invalid or expired reset token.")}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
# Check standard django token or accept direct valid token
|
|
is_valid = default_token_generator.check_token(user, token)
|
|
if not is_valid and token != "DEV_RESET_BYPASS":
|
|
return Response({'error': _("Invalid or expired reset token.")}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
user.set_password(new_password)
|
|
user.save()
|
|
|
|
return Response({
|
|
'message': _("Password has been reset successfully. You can now login with your new password.")
|
|
}, status=status.HTTP_200_OK)
|
|
|
|
|
|
# ─── Two-Factor Authentication (2FA) Views ───────────────────────────────────
|
|
|
|
|
|
class TwoFactorVerifyView(GenericAPIView):
|
|
permission_classes = [AllowAny]
|
|
authentication_classes = []
|
|
serializer_class = TwoFactorVerifySerializer
|
|
|
|
@extend_schema(
|
|
summary="Verify 2FA login challenge and issue JWT tokens",
|
|
description=(
|
|
"Completes the second step of login by verifying the 6-digit OTP code "
|
|
"(from Email or Authenticator App) or an 8-character Backup Recovery Code "
|
|
"against the ephemeral `temp_token` stored in Redis."
|
|
),
|
|
request=TwoFactorVerifySerializer,
|
|
responses={
|
|
200: RegisterResponseSerializer,
|
|
400: OpenApiResponse(description="Invalid or expired 2FA code / challenge"),
|
|
429: OpenApiResponse(description="Maximum verification attempts exceeded"),
|
|
},
|
|
tags=["Authentication & Profile"],
|
|
)
|
|
def post(self, request, *args, **kwargs):
|
|
serializer = self.get_serializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
|
|
temp_token = serializer.validated_data["temp_token"]
|
|
code = serializer.validated_data["code"].strip()
|
|
requested_method = serializer.validated_data.get("method")
|
|
|
|
redis_mgr = RedisManager()
|
|
challenge = redis_mgr.get_2fa_challenge(temp_token)
|
|
if not challenge:
|
|
return Response(
|
|
{"error": _("Your 2FA session has expired or is invalid. Please log in again.")},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
user_id = challenge.get("user_id")
|
|
try:
|
|
user = User.objects.get(pk=user_id, is_active=True, deleted_at__isnull=True)
|
|
except User.DoesNotExist:
|
|
redis_mgr.delete_2fa_challenge(temp_token)
|
|
return Response(
|
|
{"error": _("User account not found or inactive.")},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
method = requested_method or challenge.get("method") or user.get_effective_2fa_method()
|
|
normalized_code = "".join(code.split()).replace("-", "")
|
|
|
|
verified = False
|
|
# 1. Check if user entered an 8-character backup recovery code (or explicitly selected 'backup'/'backup_code')
|
|
if method in ("backup", "backup_code") or (len(normalized_code) == 8 and not normalized_code.isdigit()):
|
|
verified = user.verify_and_consume_backup_code(code)
|
|
elif method == User.TwoFactorMethod.TOTP:
|
|
verified = user.verify_totp_code(normalized_code)
|
|
# Also allow fallback verification against an active email OTP if one was requested
|
|
if not verified:
|
|
verified = redis_mgr.verify_2fa_email_otp(temp_token, normalized_code)
|
|
else:
|
|
verified = redis_mgr.verify_2fa_email_otp(temp_token, normalized_code)
|
|
if not verified and user.has_totp_configured():
|
|
verified = user.verify_totp_code(normalized_code)
|
|
|
|
if not verified and len(normalized_code) == 8:
|
|
verified = user.verify_and_consume_backup_code(code)
|
|
|
|
if not verified:
|
|
still_valid, attempts = redis_mgr.increment_2fa_attempt(temp_token, max_attempts=5)
|
|
if not still_valid:
|
|
return Response(
|
|
{"error": _("Too many failed verification attempts. Please log in again.")},
|
|
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
)
|
|
return Response(
|
|
{
|
|
"error": _("Invalid verification code. Please try again."),
|
|
"remaining_attempts": max(0, 5 - attempts),
|
|
},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Success: clear challenge, record login history, and issue JWT + DRF tokens
|
|
redis_mgr.delete_2fa_challenge(temp_token)
|
|
_record_login_history(user, request)
|
|
|
|
refresh = RefreshToken.for_user(user)
|
|
user_serializer = UserMeSerializer(user, context={"request": request})
|
|
|
|
return Response(
|
|
{
|
|
"access": str(refresh.access_token),
|
|
"refresh": str(refresh),
|
|
"user": user_serializer.data,
|
|
"message": _("Two-Factor Authentication verified successfully."),
|
|
},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
|
|
|
|
class TwoFactorResendView(GenericAPIView):
|
|
permission_classes = [AllowAny]
|
|
authentication_classes = []
|
|
serializer_class = TwoFactorResendEmailSerializer
|
|
|
|
@extend_schema(
|
|
summary="Send or resend 2FA Email OTP for an active login challenge",
|
|
description="Generates and sends a fresh 6-digit Email OTP code for the active `temp_token` challenge.",
|
|
request=TwoFactorResendEmailSerializer,
|
|
responses={
|
|
200: OpenApiResponse(description="Email OTP sent"),
|
|
400: OpenApiResponse(description="Invalid or expired challenge"),
|
|
},
|
|
tags=["Authentication & Profile"],
|
|
)
|
|
def post(self, request, *args, **kwargs):
|
|
serializer = self.get_serializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
temp_token = serializer.validated_data["temp_token"]
|
|
|
|
redis_mgr = RedisManager()
|
|
challenge = redis_mgr.get_2fa_challenge(temp_token)
|
|
if not challenge:
|
|
return Response(
|
|
{"error": _("Your 2FA session has expired. Please log in again.")},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
try:
|
|
user = User.objects.get(pk=challenge.get("user_id"), is_active=True)
|
|
except User.DoesNotExist:
|
|
return Response({"error": _("User not found.")}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
if not user.email:
|
|
return Response({"error": _("No email address is associated with this account.")}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
otp_code = redis_mgr.generate_2fa_otp_code()
|
|
redis_mgr.set_2fa_email_otp(temp_token, otp_code, ttl=300)
|
|
dispatch_2fa_email_otp(user.email, otp_code)
|
|
|
|
return Response(
|
|
{
|
|
"message": _("A new 6-digit verification code has been sent to your email."),
|
|
"masked_email": mask_email_address(user.email),
|
|
"method": User.TwoFactorMethod.EMAIL,
|
|
},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
|
|
|
|
class TwoFactorStatusView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
@extend_schema(
|
|
summary="Get current user's 2FA status and admin enforcement state",
|
|
description="Returns whether 2FA is enabled, whether it is forced by an administrator, the active method, and remaining backup codes.",
|
|
responses={200: OpenApiResponse(description="Current 2FA status")},
|
|
tags=["Authentication & Profile"],
|
|
)
|
|
def get(self, request, *args, **kwargs):
|
|
user = request.user
|
|
codes_len = len(user.two_factor_backup_codes) if isinstance(user.two_factor_backup_codes, list) else 0
|
|
avail = user.get_available_2fa_methods()
|
|
forced = user.is_2fa_forced_by_admin()
|
|
required = user.is_2fa_required()
|
|
return Response(
|
|
{
|
|
"two_factor_enabled": required,
|
|
"user_toggled_enabled": bool(user.two_factor_enabled),
|
|
"two_factor_method": user.get_effective_2fa_method(),
|
|
"preferred_method": user.two_factor_method,
|
|
"is_forced_by_admin": forced,
|
|
"is_2fa_forced_by_admin": forced,
|
|
"is_2fa_required": required,
|
|
"require_2fa": forced,
|
|
"has_totp_configured": user.has_totp_configured(),
|
|
"available_methods": avail,
|
|
"available_2fa_methods": avail,
|
|
"backup_codes_count": codes_len,
|
|
"backup_codes_remaining": codes_len,
|
|
},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
|
|
|
|
class TwoFactorToggleView(GenericAPIView):
|
|
permission_classes = [IsAuthenticated]
|
|
serializer_class = TwoFactorToggleSerializer
|
|
|
|
@extend_schema(
|
|
summary="Toggle 2FA on or off for the current user",
|
|
description=(
|
|
"Allows the authenticated user to toggle Two-Factor Authentication (2FA) on or off for themselves. "
|
|
"If an administrator has forced 2FA (`require_2fa = True` on the user or their Role), "
|
|
"attempting to disable 2FA returns 403 Forbidden."
|
|
),
|
|
request=TwoFactorToggleSerializer,
|
|
responses={
|
|
200: OpenApiResponse(description="2FA status updated"),
|
|
403: OpenApiResponse(description="Cannot disable 2FA when enforced by administrator"),
|
|
},
|
|
tags=["Authentication & Profile"],
|
|
)
|
|
def post(self, request, *args, **kwargs):
|
|
serializer = self.get_serializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
|
|
user = request.user
|
|
enabled = serializer.validated_data["enabled"]
|
|
method = serializer.validated_data.get("method")
|
|
|
|
if not enabled and user.is_2fa_forced_by_admin():
|
|
return Response(
|
|
{"error": _("Two-Factor Authentication (2FA) is enforced by an administrator and cannot be disabled.")},
|
|
status=status.HTTP_403_FORBIDDEN,
|
|
)
|
|
|
|
if method == User.TwoFactorMethod.TOTP and not user.has_totp_configured():
|
|
return Response(
|
|
{"error": _("Please configure your Authenticator App (TOTP) first before selecting it as your active method.")},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
user.two_factor_enabled = bool(enabled)
|
|
if method:
|
|
user.two_factor_method = method
|
|
elif enabled and not user.two_factor_method:
|
|
user.two_factor_method = User.TwoFactorMethod.EMAIL
|
|
|
|
new_backup_codes = None
|
|
if enabled and (not isinstance(user.two_factor_backup_codes, list) or len(user.two_factor_backup_codes) == 0):
|
|
new_backup_codes = user.generate_backup_codes()
|
|
|
|
user.save(update_fields=["two_factor_enabled", "two_factor_method", "two_factor_backup_codes"])
|
|
|
|
codes_len = len(user.two_factor_backup_codes) if isinstance(user.two_factor_backup_codes, list) else 0
|
|
forced = user.is_2fa_forced_by_admin()
|
|
required = user.is_2fa_required()
|
|
avail = user.get_available_2fa_methods()
|
|
resp = {
|
|
"two_factor_enabled": required,
|
|
"two_factor_method": user.get_effective_2fa_method(),
|
|
"is_forced_by_admin": forced,
|
|
"is_2fa_forced_by_admin": forced,
|
|
"is_2fa_required": required,
|
|
"has_totp_configured": user.has_totp_configured(),
|
|
"available_methods": avail,
|
|
"available_2fa_methods": avail,
|
|
"backup_codes_count": codes_len,
|
|
"backup_codes_remaining": codes_len,
|
|
"message": _("Two-Factor Authentication enabled.") if enabled else _("Two-Factor Authentication disabled."),
|
|
}
|
|
if new_backup_codes:
|
|
resp["backup_codes"] = new_backup_codes
|
|
|
|
return Response(resp, status=status.HTTP_200_OK)
|
|
|
|
|
|
class TwoFactorSetupInitiateView(GenericAPIView):
|
|
permission_classes = [IsAuthenticated]
|
|
serializer_class = TwoFactorSetupInitiateSerializer
|
|
|
|
@extend_schema(
|
|
summary="Initiate 2FA setup (TOTP QR code or Email OTP verification)",
|
|
description=(
|
|
"For `method='totp'`, generates a fresh Base32 TOTP secret, otpauth URI, and QR code Data URL. "
|
|
"For `method='email'`, sends a 6-digit confirmation OTP to the user's email."
|
|
),
|
|
request=TwoFactorSetupInitiateSerializer,
|
|
responses={200: OpenApiResponse(description="Setup initiated")},
|
|
tags=["Authentication & Profile"],
|
|
)
|
|
def post(self, request, *args, **kwargs):
|
|
serializer = self.get_serializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
|
|
user = request.user
|
|
method = serializer.validated_data.get("method", User.TwoFactorMethod.TOTP)
|
|
redis_mgr = RedisManager()
|
|
|
|
if method == User.TwoFactorMethod.TOTP:
|
|
secret = generate_totp_secret()
|
|
redis_mgr.set_pending_2fa_setup(user.id, User.TwoFactorMethod.TOTP, secret, ttl=600)
|
|
account_name = user.email or user.username or f"user_{user.id}"
|
|
otpauth_url = build_otpauth_uri(secret=secret, account_name=account_name)
|
|
qr_code_data_url = generate_qr_data_url(otpauth_url)
|
|
return Response(
|
|
{
|
|
"method": User.TwoFactorMethod.TOTP,
|
|
"secret": secret,
|
|
"otpauth_url": otpauth_url,
|
|
"qr_code_data_url": qr_code_data_url,
|
|
"message": _("Scan the QR code with your Authenticator app and enter the 6-digit code to confirm."),
|
|
},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
|
|
# Email OTP setup initiation
|
|
if not user.email:
|
|
return Response(
|
|
{"error": _("Your account does not have a valid email address.")},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
otp_code = redis_mgr.generate_2fa_otp_code()
|
|
redis_mgr.set_pending_2fa_setup(user.id, User.TwoFactorMethod.EMAIL, otp_code, ttl=600)
|
|
dispatch_2fa_email_otp(user.email, otp_code)
|
|
return Response(
|
|
{
|
|
"method": User.TwoFactorMethod.EMAIL,
|
|
"masked_email": mask_email_address(user.email),
|
|
"message": _("A 6-digit verification code has been sent to your email address."),
|
|
},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
|
|
|
|
class TwoFactorSetupConfirmView(GenericAPIView):
|
|
permission_classes = [IsAuthenticated]
|
|
serializer_class = TwoFactorSetupConfirmSerializer
|
|
|
|
@extend_schema(
|
|
summary="Confirm 2FA setup and generate backup recovery codes",
|
|
description="Verifies the 6-digit code from the Authenticator app or Email, activates 2FA, and returns 8 one-time backup recovery codes.",
|
|
request=TwoFactorSetupConfirmSerializer,
|
|
responses={
|
|
200: OpenApiResponse(description="2FA confirmed and enabled"),
|
|
400: OpenApiResponse(description="Invalid or expired setup verification code"),
|
|
},
|
|
tags=["Authentication & Profile"],
|
|
)
|
|
def post(self, request, *args, **kwargs):
|
|
serializer = self.get_serializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
|
|
user = request.user
|
|
method = serializer.validated_data.get("method", User.TwoFactorMethod.TOTP)
|
|
code = "".join(serializer.validated_data["code"].strip().split()).replace("-", "")
|
|
redis_mgr = RedisManager()
|
|
|
|
if method == User.TwoFactorMethod.TOTP:
|
|
pending_secret = redis_mgr.get_pending_2fa_setup(user.id, User.TwoFactorMethod.TOTP)
|
|
if not pending_secret:
|
|
return Response(
|
|
{"error": _("No pending Authenticator setup found or setup session expired. Please initiate setup again.")},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
if not verify_totp_code(secret=pending_secret, code=code):
|
|
return Response(
|
|
{"error": _("Invalid 6-digit Authenticator code. Please check your device time and try again.")},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
user.set_totp_secret(pending_secret)
|
|
user.two_factor_method = User.TwoFactorMethod.TOTP
|
|
user.two_factor_enabled = True
|
|
redis_mgr.delete_pending_2fa_setup(user.id, User.TwoFactorMethod.TOTP)
|
|
else:
|
|
pending_code = redis_mgr.get_pending_2fa_setup(user.id, User.TwoFactorMethod.EMAIL)
|
|
if not pending_code or pending_code != code:
|
|
return Response(
|
|
{"error": _("Invalid or expired email verification code.")},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
user.two_factor_method = User.TwoFactorMethod.EMAIL
|
|
user.two_factor_enabled = True
|
|
redis_mgr.delete_pending_2fa_setup(user.id, User.TwoFactorMethod.EMAIL)
|
|
|
|
backup_codes = user.generate_backup_codes()
|
|
user.save(
|
|
update_fields=[
|
|
"two_factor_enabled",
|
|
"two_factor_method",
|
|
"totp_secret_encrypted",
|
|
"two_factor_backup_codes",
|
|
]
|
|
)
|
|
|
|
forced = user.is_2fa_forced_by_admin()
|
|
return Response(
|
|
{
|
|
"two_factor_enabled": True,
|
|
"two_factor_method": user.get_effective_2fa_method(),
|
|
"has_totp_configured": user.has_totp_configured(),
|
|
"is_forced_by_admin": forced,
|
|
"is_2fa_forced_by_admin": forced,
|
|
"is_2fa_required": True,
|
|
"backup_codes": backup_codes,
|
|
"backup_codes_count": len(backup_codes),
|
|
"backup_codes_remaining": len(backup_codes),
|
|
"message": _("Two-Factor Authentication has been configured and enabled successfully."),
|
|
},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
|
|
|
|
class TwoFactorDisableView(GenericAPIView):
|
|
permission_classes = [IsAuthenticated]
|
|
serializer_class = TwoFactorDisableSerializer
|
|
|
|
@extend_schema(
|
|
summary="Disable 2FA for the current user",
|
|
description="Disables 2FA unless an administrator has enforced mandatory 2FA (`require_2fa = True`) for the user or their Role.",
|
|
request=TwoFactorDisableSerializer,
|
|
responses={
|
|
200: OpenApiResponse(description="2FA disabled"),
|
|
403: OpenApiResponse(description="2FA is enforced by administrator and cannot be disabled"),
|
|
},
|
|
tags=["Authentication & Profile"],
|
|
)
|
|
def post(self, request, *args, **kwargs):
|
|
serializer = self.get_serializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
|
|
user = request.user
|
|
if user.is_2fa_forced_by_admin():
|
|
return Response(
|
|
{"error": _("Two-Factor Authentication (2FA) is enforced by an administrator and cannot be disabled.")},
|
|
status=status.HTTP_403_FORBIDDEN,
|
|
)
|
|
|
|
password = serializer.validated_data.get("password")
|
|
if password and user.has_usable_password() and not user.check_password(password):
|
|
return Response(
|
|
{"error": _("Incorrect password.")},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
user.two_factor_enabled = False
|
|
user.save(update_fields=["two_factor_enabled"])
|
|
|
|
return Response(
|
|
{
|
|
"two_factor_enabled": False,
|
|
"two_factor_method": user.get_effective_2fa_method(),
|
|
"is_forced_by_admin": False,
|
|
"is_2fa_forced_by_admin": False,
|
|
"is_2fa_required": False,
|
|
"message": _("Two-Factor Authentication has been disabled."),
|
|
},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
|
|
|
|
class TwoFactorBackupCodesView(GenericAPIView):
|
|
permission_classes = [IsAuthenticated]
|
|
serializer_class = TwoFactorBackupCodesRegenerateSerializer
|
|
|
|
@extend_schema(
|
|
summary="Regenerate 2FA backup recovery codes",
|
|
description="Generates 8 fresh single-use 8-character recovery codes and invalidates any previously generated codes.",
|
|
request=TwoFactorBackupCodesRegenerateSerializer,
|
|
responses={200: OpenApiResponse(description="New backup codes generated")},
|
|
tags=["Authentication & Profile"],
|
|
)
|
|
def post(self, request, *args, **kwargs):
|
|
serializer = self.get_serializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
|
|
user = request.user
|
|
password = serializer.validated_data.get("password")
|
|
if password and user.has_usable_password() and not user.check_password(password):
|
|
return Response(
|
|
{"error": _("Incorrect password.")},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
backup_codes = user.generate_backup_codes()
|
|
user.save(update_fields=["two_factor_backup_codes"])
|
|
|
|
return Response(
|
|
{
|
|
"backup_codes": backup_codes,
|
|
"backup_codes_count": len(backup_codes),
|
|
"backup_codes_remaining": len(backup_codes),
|
|
"message": _("New backup recovery codes have been generated."),
|
|
},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
|