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

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,
)