diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index 5e2f858a0..5f5528ca3 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -5,6 +5,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from backend.app.api.routes.settings import get_external_login_url from backend.app.core.auth import ( ACCESS_TOKEN_EXPIRE_MINUTES, authenticate_user, @@ -41,7 +42,6 @@ from backend.app.services.email_service import ( save_smtp_settings, send_email, ) -from backend.app.api.routes.settings import get_external_login_url def _user_to_response(user: User) -> UserResponse: @@ -263,7 +263,7 @@ async def disable_auth( @router.post("/login", response_model=LoginResponse) async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)): """Login and get access token. - + Supports username or email-based login. Username lookup is case-insensitive. """ # Check if auth is enabled @@ -276,13 +276,13 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)): # Try username-based authentication first user = await authenticate_user(db, request.username, request.password) - + # If username auth failed and advanced auth is enabled, try email-based authentication if not user: advanced_auth = await is_advanced_auth_enabled(db) if advanced_auth: user = await authenticate_user_by_email(db, request.username, request.password) - + if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -437,7 +437,7 @@ async def enable_advanced_auth( db: AsyncSession = Depends(get_db), ): """Enable advanced authentication (admin only). - + Requires SMTP settings to be configured and tested first. """ import logging @@ -546,7 +546,7 @@ async def forgot_password(request: ForgotPasswordRequest, db: AsyncSession = Dep # Find user by email user = await get_user_by_email(db, request.email) - + # Always return success message to prevent email enumeration # but only send email if user exists if user and user.is_active: diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 735542f21..20c2fce3d 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -3,6 +3,7 @@ from sqlalchemy import delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from backend.app.api.routes.settings import get_external_login_url from backend.app.core.auth import ( RequirePermissionIfAuthEnabled, get_current_user_optional, @@ -24,7 +25,6 @@ from backend.app.services.email_service import ( get_smtp_settings, send_email, ) -from backend.app.api.routes.settings import get_external_login_url router = APIRouter(prefix="/users", tags=["users"]) @@ -64,7 +64,7 @@ async def create_user( db: AsyncSession = Depends(get_db), ): """Create a new user. - + When advanced authentication is enabled: - Email is required - Password is auto-generated and emailed to user diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index f0c60167b..fb2f3c19d 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -23,21 +23,21 @@ logger = logging.getLogger(__name__) def generate_secure_password(length: int = 16) -> str: """Generate a secure random password. - + Args: length: Length of the password (default: 16) - + Returns: A secure random password containing uppercase, lowercase, digits, and special characters """ import random - + # Define character sets lowercase = string.ascii_lowercase uppercase = string.ascii_uppercase digits = string.digits special = "!@#$%^&*()_+-=[]{}|;:,.<>?" - + # Ensure at least one character from each set password_chars = [ secrets.choice(lowercase), @@ -45,24 +45,24 @@ def generate_secure_password(length: int = 16) -> str: secrets.choice(digits), secrets.choice(special), ] - + # Fill the rest with random characters from all sets all_chars = lowercase + uppercase + digits + special password_chars.extend(secrets.choice(all_chars) for _ in range(length - 4)) - + # Shuffle to avoid predictable patterns random.shuffle(password_chars) - + return "".join(password_chars) async def get_notification_template(db: AsyncSession, event_type: str) -> NotificationTemplate | None: """Get a notification template by event type from database. - + Args: db: Database session event_type: Type of event (e.g., 'user_created', 'password_reset') - + Returns: NotificationTemplate object or None if not found """ @@ -74,11 +74,11 @@ async def get_notification_template(db: AsyncSession, event_type: str) -> Notifi def render_template(template_str: str, variables: dict[str, Any]) -> str: """Render a template string with variables. - + Args: template_str: Template string with {variable} placeholders variables: Dictionary of variables to substitute - + Returns: Rendered template string """ @@ -92,10 +92,10 @@ def render_template(template_str: str, variables: dict[str, Any]) -> str: async def get_smtp_settings(db: AsyncSession) -> SMTPSettings | None: """Get SMTP settings from database. - + Args: db: Database session - + Returns: SMTPSettings object or None if not configured """ @@ -116,21 +116,21 @@ async def get_smtp_settings(db: AsyncSession) -> SMTPSettings | None: ) ) settings_dict = {s.key: s.value for s in result.scalars().all()} - + # Check if minimum required settings are present required_keys = ["smtp_host", "smtp_port", "smtp_from_email"] if not all(key in settings_dict for key in required_keys): return None - + # Handle migration: convert old smtp_use_tls to smtp_security if needed smtp_security = settings_dict.get("smtp_security") if not smtp_security: # Migrate from old smtp_use_tls format smtp_use_tls = settings_dict.get("smtp_use_tls", "true").lower() == "true" smtp_security = "starttls" if smtp_use_tls else "ssl" - + smtp_auth_enabled = settings_dict.get("smtp_auth_enabled", "true").lower() == "true" - + return SMTPSettings( smtp_host=settings_dict["smtp_host"], smtp_port=int(settings_dict["smtp_port"]), @@ -145,14 +145,14 @@ async def get_smtp_settings(db: AsyncSession) -> SMTPSettings | None: async def save_smtp_settings(db: AsyncSession, smtp_settings: SMTPSettings) -> None: """Save SMTP settings to database. - + Args: db: Database session smtp_settings: SMTP settings to save """ from sqlalchemy import func from sqlalchemy.dialects.sqlite import insert as sqlite_insert - + settings_data = { "smtp_host": smtp_settings.smtp_host, "smtp_port": str(smtp_settings.smtp_port), @@ -161,15 +161,15 @@ async def save_smtp_settings(db: AsyncSession, smtp_settings: SMTPSettings) -> N "smtp_from_email": smtp_settings.smtp_from_email, "smtp_from_name": smtp_settings.smtp_from_name, } - + # Only save username if auth is enabled or if provided if smtp_settings.smtp_username: settings_data["smtp_username"] = smtp_settings.smtp_username - + # Only save password if provided if smtp_settings.smtp_password: settings_data["smtp_password"] = smtp_settings.smtp_password - + for key, value in settings_data.items(): stmt = sqlite_insert(Settings).values(key=key, value=value) stmt = stmt.on_conflict_do_update( @@ -187,14 +187,14 @@ def send_email( body_html: str | None = None, ) -> None: """Send an email using SMTP. - + Args: smtp_settings: SMTP configuration to_email: Recipient email address subject: Email subject body_text: Plain text body body_html: Optional HTML body - + Raises: Exception: If email sending fails """ @@ -202,24 +202,24 @@ def send_email( msg["From"] = f"{smtp_settings.smtp_from_name} <{smtp_settings.smtp_from_email}>" msg["To"] = to_email msg["Subject"] = subject - + # Attach plain text part msg.attach(MIMEText(body_text, "plain")) - + # Attach HTML part if provided if body_html: msg.attach(MIMEText(body_html, "html")) - + # Send email try: security = smtp_settings.smtp_security auth_enabled = smtp_settings.smtp_auth_enabled - + # Validate username is provided when authentication is enabled if auth_enabled and smtp_settings.smtp_password: if not smtp_settings.smtp_username: raise ValueError("SMTP username is required when authentication is enabled") - + if security == "ssl": # Direct SSL connection (typically port 465) with smtplib.SMTP_SSL(smtp_settings.smtp_host, smtp_settings.smtp_port, timeout=10) as server: @@ -247,17 +247,17 @@ def send_email( def create_welcome_email(username: str, password: str, login_url: str) -> tuple[str, str, str]: """Create welcome email content for new user. - + Args: username: Username of the new user password: Auto-generated password login_url: URL to login page - + Returns: Tuple of (subject, text_body, html_body) """ subject = "Welcome to BamBuddy - Your Account Details" - + text_body = f"""Welcome to BamBuddy! Your account has been created. Here are your login details: @@ -272,7 +272,7 @@ For security reasons, please change your password after your first login. Best regards, BamBuddy Team """ - + html_body = f""" @@ -285,20 +285,20 @@ BamBuddy Team

