updates
This commit is contained in:
@@ -22,17 +22,15 @@ import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthService:
|
||||
def __init__(self):
|
||||
# Use settings, fallback to env vars, then to defaults for development
|
||||
|
||||
self.jwt_secret = getattr(settings, 'JWT_SECRET', None) or os.getenv("JWT_SECRET", "dev-secret-key-change-in-production-12345")
|
||||
self.jwt_refresh_secret = os.getenv("JWT_REFRESH_SECRET") or (self.jwt_secret + "-refresh")
|
||||
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:
|
||||
"""Generate JWT tokens"""
|
||||
access_token = jwt.encode(
|
||||
{"userId": user_id},
|
||||
self.jwt_secret,
|
||||
@@ -48,24 +46,20 @@ class AuthService:
|
||||
return {"accessToken": access_token, "refreshToken": refresh_token}
|
||||
|
||||
def verify_access_token(self, token: str) -> dict:
|
||||
"""Verify JWT access token"""
|
||||
return jwt.decode(token, self.jwt_secret, algorithms=["HS256"])
|
||||
|
||||
def verify_refresh_token(self, token: str) -> dict:
|
||||
"""Verify JWT refresh token"""
|
||||
return jwt.decode(token, self.jwt_refresh_secret, algorithms=["HS256"])
|
||||
|
||||
def hash_password(self, password: str) -> str:
|
||||
"""Hash password using bcrypt"""
|
||||
# bcrypt has 72 byte limit, but it handles truncation automatically
|
||||
|
||||
password_bytes = password.encode('utf-8')
|
||||
# Generate salt and hash password
|
||||
|
||||
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:
|
||||
"""Verify password using bcrypt"""
|
||||
try:
|
||||
password_bytes = plain_password.encode('utf-8')
|
||||
hashed_bytes = hashed_password.encode('utf-8')
|
||||
@@ -74,7 +68,6 @@ class AuthService:
|
||||
return False
|
||||
|
||||
def format_user_response(self, user: User) -> dict:
|
||||
"""Format user response"""
|
||||
return {
|
||||
"id": user.id,
|
||||
"name": user.full_name,
|
||||
@@ -88,34 +81,28 @@ class AuthService:
|
||||
}
|
||||
|
||||
async def register(self, db: Session, name: str, email: str, password: str, phone: Optional[str] = None) -> dict:
|
||||
"""Register new user"""
|
||||
# Check if email exists
|
||||
|
||||
existing_user = db.query(User).filter(User.email == email).first()
|
||||
if existing_user:
|
||||
raise ValueError("Email already registered")
|
||||
|
||||
# Hash password
|
||||
hashed_password = self.hash_password(password)
|
||||
|
||||
# Create user (default role_id = 3 for customer)
|
||||
user = User(
|
||||
full_name=name,
|
||||
email=email,
|
||||
password=hashed_password,
|
||||
phone=phone,
|
||||
role_id=3 # Customer role
|
||||
role_id=3
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
# Load role
|
||||
user.role = db.query(Role).filter(Role.id == user.role_id).first()
|
||||
|
||||
# Generate tokens
|
||||
tokens = self.generate_tokens(user.id)
|
||||
|
||||
# Save refresh token (expires in 7 days)
|
||||
expires_at = datetime.utcnow() + timedelta(days=7)
|
||||
refresh_token = RefreshToken(
|
||||
user_id=user.id,
|
||||
@@ -125,7 +112,6 @@ class AuthService:
|
||||
db.add(refresh_token)
|
||||
db.commit()
|
||||
|
||||
# Send welcome email (non-blocking)
|
||||
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)
|
||||
@@ -145,62 +131,52 @@ class AuthService:
|
||||
}
|
||||
|
||||
async def login(self, db: Session, email: str, password: str, remember_me: bool = False, mfa_token: str = None) -> dict:
|
||||
"""Login user with optional MFA verification"""
|
||||
# Normalize email (lowercase and strip whitespace)
|
||||
|
||||
email = email.lower().strip() if email else ""
|
||||
if not email:
|
||||
raise ValueError("Invalid email or password")
|
||||
|
||||
# Find user with role and 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")
|
||||
|
||||
# Check if user is active
|
||||
if not user.is_active:
|
||||
logger.warning(f"Login attempt for inactive user: {email}")
|
||||
raise ValueError("Account is disabled. Please contact support.")
|
||||
|
||||
# Load role
|
||||
user.role = db.query(Role).filter(Role.id == user.role_id).first()
|
||||
|
||||
# Check password
|
||||
if not self.verify_password(password, user.password):
|
||||
logger.warning(f"Login attempt with invalid password for user: {email}")
|
||||
raise ValueError("Invalid email or password")
|
||||
|
||||
# Check if MFA is enabled
|
||||
if user.mfa_enabled:
|
||||
if not mfa_token:
|
||||
# Return special response indicating MFA is required
|
||||
|
||||
return {
|
||||
"requires_mfa": True,
|
||||
"user_id": user.id
|
||||
}
|
||||
|
||||
# Verify MFA token
|
||||
|
||||
from ..services.mfa_service import mfa_service
|
||||
is_backup_code = len(mfa_token) == 8 # Backup codes are 8 characters
|
||||
is_backup_code = len(mfa_token) == 8
|
||||
if not mfa_service.verify_mfa(db, user.id, mfa_token, is_backup_code):
|
||||
raise ValueError("Invalid MFA token")
|
||||
|
||||
# Generate tokens
|
||||
tokens = self.generate_tokens(user.id)
|
||||
|
||||
# Calculate expiry based on remember_me
|
||||
expiry_days = 7 if remember_me else 1
|
||||
expires_at = datetime.utcnow() + timedelta(days=expiry_days)
|
||||
|
||||
# Delete old/expired refresh tokens for this user to prevent duplicates
|
||||
# This ensures we don't have multiple active tokens and prevents unique constraint violations
|
||||
try:
|
||||
db.query(RefreshToken).filter(
|
||||
RefreshToken.user_id == user.id
|
||||
).delete()
|
||||
db.flush() # Flush to ensure deletion happens before insert
|
||||
db.flush()
|
||||
|
||||
# Save new refresh token
|
||||
refresh_token = RefreshToken(
|
||||
user_id=user.id,
|
||||
token=tokens["refreshToken"],
|
||||
@@ -211,7 +187,6 @@ class AuthService:
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error saving refresh token for user {user.id}: {str(e)}", exc_info=True)
|
||||
# If there's still a duplicate, try to delete and retry once
|
||||
try:
|
||||
db.query(RefreshToken).filter(
|
||||
RefreshToken.token == tokens["refreshToken"]
|
||||
@@ -236,14 +211,11 @@ class AuthService:
|
||||
}
|
||||
|
||||
async def refresh_access_token(self, db: Session, refresh_token_str: str) -> dict:
|
||||
"""Refresh access token"""
|
||||
if not refresh_token_str:
|
||||
raise ValueError("Refresh token is required")
|
||||
|
||||
# Verify refresh token
|
||||
decoded = self.verify_refresh_token(refresh_token_str)
|
||||
|
||||
# Check if refresh token exists in database
|
||||
stored_token = db.query(RefreshToken).filter(
|
||||
RefreshToken.token == refresh_token_str,
|
||||
RefreshToken.user_id == decoded["userId"]
|
||||
@@ -252,13 +224,11 @@ class AuthService:
|
||||
if not stored_token:
|
||||
raise ValueError("Invalid refresh token")
|
||||
|
||||
# Check if token is expired
|
||||
if datetime.utcnow() > stored_token.expires_at:
|
||||
db.delete(stored_token)
|
||||
db.commit()
|
||||
raise ValueError("Refresh token expired")
|
||||
|
||||
# Generate new access token
|
||||
access_token = jwt.encode(
|
||||
{"userId": decoded["userId"]},
|
||||
self.jwt_secret,
|
||||
@@ -268,19 +238,16 @@ class AuthService:
|
||||
return {"token": access_token}
|
||||
|
||||
async def logout(self, db: Session, refresh_token_str: str) -> bool:
|
||||
"""Logout user"""
|
||||
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:
|
||||
"""Get user profile"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise ValueError("User not found")
|
||||
|
||||
# Load role
|
||||
user.role = db.query(Role).filter(Role.id == user.role_id).first()
|
||||
|
||||
return self.format_user_response(user)
|
||||
@@ -296,25 +263,22 @@ class AuthService:
|
||||
current_password: Optional[str] = None,
|
||||
currency: Optional[str] = None
|
||||
) -> dict:
|
||||
"""Update user profile"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise ValueError("User not found")
|
||||
|
||||
# If password is being changed, verify current password
|
||||
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")
|
||||
# Hash new password
|
||||
|
||||
user.password = self.hash_password(password)
|
||||
|
||||
# Update other fields
|
||||
if full_name is not None:
|
||||
user.full_name = full_name
|
||||
if email is not None:
|
||||
# Check if email is already taken by another user
|
||||
|
||||
existing_user = db.query(User).filter(
|
||||
User.email == email,
|
||||
User.id != user_id
|
||||
@@ -325,7 +289,7 @@ class AuthService:
|
||||
if phone_number is not None:
|
||||
user.phone = phone_number
|
||||
if currency is not None:
|
||||
# Validate currency code (ISO 4217, 3 characters)
|
||||
|
||||
if len(currency) == 3 and currency.isalpha():
|
||||
user.currency = currency.upper()
|
||||
else:
|
||||
@@ -334,36 +298,29 @@ class AuthService:
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
# Load role
|
||||
user.role = db.query(Role).filter(Role.id == user.role_id).first()
|
||||
|
||||
return self.format_user_response(user)
|
||||
|
||||
def generate_reset_token(self) -> tuple:
|
||||
"""Generate reset token"""
|
||||
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:
|
||||
"""Forgot Password - Send reset link"""
|
||||
# Find user by email
|
||||
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
|
||||
# Always return success to prevent email enumeration
|
||||
if not user:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "If email exists, reset link has been sent"
|
||||
}
|
||||
|
||||
# Generate reset token
|
||||
reset_token, hashed_token = self.generate_reset_token()
|
||||
|
||||
# Delete old tokens
|
||||
db.query(PasswordResetToken).filter(PasswordResetToken.user_id == user.id).delete()
|
||||
|
||||
# Save token (expires in 1 hour)
|
||||
expires_at = datetime.utcnow() + timedelta(hours=1)
|
||||
reset_token_obj = PasswordResetToken(
|
||||
user_id=user.id,
|
||||
@@ -373,31 +330,17 @@ class AuthService:
|
||||
db.add(reset_token_obj)
|
||||
db.commit()
|
||||
|
||||
# Build reset URL
|
||||
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
|
||||
reset_url = f"{client_url}/reset-password/{reset_token}"
|
||||
|
||||
# Try to send email
|
||||
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)
|
||||
|
||||
# Create plain text version for better email deliverability
|
||||
plain_text = f"""
|
||||
Password Reset Request
|
||||
|
||||
You (or someone) has requested to reset your password for your Hotel Booking account.
|
||||
|
||||
Click the link below to reset your password. This link will expire in 1 hour:
|
||||
|
||||
{reset_url}
|
||||
|
||||
If you did not request this, please ignore this email.
|
||||
|
||||
Best regards,
|
||||
Hotel Booking Team
|
||||
""".strip()
|
||||
plain_text = f
|
||||
.strip()
|
||||
|
||||
await send_email(
|
||||
to=user.email,
|
||||
@@ -408,7 +351,6 @@ Hotel Booking Team
|
||||
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)
|
||||
# Still return success to prevent email enumeration, but log the error
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -416,14 +358,11 @@ Hotel Booking Team
|
||||
}
|
||||
|
||||
async def reset_password(self, db: Session, token: str, password: str) -> dict:
|
||||
"""Reset Password - Update password with token"""
|
||||
if not token or not password:
|
||||
raise ValueError("Token and password are required")
|
||||
|
||||
# Hash the token to compare
|
||||
hashed_token = hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
# Find valid token
|
||||
reset_token = db.query(PasswordResetToken).filter(
|
||||
PasswordResetToken.token == hashed_token,
|
||||
PasswordResetToken.expires_at > datetime.utcnow(),
|
||||
@@ -433,27 +372,21 @@ Hotel Booking Team
|
||||
if not reset_token:
|
||||
raise ValueError("Invalid or expired reset token")
|
||||
|
||||
# Find user
|
||||
user = db.query(User).filter(User.id == reset_token.user_id).first()
|
||||
if not user:
|
||||
raise ValueError("User not found")
|
||||
|
||||
# Check if new password matches old password
|
||||
if self.verify_password(password, user.password):
|
||||
raise ValueError("New password must be different from the old password")
|
||||
|
||||
# Hash new password
|
||||
hashed_password = self.hash_password(password)
|
||||
|
||||
# Update password
|
||||
user.password = hashed_password
|
||||
db.commit()
|
||||
|
||||
# Mark token as used
|
||||
reset_token.used = True
|
||||
db.commit()
|
||||
|
||||
# Send confirmation email (non-blocking)
|
||||
try:
|
||||
logger.info(f"Attempting to send password changed confirmation email to {user.email}")
|
||||
email_html = password_changed_email_template(user.email)
|
||||
@@ -471,6 +404,5 @@ Hotel Booking Team
|
||||
"message": "Password has been reset successfully"
|
||||
}
|
||||
|
||||
|
||||
auth_service = AuthService()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user