Files
saas_backend/app/services/auth/auth_service.py
T
2026-02-16 15:32:05 +05:30

385 lines
14 KiB
Python

from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from app.models.auth.user_model import User
from app.schemas.auth.auth_schema import UserSignup, UserSignin, UserUpdate
from app.config.security import security
from datetime import datetime, timedelta, timezone
import secrets
import uuid
import logging
import jwt
from app.config.settings import settings
from app.services.auth.email_service import EmailService
from app.core.redis import sync_redis_client
logger = logging.getLogger(__name__)
class AuthService:
@staticmethod
def create_user(
db: Session, user_data: UserSignup, tenant_id: uuid.UUID = None
) -> User:
if db.query(User).filter(User.email == user_data.email).first():
raise HTTPException(status_code=400, detail="Email already registered")
if not security.validate_password_strength(user_data.password):
raise HTTPException(status_code=400, detail="Password too weak")
user = User(
email=user_data.email,
password=security.hash_password(user_data.password),
first_name=user_data.first_name,
last_name=user_data.last_name,
phone_number=user_data.phone_number,
status=user_data.status or "active",
tenant_id=tenant_id,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@staticmethod
def signin(db: Session, signin_data: UserSignin):
user = db.query(User).filter(User.email == signin_data.email).first()
if not user or not security.verify_password(
signin_data.password, user.password
):
raise HTTPException(status_code=401, detail="Invalid credentials")
if user.status != "active":
raise HTTPException(status_code=403, detail="User inactive")
role_data = None
if user.role:
role_data = {
"id": str(user.role.id),
"role_name": user.role.role_name,
"accesses": [ra.access.access_code for ra in user.role.role_accesses],
}
return {
"access_token": security.generate_access_token(
{"sub": str(user.id)}, user.tenant_id
),
"refresh_token": security.generate_refresh_token(
{"sub": str(user.id)}, user.tenant_id
),
"token_type": "bearer",
"user": {
"id": str(user.id),
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name,
"phone_number": user.phone_number,
"status": user.status,
"tenant_id": user.tenant_id,
"tenant_name": user.tenant.tenant_name if user.tenant else None,
"tenant_logo_url": user.tenant.tenant_logo_url if user.tenant else None,
"created_at": user.created_at,
"updated_at": user.updated_at,
"role": role_data,
},
}
@staticmethod
def refresh_access_token(db: Session, refresh_token: str):
payload = security.verify_refresh_token(refresh_token)
user_id = payload.get("sub")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found"
)
if user.status != "active":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="User account is inactive"
)
new_access_token = security.generate_access_token(
{"sub": str(user.id)}, tenant_id=user.tenant_id
)
new_refresh_token = security.generate_refresh_token(
{"sub": str(user.id)}, tenant_id=user.tenant_id
)
return {
"access_token": new_access_token,
"refresh_token": new_refresh_token,
"token_type": "bearer",
"user": user,
}
@staticmethod
def update_user(
db: Session, user_id: uuid.UUID, update_data: UserUpdate, current_user: User
):
if current_user.id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to update this profile",
)
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
update_dict = update_data.model_dump(exclude_unset=True)
if "email" in update_dict and update_dict["email"] != user.email:
if db.query(User).filter(User.email == update_dict["email"]).first():
raise HTTPException(status_code=400, detail="Email already used")
for key, value in update_dict.items():
setattr(user, key, value)
db.commit()
db.refresh(user)
return user
@staticmethod
def reset_password(
db: Session, current_user: User, old_password: str, new_password: str
):
if not security.verify_password(old_password, current_user.password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Incorrect old password"
)
if not security.validate_password_strength(new_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Password too weak"
)
current_user.password = security.hash_password(new_password)
current_user.password_updated_at = datetime.utcnow()
db.commit()
return {"message": "Password updated successfully"}
@staticmethod
def logout(current_user: User, token: str):
try:
payload = jwt.decode(token, settings.ACCESS_TOKEN_SECRET, algorithms=["HS256"])
jti = payload.get("jti")
exp = payload.get("exp")
if jti and exp and sync_redis_client.client:
remaining_ttl = int(exp - datetime.now(timezone.utc).timestamp())
if remaining_ttl > 0:
sync_redis_client.client.setex(f"blacklist:{jti}", remaining_ttl, "1")
except Exception as e:
logger.warning(f"Failed to blacklist token on logout: {e}")
return {"message": "Logged out successfully"}
@staticmethod
def me(db: Session, current_user: User):
role_data = None
if current_user.role:
role_data = {
"id": str(current_user.role.id),
"role_name": current_user.role.role_name,
"accesses": [
ra.access.access_code for ra in current_user.role.role_accesses
],
}
return {
"id": str(current_user.id),
"email": current_user.email,
"first_name": current_user.first_name,
"last_name": current_user.last_name,
"phone_number": current_user.phone_number,
"status": current_user.status,
"tenant_id": current_user.tenant_id,
"tenant_name": (
current_user.tenant.tenant_name if current_user.tenant else None
),
"tenant_logo_url": (
current_user.tenant.tenant_logo_url if current_user.tenant else None
),
"created_at": current_user.created_at,
"updated_at": current_user.updated_at,
"role": role_data,
}
@staticmethod
def forgot_password(db: Session, email: str):
"""Generate and send OTP for password reset via Redis storage."""
user = db.query(User).filter(User.email == email).first()
if not user:
return {"message": "If the email is registered, an OTP has been sent."}
otp_code = "".join([secrets.choice("0123456789") for _ in range(6)])
redis_key = f"otp:{email}"
try:
if sync_redis_client.client:
sync_redis_client.client.setex(redis_key, 600, otp_code) # 600s = 10 minutes
logger.info(f"OTP generated for email: {email}")
else:
logger.error("Redis client unavailable for OTP storage")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Password reset service temporarily unavailable"
)
except Exception as e:
logger.error(f"Redis error storing OTP: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to generate OTP"
)
try:
EmailService.send_otp(email, otp_code)
except Exception as e:
logger.error(f"Failed to send OTP email to {email}: {e}")
return {"message": "If the email is registered, an OTP has been sent."}
@staticmethod
def _check_otp_attempts(email: str):
"""Check if OTP attempts are exceeded. Raises 429 if locked out."""
attempts_key = f"otp_attempts:{email}"
try:
attempts = sync_redis_client.client.get(attempts_key)
if attempts and int(attempts) >= 5:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many attempts. Try again in 15 minutes."
)
except HTTPException:
raise
except Exception as e:
logger.warning(f"Redis error checking OTP attempts: {e}")
@staticmethod
def _increment_otp_attempts(email: str):
"""Increment failed OTP attempt counter with 15-minute TTL."""
attempts_key = f"otp_attempts:{email}"
try:
pipe = sync_redis_client.client.pipeline()
pipe.incr(attempts_key)
pipe.expire(attempts_key, 900)
pipe.execute()
except Exception as e:
logger.warning(f"Redis error incrementing OTP attempts: {e}")
@staticmethod
def verify_otp(db: Session, email: str, otp: str):
"""Verify OTP from Redis storage using constant-time comparison."""
redis_key = f"otp:{email}"
try:
if not sync_redis_client.client:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Verification service temporarily unavailable"
)
AuthService._check_otp_attempts(email)
stored_otp = sync_redis_client.client.get(redis_key)
if not stored_otp:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired OTP"
)
if not secrets.compare_digest(otp, stored_otp):
AuthService._increment_otp_attempts(email)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired OTP"
)
sync_redis_client.client.delete(redis_key)
sync_redis_client.client.delete(f"otp_attempts:{email}")
sync_redis_client.client.setex(f"otp_verified:{email}", 300, "1")
logger.info(f"OTP verified successfully for email: {email}")
return {"message": "OTP verified successfully"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Redis error verifying OTP: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to verify OTP"
)
@staticmethod
def reset_password_with_otp(db: Session, email: str, otp: str, new_password: str):
"""Reset password after verifying OTP and delete OTP from Redis."""
redis_key = f"otp:{email}"
verified_key = f"otp_verified:{email}"
try:
if not sync_redis_client.client:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Password reset service temporarily unavailable"
)
AuthService._check_otp_attempts(email)
is_pre_verified = sync_redis_client.client.get(verified_key)
if not is_pre_verified:
stored_otp = sync_redis_client.client.get(redis_key)
if not stored_otp:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired OTP"
)
if not secrets.compare_digest(otp, stored_otp):
AuthService._increment_otp_attempts(email)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired OTP"
)
user = db.query(User).filter(User.email == email).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
if not security.validate_password_strength(new_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password too weak"
)
user.password = security.hash_password(new_password)
user.password_updated_at = datetime.now(timezone.utc)
db.commit()
sync_redis_client.client.delete(redis_key)
sync_redis_client.client.delete(verified_key)
sync_redis_client.client.delete(f"otp_attempts:{email}")
logger.info(f"Password reset successfully for email: {email}")
return {"message": "Password updated successfully"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error resetting password: {e}")
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to reset password"
)