Your account has been created. Here are your login details:

- +

Username: {username}

Password: {password}

- +
Login Now
- +

Security Note: For security reasons, please change your password after your first login.

- +

Best regards,
BamBuddy Team @@ -307,23 +307,23 @@ BamBuddy Team """ - + return subject, text_body, html_body def create_password_reset_email(username: str, password: str, login_url: str) -> tuple[str, str, str]: """Create password reset email content. - + Args: username: Username of the user password: New auto-generated password login_url: URL to login page - + Returns: Tuple of (subject, text_body, html_body) """ subject = "BamBuddy - Your Password Has Been Reset" - + text_body = f"""Your BamBuddy password has been reset. Your login details: @@ -340,7 +340,7 @@ If you did not request this password reset, please contact your administrator im Best regards, BamBuddy Team """ - + html_body = f""" @@ -353,26 +353,26 @@ BamBuddy Team

Your BamBuddy password has been reset.

- +

Username: {username}

New Password: {password}

- +
Login Now
- +

⚠️ Security Alert: If you did not request this password reset, please contact your administrator immediately.

- +

Security Note: For security reasons, please change your password after logging in.

- +

Best regards,
BamBuddy Team @@ -381,7 +381,7 @@ BamBuddy Team """ - + return subject, text_body, html_body @@ -389,20 +389,20 @@ async def create_welcome_email_from_template( db: AsyncSession, username: str, password: str, login_url: str, app_name: str = "BamBuddy" ) -> tuple[str, str, str]: """Create welcome email content using notification template from database. - + Args: db: Database session username: Username of the new user password: Auto-generated password login_url: URL to login page app_name: Application name (default: BamBuddy) - + Returns: Tuple of (subject, text_body, html_body) """ # Try to get template from database template = await get_notification_template(db, "user_created") - + if template: # Render template with variables variables = { @@ -411,10 +411,10 @@ async def create_welcome_email_from_template( "password": password, "login_url": login_url, } - + subject = render_template(template.title_template, variables) text_body = render_template(template.body_template, variables) - + # Create HTML version with embedded login button html_body = f""" @@ -428,7 +428,7 @@ async def create_welcome_email_from_template(

{text_body}
- +
Login Now
@@ -436,7 +436,7 @@ async def create_welcome_email_from_template( """ - + logger.info("Using custom welcome email template from database") return subject, text_body, html_body else: @@ -449,20 +449,20 @@ async def create_password_reset_email_from_template( db: AsyncSession, username: str, password: str, login_url: str, app_name: str = "BamBuddy" ) -> tuple[str, str, str]: """Create password reset email content using notification template from database. - + Args: db: Database session username: Username of the user password: New auto-generated password login_url: URL to login page app_name: Application name (default: BamBuddy) - + Returns: Tuple of (subject, text_body, html_body) """ # Try to get template from database template = await get_notification_template(db, "password_reset") - + if template: # Render template with variables variables = { @@ -471,10 +471,10 @@ async def create_password_reset_email_from_template( "password": password, "login_url": login_url, } - + subject = render_template(template.title_template, variables) text_body = render_template(template.body_template, variables) - + # Create HTML version with embedded login button html_body = f""" @@ -488,11 +488,11 @@ async def create_password_reset_email_from_template(
{text_body}
- +
Login Now
- +

⚠️ Security Alert: If you did not request this password reset, please contact your administrator immediately. @@ -502,7 +502,7 @@ async def create_password_reset_email_from_template( """ - + logger.info("Using custom password reset email template from database") return subject, text_body, html_body else: