Spaces:
Running
Running
| # ============================================================ | |
| # 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 | |
| # ============================================================ | |
| 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 | |
| # ============================================================ | |
| 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 | |
| # ============================================================ | |
| 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 | |
| # ============================================================ | |
| 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 | |
| # ============================================================ | |
| 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 | |
| # ============================================================ | |
| 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 | |
| # ============================================================ | |
| 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() |