from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession import httpx import jwt from forget_password.models import PasswordResetCodes from forget_password.plugins import ( RESET_CODE_MAX_ATTEMPTS, RESET_CODE_RESEND_SECONDS, code_expiry, generate_code, hash_code, now_utc, render_reset_email, send_reset_mail, verify_code, RESET_CODE_TTL_SECONDS, ) from forget_password.serializers import serialize_reset_request,serialize_reset_result from users.models import Users from users.plugins import decode_token,hash_password class ForgetPassword: def __init__(self,session:AsyncSession): self.session=session async def request_code(self,email): user=await Users.get_user_by_email(self.session,email) if not user or user.is_deleted or not user.is_active: raise HTTPException(status_code=404,detail="No account found for this email") active=await PasswordResetCodes.get_active_code_by_email(self.session,email) if active: age=(now_utc()-active.created_at).total_seconds() if agenow_utc(): raise HTTPException(status_code=429,detail="Please wait before requesting another code") await PasswordResetCodes.invalidate_codes_for_email(self.session,email) code=generate_code() expires_at=code_expiry() row=await PasswordResetCodes.insert_code(self.session,{ "email":email, "code_hash":hash_code(code), "expires_at":expires_at, }) subject,html=render_reset_email(code,RESET_CODE_TTL_SECONDS) try: await send_reset_mail(email,subject,html) except (httpx.HTTPError,RuntimeError) as e: await PasswordResetCodes.mark_used(self.session,str(row.id)) raise HTTPException(status_code=502,detail="Failed to send reset email") from e return serialize_reset_request(email,expires_at) async def verify_code(self,email,code): row=await PasswordResetCodes.get_active_code_by_email(self.session,email) if not row: raise HTTPException(status_code=400,detail="No active reset code for this email") if row.expires_at<=now_utc(): raise HTTPException(status_code=400,detail="Reset code has expired") if (row.attempts or 0)>=RESET_CODE_MAX_ATTEMPTS: raise HTTPException(status_code=429,detail="Too many invalid attempts") if not verify_code(code,row.code_hash): updated=await PasswordResetCodes.increment_attempts(self.session,str(row.id)) if updated and (updated.attempts or 0)>=RESET_CODE_MAX_ATTEMPTS: raise HTTPException(status_code=429,detail="Too many invalid attempts") raise HTTPException(status_code=400,detail="Invalid reset code") await PasswordResetCodes.mark_verified(self.session,str(row.id)) return email,str(row.id) async def set_new_password(self,reset_token,password): try: payload=decode_token(reset_token,expected_type="reset") except jwt.PyJWTError: raise HTTPException( status_code=401, detail="Invalid or expired reset token", headers={"WWW-Authenticate":"Bearer"}, ) email=payload.get("sub") code_id=payload.get("crid") if not email or not code_id: raise HTTPException( status_code=401, detail="Invalid or expired reset token", headers={"WWW-Authenticate":"Bearer"}, ) row=await PasswordResetCodes.get_code_by_id(self.session,code_id) if not row or row.is_used or row.email!=email or not row.verified_at: raise HTTPException( status_code=401, detail="Invalid or expired reset token", headers={"WWW-Authenticate":"Bearer"}, ) user=await Users.get_user_by_email(self.session,email) if not user or user.is_deleted: raise HTTPException(status_code=404,detail="User not found") await Users.update_user(self.session,str(user.id),{"password":hash_password(password)}) await PasswordResetCodes.mark_used(self.session,str(row.id)) return serialize_reset_result(email)