# ============================================================ # app/services/auth_service.py - COMPLETE with All Errors # ============================================================ import logging from datetime import datetime from app.database import get_db from app.models.user import User from app.core.security import ( hash_password, verify_password, create_login_token, create_reset_token, decode_reset_token, ) from app.core.exceptions import ( InvalidCredentialsException, UserNotFoundException, UserAlreadyExistsException, AccountInactiveException, InvalidOtpException, OtpExpiredException, OtpNotSentException, OtpSendFailedException, InvalidResetTokenException, TokenMismatchException, PasswordResetFailedException, DatabaseException, WeakPasswordException, MissingEmailPhoneException, OtpResendTooSoonException, OtpStillValidException, SignupFailedException, ) from app.services.otp_service import otp_service from app.schemas.auth import ( SignupDto, LoginDto, ResetPasswordDto, ResendOtpDto, ) logger = logging.getLogger(__name__) class AuthService: """Complete Authentication Service with comprehensive error handling""" # ============================================================ # SIGNUP # ============================================================ @staticmethod async def signup(signup_dto: SignupDto) -> dict: """ Step 1: Create user account and send OTP Raises: - UserAlreadyExistsException: Email/phone already registered - MissingEmailPhoneException: Neither email nor phone provided - WeakPasswordException: Password doesn't meet requirements - SignupFailedException: Database or OTP send error """ try: db = await get_db() users_collection = db["users"] email = signup_dto.email.lower() if signup_dto.email else None phone = signup_dto.phone identifier = email or phone # Validate at least one contact method if not identifier: raise MissingEmailPhoneException() # Check if user already exists if email: existing = await users_collection.find_one({"email": email}) if existing: raise UserAlreadyExistsException(email) if phone: existing = await users_collection.find_one({"phone": phone}) if existing: raise UserAlreadyExistsException(phone) # Hash password hashed_password = hash_password(signup_dto.password) # Create user document user_doc = User.create_document( first_name=signup_dto.first_name.strip(), last_name=signup_dto.last_name.strip(), email=email, phone=phone, hashed_password=hashed_password, role=signup_dto.role, ) # Insert user await users_collection.insert_one(user_doc) logger.info(f"User created: {identifier}") # Send OTP try: await otp_service.send_otp(identifier, "signup") except Exception as e: logger.error(f"OTP send failed: {str(e)}") raise OtpSendFailedException("email/SMS") logger.info(f"Signup initiated: {identifier}") return { "success": True, "message": "Account created! OTP sent to your email/phone.", "data": { "identifier": identifier, "type": "email" if email else "phone", }, } except (UserAlreadyExistsException, MissingEmailPhoneException, WeakPasswordException, OtpSendFailedException): raise except Exception as e: logger.error(f"Signup error: {str(e)}") raise SignupFailedException("Please try again") # ============================================================ # VERIFY SIGNUP OTP # ============================================================ @staticmethod async def verify_signup_otp(identifier: str, code: str) -> dict: """ Step 2: Verify signup OTP and complete signup Raises: - InvalidOtpException: OTP code is incorrect - OtpExpiredException: OTP has expired - OtpNotSentException: No OTP was sent - UserNotFoundException: User not found - DatabaseException: Database error during verification """ try: # Verify OTP try: await otp_service.verify_otp(identifier, code, "signup") except InvalidOtpException: raise except OtpExpiredException: raise except Exception as e: logger.error(f"OTP verification error: {str(e)}") raise OtpNotSentException() db = await get_db() users_collection = db["users"] # Find user query = ( {"email": identifier.lower()} if "@" in identifier else {"phone": identifier} ) user = await users_collection.find_one(query) if not user: raise UserNotFoundException(identifier) # Update verification status update_data = {} if "@" in identifier: update_data["isEmailVerified"] = True else: update_data["isPhoneVerified"] = True update_data["isActive"] = True update_data["lastLogin"] = datetime.utcnow() update_data["updatedAt"] = datetime.utcnow() await users_collection.update_one( {"_id": user["_id"]}, {"$set": update_data} ) # Refresh user user = await users_collection.find_one({"_id": user["_id"]}) # Delete OTP await otp_service.delete_otp(identifier, "signup") # Generate token token = create_login_token( user_id=str(user["_id"]), email=user.get("email"), phone=user.get("phone"), role=user.get("role"), ) logger.info(f"Signup verified: {identifier}") return { "success": True, "message": "Welcome! Your account is now verified.", "data": { "user": User.format_response(user), "token": token, }, } except (InvalidOtpException, OtpExpiredException, OtpNotSentException, UserNotFoundException): raise except Exception as e: logger.error(f"Signup verification error: {str(e)}") raise DatabaseException("signup verification") # ============================================================ # LOGIN # ============================================================ @staticmethod async def login(login_dto: LoginDto) -> dict: """ Login - Authenticate user and return JWT token Raises: - InvalidCredentialsException: Wrong email/phone or password - AccountInactiveException: Account is deactivated - DatabaseException: Database error """ identifier = login_dto.identifier password = login_dto.password logger.info(f"Login attempt: {identifier}") try: db = await get_db() users_collection = db["users"] # Find user query = ( {"email": identifier.lower()} if "@" in identifier else {"phone": identifier} ) user = await users_collection.find_one(query) # User not found if not user: logger.warning(f"Login failed - user not found: {identifier}") raise InvalidCredentialsException() # Account inactive if not user.get("isActive"): logger.warning(f"Login failed - account inactive: {identifier}") raise AccountInactiveException() # Password incorrect if not verify_password(password, user.get("password")): logger.warning(f"Login failed - invalid password: {identifier}") raise InvalidCredentialsException() # Update last login now = datetime.utcnow() await users_collection.update_one( {"_id": user["_id"]}, {"$set": {"lastLogin": now, "updatedAt": now}} ) # Generate token token = create_login_token( user_id=str(user["_id"]), email=user.get("email"), phone=user.get("phone"), role=user.get("role"), ) logger.info(f"Login successful: {identifier}") return { "success": True, "message": "Login successful!", "data": { "user": User.format_response(user), "token": token, }, } except (InvalidCredentialsException, AccountInactiveException): raise except Exception as e: logger.error(f"Login error: {str(e)}") raise DatabaseException("login") # ============================================================ # SEND PASSWORD RESET OTP # ============================================================ @staticmethod async def send_password_reset_otp(identifier: str) -> dict: """ Step 1: Request password reset OTP Raises: - OtpSendFailedException: Failed to send OTP Note: Doesn't reveal if account exists (security) """ try: db = await get_db() users_collection = db["users"] query = ( {"email": identifier.lower()} if "@" in identifier else {"phone": identifier} ) user = await users_collection.find_one(query) if user: try: await otp_service.send_otp(identifier, "password_reset") logger.info(f"Password reset OTP sent: {identifier}") except Exception as e: logger.error(f"OTP send failed: {str(e)}") raise OtpSendFailedException() except OtpSendFailedException: raise except Exception as e: logger.error(f"Password reset request error: {str(e)}") # Always return success for security return { "success": True, "message": "If an account exists, you'll receive an OTP shortly.", "data": {"identifier": identifier}, } # ============================================================ # VERIFY PASSWORD RESET OTP # ============================================================ @staticmethod async def verify_password_reset_otp(identifier: str, code: str) -> dict: """ Step 2: Verify password reset OTP Raises: - InvalidOtpException: OTP code is incorrect - OtpExpiredException: OTP has expired """ try: # Verify OTP await otp_service.verify_otp(identifier, code, "password_reset") # Generate reset token reset_token = create_reset_token(identifier) logger.info(f"Password reset OTP verified: {identifier}") return { "success": True, "message": "OTP verified! You can now reset your password.", "data": {"resetToken": reset_token}, } except (InvalidOtpException, OtpExpiredException): raise except Exception as e: logger.error(f"OTP verification error: {str(e)}") raise DatabaseException("OTP verification") # ============================================================ # RESET PASSWORD # ============================================================ @staticmethod async def reset_password( reset_password_dto: ResetPasswordDto, reset_token: str, ) -> dict: """ Step 3: Reset password using reset token Raises: - InvalidResetTokenException: Token is invalid - TokenMismatchException: Token identifier doesn't match - UserNotFoundException: User not found - PasswordResetFailedException: Password reset failed """ try: # Verify token payload = decode_reset_token(reset_token) if not payload: raise InvalidResetTokenException() identifier = reset_password_dto.identifier if payload.get("identifier") != identifier: raise TokenMismatchException() db = await get_db() users_collection = db["users"] # Find user query = ( {"email": identifier.lower()} if "@" in identifier else {"phone": identifier} ) user = await users_collection.find_one(query) if not user: raise UserNotFoundException(identifier) # Hash new password hashed_password = hash_password(reset_password_dto.new_password) # Update password await users_collection.update_one( {"_id": user["_id"]}, {"$set": { "password": hashed_password, "updatedAt": datetime.utcnow(), }} ) # Delete OTP await otp_service.delete_otp(identifier, "password_reset") logger.info(f"Password reset successful: {identifier}") return { "success": True, "message": "Password reset successfully! Please log in with your new password.", } except (InvalidResetTokenException, TokenMismatchException, UserNotFoundException): raise except Exception as e: logger.error(f"Password reset error: {str(e)}") raise PasswordResetFailedException() # ============================================================ # RESEND OTP # ============================================================ @staticmethod async def resend_otp(dto: ResendOtpDto) -> dict: """ Resend OTP for signup or password reset Raises: - OtpStillValidException: Current OTP is still valid - OtpResendTooSoonException: Resend requested too soon - OtpSendFailedException: Failed to send new OTP """ try: db = await get_db() # Check if OTP is still valid is_expired = await otp_service.is_otp_expired(dto.identifier, dto.purpose) if not is_expired: remaining = await otp_service.get_otp_remaining_time( dto.identifier, dto.purpose ) raise OtpStillValidException(remaining) # Send new OTP try: await otp_service.send_otp(dto.identifier, dto.purpose) except Exception as e: logger.error(f"OTP resend failed: {str(e)}") raise OtpSendFailedException() logger.info(f"OTP resent for {dto.purpose}: {dto.identifier}") return { "success": True, "message": f"New OTP sent to your {('email' if '@' in dto.identifier else 'phone')}.", "data": {"identifier": dto.identifier}, } except (OtpStillValidException, OtpResendTooSoonException, OtpSendFailedException): raise except Exception as e: logger.error(f"Resend OTP error: {str(e)}") raise DatabaseException("OTP resend") # Create singleton instance auth_service = AuthService()