Files
wizarr/app/services/ldap/client.py
Matthieu B 672475469d fix(ldap): security, architecture, and code quality improvements
- Fix SUBTREE→BASE scope in _fetch_user_attributes (security)
- Remove is_ldap_user threading from all 8 media client interfaces;
  set flag after join via workflow-level User.query.update instead
- Use find_user_dn (search-based) instead of build_user_dn (concatenation)
  in delete_user and reset_user_password for robustness
- Wrap all user-facing strings in _() for i18n in ldap_auth.py
- Fix username fallback from hardcoded "wizarr" to unique per-user
- Return failure from _update_existing_user when password update fails
- Replace dynamic Tailwind classes with explicit conditionals in alert
- Add stale group cleanup and upsert in sync_ldap_groups
- Remove fake @ldap.local email fallback in user_sync
2026-03-29 16:18:41 +02:00

471 lines
15 KiB
Python

import logging
import ssl
from typing import Any
from ldap3 import ALL, BASE, MODIFY_REPLACE, SUBTREE, Connection, Server, Tls
from ldap3.core.exceptions import (
LDAPBindError,
LDAPException,
LDAPInvalidDnError,
LDAPSocketOpenError,
)
from ldap3.utils.conv import escape_filter_chars
from ldap3.utils.dn import escape_rdn
from app.models import LDAPConfiguration
from .encryption import decrypt_credential
logger = logging.getLogger(__name__)
class LDAPClient:
def __init__(self, config: LDAPConfiguration):
self.config = config
self._server = self._create_server()
def _create_server(self) -> Server:
tls_config = None
if self.config.use_tls:
validate = ssl.CERT_REQUIRED if self.config.verify_cert else ssl.CERT_NONE
tls_config = Tls(validate=validate)
return Server(
self.config.server_url,
use_ssl=self.config.use_tls,
tls=tls_config,
get_info=ALL,
)
def build_user_dn(self, username: str) -> str:
"""Build a user DN from a username, escaping special characters."""
escaped_name = escape_rdn(username)
return f"{self.config.username_attribute}={escaped_name},{self.config.user_base_dn}"
def test_connection(self) -> tuple[bool, str]:
if not self.config.service_account_dn:
return False, "Service account DN not configured"
conn = None
try:
password = decrypt_credential(
self.config.service_account_password_encrypted or ""
)
if not password:
return False, "Service account password not configured"
conn = Connection(
self._server,
user=self.config.service_account_dn,
password=password,
auto_bind=True,
)
return True, "Connection successful"
except LDAPSocketOpenError as e:
logger.warning("LDAP connection failed: %s", e)
return False, f"Cannot connect to LDAP server: {e}"
except LDAPBindError as e:
logger.warning("LDAP bind failed: %s", e)
return False, f"Authentication failed: {e}"
except LDAPException as e:
logger.exception("LDAP test connection error")
return False, f"LDAP error: {e}"
except Exception as e:
logger.exception("Unexpected error testing LDAP connection")
return False, f"Unexpected error: {e}"
finally:
if conn:
conn.unbind()
def authenticate_user(self, username: str, password: str) -> tuple[bool, dict]:
conn = None
try:
# Search for user DN first
user_dn = self.find_user_dn(username)
if not user_dn:
return False, {}
# Attempt bind with user credentials
conn = Connection(
self._server,
user=user_dn,
password=password,
auto_bind=True,
)
# Fetch user attributes
attrs = self._fetch_user_attributes(conn, user_dn)
return True, attrs
except LDAPBindError:
logger.info("LDAP authentication failed for user: %s", username)
return False, {}
except LDAPException:
logger.exception("LDAP authentication error for user: %s", username)
return False, {}
finally:
if conn:
conn.unbind()
def find_user_dn(self, username: str) -> str | None:
"""Search for a user's DN by username."""
conn = None
try:
conn = self.service_connection()
if not conn:
return None
# Escape username to prevent LDAP injection
escaped_username = escape_filter_chars(username)
search_filter = self.config.user_search_filter.replace(
"{username}", escaped_username
)
conn.search(
search_base=self.config.user_base_dn,
search_filter=search_filter,
search_scope=SUBTREE,
attributes=[self.config.username_attribute],
)
if conn.entries:
return str(conn.entries[0].entry_dn)
return None
except LDAPException:
logger.exception("Error searching for user DN")
return None
finally:
if conn:
conn.unbind()
def _fetch_user_attributes(self, conn: Connection, user_dn: str) -> dict:
attributes = [
self.config.username_attribute,
self.config.email_attribute,
]
conn.search(
search_base=user_dn,
search_filter="(objectClass=*)",
search_scope=BASE,
attributes=attributes,
)
if not conn.entries:
return {"dn": user_dn}
entry = conn.entries[0]
attrs: dict[str, Any] = {"dn": user_dn}
# Extract attributes safely
for attr in attributes:
if hasattr(entry, attr):
value = getattr(entry, attr).value
attrs[attr] = str(value) if value else None
return attrs
def create_user(
self,
username: str,
email: str,
password: str,
) -> tuple[bool, str]:
conn = None
try:
conn = self.service_connection()
if not conn:
return False, "Cannot connect to LDAP server"
user_dn = self.build_user_dn(username)
# Check if user already exists
conn.search(
search_base=user_dn,
search_filter="(objectClass=*)",
search_scope=BASE,
)
if conn.entries:
return self._update_existing_user(conn, user_dn, email, password)
return self._create_new_user(conn, user_dn, username, email, password)
except LDAPInvalidDnError as e:
logger.exception("Invalid DN when creating/updating user")
return False, f"Invalid DN: {e}"
except LDAPException as e:
logger.exception("Error creating/updating LDAP user")
return False, f"LDAP error: {e}"
finally:
if conn:
conn.unbind()
def _update_existing_user(
self,
conn: Connection,
user_dn: str,
email: str,
password: str,
) -> tuple[bool, str]:
logger.info("User %s already exists, updating attributes", user_dn)
changes = {
self.config.email_attribute: [(MODIFY_REPLACE, [email])],
}
success = conn.modify(user_dn, changes)
if not success:
return False, f"Failed to update user attributes: {conn.result}"
# Update password via RFC 3062 extended operation
try:
password_success = conn.extend.standard.modify_password(
user_dn, None, password
)
if password_success:
logger.info("Updated LDAP user password: %s", user_dn)
else:
error_msg = (
f"Failed to update password for {user_dn}. "
f"Ensure service account is in lldap_password_manager group. Result: {conn.result}"
)
logger.error(error_msg)
return False, error_msg
except Exception as e:
logger.exception("Password update failed for %s", user_dn)
return False, f"Password update failed: {e}"
return True, user_dn
def _create_new_user(
self,
conn: Connection,
user_dn: str,
username: str,
email: str,
password: str,
) -> tuple[bool, str]:
attrs: dict[str, Any] = {
"objectClass": [self.config.user_object_class],
self.config.username_attribute: username,
self.config.email_attribute: email,
}
success = conn.add(user_dn, attributes=attrs)
if not success:
return False, f"Failed to create user: {conn.result}"
# Set password via RFC 3062 extended operation
try:
password_success = conn.extend.standard.modify_password(
user_dn, None, password
)
if not password_success:
logger.error(
"Failed to set password for new user %s. "
"Ensure service account is in lldap_password_manager group. Result: %s",
user_dn,
conn.result,
)
return (
False,
f"User created but password not set: {conn.result}",
)
except Exception as e:
logger.exception("Password set failed for new user %s", user_dn)
return False, f"User created but password set failed: {e}"
logger.info("Created LDAP user with password: %s", user_dn)
return True, user_dn
def delete_user(self, user_dn: str) -> tuple[bool, str]:
if not user_dn:
return False, "User DN is required"
conn = None
try:
conn = self.service_connection()
if not conn:
return False, "Cannot connect to LDAP server"
success = conn.delete(user_dn)
if not success:
error_msg = f"Failed to delete LDAP user: {conn.result}"
logger.error(error_msg)
return False, error_msg
logger.info("Deleted LDAP user: %s", user_dn)
return True, "User deleted successfully"
except LDAPInvalidDnError as e:
logger.exception("Invalid DN when deleting user: %s", user_dn)
return False, f"Invalid DN: {e}"
except LDAPException as e:
logger.exception("Error deleting LDAP user: %s", user_dn)
return False, f"LDAP error: {e}"
finally:
if conn:
conn.unbind()
def change_password(self, user_dn: str, new_password: str) -> tuple[bool, str]:
if not user_dn:
return False, "User DN is required"
if not new_password:
return False, "New password is required"
conn = None
try:
conn = self.service_connection()
if not conn:
return False, "Cannot connect to LDAP server"
success = conn.extend.standard.modify_password(user_dn, None, new_password)
if not success:
error_msg = (
"Failed to change password. "
"Ensure service account is in lldap_password_manager group. "
f"Result: {conn.result}"
)
logger.error(error_msg)
return False, error_msg
logger.info("Changed password for LDAP user: %s", user_dn)
return True, "Password changed successfully"
except LDAPException as e:
logger.exception("Error changing LDAP user password: %s", user_dn)
return False, f"LDAP error: {e}"
finally:
if conn:
conn.unbind()
def search_groups(self) -> list[dict]:
if not self.config.group_base_dn:
logger.warning("group_base_dn not configured - cannot search for groups")
return []
conn = None
try:
conn = self.service_connection()
if not conn:
logger.error(
"Failed to establish service account connection for group search"
)
return []
filter_str = f"(objectClass={self.config.group_object_class})"
logger.info(
"Searching for LDAP groups: base_dn=%s, filter=%s",
self.config.group_base_dn,
filter_str,
)
success = conn.search(
search_base=self.config.group_base_dn,
search_filter=filter_str,
search_scope=SUBTREE,
attributes=["cn"],
)
if not success:
logger.warning(
"LDAP group search failed: %s (result: %s)",
conn.result.get("description", "Unknown error"),
conn.result,
)
return []
logger.info("Found %d group entries", len(conn.entries))
return [
{
"dn": str(entry.entry_dn),
"cn": str(entry.cn.value) if hasattr(entry, "cn") else "",
"description": None,
}
for entry in conn.entries
]
except LDAPException as e:
logger.exception("Error searching LDAP groups: %s", e)
return []
finally:
if conn:
conn.unbind()
def get_user_groups(self, user_dn: str) -> list[dict]:
"""Get groups where user is a member. Requires group_base_dn."""
if not self.config.group_base_dn:
logger.warning(
"group_base_dn not configured - cannot check group membership. "
"Configure Group Base DN in LDAP settings to enable group-based authorization."
)
return []
conn = None
try:
conn = self.service_connection()
if not conn:
return []
member_filter = f"({self.config.group_member_attribute}={user_dn})"
object_class_filter = f"(objectClass={self.config.group_object_class})"
search_filter = f"(&{object_class_filter}{member_filter})"
conn.search(
search_base=self.config.group_base_dn,
search_filter=search_filter,
search_scope=SUBTREE,
attributes=["cn"],
)
groups = [
{
"dn": str(entry.entry_dn),
"cn": str(entry.cn.value) if hasattr(entry, "cn") else "",
}
for entry in conn.entries
]
logger.info("Found %d groups for user %s", len(groups), user_dn)
return groups
except LDAPException:
logger.exception("Error fetching user groups")
return []
finally:
if conn:
conn.unbind()
def service_connection(self) -> Connection | None:
"""Create an authenticated connection using the service account."""
try:
if not self.config.service_account_dn:
logger.error("Service account DN not configured")
return None
password = decrypt_credential(
self.config.service_account_password_encrypted or ""
)
if not password:
logger.error("Service account password not configured")
return None
return Connection(
self._server,
user=self.config.service_account_dn,
password=password,
auto_bind=True,
)
except LDAPException:
logger.exception("Error connecting with service account")
return None