from jose import jwt import bcrypt from datetime import datetime, timedelta import secrets import hashlib from sqlalchemy.orm import Session from typing import Optional import logging from ..models.user import User from ..models.refresh_token import RefreshToken from ..models.password_reset_token import PasswordResetToken from ..models.role import Role from ...shared.utils.mailer import send_email from ...shared.utils.email_templates import ( welcome_email_template, password_reset_email_template, password_changed_email_template ) from ...shared.config.settings import settings import os logger = logging.getLogger(__name__) class AuthService: def __init__(self): # Security: Fail fast if JWT_SECRET is not configured - never use default values self.jwt_secret = getattr(settings, 'JWT_SECRET', None) or os.getenv("JWT_SECRET") if not self.jwt_secret: error_msg = ( 'CRITICAL: JWT_SECRET is not configured. ' 'Please set JWT_SECRET environment variable to a secure random string (minimum 32 characters).' ) logger.error(error_msg) if settings.is_production: raise ValueError(error_msg) else: # In development, generate a secure secret but warn import secrets self.jwt_secret = secrets.token_urlsafe(64) logger.warning( f'JWT_SECRET not configured. Auto-generated secret for development. ' f'Set JWT_SECRET environment variable for production: {self.jwt_secret}' ) # Validate JWT secret strength if len(self.jwt_secret) < 32: error_msg = 'JWT_SECRET must be at least 32 characters long for security.' logger.error(error_msg) if settings.is_production: raise ValueError(error_msg) else: logger.warning(error_msg) # Refresh secret should be different from access secret self.jwt_refresh_secret = os.getenv("JWT_REFRESH_SECRET") if not self.jwt_refresh_secret: # Use a derived secret if not explicitly set, but different from access secret import hashlib self.jwt_refresh_secret = hashlib.sha256((self.jwt_secret + "-refresh").encode()).hexdigest() if not settings.is_production: logger.info('JWT_REFRESH_SECRET not set, using derived secret') self.jwt_expires_in = os.getenv("JWT_EXPIRES_IN", "1h") self.jwt_refresh_expires_in = os.getenv("JWT_REFRESH_EXPIRES_IN", "7d") def generate_tokens(self, user_id: int) -> dict: access_token = jwt.encode( {"userId": user_id}, self.jwt_secret, algorithm="HS256" ) refresh_token = jwt.encode( {"userId": user_id}, self.jwt_refresh_secret, algorithm="HS256" ) return {"accessToken": access_token, "refreshToken": refresh_token} def verify_access_token(self, token: str) -> dict: return jwt.decode(token, self.jwt_secret, algorithms=["HS256"]) def verify_refresh_token(self, token: str) -> dict: return jwt.decode(token, self.jwt_refresh_secret, algorithms=["HS256"]) def hash_password(self, password: str) -> str: password_bytes = password.encode('utf-8') salt = bcrypt.gensalt() hashed = bcrypt.hashpw(password_bytes, salt) return hashed.decode('utf-8') def verify_password(self, plain_password: str, hashed_password: str) -> bool: try: password_bytes = plain_password.encode('utf-8') hashed_bytes = hashed_password.encode('utf-8') return bcrypt.checkpw(password_bytes, hashed_bytes) except Exception: return False def format_user_response(self, user: User) -> dict: return { "id": user.id, "name": user.full_name, "email": user.email, "phone": user.phone, "avatar": user.avatar, "currency": getattr(user, 'currency', 'VND'), "role": user.role.name if user.role else "customer", "createdAt": user.created_at.isoformat() if user.created_at else None, "updatedAt": user.updated_at.isoformat() if user.updated_at else None, } async def register(self, db: Session, name: str, email: str, password: str, phone: Optional[str] = None) -> dict: # Validate password strength from ...shared.utils.password_validation import validate_password_strength is_valid, errors = validate_password_strength(password) if not is_valid: error_message = 'Password does not meet requirements: ' + '; '.join(errors) raise ValueError(error_message) existing_user = db.query(User).filter(User.email == email).first() if existing_user: raise ValueError("Email already registered") hashed_password = self.hash_password(password) user = User( full_name=name, email=email, password=hashed_password, phone=phone, role_id=3 ) db.add(user) db.commit() db.refresh(user) user.role = db.query(Role).filter(Role.id == user.role_id).first() tokens = self.generate_tokens(user.id) expires_at = datetime.utcnow() + timedelta(days=7) refresh_token = RefreshToken( user_id=user.id, token=tokens["refreshToken"], expires_at=expires_at ) db.add(refresh_token) db.commit() try: client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") email_html = welcome_email_template(user.full_name, user.email, client_url) await send_email( to=user.email, subject="Welcome to Hotel Booking", html=email_html ) logger.info(f"Welcome email sent successfully to {user.email}") except Exception as e: logger.error(f"Failed to send welcome email to {user.email}: {type(e).__name__}: {str(e)}", exc_info=True) return { "user": self.format_user_response(user), "token": tokens["accessToken"], "refreshToken": tokens["refreshToken"] } async def login(self, db: Session, email: str, password: str, remember_me: bool = False, mfa_token: str = None) -> dict: email = email.lower().strip() if email else "" if not email: raise ValueError("Invalid email or password") user = db.query(User).filter(User.email == email).first() if not user: logger.warning(f"Login attempt with non-existent email: {email}") raise ValueError("Invalid email or password") if not user.is_active: logger.warning(f"Login attempt for inactive user: {email}") raise ValueError("Account is disabled. Please contact support.") # Check if account is locked (reset if lockout expired) if user.locked_until: if user.locked_until > datetime.utcnow(): remaining_minutes = int((user.locked_until - datetime.utcnow()).total_seconds() / 60) logger.warning(f"Login attempt for locked account: {email} (locked until {user.locked_until})") raise ValueError(f"Account is temporarily locked due to multiple failed login attempts. Please try again in {remaining_minutes} minute(s).") else: # Lockout expired, reset it user.locked_until = None user.failed_login_attempts = 0 db.commit() user.role = db.query(Role).filter(Role.id == user.role_id).first() password_valid = self.verify_password(password, user.password) # Handle failed login attempt if not password_valid: user.failed_login_attempts = (user.failed_login_attempts or 0) + 1 max_attempts = settings.MAX_LOGIN_ATTEMPTS lockout_duration = settings.ACCOUNT_LOCKOUT_DURATION_MINUTES # Lock account if max attempts reached if user.failed_login_attempts >= max_attempts: user.locked_until = datetime.utcnow() + timedelta(minutes=lockout_duration) logger.warning(f"Account locked due to {user.failed_login_attempts} failed login attempts: {email}") db.commit() raise ValueError(f"Account has been temporarily locked due to {max_attempts} failed login attempts. Please try again in {lockout_duration} minute(s).") else: remaining_attempts = max_attempts - user.failed_login_attempts logger.warning(f"Login attempt with invalid password for user: {email} ({user.failed_login_attempts}/{max_attempts} failed attempts)") db.commit() raise ValueError(f"Invalid email or password. {remaining_attempts} attempt(s) remaining before account lockout.") if user.mfa_enabled: if not mfa_token: return { "requires_mfa": True, "user_id": user.id } from ..services.mfa_service import mfa_service is_backup_code = len(mfa_token) == 8 if not mfa_service.verify_mfa(db, user.id, mfa_token, is_backup_code): # Increment failed attempts on MFA failure user.failed_login_attempts = (user.failed_login_attempts or 0) + 1 max_attempts = settings.MAX_LOGIN_ATTEMPTS lockout_duration = settings.ACCOUNT_LOCKOUT_DURATION_MINUTES if user.failed_login_attempts >= max_attempts: user.locked_until = datetime.utcnow() + timedelta(minutes=lockout_duration) logger.warning(f"Account locked due to {user.failed_login_attempts} failed attempts (MFA failure): {email}") db.commit() raise ValueError(f"Account has been temporarily locked due to {max_attempts} failed login attempts. Please try again in {lockout_duration} minute(s).") else: remaining_attempts = max_attempts - user.failed_login_attempts db.commit() raise ValueError(f"Invalid MFA token. {remaining_attempts} attempt(s) remaining before account lockout.") # Reset failed login attempts and unlock account on successful login if user.failed_login_attempts > 0 or user.locked_until: user.failed_login_attempts = 0 user.locked_until = None db.commit() tokens = self.generate_tokens(user.id) expiry_days = 7 if remember_me else 1 expires_at = datetime.utcnow() + timedelta(days=expiry_days) try: db.query(RefreshToken).filter( RefreshToken.user_id == user.id ).delete() db.flush() refresh_token = RefreshToken( user_id=user.id, token=tokens["refreshToken"], expires_at=expires_at ) db.add(refresh_token) db.commit() except Exception as e: db.rollback() logger.error(f"Error saving refresh token for user {user.id}: {str(e)}", exc_info=True) try: db.query(RefreshToken).filter( RefreshToken.token == tokens["refreshToken"] ).delete() db.flush() refresh_token = RefreshToken( user_id=user.id, token=tokens["refreshToken"], expires_at=expires_at ) db.add(refresh_token) db.commit() except Exception as retry_error: db.rollback() logger.error(f"Retry failed for refresh token: {str(retry_error)}", exc_info=True) raise ValueError("Failed to create session. Please try again.") return { "user": self.format_user_response(user), "token": tokens["accessToken"], "refreshToken": tokens["refreshToken"] } async def refresh_access_token(self, db: Session, refresh_token_str: str) -> dict: if not refresh_token_str: raise ValueError("Refresh token is required") decoded = self.verify_refresh_token(refresh_token_str) stored_token = db.query(RefreshToken).filter( RefreshToken.token == refresh_token_str, RefreshToken.user_id == decoded["userId"] ).first() if not stored_token: raise ValueError("Invalid refresh token") if datetime.utcnow() > stored_token.expires_at: db.delete(stored_token) db.commit() raise ValueError("Refresh token expired") access_token = jwt.encode( {"userId": decoded["userId"]}, self.jwt_secret, algorithm="HS256" ) return {"token": access_token} async def logout(self, db: Session, refresh_token_str: str) -> bool: if refresh_token_str: db.query(RefreshToken).filter(RefreshToken.token == refresh_token_str).delete() db.commit() return True async def get_profile(self, db: Session, user_id: int) -> dict: user = db.query(User).filter(User.id == user_id).first() if not user: raise ValueError("User not found") user.role = db.query(Role).filter(Role.id == user.role_id).first() return self.format_user_response(user) async def update_profile( self, db: Session, user_id: int, full_name: Optional[str] = None, email: Optional[str] = None, phone_number: Optional[str] = None, password: Optional[str] = None, current_password: Optional[str] = None, currency: Optional[str] = None ) -> dict: user = db.query(User).filter(User.id == user_id).first() if not user: raise ValueError("User not found") if password: if not current_password: raise ValueError("Current password is required to change password") if not self.verify_password(current_password, user.password): raise ValueError("Current password is incorrect") # Validate new password strength from ...shared.utils.password_validation import validate_password_strength is_valid, errors = validate_password_strength(password) if not is_valid: error_message = 'New password does not meet requirements: ' + '; '.join(errors) raise ValueError(error_message) user.password = self.hash_password(password) if full_name is not None: user.full_name = full_name if email is not None: existing_user = db.query(User).filter( User.email == email, User.id != user_id ).first() if existing_user: raise ValueError("Email already registered") user.email = email if phone_number is not None: user.phone = phone_number if currency is not None: if len(currency) == 3 and currency.isalpha(): user.currency = currency.upper() else: raise ValueError("Invalid currency code. Must be a 3-letter ISO 4217 code (e.g., USD, EUR, VND)") db.commit() db.refresh(user) user.role = db.query(Role).filter(Role.id == user.role_id).first() return self.format_user_response(user) def generate_reset_token(self) -> tuple: reset_token = secrets.token_hex(32) hashed_token = hashlib.sha256(reset_token.encode()).hexdigest() return reset_token, hashed_token async def forgot_password(self, db: Session, email: str) -> dict: user = db.query(User).filter(User.email == email).first() if not user: return { "success": True, "message": "If email exists, reset link has been sent" } reset_token, hashed_token = self.generate_reset_token() db.query(PasswordResetToken).filter(PasswordResetToken.user_id == user.id).delete() expires_at = datetime.utcnow() + timedelta(hours=1) reset_token_obj = PasswordResetToken( user_id=user.id, token=hashed_token, expires_at=expires_at ) db.add(reset_token_obj) db.commit() client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") reset_url = f"{client_url}/reset-password/{reset_token}" try: logger.info(f"Attempting to send password reset email to {user.email}") logger.info(f"Reset URL: {reset_url}") email_html = password_reset_email_template(reset_url) plain_text = f"Please click the following link to reset your password: {reset_url}\n\nIf you did not request this, please ignore this email." await send_email( to=user.email, subject="Reset password - Hotel Booking", html=email_html, text=plain_text ) logger.info(f"Password reset email sent successfully to {user.email} with reset URL: {reset_url}") except Exception as e: logger.error(f"Failed to send password reset email to {user.email}: {type(e).__name__}: {str(e)}", exc_info=True) return { "success": True, "message": "Password reset link has been sent to your email" } async def reset_password(self, db: Session, token: str, password: str) -> dict: if not token or not password: raise ValueError("Token and password are required") hashed_token = hashlib.sha256(token.encode()).hexdigest() reset_token = db.query(PasswordResetToken).filter( PasswordResetToken.token == hashed_token, PasswordResetToken.expires_at > datetime.utcnow(), PasswordResetToken.used == False ).first() if not reset_token: raise ValueError("Invalid or expired reset token") user = db.query(User).filter(User.id == reset_token.user_id).first() if not user: raise ValueError("User not found") if self.verify_password(password, user.password): raise ValueError("New password must be different from the old password") hashed_password = self.hash_password(password) user.password = hashed_password db.commit() reset_token.used = True db.commit() try: logger.info(f"Attempting to send password changed confirmation email to {user.email}") email_html = password_changed_email_template(user.email) await send_email( to=user.email, subject="Password Changed", html=email_html ) logger.info(f"Password changed confirmation email sent successfully to {user.email}") except Exception as e: logger.error(f"Failed to send password changed confirmation email to {user.email}: {type(e).__name__}: {str(e)}", exc_info=True) return { "success": True, "message": "Password has been reset successfully" } auth_service = AuthService()