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

@@ -0,0 +1,82 @@
"""
Audit logging service for tracking important actions
"""
from sqlalchemy.orm import Session
from typing import Optional, Dict, Any
from datetime import datetime
from ..models.audit_log import AuditLog
from ..config.logging_config import get_logger
logger = get_logger(__name__)
class AuditService:
"""Service for creating audit log entries"""
@staticmethod
async def log_action(
db: Session,
action: str,
resource_type: str,
user_id: Optional[int] = None,
resource_id: Optional[int] = None,
ip_address: Optional[str] = None,
user_agent: Optional[str] = None,
request_id: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
status: str = "success",
error_message: Optional[str] = None
):
"""
Create an audit log entry
Args:
db: Database session
action: Action performed (e.g., "user.created", "booking.cancelled")
resource_type: Type of resource (e.g., "user", "booking")
user_id: ID of user who performed the action
resource_id: ID of the resource affected
ip_address: IP address of the request
user_agent: User agent string
request_id: Request ID for tracing
details: Additional context as dictionary
status: Status of the action (success, failed, error)
error_message: Error message if action failed
"""
try:
audit_log = AuditLog(
user_id=user_id,
action=action,
resource_type=resource_type,
resource_id=resource_id,
ip_address=ip_address,
user_agent=user_agent,
request_id=request_id,
details=details,
status=status,
error_message=error_message
)
db.add(audit_log)
db.commit()
logger.info(
f"Audit log created: {action} on {resource_type}",
extra={
"action": action,
"resource_type": resource_type,
"resource_id": resource_id,
"user_id": user_id,
"status": status,
"request_id": request_id
}
)
except Exception as e:
logger.error(f"Failed to create audit log: {str(e)}", exc_info=True)
db.rollback()
# Don't raise exception - audit logging failures shouldn't break the app
# Global audit service instance
audit_service = AuditService()

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,

View File

@@ -0,0 +1,98 @@
from sqlalchemy.orm import Session
from ..models.cookie_policy import CookiePolicy
from ..models.cookie_integration_config import CookieIntegrationConfig
from ..models.user import User
from ..schemas.admin_privacy import (
CookieIntegrationSettings,
CookiePolicySettings,
PublicPrivacyConfig,
)
class PrivacyAdminService:
"""
Service layer for admin-controlled cookie policy and integrations.
"""
# Policy
@staticmethod
def get_or_create_policy(db: Session) -> CookiePolicy:
policy = db.query(CookiePolicy).first()
if policy:
return policy
policy = CookiePolicy()
db.add(policy)
db.commit()
db.refresh(policy)
return policy
@staticmethod
def get_policy_settings(db: Session) -> CookiePolicySettings:
policy = PrivacyAdminService.get_or_create_policy(db)
return CookiePolicySettings(
analytics_enabled=policy.analytics_enabled,
marketing_enabled=policy.marketing_enabled,
preferences_enabled=policy.preferences_enabled,
)
@staticmethod
def update_policy(
db: Session, settings: CookiePolicySettings, updated_by: User | None
) -> CookiePolicy:
policy = PrivacyAdminService.get_or_create_policy(db)
policy.analytics_enabled = settings.analytics_enabled
policy.marketing_enabled = settings.marketing_enabled
policy.preferences_enabled = settings.preferences_enabled
if updated_by:
policy.updated_by_id = updated_by.id
db.add(policy)
db.commit()
db.refresh(policy)
return policy
# Integrations
@staticmethod
def get_or_create_integrations(db: Session) -> CookieIntegrationConfig:
config = db.query(CookieIntegrationConfig).first()
if config:
return config
config = CookieIntegrationConfig()
db.add(config)
db.commit()
db.refresh(config)
return config
@staticmethod
def get_integration_settings(db: Session) -> CookieIntegrationSettings:
cfg = PrivacyAdminService.get_or_create_integrations(db)
return CookieIntegrationSettings(
ga_measurement_id=cfg.ga_measurement_id,
fb_pixel_id=cfg.fb_pixel_id,
)
@staticmethod
def update_integrations(
db: Session, settings: CookieIntegrationSettings, updated_by: User | None
) -> CookieIntegrationConfig:
cfg = PrivacyAdminService.get_or_create_integrations(db)
cfg.ga_measurement_id = settings.ga_measurement_id
cfg.fb_pixel_id = settings.fb_pixel_id
if updated_by:
cfg.updated_by_id = updated_by.id
db.add(cfg)
db.commit()
db.refresh(cfg)
return cfg
@staticmethod
def get_public_privacy_config(db: Session) -> PublicPrivacyConfig:
policy = PrivacyAdminService.get_policy_settings(db)
integrations = PrivacyAdminService.get_integration_settings(db)
return PublicPrivacyConfig(policy=policy, integrations=integrations)
privacy_admin_service = PrivacyAdminService()