This commit is contained in:
Iliyan Angelov
2025-12-06 03:27:35 +02:00
parent 7667eb5eda
commit 5a8ca3c475
2211 changed files with 28086 additions and 37066 deletions

View File

@@ -0,0 +1,17 @@
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey
from sqlalchemy.orm import relationship
from datetime import datetime
from ...shared.config.database import Base
class EmailVerificationToken(Base):
__tablename__ = 'email_verification_tokens'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
token = Column(String(255), unique=True, nullable=False, index=True)
email = Column(String(100), nullable=False) # Email being verified (may differ from user.email if email changed)
expires_at = Column(DateTime, nullable=False, index=True)
used = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
user = relationship('User')

View File

@@ -0,0 +1,13 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from datetime import datetime
from ...shared.config.database import Base
class PasswordHistory(Base):
__tablename__ = 'password_history'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
password_hash = Column(String(255), nullable=False) # Hashed password
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
user = relationship('User', back_populates='password_history')

View File

@@ -15,6 +15,7 @@ class User(Base):
avatar = Column(String(255), nullable=True)
currency = Column(String(3), nullable=False, default='VND')
is_active = Column(Boolean, nullable=False, default=True)
email_verified = Column(Boolean, nullable=False, default=False, index=True) # Email verification status
mfa_enabled = Column(Boolean, nullable=False, default=False)
mfa_secret = Column(String(255), nullable=True)
mfa_backup_codes = Column(Text, nullable=True)
@@ -23,6 +24,9 @@ class User(Base):
failed_login_attempts = Column(Integer, nullable=False, default=0)
locked_until = Column(DateTime, nullable=True)
# Password expiry (optional - can be None for no expiry)
password_changed_at = Column(DateTime, nullable=True, index=True)
# Guest Profile & CRM fields
is_vip = Column(Boolean, nullable=False, default=False)
lifetime_value = Column(Numeric(10, 2), nullable=True, default=0) # Total revenue from guest
@@ -50,4 +54,6 @@ class User(Base):
guest_notes = relationship('GuestNote', foreign_keys='GuestNote.user_id', back_populates='user', cascade='all, delete-orphan')
guest_tags = relationship('GuestTag', secondary='guest_tag_associations', back_populates='users')
guest_communications = relationship('GuestCommunication', foreign_keys='GuestCommunication.user_id', back_populates='user', cascade='all, delete-orphan')
guest_segments = relationship('GuestSegment', secondary='guest_segment_associations', back_populates='users')
guest_segments = relationship('GuestSegment', secondary='guest_segment_associations', back_populates='users')
email_verification_tokens = relationship('EmailVerificationToken', back_populates='user', cascade='all, delete-orphan')
password_history = relationship('PasswordHistory', back_populates='user', cascade='all, delete-orphan', order_by='PasswordHistory.created_at.desc()')

View File

