This commit is contained in:
Iliyan Angelov
2025-11-16 20:05:08 +02:00
parent 98ccd5b6ff
commit 48353cde9c
118 changed files with 9488 additions and 1336 deletions

View File

@@ -5,19 +5,29 @@ 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 ..utils.mailer import send_email
from ..utils.email_templates import (
welcome_email_template,
password_reset_email_template,
password_changed_email_template
)
from ..config.settings import settings
import os
logger = logging.getLogger(__name__)
class AuthService:
def __init__(self):
self.jwt_secret = os.getenv("JWT_SECRET")
self.jwt_refresh_secret = os.getenv("JWT_REFRESH_SECRET")
# 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")
@@ -70,6 +80,7 @@ class AuthService:
"name": user.full_name,
"email": user.email,
"phone": user.phone,
"avatar": user.avatar,
"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,
@@ -115,33 +126,16 @@ class AuthService:
# Send welcome email (non-blocking)
try:
client_url = os.getenv("CLIENT_URL", "http://localhost:5173")
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=f"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #4F46E5;">Welcome {user.full_name}!</h2>
<p>Thank you for registering an account at <strong>Hotel Booking</strong>.</p>
<p>Your account has been successfully created with email: <strong>{user.email}</strong></p>
<div style="background-color: #F3F4F6; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 0;"><strong>You can:</strong></p>
<ul style="margin-top: 10px;">
<li>Search and book hotel rooms</li>
<li>Manage your bookings</li>
<li>Update your personal information</li>
</ul>
</div>
<p>
<a href="{client_url}/login" style="background-color: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
Login Now
</a>
</p>
</div>
"""
html=email_html
)
logger.info(f"Welcome email sent successfully to {user.email}")
except Exception as e:
print(f"Failed to send welcome email: {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),
@@ -170,14 +164,42 @@ class AuthService:
expiry_days = 7 if remember_me else 1
expires_at = datetime.utcnow() + timedelta(days=expiry_days)
# Save refresh token
refresh_token = RefreshToken(
user_id=user.id,
token=tokens["refreshToken"],
expires_at=expires_at
)
db.add(refresh_token)
db.commit()
# 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
# Save new refresh token
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)
# If there's still a duplicate, try to delete and retry once
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),
@@ -235,6 +257,53 @@ class AuthService:
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
) -> 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
).first()
if existing_user:
raise ValueError("Email already registered")
user.email = email
if phone_number is not None:
user.phone = phone_number
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)
@@ -270,22 +339,41 @@ class AuthService:
db.commit()
# Build reset URL
client_url = os.getenv("CLIENT_URL", "http://localhost:5173")
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()
await send_email(
to=user.email,
subject="Reset password - Hotel Booking",
html=f"""
<p>You (or someone) has requested to reset your password.</p>
<p>Click the link below to reset your password (expires in 1 hour):</p>
<p><a href="{reset_url}">{reset_url}</a></p>
"""
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:
print(f"Failed to send reset email: {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,
@@ -332,13 +420,16 @@ class AuthService:
# 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)
await send_email(
to=user.email,
subject="Password Changed",
html=f"<p>The password for account {user.email} has been changed successfully.</p>"
html=email_html
)
logger.info(f"Password changed confirmation email sent successfully to {user.email}")
except Exception as e:
print(f"Failed to send confirmation email: {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,