Files
saas_backend/app/services/auth/auth_service.py
T

425 lines
16 KiB
Python
Raw Normal View History

2026-01-17 14:18:00 +05:30
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
2026-02-16 15:32:05 +05:30
from datetime import datetime, timedelta, timezone
import secrets
2026-01-17 14:18:00 +05:30
import uuid
2026-02-16 15:32:05 +05:30
import logging
import jwt
from app.config.settings import settings
2026-01-17 14:18:00 +05:30
from app.services.auth.email_service import EmailService
2026-02-16 15:32:05 +05:30
from app.core.redis import sync_redis_client
from app.services.auth.subscription_entitlement_service import (
SubscriptionEntitlementService,
)
2026-02-16 15:32:05 +05:30
logger = logging.getLogger(__name__)
2026-01-17 14:18:00 +05:30
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")
effective_accesses = sorted(
SubscriptionEntitlementService.get_effective_access_codes(db, user)
)
2026-01-17 14:18:00 +05:30
role_data = None
if user.role or effective_accesses:
2026-01-17 14:18:00 +05:30
role_data = {
"id": str(user.role.id) if user.role else None,
"role_name": user.role.role_name if user.role else "subscription",
"accesses": effective_accesses,
2026-01-17 14:18:00 +05:30
}
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,
"subscription_details": SubscriptionEntitlementService.get_subscription_summary(
db, user.tenant_id
),
2026-01-17 14:18:00 +05:30
"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
)
effective_accesses = sorted(
SubscriptionEntitlementService.get_effective_access_codes(db, user)
)
role_data = None
if user.role or effective_accesses:
role_data = {
"id": str(user.role.id) if user.role else None,
"role_name": user.role.role_name if user.role else "subscription",
"accesses": effective_accesses,
}
2026-01-17 14:18:00 +05:30
return {
"access_token": new_access_token,
"refresh_token": new_refresh_token,
"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,
"subscription_details": SubscriptionEntitlementService.get_subscription_summary(
db, user.tenant_id
),
"created_at": user.created_at,
"updated_at": user.updated_at,
"role": role_data,
},
2026-01-17 14:18:00 +05:30
}
@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
2026-02-16 15:32:05 +05:30
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}")
2026-01-17 14:18:00 +05:30
return {"message": "Logged out successfully"}
@staticmethod
def me(db: Session, current_user: User):
effective_accesses = sorted(
SubscriptionEntitlementService.get_effective_access_codes(db, current_user)
)
2026-01-17 14:18:00 +05:30
role_data = None
if current_user.role or effective_accesses:
2026-01-17 14:18:00 +05:30
role_data = {
"id": str(current_user.role.id) if current_user.role else None,
"role_name": current_user.role.role_name if current_user.role else "subscription",
"accesses": effective_accesses,
2026-01-17 14:18:00 +05:30
}
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
),
"subscription_details": SubscriptionEntitlementService.get_subscription_summary(
db, current_user.tenant_id
),
2026-01-17 14:18:00 +05:30
"created_at": current_user.created_at,
"updated_at": current_user.updated_at,
"role": role_data,
}
@staticmethod
def forgot_password(db: Session, email: str):
2026-02-16 15:32:05 +05:30
"""Generate and send OTP for password reset via Redis storage."""
2026-01-17 14:18:00 +05:30
user = db.query(User).filter(User.email == email).first()
if not user:
return {"message": "If the email is registered, an OTP has been sent."}
2026-02-16 15:32:05 +05:30
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"
)
2026-01-17 14:18:00 +05:30
try:
2026-02-16 15:32:05 +05:30
EmailService.send_otp(email, otp_code)
2026-01-17 14:18:00 +05:30
except Exception as e:
2026-02-16 15:32:05 +05:30
logger.error(f"Failed to send OTP email to {email}: {e}")
2026-01-17 14:18:00 +05:30
return {"message": "If the email is registered, an OTP has been sent."}
2026-02-16 15:32:05 +05:30
@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}")
2026-01-17 14:18:00 +05:30
@staticmethod
def verify_otp(db: Session, email: str, otp: str):
2026-02-16 15:32:05 +05:30
"""Verify OTP from Redis storage using constant-time comparison."""
redis_key = f"otp:{email}"
2026-01-17 14:18:00 +05:30
2026-02-16 15:32:05 +05:30
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}")
2026-01-17 14:18:00 +05:30
raise HTTPException(
2026-02-16 15:32:05 +05:30
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to verify OTP"
2026-01-17 14:18:00 +05:30
)
@staticmethod
def reset_password_with_otp(db: Session, email: str, otp: str, new_password: str):
2026-02-16 15:32:05 +05:30
"""Reset password after verifying OTP and delete OTP from Redis."""
redis_key = f"otp:{email}"
verified_key = f"otp_verified:{email}"
2026-01-17 14:18:00 +05:30
2026-02-16 15:32:05 +05:30
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()
2026-01-17 14:18:00 +05:30
raise HTTPException(
2026-02-16 15:32:05 +05:30
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to reset password"
)