@@ -3,7 +3,7 @@ User session management model.
"""
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean, Text
from sqlalchemy.orm import relationship
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from ...shared.config.database import Base
from ...shared.config.settings import settings
@@ -34,7 +34,7 @@ class UserSession(Base):
@property
def is_expired(self) -> bool:
"""Check if session is expired."""
return datetime.utcnow() > self.expires_at
return datetime.now(timezone.utc) > self.expires_at
@property
def is_valid(self) -> bool:

View File

@@ -524,7 +524,15 @@ async def update_profile(
# Rate limiting is handled by global middleware (slowapi)
# The global rate limiter applies default limits to all endpoints
# For stricter limits on profile updates, configure in settings or use endpoint-specific limits
client_ip = request.client.host if request.client else None
user_agent = request.headers.get('User-Agent')
request_id = getattr(request.state, 'request_id', None)
try:
# Track if password is being changed for audit logging
password_changed = bool(profile_data.password)
email_changed = bool(profile_data.email and profile_data.email != current_user.email)
user = await auth_service.update_profile(
db=db,
user_id=current_user.id,
@@ -535,6 +543,36 @@ async def update_profile(
current_password=profile_data.currentPassword,
currency=profile_data.currency
)
# Audit logging for sensitive profile changes
if password_changed:
await audit_service.log_action(
db=db,
action='user_password_changed',
resource_type='user',
user_id=current_user.id,
resource_id=current_user.id,
ip_address=client_ip,
user_agent=user_agent,
request_id=request_id,
details={'user_email': current_user.email},
status='success'
)
if email_changed:
await audit_service.log_action(
db=db,
action='user_email_change_requested',
resource_type='user',
user_id=current_user.id,
resource_id=current_user.id,
ip_address=client_ip,
user_agent=user_agent,
request_id=request_id,
details={'old_email': current_user.email, 'new_email': profile_data.email},
status='success'
)
return {'status': 'success', 'message': 'Profile updated successfully', 'data': {'user': user}}
except ValueError as e:
error_message = str(e)
@@ -551,15 +589,67 @@ async def forgot_password(request: ForgotPasswordRequest, db: Session=Depends(ge
return {'status': 'success', 'message': result['message']}
@router.post('/reset-password', response_model=MessageResponse)
async def reset_password(request: ResetPasswordRequest, db: Session=Depends(get_db)):
async def reset_password(request: Request, reset_request: ResetPasswordRequest, db: Session=Depends(get_db)):
client_ip = request.client.host if request.client else None
user_agent = request.headers.get('User-Agent')
request_id = getattr(request.state, 'request_id', None)
try:
result = await auth_service.reset_password(db=db, token=request.token, password=request.password)
result = await auth_service.reset_password(db=db, token=reset_request.token, password=reset_request.password)
# Get user ID from reset token for audit logging
from ..models.password_reset_token import PasswordResetToken
import hashlib
hashed_token = hashlib.sha256(reset_request.token.encode()).hexdigest()
reset_token_obj = db.query(PasswordResetToken).filter(
PasswordResetToken.token == hashed_token
).first()
if reset_token_obj:
await audit_service.log_action(
db=db,
action='user_password_reset',
resource_type='user',
user_id=reset_token_obj.user_id,
resource_id=reset_token_obj.user_id,
ip_address=client_ip,
user_agent=user_agent,
request_id=request_id,
details={'reset_method': 'token'},
status='success'
)
return {'status': 'success', 'message': result['message']}
except ValueError as e:
status_code = status.HTTP_400_BAD_REQUEST
if 'User not found' in str(e):
status_code = status.HTTP_404_NOT_FOUND
raise HTTPException(status_code=status_code, detail=str(e))
@router.post('/verify-email/{token}', response_model=MessageResponse)
async def verify_email(token: str, db: Session=Depends(get_db)):
"""Verify email address using verification token."""
try:
result = await auth_service.verify_email(db=db, token=token)
return {'status': 'success', 'message': result['message']}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
logger.error(f'Error verifying email: {str(e)}', exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='An error occurred while verifying your email')
@router.post('/resend-verification', response_model=MessageResponse)
async def resend_verification(current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
"""Resend email verification link."""
try:
result = await auth_service.resend_verification_email(db=db, user_id=current_user.id)
return {'status': 'success', 'message': result['message']}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
logger.error(f'Error resending verification email: {str(e)}', exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='An error occurred while resending verification email')
from ..services.mfa_service import mfa_service
from ...shared.config.settings import settings
@@ -578,9 +668,28 @@ async def init_mfa(current_user: User=Depends(get_current_user), db: Session=Dep
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error initializing MFA: {str(e)}')
@router.post('/mfa/enable')
async def enable_mfa(request: EnableMFARequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
async def enable_mfa(request: Request, mfa_request: EnableMFARequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
client_ip = request.client.host if request.client else None
user_agent = request.headers.get('User-Agent')
request_id = getattr(request.state, 'request_id', None)
try:
success, backup_codes = mfa_service.enable_mfa(db=db, user_id=current_user.id, secret=request.secret, verification_token=request.verification_token)
success, backup_codes = mfa_service.enable_mfa(db=db, user_id=current_user.id, secret=mfa_request.secret, verification_token=mfa_request.verification_token)
# Audit logging for MFA enable
await audit_service.log_action(
db=db,
action='user_mfa_enabled',
resource_type='user',
user_id=current_user.id,
resource_id=current_user.id,
ip_address=client_ip,
user_agent=user_agent,
request_id=request_id,
details={'user_email': current_user.email},
status='success'
)
return {'status': 'success', 'message': 'MFA enabled successfully', 'data': {'backup_codes': backup_codes}}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@@ -588,9 +697,28 @@ async def enable_mfa(request: EnableMFARequest, current_user: User=Depends(get_c
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error enabling MFA: {str(e)}')
@router.post('/mfa/disable')
async def disable_mfa(current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
async def disable_mfa(request: Request, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
client_ip = request.client.host if request.client else None
user_agent = request.headers.get('User-Agent')
request_id = getattr(request.state, 'request_id', None)
try:
mfa_service.disable_mfa(db=db, user_id=current_user.id)
# Audit logging for MFA disable
await audit_service.log_action(
db=db,
action='user_mfa_disabled',
resource_type='user',
user_id=current_user.id,
resource_id=current_user.id,
ip_address=client_ip,
user_agent=user_agent,
request_id=request_id,
details={'user_email': current_user.email},
status='success'
)
return {'status': 'success', 'message': 'MFA disabled successfully'}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))

View File

@@ -5,6 +5,7 @@ from typing import Optional
import bcrypt
from ...shared.config.database import get_db
from ...security.middleware.auth import get_current_user, authorize_roles
from ...security.middleware.step_up_auth import require_step_up_auth
from ..models.user import User
from ..models.role import Role
from ...bookings.models.booking import Booking, BookingStatus
@@ -53,7 +54,7 @@ async def get_user_by_id(id: int, current_user: User=Depends(authorize_roles('ad
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post('/', dependencies=[Depends(authorize_roles('admin'))])
@router.post('/', dependencies=[Depends(authorize_roles('admin')), Depends(require_step_up_auth("user creation"))])
async def create_user(
request: Request,
user_data: CreateUserRequest,
@@ -77,7 +78,17 @@ async def create_user(
password_bytes = password.encode('utf-8')
salt = bcrypt.gensalt()
hashed_password = bcrypt.hashpw(password_bytes, salt).decode('utf-8')
user = User(email=email, password=hashed_password, full_name=full_name, phone=phone_number, role_id=role_id, is_active=True)
from datetime import datetime
user = User(
email=email,
password=hashed_password,
full_name=full_name,
phone=phone_number,
role_id=role_id,
is_active=True,
email_verified=False, # Admin-created users need email verification
password_changed_at=datetime.utcnow() # Set initial password change timestamp
)
db.add(user)
db.commit()
db.refresh(user)
@@ -110,7 +121,13 @@ async def create_user(
raise HTTPException(status_code=500, detail=str(e))
@router.put('/{id}')
async def update_user(id: int, request: Request, user_data: UpdateUserRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
async def update_user(
id: int,
request: Request,
user_data: UpdateUserRequest,
current_user: User=Depends(get_current_user),
db: Session=Depends(get_db)
):
"""Update a user with validated input using Pydantic schema."""
client_ip = request.client.host if request.client else None
user_agent = request.headers.get('User-Agent')
@@ -119,6 +136,30 @@ async def update_user(id: int, request: Request, user_data: UpdateUserRequest, c
try:
if not can_manage_users(current_user, db) and current_user.id != id:
raise HTTPException(status_code=403, detail='Forbidden')
# SECURITY: Require step-up auth for admin user management operations
is_admin_managing_user = can_manage_users(current_user, db) and current_user.id != id
if is_admin_managing_user:
# Check if step-up auth is required (this will raise if not authenticated)
from ...payments.services.accountant_security_service import accountant_security_service
session_token = request.headers.get('X-Session-Token') or request.cookies.get('session_token')
requires_step_up, reason = accountant_security_service.require_step_up(
db=db,
user_id=current_user.id,
session_token=session_token,
action_description="user management"
)
if requires_step_up:
from fastapi import status
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
'error': 'step_up_required',
'message': reason or 'Step-up authentication required for user management operations',
'action': 'user_management'
}
)
user = db.query(User).options(joinedload(User.role)).filter(User.id == id).first()
if not user:
raise HTTPException(status_code=404, detail='User not found')
@@ -128,6 +169,7 @@ async def update_user(id: int, request: Request, user_data: UpdateUserRequest, c
old_role_id = user.role_id
old_role_name = user.role.name if user.role else None
old_is_active = user.is_active
old_email_verified = getattr(user, 'email_verified', True)
# Check email uniqueness if being updated
if user_data.email and user_data.email != user.email:
@@ -230,7 +272,7 @@ async def update_user(id: int, request: Request, user_data: UpdateUserRequest, c
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))])
@router.delete('/{id}', dependencies=[Depends(authorize_roles('admin')), Depends(require_step_up_auth("user deletion"))])
async def delete_user(id: int, request: Request, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
client_ip = request.client.host if request.client else None
user_agent = request.headers.get('User-Agent')

View File

@@ -10,13 +10,18 @@ import logging
from ..models.user import User
from ..models.refresh_token import RefreshToken
from ..models.password_reset_token import PasswordResetToken
from ..models.email_verification_token import EmailVerificationToken
from ..models.password_history import PasswordHistory
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
password_changed_email_template,
email_verification_template,
email_changed_verification_template
)
from ...auth.services.session_service import session_service
from ...shared.config.settings import settings
import os
@@ -137,6 +142,7 @@ class AuthService:
"avatar": user.avatar,
"currency": getattr(user, 'currency', 'VND'),
"role": user.role.name if user.role else "customer",
"emailVerified": getattr(user, 'email_verified', True), # Default to True for backward compatibility
"createdAt": user.created_at.isoformat() if user.created_at else None,
"updatedAt": user.updated_at.isoformat() if user.updated_at else None,
}
@@ -160,7 +166,9 @@ class AuthService:
email=email,
password=hashed_password,
phone=phone,
role_id=3
role_id=3,
email_verified=False, # Email not verified on registration
password_changed_at=datetime.utcnow() # Set initial password change timestamp
)
db.add(user)
db.commit()
@@ -168,6 +176,20 @@ class AuthService:
user.role = db.query(Role).filter(Role.id == user.role_id).first()
# Generate email verification token
verification_token = secrets.token_hex(32)
hashed_verification_token = hashlib.sha256(verification_token.encode()).hexdigest()
expires_at = datetime.utcnow() + timedelta(hours=24) # 24 hour expiration
verification_token_obj = EmailVerificationToken(
user_id=user.id,
token=hashed_verification_token,
email=email,
expires_at=expires_at
)
db.add(verification_token_obj)
db.commit()
tokens = self.generate_tokens(user.id)
expires_at = datetime.utcnow() + timedelta(days=7)
@@ -179,17 +201,19 @@ class AuthService:
db.add(refresh_token)
db.commit()
# Send welcome email with verification link
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)
verification_url = f"{client_url}/verify-email/{verification_token}"
email_html = email_verification_template(verification_url, user.full_name)
await send_email(
to=user.email,
subject="Welcome to Hotel Booking",
subject="Welcome to Hotel Booking - Verify Your Email",
html=email_html
)
logger.info(f"Welcome email sent successfully to {user.email}")
logger.info(f"Verification 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)
logger.error(f"Failed to send verification email to {user.email}: {type(e).__name__}: {str(e)}", exc_info=True)
return {
"user": self.format_user_response(user),
@@ -212,6 +236,13 @@ class AuthService:
if not user.is_active:
logger.warning(f"Login attempt for inactive user: {email}")
raise ValueError("Account is disabled. Please contact support.")
# Check password expiry (if enabled)
if settings.PASSWORD_EXPIRY_DAYS > 0 and user.password_changed_at:
expiry_date = user.password_changed_at + timedelta(days=settings.PASSWORD_EXPIRY_DAYS)
if datetime.utcnow() > expiry_date:
days_expired = (datetime.utcnow() - expiry_date).days
raise ValueError(f"Your password has expired {days_expired} day(s) ago. Please reset your password to continue.")
# Check if account is locked (reset if lockout expired)
if user.locked_until:
@@ -248,12 +279,18 @@ class AuthService:
# SECURITY: Don't reveal remaining attempts to prevent enumeration
raise ValueError("Invalid email or password")
# Check email verification (warn but allow login for better UX)
email_verification_required = not user.email_verified
if email_verification_required:
logger.info(f"Login attempt for unverified email: {email}")
if user.mfa_enabled:
if not mfa_token:
return {
"requires_mfa": True,
"user_id": user.id
"user_id": user.id,
"email_verified": user.email_verified
}
@@ -411,27 +448,118 @@ class AuthService:
if not self.verify_password(current_password, user.password):
raise ValueError("Current password is incorrect")
# Check password minimum age
if user.password_changed_at and settings.PASSWORD_MIN_AGE_DAYS > 0:
min_age_date = user.password_changed_at + timedelta(days=settings.PASSWORD_MIN_AGE_DAYS)
if datetime.utcnow() < min_age_date:
days_remaining = (min_age_date - datetime.utcnow()).days + 1
raise ValueError(f"Password cannot be changed yet. Minimum age is {settings.PASSWORD_MIN_AGE_DAYS} days. Please try again in {days_remaining} day(s).")
# 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)
# Check password history to prevent reuse
hashed_new_password = self.hash_password(password)
if settings.PASSWORD_HISTORY_COUNT > 0:
# Get recent password history
recent_passwords = db.query(PasswordHistory).filter(
PasswordHistory.user_id == user.id
).order_by(PasswordHistory.created_at.desc()).limit(settings.PASSWORD_HISTORY_COUNT).all()
# Check if new password matches any recent password
for old_password in recent_passwords:
if self.verify_password(password, old_password.password_hash):
raise ValueError(f"Password cannot be reused. You must use a password that hasn't been used in your last {settings.PASSWORD_HISTORY_COUNT} password changes.")
# Check if new password is same as current password
if self.verify_password(password, user.password):
raise ValueError("New password must be different from your current password")
user.password = self.hash_password(password)
# SECURITY: Revoke all existing sessions and refresh tokens on password change
# This prevents compromised sessions from remaining valid after password change
from ..models.refresh_token import RefreshToken
revoked_sessions = session_service.revoke_all_user_sessions(db, user.id)
revoked_tokens = db.query(RefreshToken).filter(RefreshToken.user_id == user.id).delete()
logger.info(f"Password change for user {user.id}: Revoked {revoked_sessions} sessions and {revoked_tokens} refresh tokens")
# Save current password to history before changing
if settings.PASSWORD_HISTORY_COUNT > 0:
password_history_entry = PasswordHistory(
user_id=user.id,
password_hash=user.password
)
db.add(password_history_entry)
# Clean up old password history (keep only the configured number)
old_passwords = db.query(PasswordHistory).filter(
PasswordHistory.user_id == user.id
).order_by(PasswordHistory.created_at.desc()).offset(settings.PASSWORD_HISTORY_COUNT).all()
for old_pwd in old_passwords:
db.delete(old_pwd)
# Update password and timestamp
user.password = hashed_new_password
user.password_changed_at = datetime.utcnow()
if full_name is not None:
user.full_name = full_name
email_verification_required = False
verification_token = None
if email is not None:
# Normalize email (lowercase and trim)
email = email.lower().strip()
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 email is changing, require re-verification
if email != user.email:
existing_user = db.query(User).filter(
User.email == email,
User.id != user_id
).first()
if existing_user:
raise ValueError("Email already in use")
# Create email verification token for new email
verification_token = secrets.token_hex(32)
hashed_verification_token = hashlib.sha256(verification_token.encode()).hexdigest()
expires_at = datetime.utcnow() + timedelta(hours=24)
# Mark email as unverified until new email is verified
user.email_verified = False
email_verification_required = True
# Delete old verification tokens for this user
db.query(EmailVerificationToken).filter(EmailVerificationToken.user_id == user.id).delete()
verification_token_obj = EmailVerificationToken(
user_id=user.id,
token=hashed_verification_token,
email=email, # New email to verify
expires_at=expires_at
)
db.add(verification_token_obj)
# Update email (but it's not verified yet)
user.email = email
# Send verification email to new address
try:
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
verification_url = f"{client_url}/verify-email/{verification_token}"
email_html = email_changed_verification_template(verification_url, user.full_name, email)
await send_email(
to=email,
subject="Verify Your New Email Address",
html=email_html
)
logger.info(f"Email change verification sent to {email}")
except Exception as e:
logger.error(f"Failed to send email change verification to {email}: {type(e).__name__}: {str(e)}", exc_info=True)
if phone_number is not None:
user.phone = phone_number
if currency is not None:
@@ -446,7 +574,12 @@ class AuthService:
user.role = db.query(Role).filter(Role.id == user.role_id).first()
return self.format_user_response(user)
response = self.format_user_response(user)
if email_verification_required:
response["email_verification_required"] = True
response["message"] = "Profile updated. Please verify your new email address. A verification link has been sent."
return response
def generate_reset_token(self) -> tuple:
reset_token = secrets.token_hex(32)
@@ -524,14 +657,50 @@ class AuthService:
if self.verify_password(password, user.password):
raise ValueError("New password must be different from the old password")
hashed_password = self.hash_password(password)
# Check password history to prevent reuse (if enabled)
hashed_new_password = self.hash_password(password)
if settings.PASSWORD_HISTORY_COUNT > 0:
# Get recent password history
recent_passwords = db.query(PasswordHistory).filter(
PasswordHistory.user_id == user.id
).order_by(PasswordHistory.created_at.desc()).limit(settings.PASSWORD_HISTORY_COUNT).all()
# Check if new password matches any recent password
for old_password in recent_passwords:
if self.verify_password(password, old_password.password_hash):
raise ValueError(f"Password cannot be reused. You must use a password that hasn't been used in your last {settings.PASSWORD_HISTORY_COUNT} password changes.")
# Save current password to history before changing
if settings.PASSWORD_HISTORY_COUNT > 0:
password_history_entry = PasswordHistory(
user_id=user.id,
password_hash=user.password
)
db.add(password_history_entry)
# Clean up old password history (keep only the configured number)
old_passwords = db.query(PasswordHistory).filter(
PasswordHistory.user_id == user.id
).order_by(PasswordHistory.created_at.desc()).offset(settings.PASSWORD_HISTORY_COUNT).all()
for old_pwd in old_passwords:
db.delete(old_pwd)
user.password = hashed_password
user.password = hashed_new_password
user.password_changed_at = datetime.utcnow()
# SECURITY: Revoke all existing sessions and refresh tokens on password reset
# This prevents compromised sessions from remaining valid after password change
from ..models.refresh_token import RefreshToken
revoked_sessions = session_service.revoke_all_user_sessions(db, user.id)
revoked_tokens = db.query(RefreshToken).filter(RefreshToken.user_id == user.id).delete()
db.commit()
reset_token.used = True
db.commit()
logger.info(f"Password reset for user {user.id}: Revoked {revoked_sessions} sessions and {revoked_tokens} refresh tokens")
try:
logger.info(f"Attempting to send password changed confirmation email to {user.email}")
email_html = password_changed_email_template(user.email)
@@ -546,7 +715,101 @@ class AuthService:
return {
"success": True,
"message": "Password has been reset successfully"
"message": "Password has been reset successfully. All existing sessions have been revoked for security."
}
async def verify_email(self, db: Session, token: str) -> dict:
"""Verify email address using verification token."""
if not token:
raise ValueError("Verification token is required")
hashed_token = hashlib.sha256(token.encode()).hexdigest()
verification_token = db.query(EmailVerificationToken).filter(
EmailVerificationToken.token == hashed_token,
EmailVerificationToken.expires_at > datetime.utcnow(),
EmailVerificationToken.used == False
).first()
if not verification_token:
raise ValueError("Invalid or expired verification token")
user = db.query(User).filter(User.id == verification_token.user_id).first()
if not user:
raise ValueError("User not found")
# Update email if it was changed (verification token contains the new email)
if verification_token.email != user.email:
# Check if new email is already in use
existing_user = db.query(User).filter(User.email == verification_token.email).first()
if existing_user and existing_user.id != user.id:
raise ValueError("Email address is already in use")
user.email = verification_token.email
# Mark email as verified
user.email_verified = True
verification_token.used = True
# Delete all other verification tokens for this user
db.query(EmailVerificationToken).filter(
EmailVerificationToken.user_id == user.id,
EmailVerificationToken.id != verification_token.id
).delete()
db.commit()
logger.info(f"Email verified for user {user.id}: {user.email}")
return {
"success": True,
"message": "Email address verified successfully",
"email": user.email
}
async def resend_verification_email(self, db: Session, user_id: int) -> dict:
"""Resend email verification link."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise ValueError("User not found")
if user.email_verified:
raise ValueError("Email is already verified")
# Delete old verification tokens
db.query(EmailVerificationToken).filter(EmailVerificationToken.user_id == user.id).delete()
# Create new verification token
verification_token = secrets.token_hex(32)
hashed_verification_token = hashlib.sha256(verification_token.encode()).hexdigest()
expires_at = datetime.utcnow() + timedelta(hours=24)
verification_token_obj = EmailVerificationToken(
user_id=user.id,
token=hashed_verification_token,
email=user.email,
expires_at=expires_at
)
db.add(verification_token_obj)
db.commit()
# Send verification email
try:
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
verification_url = f"{client_url}/verify-email/{verification_token}"
email_html = email_verification_template(verification_url, user.full_name)
await send_email(
to=user.email,
subject="Verify Your Email Address",
html=email_html
)
logger.info(f"Verification email resent to {user.email}")
except Exception as e:
logger.error(f"Failed to resend verification email to {user.email}: {type(e).__name__}: {str(e)}", exc_info=True)
raise ValueError("Failed to send verification email. Please try again later.")
return {
"success": True,
"message": "Verification email sent successfully"
}
auth_service = AuthService()

