385 lines
14 KiB
Python
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"
|
|
) |