updates
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
17
Backend/src/auth/models/email_verification_token.py
Normal file
17
Backend/src/auth/models/email_verification_token.py
Normal 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')
|
||||
|
||||
13
Backend/src/auth/models/password_history.py
Normal file
13
Backend/src/auth/models/password_history.py
Normal 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')
|
||||
|
||||
@@ -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()')
|
||||
@@ -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:
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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))
|
||||
|
||||
@@ -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')
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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__)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user