AIDA / app /services /auth_service.py
destinyebuka's picture
Deploy Lojiz Platform with Aida AI backend
79ef7e1
# ============================================================
# 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()