From 9bb1f933f6f967461a8cdc41a9a1d3deb13b27e6 Mon Sep 17 00:00:00 2001 From: vikrantwiz02 Date: Wed, 4 Mar 2026 12:07:27 +0530 Subject: [PATCH 1/2] OTP Based Password Reset --- FusionIIIT/applications/globals/api/urls.py | 38 +- FusionIIIT/applications/globals/api/views.py | 493 +++++++++++++----- .../migrations/0006_auto_20260304_0836.py | 31 ++ FusionIIIT/applications/globals/models.py | 37 ++ 4 files changed, 465 insertions(+), 134 deletions(-) create mode 100644 FusionIIIT/applications/globals/migrations/0006_auto_20260304_0836.py diff --git a/FusionIIIT/applications/globals/api/urls.py b/FusionIIIT/applications/globals/api/urls.py index f78aeca97..b14c15b64 100644 --- a/FusionIIIT/applications/globals/api/urls.py +++ b/FusionIIIT/applications/globals/api/urls.py @@ -1,25 +1,31 @@ -from django.conf.urls import url +from django.urls import re_path from . import views urlpatterns = [ - url(r'^auth/login/', views.login, name='login-api'), - url(r'^auth/logout/', views.logout, name='logout-api'), - url(r'^auth/me', views.auth_view, name='auth-api'), - url(r'^update-role/', views.update_last_selected_role, name='update_last_selected_role'), - + re_path(r'^auth/login/', views.login, name='login-api'), + re_path(r'^auth/logout/', views.logout, name='logout-api'), + re_path(r'^auth/me', views.auth_view, name='auth-api'), + + # OTP-based password reset (no authentication required) + re_path(r'^auth/password-reset/send-otp/', views.password_reset_send_otp, name='password-reset-send-otp'), + re_path(r'^auth/password-reset/verify-otp/', views.password_reset_verify_otp, name='password-reset-verify-otp'), + re_path(r'^auth/password-reset/reset/', views.password_reset_reset, name='password-reset-reset'), + + re_path(r'^update-role/', views.update_last_selected_role, name='update_last_selected_role'), + # Profile endpoints - url(r'^profile/(?P.+)/', views.profile, name='profile-api'), - url(r'^profile/', views.profile, name='profile-api'), - url(r'^profile_update/', views.profile_update, name='update-profile-api'), - url(r'^profile_delete/(?P[0-9]+)/', views.profile_delete, name='delete-profile-api'), + re_path(r'^profile/(?P.+)/', views.profile, name='profile-api'), + re_path(r'^profile/', views.profile, name='profile-api'), + re_path(r'^profile_update/', views.profile_update, name='update-profile-api'), + re_path(r'^profile_delete/(?P[0-9]+)/', views.profile_delete, name='delete-profile-api'), # Notification endpoints - url(r'^notification/',views.notification,name='notification'), - url(r'^notificationread',views.NotificationRead,name='notifications-read'), - url(r'^notificationdelete',views.delete_notification,name='notifications-delete'), - url(r'^notificationunread',views.NotificationUnread,name='notifications-unread'), - + re_path(r'^notification/', views.notification, name='notification'), + re_path(r'^notificationread', views.NotificationRead, name='notifications-read'), + re_path(r'^notificationdelete', views.delete_notification, name='notifications-delete'), + re_path(r'^notificationunread', views.NotificationUnread, name='notifications-unread'), + # Course management proxy - url(r'^admin_delete_course/(?P\d+)/', views.admin_delete_course_proxy, name='admin_delete_course_proxy') + re_path(r'^admin_delete_course/(?P\d+)/', views.admin_delete_course_proxy, name='admin_delete_course_proxy') ] \ No newline at end of file diff --git a/FusionIIIT/applications/globals/api/views.py b/FusionIIIT/applications/globals/api/views.py index 50b969321..dbd674872 100644 --- a/FusionIIIT/applications/globals/api/views.py +++ b/FusionIIIT/applications/globals/api/views.py @@ -1,28 +1,41 @@ from django.contrib.auth import get_user_model from applications.academic_information.models import Student -from applications.eis.api.views import profile as eis_profile -from applications.globals.models import (HoldsDesignation,Designation) -from applications.gymkhana.api.views import coordinator_club from applications.placement_cell.models import (Achievement, Course, Education, Experience, Has, Patent, Project, Publication, Skill) -from django.shortcuts import get_object_or_404, redirect +from applications.programme_curriculum.models import ( + Course as CurriculumCourse, CourseSlot, CourseInstructor +) +from django.shortcuts import get_object_or_404 +from django.db import transaction + +import hashlib +import hmac +import logging +import secrets +import re +from datetime import timedelta +from django.conf import settings +from django.core.mail import EmailMultiAlternatives +from django.utils import timezone from rest_framework.permissions import IsAuthenticated from rest_framework.authentication import TokenAuthentication from rest_framework import status -from rest_framework.decorators import api_view, permission_classes,authentication_classes +from rest_framework.decorators import api_view, permission_classes, authentication_classes from rest_framework.permissions import AllowAny from rest_framework.response import Response from django.http import JsonResponse - from . import serializers -from applications.globals.models import (ExtraInfo, Feedback, HoldsDesignation, - Issue, IssueImage, DepartmentInfo, ModuleAccess) +from applications.globals.models import (ExtraInfo, HoldsDesignation, ModuleAccess, + Designation, PasswordResetOTP) from .utils import get_and_authenticate_user from notifications.models import Notification +# Module-level security logger — use fusion.security in LOGGING settings +_security_log = logging.getLogger("fusion.security") + User = get_user_model() @api_view(['POST']) @@ -33,8 +46,6 @@ def login(request): user = get_and_authenticate_user(**serializer.validated_data) data = serializers.AuthUserSerializer(user).data - desig = list(HoldsDesignation.objects.select_related('user','working','designation').all().filter(working = user).values_list('designation')) - b = [i for sub in desig for i in sub] design = HoldsDesignation.objects.select_related('user','designation').filter(working=user) designation=[] @@ -47,23 +58,26 @@ def login(request): designation.append(str(i.designation)) resp = { - 'success' : 'True', + 'success' : True, 'message' : 'User logged in successfully', 'token' : data['auth_token'], - 'designations':designation + 'designations': designation } return Response(data=resp, status=status.HTTP_200_OK) @api_view(['POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) def logout(request): - request.user.auth_token.delete() - resp = { - 'message' : 'User logged out successfully' - } - return Response(data=resp, status=status.HTTP_200_OK) + try: + request.user.auth_token.delete() + except Exception: + pass # token already deleted or doesn't exist — still return success + return Response({'message': 'User logged out successfully'}, status=status.HTTP_200_OK) @api_view(['GET']) -@permission_classes([AllowAny]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) def auth_view(request): user=request.user name = request.user.first_name +"_"+ request.user.last_name @@ -72,12 +86,10 @@ def auth_view(request): extra_info = get_object_or_404(ExtraInfo, user=user) last_selected_role = extra_info.last_selected_role - designation_list = list(HoldsDesignation.objects.all().filter(working = request.user).values_list('designation')) - designation_id = [designation for designations in designation_list for designation in designations] - designation_info = [] - for id in designation_id : - name_ = get_object_or_404(Designation, id = id) - designation_info.append(str(name_.name)) + designation_list = list(HoldsDesignation.objects.filter(working=request.user).values_list('designation_id', flat=True)) + designation_info = list( + Designation.objects.filter(id__in=designation_list).values_list('name', flat=True) + ) accessible_modules = {} @@ -117,6 +129,7 @@ def notification(request): @api_view(['PATCH']) @permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) def update_last_selected_role(request): new_role = request.data.get('last_selected_role') @@ -126,18 +139,20 @@ def update_last_selected_role(request): extra_info = get_object_or_404(ExtraInfo, user=request.user) extra_info.last_selected_role = new_role - extra_info.save() + extra_info.save(update_fields=['last_selected_role']) return Response({'message': 'last_selected_role updated successfully'}, status=status.HTTP_200_OK) @api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) def profile(request, username=None): user = get_object_or_404(User, username=username) if username else request.user profile = serializers.ExtraInfoSerializer(user.extrainfo).data if profile['user_type'] == 'student': student = user.extrainfo.student - std_sem = Student.objects.get(id=student.id).curr_semester_no + std_sem = student.curr_semester_no skills = list( Has.objects.filter(unique_id_id=student) .select_related("skill_id") @@ -173,6 +188,8 @@ def profile(request, username=None): return Response(data={'error': 'User is not a student'}, status=status.HTTP_400_BAD_REQUEST) @api_view(['PUT']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) def profile_update(request): user = request.user profile = user.extrainfo @@ -207,7 +224,7 @@ def profile_update(request): has_obj, created = Has.objects.get_or_create(skill_id=skill, unique_id=student, defaults={"skill_rating": skill_rating}) if not created: has_obj.skill_rating = skill_rating - has_obj.save() + has_obj.save(update_fields=['skill_rating']) return Response({"message": "Skill added successfully"}, status=status.HTTP_200_OK) @@ -259,63 +276,64 @@ def profile_update(request): return Response({'error': 'Cannot update'}, status=status.HTTP_400_BAD_REQUEST) @api_view(['DELETE']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) def profile_delete(request, id): user = request.user profile = user.extrainfo - student = profile.student if 'deleteskill' in request.data: try: skill = Has.objects.get(id=id) - except: + except Has.DoesNotExist: return Response({'error': 'Skill does not exist'}, status=status.HTTP_400_BAD_REQUEST) skill.delete() return Response({'message': 'Skill deleted successfully'}, status=status.HTTP_200_OK) elif 'deleteedu' in request.data: try: education = Education.objects.get(id=id) - except: + except Education.DoesNotExist: return Response({'error': 'Education does not exist'}, status=status.HTTP_400_BAD_REQUEST) education.delete() return Response({'message': 'Education deleted successfully'}, status=status.HTTP_200_OK) elif 'deletecourse' in request.data: try: course = Course.objects.get(id=id) - except: + except Course.DoesNotExist: return Response({'error': 'Course does not exist'}, status=status.HTTP_400_BAD_REQUEST) course.delete() return Response({'message': 'Course deleted successfully'}, status=status.HTTP_200_OK) elif 'deleteexp' in request.data: try: experience = Experience.objects.get(id=id) - except: + except Experience.DoesNotExist: return Response({'error': 'Experience does not exist'}, status=status.HTTP_400_BAD_REQUEST) experience.delete() return Response({'message': 'Experience deleted successfully'}, status=status.HTTP_200_OK) elif 'deletepro' in request.data: try: project = Project.objects.get(id=id) - except: + except Project.DoesNotExist: return Response({'error': 'Project does not exist'}, status=status.HTTP_400_BAD_REQUEST) project.delete() return Response({'message': 'Project deleted successfully'}, status=status.HTTP_200_OK) elif 'deleteach' in request.data: try: achievement = Achievement.objects.get(id=id) - except: + except Achievement.DoesNotExist: return Response({'error': 'Achievement does not exist'}, status=status.HTTP_400_BAD_REQUEST) achievement.delete() return Response({'message': 'Achievement deleted successfully'}, status=status.HTTP_200_OK) elif 'deletepub' in request.data: try: publication = Publication.objects.get(id=id) - except: + except Publication.DoesNotExist: return Response({'error': 'Publication does not exist'}, status=status.HTTP_400_BAD_REQUEST) publication.delete() return Response({'message': 'Publication deleted successfully'}, status=status.HTTP_200_OK) elif 'deletepat' in request.data: try: patent = Patent.objects.get(id=id) - except: + except Patent.DoesNotExist: return Response({'error': 'Patent does not exist'}, status=status.HTTP_400_BAD_REQUEST) patent.delete() return Response({'message': 'Patent deleted successfully'}, status=status.HTTP_200_OK) @@ -326,51 +344,37 @@ def profile_delete(request, id): @authentication_classes([TokenAuthentication]) def NotificationRead(request): try: - notifId=int(request.data['id']) - user=request.user - notification = get_object_or_404(Notification, recipient=request.user, id=notifId) + notif_id = int(request.data['id']) + notification = get_object_or_404(Notification, recipient=request.user, id=notif_id) notification.mark_as_read() - response ={ - 'message':'notfication successfully marked as seen.' - } - return Response(response,status=status.HTTP_200_OK) - except: - response ={ - 'error':'Failed, notification is not marked as seen.' - } - return Response(response,status=status.HTTP_404_NOT_FOUND) + return Response({'message': 'Notification successfully marked as seen.'}, status=status.HTTP_200_OK) + except Exception: + return Response({'error': 'Failed to mark notification as seen.'}, status=status.HTTP_404_NOT_FOUND) @api_view(['POST']) @permission_classes([IsAuthenticated]) @authentication_classes([TokenAuthentication]) def NotificationUnread(request): try: - notifId = int(request.data['id']) - user = request.user - notification = get_object_or_404(Notification, recipient=user, id=notifId) - if not notification.unread: + notif_id = int(request.data['id']) + notification = get_object_or_404(Notification, recipient=request.user, id=notif_id) + if not notification.unread: notification.unread = True - notification.save() - response = { - 'message': 'Notification successfully marked as unread.' - } - return Response(response, status=status.HTTP_200_OK) - except: - response = { - 'error': 'Failed to mark the notification as unread.' - } - return Response(response, status=status.HTTP_404_NOT_FOUND) + notification.save(update_fields=['unread']) + return Response({'message': 'Notification successfully marked as unread.'}, status=status.HTTP_200_OK) + except Exception: + return Response({'error': 'Failed to mark the notification as unread.'}, status=status.HTTP_404_NOT_FOUND) @api_view(['POST']) @permission_classes([IsAuthenticated]) @authentication_classes([TokenAuthentication]) def delete_notification(request): try: - notifId = int(request.data['id']) - notification = get_object_or_404(Notification, recipient=request.user, id=notifId) + notif_id = int(request.data['id']) + notification = get_object_or_404(Notification, recipient=request.user, id=notif_id) notification.deleted = True - notification.save() + notification.save(update_fields=['deleted']) response = { 'message': 'Notification marked as deleted.' @@ -383,68 +387,321 @@ def delete_notification(request): } return Response(response, status=status.HTTP_400_BAD_REQUEST) -from django.db import transaction @api_view(['DELETE']) @permission_classes([IsAuthenticated]) @authentication_classes([TokenAuthentication]) def admin_delete_course_proxy(request, course_id): """ - Proxy function to call the actual course delete function from programme_curriculum API + Delete a curriculum course after validating no instructor or slot dependencies exist. """ try: - from applications.programme_curriculum.models import Course, CourseSlot, CourseInstructor - - try: - course = Course.objects.get(id=course_id) - except Course.DoesNotExist: - return JsonResponse({ - 'success': False, - 'message': 'Course not found.' - }, status=404) + course = CurriculumCourse.objects.get(id=course_id) + except CurriculumCourse.DoesNotExist: + return JsonResponse({'success': False, 'message': 'Course not found.'}, status=404) - course_name = course.name - course_code = course.code - - try: - instructor_count = CourseInstructor.objects.filter(course_id=course).count() - if instructor_count > 0: - return JsonResponse({ - 'success': False, - 'message': f'Cannot delete course. It has {instructor_count} active instructor assignment(s). Please remove instructor assignments first.' - }, status=400) - - slot_count = CourseSlot.objects.filter(courses=course).count() - if slot_count > 0: - return JsonResponse({ - 'success': False, - 'message': f'Cannot delete course. It is assigned to {slot_count} course slot(s) in curriculum(s). Please remove from course slots first.' - }, status=400) - - except Exception as dependency_error: - return JsonResponse({ - 'success': False, - 'message': f'Error checking course dependencies: {str(dependency_error)}' - }, status=500) + course_name = course.name - try: - with transaction.atomic(): - course.delete() - - return JsonResponse({ - 'success': True, - 'message': f'Course "{course_name}" has been successfully deleted.' - }, status=200) - - except Exception as delete_error: - return JsonResponse({ - 'success': False, - 'message': f'Error deleting course: {str(delete_error)}' - }, status=500) - - except Exception as e: + instructor_count = CourseInstructor.objects.filter(course_id=course).count() + if instructor_count > 0: + return JsonResponse({ + 'success': False, + 'message': f'Cannot delete course. It has {instructor_count} active instructor assignment(s). Remove instructor assignments first.' + }, status=400) + + slot_count = CourseSlot.objects.filter(courses=course).count() + if slot_count > 0: return JsonResponse({ 'success': False, - 'message': 'An unexpected error occurred while deleting the course.', - 'error': str(e) - }, status=500) \ No newline at end of file + 'message': f'Cannot delete course. It is assigned to {slot_count} course slot(s). Remove from course slots first.' + }, status=400) + + try: + with transaction.atomic(): + course.delete() + return JsonResponse({'success': True, 'message': f'Course "{course_name}" deleted successfully.'}, status=200) + except Exception as e: + return JsonResponse({'success': False, 'message': f'Error deleting course: {e}'}, status=500) + + +# OTP-based Password Reset + +def _otp_hash(otp: str) -> str: + return hmac.new( + settings.SECRET_KEY.encode(), + otp.encode(), + hashlib.sha256, + ).hexdigest() + + +def _token_hash(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + +_safe_eq = hmac.compare_digest + +_SEND_OTP_OK = { + "success": True, + "message": "If the username exists, an OTP has been sent to the registered e-mail.", +} + + +@api_view(["POST"]) +@permission_classes([AllowAny]) +def password_reset_send_otp(request): + """ + POST /api/auth/password-reset/send-otp/ + Body: { "username": "" } + Generates a 6-digit OTP, stores its HMAC hash, and e-mails it to the + user's registered address. + Rate-limited: max OTP_HOURLY_LIMIT sends per hour per username. + Always returns HTTP 200 with an identical message to prevent enumeration. + """ + username = (request.data.get("username") or "").strip().lower() + if not username: + return Response({"success": False, "message": "Username is required."}, status=400) + + try: + user = User.objects.get(username__iexact=username) + except User.DoesNotExist: + return Response(_SEND_OTP_OK) + + if not user.email: + return Response(_SEND_OTP_OK) + + now = timezone.now() + record = PasswordResetOTP.objects.filter(username=user.username).first() + + if record: + window_age = (now - record.window_start).total_seconds() + if window_age < 3600: + if record.send_count >= PasswordResetOTP.OTP_HOURLY_LIMIT: + return Response( + {"success": False, "message": "Too many OTP requests. Please wait before trying again."}, + status=429, + ) + record.send_count += 1 + else: + record.send_count = 1 + record.window_start = now + else: + record = PasswordResetOTP(username=user.username, window_start=now) + + otp = f"{secrets.randbelow(1_000_000):06d}" + record.otp_hash = _otp_hash(otp) + record.attempts = 0 + record.expires_at = now + timedelta(minutes=PasswordResetOTP.OTP_TTL_MINUTES) + record.reset_token_hash = None + record.token_expires_at = None + record.token_used = False + record.save() + + try: + subject = "Fusion – Password Reset OTP" + + text_message = ( + f"Hello {user.first_name or user.username},\n\n" + f"Your OTP for password reset is: {otp}\n\n" + f"It is valid for {PasswordResetOTP.OTP_TTL_MINUTES} minutes.\n" + f"Do NOT share this OTP with anyone.\n\n" + f"If you did not request this, you can safely ignore this e-mail.\n\n" + f"— PDPM IIITDM Jabalpur" + ) + + html_content = f""" + + +
+

Dear {user.first_name or user.username},

+ +

We received a request to reset your FUSION account password. Please use the One-Time Password (OTP) below to complete the password reset process:

+ +
+

Your OTP Code:

+

{otp}

+

Valid for {PasswordResetOTP.OTP_TTL_MINUTES} minutes

+
+ +
+

⚠️ Security Note

+
    +
  • Do NOT share this OTP with anyone.
  • +
  • This OTP will expire in {PasswordResetOTP.OTP_TTL_MINUTES} minutes
  • +
  • If you did not request this reset, please ignore this email
  • +
+
+ +
+

📞 Need Help?

+

+ For technical support or security concerns, contact:
+ 📧 {settings.EMAIL_HOST_USER}
+ 🏢 PDPM IIITDM Jabalpur +

+
+ +
+ This is an automated security message from FUSION. Do not reply to this email. + Generated on {timezone.now().strftime('%B %d, %Y at %I:%M %p IST')} +
+
+ + + """ + + email = EmailMultiAlternatives( + subject=subject, + body=text_message, + from_email=settings.DEFAULT_FROM_EMAIL, + to=[user.email] + ) + + email.attach_alternative(html_content, "text/html") + email.send() + + except Exception: + _security_log.exception("OTP email delivery failed | user=%s", user.username) + + return Response(_SEND_OTP_OK) + + +@api_view(["POST"]) +@permission_classes([AllowAny]) +def password_reset_verify_otp(request): + """ + POST /api/auth/password-reset/verify-otp/ + Body: { "username": "", "otp": "<6-digit OTP>" } + Validates the OTP and, on success, returns a single-use reset token. + Locked after OTP_MAX_ATTEMPTS failed attempts. + """ + username = (request.data.get("username") or "").strip().lower() + otp = (request.data.get("otp") or "").strip() + + if not username or not otp: + return Response({"success": False, "message": "Username and OTP are required."}, status=400) + + _INVALID = {"success": False, "message": "Invalid or expired OTP."} + + record = PasswordResetOTP.objects.filter(username__iexact=username).first() + if not record: + return Response(_INVALID, status=400) + + now = timezone.now() + + if now > record.expires_at: + return Response({"success": False, "message": "OTP has expired. Please request a new one."}, status=400) + + if record.attempts >= PasswordResetOTP.OTP_MAX_ATTEMPTS: + return Response( + {"success": False, "message": "Too many incorrect attempts. Please request a new OTP."}, + status=429, + ) + + if not _safe_eq(_otp_hash(otp), record.otp_hash): + record.attempts += 1 + record.save(update_fields=["attempts"]) + remaining = PasswordResetOTP.OTP_MAX_ATTEMPTS - record.attempts + return Response( + {"success": False, "message": f"Incorrect OTP. {remaining} attempt(s) remaining."}, + status=400, + ) + + reset_token = secrets.token_urlsafe(32) + record.otp_hash = "" + record.attempts = PasswordResetOTP.OTP_MAX_ATTEMPTS + record.reset_token_hash = _token_hash(reset_token) + record.token_expires_at = now + timedelta(minutes=PasswordResetOTP.TOKEN_TTL_MINUTES) + record.token_used = False + record.save() + + return Response({"success": True, "reset_token": reset_token}) + + +def _validate_password_complexity(password): + """ + Validates password complexity requirements: + - At least 8 characters + - At most 72 characters (bcrypt hard truncation limit) + - At least one lowercase letter (a-z) + - At least one uppercase letter (A-Z) + - At least one number (0-9) + - At least one special character + """ + if len(password) < 8: + return False, "Password must be at least 8 characters." + + if len(password) > 72: # bcrypt silently truncates beyond 72 bytes (DoS vector) + return False, "Password exceeds maximum length (72 characters)." + + if not re.search(r'[a-z]', password): + return False, "Password must contain at least one lowercase letter (a-z)." + + if not re.search(r'[A-Z]', password): + return False, "Password must contain at least one uppercase letter (A-Z)." + + if not re.search(r'[0-9]', password): + return False, "Password must contain at least one number (0-9)." + + if not re.search(r'[!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?]', password): + return False, "Password must contain at least one special character (!@#$%^&* etc)." + + return True, "" + + +@api_view(["POST"]) +@permission_classes([AllowAny]) +def password_reset_reset(request): + """ + POST /api/auth/password-reset/reset/ + Body: { "username": "", "reset_token": "", "new_password": "" } + Validates the single-use token and sets the new password. + """ + username = (request.data.get("username") or "").strip().lower() + reset_token = (request.data.get("reset_token") or "").strip() + new_password = request.data.get("new_password", "") + + if not username or not reset_token or not new_password: + return Response({"success": False, "message": "All fields are required."}, status=400) + + # Validate password complexity + is_valid, error_message = _validate_password_complexity(new_password) + if not is_valid: + return Response({"success": False, "message": error_message}, status=400) + + _INVALID = {"success": False, "message": "Invalid or expired reset token."} + + record = PasswordResetOTP.objects.filter(username__iexact=username).first() + if not record or not record.reset_token_hash: + return Response(_INVALID, status=400) + + now = timezone.now() + + if record.token_used: + return Response(_INVALID, status=400) + + if not record.token_expires_at or now > record.token_expires_at: + return Response({"success": False, "message": "Reset token has expired. Please start over."}, status=400) + + if not _safe_eq(_token_hash(reset_token), record.reset_token_hash): + return Response(_INVALID, status=400) + + try: + user = User.objects.get(username__iexact=username) + except User.DoesNotExist: + return Response(_INVALID, status=400) + + record.token_used = True + record.save(update_fields=["token_used"]) + + user.set_password(new_password) + user.save(update_fields=["password"]) + + record.delete() + + # Audit trail — visible in logs when fusion.security logger is configured + _security_log.info( + "[PASSWORD_RESET] success | user=%s | ip=%s", + username, + request.META.get("HTTP_X_FORWARDED_FOR", request.META.get("REMOTE_ADDR", "unknown")), + ) + + return Response({"success": True, "message": "Password has been reset successfully."}) \ No newline at end of file diff --git a/FusionIIIT/applications/globals/migrations/0006_auto_20260304_0836.py b/FusionIIIT/applications/globals/migrations/0006_auto_20260304_0836.py new file mode 100644 index 000000000..192225da3 --- /dev/null +++ b/FusionIIIT/applications/globals/migrations/0006_auto_20260304_0836.py @@ -0,0 +1,31 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('globals', '0005_moduleaccess_database'), + ] + + operations = [ + migrations.CreateModel( + name='PasswordResetOTP', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('username', models.CharField(db_index=True, max_length=150)), + ('otp_hash', models.CharField(max_length=64)), + ('attempts', models.PositiveSmallIntegerField(default=0)), + ('send_count', models.PositiveSmallIntegerField(default=1)), + ('window_start', models.DateTimeField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('expires_at', models.DateTimeField()), + ('reset_token_hash', models.CharField(blank=True, max_length=64, null=True)), + ('token_expires_at', models.DateTimeField(blank=True, null=True)), + ('token_used', models.BooleanField(default=False)), + ], + ), + migrations.AddConstraint( + model_name='passwordresetotp', + constraint=models.UniqueConstraint(fields=('username',), name='unique_active_otp_per_user'), + ), + ] diff --git a/FusionIIIT/applications/globals/models.py b/FusionIIIT/applications/globals/models.py index 3e200d39a..618b917bc 100644 --- a/FusionIIIT/applications/globals/models.py +++ b/FusionIIIT/applications/globals/models.py @@ -348,3 +348,40 @@ class PasswordResetTracker(models.Model): def __str__(self): return self.email + + +class PasswordResetOTP(models.Model): + """ + Stores a single active OTP record per username. + - otp_hash : HMAC-SHA256(SECRET_KEY, otp) — never stores plaintext OTP + - attempts : failed verify attempts; locked after OTP_MAX_ATTEMPTS + - send_count : OTPs sent in the current rate-limit window + - window_start : start of the current hour-long rate-limit window + - expires_at : OTP validity deadline (OTP_TTL_MINUTES from creation) + - reset_token_hash : SHA-256 of the single-use reset token (set after OTP verified) + - token_expires_at : reset token validity deadline (TOKEN_TTL_MINUTES from issue) + - token_used : True once the reset token has been consumed + """ + OTP_TTL_MINUTES = 10 + TOKEN_TTL_MINUTES = 15 + OTP_MAX_ATTEMPTS = 5 + OTP_HOURLY_LIMIT = 3 + + username = models.CharField(max_length=150, db_index=True) + otp_hash = models.CharField(max_length=64) + attempts = models.PositiveSmallIntegerField(default=0) + send_count = models.PositiveSmallIntegerField(default=1) + window_start = models.DateTimeField() + created_at = models.DateTimeField(auto_now_add=True) + expires_at = models.DateTimeField() + reset_token_hash = models.CharField(max_length=64, null=True, blank=True) + token_expires_at = models.DateTimeField(null=True, blank=True) + token_used = models.BooleanField(default=False) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["username"], name="unique_active_otp_per_user") + ] + + def __str__(self): + return f"OTP record for {self.username}" From 8a71074e7f67d7ef088df7fca563078f9ceb2802 Mon Sep 17 00:00:00 2001 From: vikrantwiz02 Date: Wed, 4 Mar 2026 12:31:09 +0530 Subject: [PATCH 2/2] Profile Error handler --- FusionIIIT/applications/globals/api/views.py | 235 +++++++++---------- 1 file changed, 115 insertions(+), 120 deletions(-) diff --git a/FusionIIIT/applications/globals/api/views.py b/FusionIIIT/applications/globals/api/views.py index dbd674872..8b29f24f8 100644 --- a/FusionIIIT/applications/globals/api/views.py +++ b/FusionIIIT/applications/globals/api/views.py @@ -32,8 +32,6 @@ Designation, PasswordResetOTP) from .utils import get_and_authenticate_user from notifications.models import Notification - -# Module-level security logger — use fusion.security in LOGGING settings _security_log = logging.getLogger("fusion.security") User = get_user_model() @@ -185,7 +183,21 @@ def profile(request, username=None): } return Response(data=resp, status=status.HTTP_200_OK) else: - return Response(data={'error': 'User is not a student'}, status=status.HTTP_400_BAD_REQUEST) + current = serializers.HoldsDesignationSerializer(user.current_designation.all(), many=True).data + resp = { + 'profile' : profile, + 'semester_no' : None, + 'skills' : [], + 'education' : [], + 'course' : [], + 'experience' : [], + 'project' : [], + 'achievement' : [], + 'publication' : [], + 'patent' : [], + 'current' : current, + } + return Response(data=resp, status=status.HTTP_200_OK) @api_view(['PUT']) @permission_classes([IsAuthenticated]) @@ -193,86 +205,91 @@ def profile(request, username=None): def profile_update(request): user = request.user profile = user.extrainfo + + # Basic profile fields apply to ALL users (students and non-students alike) + if 'profilesubmit' in request.data: + serializer = serializers.ExtraInfoSerializer(profile, data=request.data['profilesubmit'], partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + # For student-only current = user.current_designation.filter(designation__name="student") - if current: - student = profile.student - if 'education' in request.data: - data = request.data - data['education']['unique_id'] = profile - serializer = serializers.EducationSerializer(data=data['education']) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - elif 'profilesubmit' in request.data: - serializer = serializers.ExtraInfoSerializer(profile, data=request.data['profilesubmit'],partial=True) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - elif 'skillsubmit' in request.data: - try: - skill_data = request.data['skillsubmit'] - skill_id = skill_data['skill_id'] - skill_name = skill_id['skill_name'] - skill_rating = skill_data['skill_rating'] - - if not skill_name or skill_rating is None: - return Response({"error": "Missing skill_name or skill_rating"}, status=status.HTTP_400_BAD_REQUEST) - - skill, created = Skill.objects.get_or_create(skill=skill_name) - has_obj, created = Has.objects.get_or_create(skill_id=skill, unique_id=student, defaults={"skill_rating": skill_rating}) - if not created: - has_obj.skill_rating = skill_rating - has_obj.save(update_fields=['skill_rating']) - - return Response({"message": "Skill added successfully"}, status=status.HTTP_200_OK) - - except Exception as e: - return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - elif 'achievementsubmit' in request.data: - request.data['achievementsubmit']['unique_id'] = profile - serializer = serializers.AchievementSerializer(data=request.data['achievementsubmit']) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - elif 'publicationsubmit' in request.data: - request.data['publicationsubmit']['unique_id'] = profile - serializer = serializers.PublicationSerializer(data=request.data['publicationsubmit']) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - elif 'patentsubmit' in request.data: - request.data['patentsubmit']['unique_id'] = profile - serializer = serializers.PatentSerializer(data=request.data['patentsubmit']) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - elif 'coursesubmit' in request.data: - request.data['coursesubmit']['unique_id'] = profile - serializer = serializers.CourseSerializer(data=request.data['coursesubmit']) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - elif 'projectsubmit' in request.data: - request.data['projectsubmit']['unique_id'] = profile - serializer = serializers.ProjectSerializer(data=request.data['projectsubmit']) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - elif 'experiencesubmit' in request.data: - request.data['experiencesubmit']['unique_id'] = profile - serializer = serializers.ExperienceSerializer(data=request.data['experiencesubmit']) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + if not current: + return Response({'error': 'Cannot update'}, status=status.HTTP_400_BAD_REQUEST) + + student = profile.student + if 'education' in request.data: + data = request.data + data['education']['unique_id'] = profile + serializer = serializers.EducationSerializer(data=data['education']) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + elif 'skillsubmit' in request.data: + try: + skill_data = request.data['skillsubmit'] + skill_id = skill_data['skill_id'] + skill_name = skill_id['skill_name'] + skill_rating = skill_data['skill_rating'] + + if not skill_name or skill_rating is None: + return Response({"error": "Missing skill_name or skill_rating"}, status=status.HTTP_400_BAD_REQUEST) + + skill, _ = Skill.objects.get_or_create(skill=skill_name) + has_obj, created = Has.objects.get_or_create(skill_id=skill, unique_id=student, defaults={"skill_rating": skill_rating}) + if not created: + has_obj.skill_rating = skill_rating + has_obj.save(update_fields=['skill_rating']) + + return Response({"message": "Skill added successfully"}, status=status.HTTP_200_OK) + + except Exception as e: + return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + elif 'achievementsubmit' in request.data: + request.data['achievementsubmit']['unique_id'] = profile + serializer = serializers.AchievementSerializer(data=request.data['achievementsubmit']) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + elif 'publicationsubmit' in request.data: + request.data['publicationsubmit']['unique_id'] = profile + serializer = serializers.PublicationSerializer(data=request.data['publicationsubmit']) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + elif 'patentsubmit' in request.data: + request.data['patentsubmit']['unique_id'] = profile + serializer = serializers.PatentSerializer(data=request.data['patentsubmit']) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + elif 'coursesubmit' in request.data: + request.data['coursesubmit']['unique_id'] = profile + serializer = serializers.CourseSerializer(data=request.data['coursesubmit']) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + elif 'projectsubmit' in request.data: + request.data['projectsubmit']['unique_id'] = profile + serializer = serializers.ProjectSerializer(data=request.data['projectsubmit']) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + elif 'experiencesubmit' in request.data: + request.data['experiencesubmit']['unique_id'] = profile + serializer = serializers.ExperienceSerializer(data=request.data['experiencesubmit']) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response({'error': 'Cannot update'}, status=status.HTTP_400_BAD_REQUEST) @api_view(['DELETE']) @@ -281,60 +298,39 @@ def profile_update(request): def profile_delete(request, id): user = request.user profile = user.extrainfo + # All records are scoped to the requesting user's own profile via unique_id__extrainfo. + # Using get_object_or_404 with the ownership filter ensures that a record belonging + # to another user is indistinguishable from a missing record (no ID enumeration). if 'deleteskill' in request.data: - try: - skill = Has.objects.get(id=id) - except Has.DoesNotExist: - return Response({'error': 'Skill does not exist'}, status=status.HTTP_400_BAD_REQUEST) + skill = get_object_or_404(Has, id=id, unique_id__extrainfo=profile) skill.delete() return Response({'message': 'Skill deleted successfully'}, status=status.HTTP_200_OK) elif 'deleteedu' in request.data: - try: - education = Education.objects.get(id=id) - except Education.DoesNotExist: - return Response({'error': 'Education does not exist'}, status=status.HTTP_400_BAD_REQUEST) + education = get_object_or_404(Education, id=id, unique_id__extrainfo=profile) education.delete() return Response({'message': 'Education deleted successfully'}, status=status.HTTP_200_OK) elif 'deletecourse' in request.data: - try: - course = Course.objects.get(id=id) - except Course.DoesNotExist: - return Response({'error': 'Course does not exist'}, status=status.HTTP_400_BAD_REQUEST) + course = get_object_or_404(Course, id=id, unique_id__extrainfo=profile) course.delete() return Response({'message': 'Course deleted successfully'}, status=status.HTTP_200_OK) elif 'deleteexp' in request.data: - try: - experience = Experience.objects.get(id=id) - except Experience.DoesNotExist: - return Response({'error': 'Experience does not exist'}, status=status.HTTP_400_BAD_REQUEST) + experience = get_object_or_404(Experience, id=id, unique_id__extrainfo=profile) experience.delete() return Response({'message': 'Experience deleted successfully'}, status=status.HTTP_200_OK) elif 'deletepro' in request.data: - try: - project = Project.objects.get(id=id) - except Project.DoesNotExist: - return Response({'error': 'Project does not exist'}, status=status.HTTP_400_BAD_REQUEST) + project = get_object_or_404(Project, id=id, unique_id__extrainfo=profile) project.delete() return Response({'message': 'Project deleted successfully'}, status=status.HTTP_200_OK) elif 'deleteach' in request.data: - try: - achievement = Achievement.objects.get(id=id) - except Achievement.DoesNotExist: - return Response({'error': 'Achievement does not exist'}, status=status.HTTP_400_BAD_REQUEST) + achievement = get_object_or_404(Achievement, id=id, unique_id__extrainfo=profile) achievement.delete() return Response({'message': 'Achievement deleted successfully'}, status=status.HTTP_200_OK) elif 'deletepub' in request.data: - try: - publication = Publication.objects.get(id=id) - except Publication.DoesNotExist: - return Response({'error': 'Publication does not exist'}, status=status.HTTP_400_BAD_REQUEST) + publication = get_object_or_404(Publication, id=id, unique_id__extrainfo=profile) publication.delete() return Response({'message': 'Publication deleted successfully'}, status=status.HTTP_200_OK) elif 'deletepat' in request.data: - try: - patent = Patent.objects.get(id=id) - except Patent.DoesNotExist: - return Response({'error': 'Patent does not exist'}, status=status.HTTP_400_BAD_REQUEST) + patent = get_object_or_404(Patent, id=id, unique_id__extrainfo=profile) patent.delete() return Response({'message': 'Patent deleted successfully'}, status=status.HTTP_200_OK) return Response({'error': 'Wrong attribute'}, status=status.HTTP_400_BAD_REQUEST) @@ -380,12 +376,11 @@ def delete_notification(request): 'message': 'Notification marked as deleted.' } return Response(response, status=status.HTTP_200_OK) - except Exception as e: - response = { - 'error': 'Failed to mark the notification as deleted.', - 'details': str(e) - } - return Response(response, status=status.HTTP_400_BAD_REQUEST) + except Exception: + return Response( + {'error': 'Failed to mark the notification as deleted.'}, + status=status.HTTP_400_BAD_REQUEST, + ) @api_view(['DELETE'])