View File

@@ -1,9 +1,9 @@
"""
User session management service.
"""
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, noload
from typing import Optional, List
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
import secrets
from ..models.user_session import UserSession
from ...shared.config.settings import settings
@@ -27,7 +27,7 @@ class SessionService:
refresh_token = secrets.token_urlsafe(64)
# Calculate expiration
expires_at = datetime.utcnow() + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
session = UserSession(
user_id=user_id,
@@ -52,7 +52,7 @@ class SessionService:
session_token: str
) -> Optional[UserSession]:
"""Get a session by token."""
return db.query(UserSession).filter(
return db.query(UserSession).options(noload(UserSession.user)).filter(
UserSession.session_token == session_token,
UserSession.is_active == True
).first()
@@ -65,7 +65,7 @@ class SessionService:
"""Update session last activity timestamp."""
session = SessionService.get_session(db, session_token)
if session and session.is_valid:
session.last_activity = datetime.utcnow()
session.last_activity = datetime.now(timezone.utc)
db.commit()
db.refresh(session)
return session
@@ -119,12 +119,12 @@ class SessionService:
active_only: bool = True
) -> List[UserSession]:
"""Get all sessions for a user."""
query = db.query(UserSession).filter(UserSession.user_id == user_id)
query = db.query(UserSession).options(noload(UserSession.user)).filter(UserSession.user_id == user_id)
if active_only:
query = query.filter(
UserSession.is_active == True,
UserSession.expires_at > datetime.utcnow()
UserSession.expires_at > datetime.now(timezone.utc)
)
return query.order_by(UserSession.last_activity.desc()).all()
@@ -133,7 +133,7 @@ class SessionService:
def cleanup_expired_sessions(db: Session) -> int:
"""Clean up expired sessions."""
expired = db.query(UserSession).filter(
UserSession.expires_at < datetime.utcnow(),
UserSession.expires_at < datetime.now(timezone.utc),
UserSession.is_active == True
).all()

View File

@@ -549,8 +549,9 @@ async def get_booking_by_id(id: int, request: Request, current_user: User=Depend
booking = db.query(Booking).options(selectinload(Booking.payments), selectinload(Booking.service_usages).selectinload(ServiceUsage.service), joinedload(Booking.user), joinedload(Booking.room).joinedload(Room.room_type)).filter(Booking.id == id).first()
if not booking:
raise HTTPException(status_code=404, detail='Booking not found')
from ...shared.utils.role_helpers import is_admin
if not is_admin(current_user, db) and booking.user_id != current_user.id:
from ...shared.utils.role_helpers import can_manage_bookings
# Allow admin/staff to view any booking, customers can only view their own
if not can_manage_bookings(current_user, db) and booking.user_id != current_user.id:
raise HTTPException(status_code=403, detail='Forbidden')
import logging
logger = logging.getLogger(__name__)

Some files were not shown because too many files have changed in this diff Show More