489 lines
16 KiB
Python
489 lines
16 KiB
Python
from sqlalchemy.orm import Session
|
|
from sqlalchemy.exc import SQLAlchemyError
|
|
from src.models.models import User, Client
|
|
from src.schemas.user import UserCreate
|
|
from src.utils.security import get_password_hash, verify_password, generate_token
|
|
from src.services.email_service import (
|
|
send_verification_email,
|
|
send_password_reset_email,
|
|
)
|
|
from datetime import datetime, timedelta
|
|
import uuid
|
|
import logging
|
|
from typing import Optional, Tuple
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def create_user(
|
|
db: Session,
|
|
user_data: UserCreate,
|
|
is_admin: bool = False,
|
|
client_id: Optional[uuid.UUID] = None,
|
|
auto_verify: bool = False,
|
|
) -> Tuple[Optional[User], str]:
|
|
"""
|
|
Creates a new user in the system
|
|
|
|
Args:
|
|
db: Database session
|
|
user_data: User data to be created
|
|
is_admin: If the user is an administrator
|
|
client_id: Associated client ID (optional, a new one will be created if not provided)
|
|
auto_verify: If True, user is created with email already verified and active
|
|
|
|
Returns:
|
|
Tuple[Optional[User], str]: Tuple with the created user (or None in case of error) and status message
|
|
"""
|
|
try:
|
|
# Check if email already exists
|
|
db_user = db.query(User).filter(User.email == user_data.email).first()
|
|
if db_user:
|
|
logger.warning(
|
|
f"Attempt to register with existing email: {user_data.email}"
|
|
)
|
|
return None, "Email already registered"
|
|
|
|
# Create verification token if needed
|
|
verification_token = None
|
|
token_expiry = None
|
|
if not auto_verify:
|
|
verification_token = generate_token()
|
|
token_expiry = datetime.utcnow() + timedelta(hours=24)
|
|
|
|
# Start transaction
|
|
user = None
|
|
local_client_id = client_id
|
|
|
|
try:
|
|
# If not admin and no client_id, create an associated client
|
|
if not is_admin and local_client_id is None:
|
|
client = Client(name=user_data.name, email=user_data.email)
|
|
db.add(client)
|
|
db.flush() # Get the client ID
|
|
local_client_id = client.id
|
|
|
|
# Create user
|
|
user = User(
|
|
email=user_data.email,
|
|
password_hash=get_password_hash(user_data.password),
|
|
client_id=local_client_id,
|
|
is_admin=is_admin,
|
|
is_active=auto_verify,
|
|
email_verified=auto_verify,
|
|
verification_token=verification_token,
|
|
verification_token_expiry=token_expiry,
|
|
)
|
|
db.add(user)
|
|
db.commit()
|
|
|
|
# Send verification email if not auto-verified
|
|
if not auto_verify:
|
|
email_sent = send_verification_email(user.email, verification_token)
|
|
if not email_sent:
|
|
logger.error(f"Failed to send verification email to {user.email}")
|
|
# We don't do rollback here, we just log the error
|
|
|
|
logger.info(f"User created successfully: {user.email}")
|
|
return (
|
|
user,
|
|
"User created successfully. Check your email to activate your account.",
|
|
)
|
|
else:
|
|
logger.info(f"User created and auto-verified: {user.email}")
|
|
return (
|
|
user,
|
|
"User created successfully.",
|
|
)
|
|
|
|
except SQLAlchemyError as e:
|
|
db.rollback()
|
|
logger.error(f"Error creating user: {str(e)}")
|
|
return None, f"Error creating user: {str(e)}"
|
|
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error creating user: {str(e)}")
|
|
return None, f"Unexpected error: {str(e)}"
|
|
|
|
|
|
def verify_email(db: Session, token: str) -> Tuple[bool, str]:
|
|
"""
|
|
Verify the user's email using the provided token
|
|
|
|
Args:
|
|
db: Database session
|
|
token: Verification token
|
|
|
|
Returns:
|
|
Tuple[bool, str]: Tuple with verification status and message
|
|
"""
|
|
try:
|
|
# Search for user by token
|
|
user = db.query(User).filter(User.verification_token == token).first()
|
|
|
|
if not user:
|
|
logger.warning(f"Attempt to verify with invalid token: {token}")
|
|
return False, "Invalid verification token"
|
|
|
|
# Check if the token has expired
|
|
now = datetime.utcnow()
|
|
expiry = user.verification_token_expiry
|
|
|
|
# Ensure both dates are of the same type (aware or naive)
|
|
if expiry.tzinfo is not None and now.tzinfo is None:
|
|
# If expiry has timezone and now doesn't, convert now to have timezone
|
|
now = now.replace(tzinfo=expiry.tzinfo)
|
|
elif now.tzinfo is not None and expiry.tzinfo is None:
|
|
# If now has timezone and expiry doesn't, convert expiry to have timezone
|
|
expiry = expiry.replace(tzinfo=now.tzinfo)
|
|
|
|
if expiry < now:
|
|
logger.warning(
|
|
f"Attempt to verify with expired token for user: {user.email}"
|
|
)
|
|
return False, "Verification token expired"
|
|
|
|
# Update user
|
|
user.email_verified = True
|
|
user.is_active = True
|
|
user.verification_token = None
|
|
user.verification_token_expiry = None
|
|
|
|
db.commit()
|
|
logger.info(f"Email verified successfully for user: {user.email}")
|
|
return True, "Email verified successfully. Your account is active."
|
|
|
|
except SQLAlchemyError as e:
|
|
db.rollback()
|
|
logger.error(f"Error verifying email: {str(e)}")
|
|
return False, f"Error verifying email: {str(e)}"
|
|
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error verifying email: {str(e)}")
|
|
return False, f"Unexpected error: {str(e)}"
|
|
|
|
|
|
def resend_verification(db: Session, email: str) -> Tuple[bool, str]:
|
|
"""
|
|
Resend the verification email
|
|
|
|
Args:
|
|
db: Database session
|
|
email: User email
|
|
|
|
Returns:
|
|
Tuple[bool, str]: Tuple with operation status and message
|
|
"""
|
|
try:
|
|
# Search for user by email
|
|
user = db.query(User).filter(User.email == email).first()
|
|
|
|
if not user:
|
|
logger.warning(
|
|
f"Attempt to resend verification email for non-existent email: {email}"
|
|
)
|
|
return False, "Email not found"
|
|
|
|
if user.email_verified:
|
|
logger.info(
|
|
f"Attempt to resend verification email for already verified email: {email}"
|
|
)
|
|
return False, "Email already verified"
|
|
|
|
# Generate new token
|
|
verification_token = generate_token()
|
|
token_expiry = datetime.utcnow() + timedelta(hours=24)
|
|
|
|
# Update user
|
|
user.verification_token = verification_token
|
|
user.verification_token_expiry = token_expiry
|
|
|
|
db.commit()
|
|
|
|
# Send email
|
|
email_sent = send_verification_email(user.email, verification_token)
|
|
if not email_sent:
|
|
logger.error(f"Failed to resend verification email to {user.email}")
|
|
return False, "Failed to send verification email"
|
|
|
|
logger.info(f"Verification email resent successfully to: {user.email}")
|
|
return True, "Verification email resent. Check your inbox."
|
|
|
|
except SQLAlchemyError as e:
|
|
db.rollback()
|
|
logger.error(f"Error resending verification: {str(e)}")
|
|
return False, f"Error resending verification: {str(e)}"
|
|
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error resending verification: {str(e)}")
|
|
return False, f"Unexpected error: {str(e)}"
|
|
|
|
|
|
def forgot_password(db: Session, email: str) -> Tuple[bool, str]:
|
|
"""
|
|
Initiates the password recovery process
|
|
|
|
Args:
|
|
db: Database session
|
|
email: User email
|
|
|
|
Returns:
|
|
Tuple[bool, str]: Tuple with operation status and message
|
|
"""
|
|
try:
|
|
# Search for user by email
|
|
user = db.query(User).filter(User.email == email).first()
|
|
|
|
if not user:
|
|
# For security, we don't inform if the email exists or not
|
|
logger.info(f"Attempt to recover password for non-existent email: {email}")
|
|
return (
|
|
True,
|
|
"If the email is registered, you will receive instructions to reset your password.",
|
|
)
|
|
|
|
# Generate reset token
|
|
reset_token = generate_token()
|
|
token_expiry = datetime.utcnow() + timedelta(hours=1) # Token valid for 1 hour
|
|
|
|
# Update user
|
|
user.password_reset_token = reset_token
|
|
user.password_reset_expiry = token_expiry
|
|
|
|
db.commit()
|
|
|
|
# Send email
|
|
email_sent = send_password_reset_email(user.email, reset_token)
|
|
if not email_sent:
|
|
logger.error(f"Failed to send password reset email to {user.email}")
|
|
return False, "Failed to send password reset email"
|
|
|
|
logger.info(f"Password reset email sent successfully to: {user.email}")
|
|
return (
|
|
True,
|
|
"If the email is registered, you will receive instructions to reset your password.",
|
|
)
|
|
|
|
except SQLAlchemyError as e:
|
|
db.rollback()
|
|
logger.error(f"Error processing password recovery: {str(e)}")
|
|
return False, f"Error processing password recovery: {str(e)}"
|
|
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error processing password recovery: {str(e)}")
|
|
return False, f"Unexpected error: {str(e)}"
|
|
|
|
|
|
def reset_password(db: Session, token: str, new_password: str) -> Tuple[bool, str]:
|
|
"""
|
|
Resets the user's password using the provided token
|
|
|
|
Args:
|
|
db: Database session
|
|
token: Password reset token
|
|
new_password: New password
|
|
|
|
Returns:
|
|
Tuple[bool, str]: Tuple with operation status and message
|
|
"""
|
|
try:
|
|
# Search for user by token
|
|
user = db.query(User).filter(User.password_reset_token == token).first()
|
|
|
|
if not user:
|
|
logger.warning(f"Attempt to reset password with invalid token: {token}")
|
|
return False, "Invalid password reset token"
|
|
|
|
# Check if the token has expired
|
|
if user.password_reset_expiry < datetime.utcnow():
|
|
logger.warning(
|
|
f"Attempt to reset password with expired token for user: {user.email}"
|
|
)
|
|
return False, "Password reset token expired"
|
|
|
|
# Update password
|
|
user.password_hash = get_password_hash(new_password)
|
|
user.password_reset_token = None
|
|
user.password_reset_expiry = None
|
|
|
|
db.commit()
|
|
logger.info(f"Password reset successfully for user: {user.email}")
|
|
return (
|
|
True,
|
|
"Password reset successfully. You can now login with your new password.",
|
|
)
|
|
|
|
except SQLAlchemyError as e:
|
|
db.rollback()
|
|
logger.error(f"Error resetting password: {str(e)}")
|
|
return False, f"Error resetting password: {str(e)}"
|
|
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error resetting password: {str(e)}")
|
|
return False, f"Unexpected error: {str(e)}"
|
|
|
|
|
|
def get_user_by_email(db: Session, email: str) -> Optional[User]:
|
|
"""
|
|
Searches for a user by email
|
|
|
|
Args:
|
|
db: Database session
|
|
email: User email
|
|
|
|
Returns:
|
|
Optional[User]: User found or None
|
|
"""
|
|
try:
|
|
return db.query(User).filter(User.email == email).first()
|
|
except Exception as e:
|
|
logger.error(f"Error searching for user by email: {str(e)}")
|
|
return None
|
|
|
|
|
|
def authenticate_user(db: Session, email: str, password: str) -> Optional[User]:
|
|
"""
|
|
Authenticates a user with email and password
|
|
|
|
Args:
|
|
db: Database session
|
|
email: User email
|
|
password: User password
|
|
|
|
Returns:
|
|
Optional[User]: Authenticated user or None
|
|
"""
|
|
user = get_user_by_email(db, email)
|
|
if not user:
|
|
return None
|
|
if not verify_password(password, user.password_hash):
|
|
return None
|
|
if not user.is_active:
|
|
return None
|
|
return user
|
|
|
|
|
|
def get_admin_users(db: Session, skip: int = 0, limit: int = 100):
|
|
"""
|
|
Lists the admin users
|
|
|
|
Args:
|
|
db: Database session
|
|
skip: Number of records to skip
|
|
limit: Maximum number of records to return
|
|
|
|
Returns:
|
|
List[User]: List of admin users
|
|
"""
|
|
try:
|
|
users = db.query(User).filter(User.is_admin).offset(skip).limit(limit).all()
|
|
logger.info(f"List of admins: {len(users)} found")
|
|
return users
|
|
|
|
except SQLAlchemyError as e:
|
|
logger.error(f"Error listing admins: {str(e)}")
|
|
return []
|
|
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error listing admins: {str(e)}")
|
|
return []
|
|
|
|
|
|
def create_admin_user(db: Session, user_data: UserCreate) -> Tuple[Optional[User], str]:
|
|
"""
|
|
Creates a new admin user
|
|
|
|
Args:
|
|
db: Database session
|
|
user_data: User data to be created
|
|
|
|
Returns:
|
|
Tuple[Optional[User], str]: Tuple with the created user (or None in case of error) and status message
|
|
"""
|
|
return create_user(db, user_data, is_admin=True, auto_verify=True)
|
|
|
|
|
|
def deactivate_user(db: Session, user_id: uuid.UUID) -> Tuple[bool, str]:
|
|
"""
|
|
Deactivates a user (does not delete, only marks as inactive)
|
|
|
|
Args:
|
|
db: Database session
|
|
user_id: ID of the user to be deactivated
|
|
|
|
Returns:
|
|
Tuple[bool, str]: Tuple with operation status and message
|
|
"""
|
|
try:
|
|
# Search for user by ID
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
|
|
if not user:
|
|
logger.warning(f"Attempt to deactivate non-existent user: {user_id}")
|
|
return False, "User not found"
|
|
|
|
# Deactivate user
|
|
user.is_active = False
|
|
|
|
db.commit()
|
|
logger.info(f"User deactivated successfully: {user.email}")
|
|
return True, "User deactivated successfully"
|
|
|
|
except SQLAlchemyError as e:
|
|
db.rollback()
|
|
logger.error(f"Error deactivating user: {str(e)}")
|
|
return False, f"Error deactivating user: {str(e)}"
|
|
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error deactivating user: {str(e)}")
|
|
return False, f"Unexpected error: {str(e)}"
|
|
|
|
|
|
def change_password(
|
|
db: Session, user_id: uuid.UUID, current_password: str, new_password: str
|
|
) -> Tuple[bool, str]:
|
|
"""
|
|
Changes the password of an authenticated user
|
|
|
|
Args:
|
|
db: Database session
|
|
user_id: ID of the user
|
|
current_password: Current password for verification
|
|
new_password: New password to set
|
|
|
|
Returns:
|
|
Tuple[bool, str]: Tuple with operation status and message
|
|
"""
|
|
try:
|
|
# Search for user by ID
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
|
|
if not user:
|
|
logger.warning(
|
|
f"Attempt to change password for non-existent user: {user_id}"
|
|
)
|
|
return False, "User not found"
|
|
|
|
# Verify current password
|
|
if not verify_password(current_password, user.password_hash):
|
|
logger.warning(
|
|
f"Attempt to change password with invalid current password for user: {user.email}"
|
|
)
|
|
return False, "Current password is incorrect"
|
|
|
|
# Update password
|
|
user.password_hash = get_password_hash(new_password)
|
|
|
|
db.commit()
|
|
logger.info(f"Password changed successfully for user: {user.email}")
|
|
return True, "Password changed successfully"
|
|
|
|
except SQLAlchemyError as e:
|
|
db.rollback()
|
|
logger.error(f"Error changing password: {str(e)}")
|
|
return False, f"Error changing password: {str(e)}"
|
|
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error changing password: {str(e)}")
|
|
return False, f"Unexpected error: {str(e)}"
|