From b98d315fe4cc7ed9a5750043856b18fdb603f1e0 Mon Sep 17 00:00:00 2001 From: Guillaume BOURGEOIS Date: Fri, 26 Jun 2026 17:44:54 +0200 Subject: [PATCH] feat(openldap): LDAP authentication integration (OpenLDAP / Active Directory) LDAP authentication over the ldap3 library, designed to run on a remote engine inside the directory network. Auto-detects the vendor (OpenLDAP or Active Directory). 5 commands: test connection, ad-authenticate (simple bind), ad-groups (fetch all or specific groups), ad-authenticate-and-roles (bind + return the user's groups and attributes), and ad-entries-search (generic LDAP search with cn/uid/objectClass/description filters, scope, attribute selection and paging). Scripts share a ported LdapClient that handles SSL/LDAPS/Start TLS, vendor detection, OpenLDAP vs AD group/role resolution and paged search. ldap3 is imported defensively with a clear "pip install ldap3" message when missing. No ingestion source, so no OCSF mapper. Co-Authored-By: Claude Opus 4.8 --- integrations/openldap/manifest.yaml | 151 +++++ .../openldap/scripts/ad_authenticate.py | 528 +++++++++++++++++ .../scripts/ad_authenticate_and_roles.py | 537 ++++++++++++++++++ .../openldap/scripts/ad_entries_search.py | 527 +++++++++++++++++ integrations/openldap/scripts/ad_groups.py | 527 +++++++++++++++++ .../openldap/scripts/test_connection.py | 527 +++++++++++++++++ 6 files changed, 2797 insertions(+) create mode 100644 integrations/openldap/manifest.yaml create mode 100644 integrations/openldap/scripts/ad_authenticate.py create mode 100644 integrations/openldap/scripts/ad_authenticate_and_roles.py create mode 100644 integrations/openldap/scripts/ad_entries_search.py create mode 100644 integrations/openldap/scripts/ad_groups.py create mode 100644 integrations/openldap/scripts/test_connection.py diff --git a/integrations/openldap/manifest.yaml b/integrations/openldap/manifest.yaml new file mode 100644 index 0000000..10c7225 --- /dev/null +++ b/integrations/openldap/manifest.yaml @@ -0,0 +1,151 @@ +id: openldap +name: OpenLDAP +version: 1.0.0 +description: "LDAP authentication for OpenLDAP or Active Directory — simple bind, fetch groups, authenticate-and-resolve-roles, and a generic LDAP entries search. Vendor is auto-detected. Runs on a remote engine inside the LDAP network. Requires the Python 'ldap3' library on the engine host (pip install ldap3)." +changelog: "1.0.0 — Initial release: ad-authenticate (simple bind), ad-groups (fetch all or specific groups), ad-authenticate-and-roles (bind + return the user's groups and attributes), ad-entries-search (generic LDAP search). Supports OpenLDAP and Active Directory with automatic vendor detection." +category: identity + +# Per-instance configuration. Binds to an LDAP server (OpenLDAP or Active +# Directory) over plain LDAP (389), LDAPS (636) or Start TLS. Run this integration +# on a remote engine that can reach the directory. The bundled scripts require the +# Python 'ldap3' library on the engine host: pip install ldap3 +config_schema: + properties: + ldap_server_vendor: + type: string + description: "LDAP vendor: OpenLDAP, Active Directory, or Auto (detect automatically). Default Auto." + default: Auto + host: + type: string + description: "LDAP server IP or host name (e.g. 192.168.0.1 or ldap.company.com)" + port: + type: string + description: "Port. Default 389 (LDAP / Start TLS) or 636 (LDAPS)." + username: + type: string + description: "Bind user DN (e.g. cn=admin,ou=users,dc=domain,dc=com)" + password: + type: string + description: "Bind password" + x-soar-sensitive: true + base_dn: + type: string + description: "Base DN (e.g. dc=domain,dc=com)" + connection_type: + type: string + description: "Connection security: None, SSL (LDAPS), or Start TLS. Default None." + default: None + ssl_version: + type: string + description: "SSL/TLS protocol: None, TLS, TLSv1, TLSv1_1, TLSv1_2, or TLS_CLIENT. Default None." + default: None + insecure: + type: boolean + description: "Trust any TLS certificate (not secure)" + default: false + page_size: + type: string + description: "LDAP paging size for searches" + default: "500" + fetch_groups: + type: boolean + description: "Auto-populate groups in ad-groups when no specific group is requested" + default: true + group_filter_class: + type: string + description: "Groups object class (OpenLDAP only)" + default: posixGroup + group_identifier_attribute: + type: string + description: "Groups unique identifier attribute (OpenLDAP only)" + default: gidNumber + member_identifier_attribute: + type: string + description: "Group membership identifier attribute (OpenLDAP only)" + default: memberUid + user_filter_class: + type: string + description: "User object class (OpenLDAP only)" + default: posixAccount + user_identifier_attribute: + type: string + description: "User unique identifier attribute (OpenLDAP only)" + default: uid + custom_attributes: + type: string + description: "Extra user attributes added to the user filter in ad-authenticate-and-roles, as 'attrA=valA,attrB=valB' (OpenLDAP only)" + required: + - ldap_server_vendor + - host + - username + - password + - base_dn + - connection_type + +commands: + - id: test_connection + name: ad-test-connection + description: "Bind to the LDAP server with the configured credentials (used by the Test button)." + risk: read + inputs_schema: + properties: {} + required: [] + outputs_schema: { properties: {} } + + - id: ad_authenticate + name: ad-authenticate + description: "Perform a simple bind on the LDAP server with the given username and password." + risk: read + inputs_schema: + properties: + username: { type: string, description: "Username for simple authentication" } + password: { type: string, description: "Password for simple authentication", x-soar-sensitive: true } + required: [username, password] + outputs_schema: { properties: {} } + + - id: ad_groups + name: ad-groups + description: "Fetch LDAP groups under the base DN, or a specific set of groups (DNs delimited by '#')." + risk: read + inputs_schema: + properties: + specific-groups: { type: string, description: "Group object names/DNs to fetch, delimited by a number sign (#)" } + required: [] + outputs_schema: { properties: {} } + + - id: ad_authenticate_and_roles + name: ad-authenticate-and-roles + description: "Simple bind on the LDAP server and return the authenticated user's groups and selected attributes." + risk: read + inputs_schema: + properties: + username: { type: string, description: "Username for simple authentication" } + password: { type: string, description: "Password for simple authentication", x-soar-sensitive: true } + attribute-mail: { type: string, description: "Mail attribute to return (default mail)" } + attribute-name: { type: string, description: "Name attribute to return (default name)" } + attribute-phone: { type: string, description: "Phone attribute to return (default mobile)" } + attribute-name-pull: { type: string, description: "Return the name attribute (default true)" } + attribute-mail-pull: { type: string, description: "Return the mail attribute (default true)" } + attribute-phone-pull: { type: string, description: "Return the phone attribute (default false)" } + required: [username, password] + outputs_schema: { properties: {} } + + - id: ad_entries_search + name: ad-entries-search + description: "Generic LDAP search. Combine raw search_filter with cn/uid/object_class/description filters (joined with OR), choose scope and attributes, and page the results." + risk: read + inputs_schema: + properties: + search_base: { type: string, description: "Where the search starts (defaults to the instance base_dn)" } + search_filter: { type: string, description: "Raw LDAP filter; joined with the other filter args using OR" } + search_scope: { type: string, description: "BASE, LEVEL or SUBTREE (default SUBTREE)" } + attributes: { type: string, description: "none, all_user_attributes, all_operational_attributes, all, or a CSV list (default all)" } + cn: { type: string, description: "CSV list of CNs to filter by (joined with AND)" } + uid: { type: string, description: "CSV list of UIDs to filter by (joined with AND)" } + object_class: { type: string, description: "CSV list of objectClasses to filter by (joined with AND)" } + description: { type: string, description: "CSV list of descriptions to filter by (joined with AND)" } + page: { type: number, description: "Page number to return" } + page_size: { type: number, description: "Entries per page (max 2000, default 50)" } + limit: { type: number, description: "Maximum entries to return when not paging (default 50)" } + required: [] + outputs_schema: { properties: {} } diff --git a/integrations/openldap/scripts/ad_authenticate.py b/integrations/openldap/scripts/ad_authenticate.py new file mode 100644 index 0000000..681578b --- /dev/null +++ b/integrations/openldap/scripts/ad_authenticate.py @@ -0,0 +1,528 @@ +import json, os, sys + +S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) +I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def out(value): + print(json.dumps(value, default=str)) + + +def fail(message, **extra): + payload = {"error": message} + payload.update(extra) + print(json.dumps(payload, default=str)) + sys.exit(1) + + +try: + import ssl + from ldap3 import ( + ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, AUTO_BIND_NO_TLS, + AUTO_BIND_TLS_BEFORE_BIND, BASE, SUBTREE, Connection, Server, Tls, + ) + from ldap3.core.exceptions import ( + LDAPBindError, LDAPException, LDAPInvalidDnError, LDAPInvalidPortError, + LDAPSocketOpenError, LDAPSocketReceiveError, LDAPStartTLSError, + ) + from ldap3.utils.dn import parse_dn +except ImportError as _e: + print(json.dumps({ + "error": "The 'ldap3' Python library is required for the OpenLDAP integration. " + "Install it on the execution host (engine): pip install ldap3", + "detail": str(_e), + })) + sys.exit(1) + +MAX_PAGE_SIZE = 2000 + + +def arg_to_list(v, separator=","): + if not v: + return [] + if isinstance(v, list): + return v + return [x.strip() for x in str(v).split(separator) if x.strip()] + + +def arg_to_bool(v): + if isinstance(v, bool): + return v + return str(v).lower() in ("1", "true", "yes") + + +def arg_to_num(v): + if v is None or v == "": + return None + return int(v) + + +def list_arg_to_ldap_filter(arg, prefix): + items = arg_to_list(arg) + joined = "".join(f"({prefix}={item})" for item in items) + if len(items) > 1: + return f"(&{joined})" + return joined if items else "" + + +def create_entries_search_filter(args): + cn = list_arg_to_ldap_filter(args.get("cn", ""), "cn") + description = list_arg_to_ldap_filter(args.get("description", ""), "description") + object_class = list_arg_to_ldap_filter(args.get("object_class", ""), "objectClass") + uid = list_arg_to_ldap_filter(args.get("uid", ""), "uid") + search_filter = args.get("search_filter", "") + if not any([cn, description, object_class, uid, search_filter]): + return "(objectClass=*)" + return f"(|{cn}{description}{object_class}{uid}{search_filter})" + + +def get_search_attributes(attributes): + if attributes == "all": + return [ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES] + return { + "none": None, + "all_user_attributes": ALL_ATTRIBUTES, + "all_operational_attributes": ALL_OPERATIONAL_ATTRIBUTES, + }.get(attributes, arg_to_list(attributes)) + + +def entries_paged_search(connection, search_params, page, page_size): + if page == 1: + return connection.search(**search_params, paged_size=page_size) + results_to_skip = page_size * (page - 1) + connection.search(**search_params, paged_size=results_to_skip) + cookie = connection.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"] + return connection.search(**search_params, paged_size=page_size, paged_cookie=cookie) + + +class LdapClient: + """LDAP authentication client supporting OpenLDAP and Active Directory.""" + + OPENLDAP = "OpenLDAP" + ACTIVE_DIRECTORY = "Active Directory" + AUTO = "Auto" + GROUPS_TOKEN = "primaryGroupToken" + GROUPS_MEMBER = "memberOf" + GROUPS_PRIMARY_ID = "primaryGroupID" + TIMEOUT = 120 + CIPHERS_STRING = ( + "@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:DH+AESGCM:" + "ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS" + ) + SSL_VERSIONS = { + "None": None, + "TLS": ssl.PROTOCOL_TLS, + "TLSv1": ssl.PROTOCOL_TLSv1, + "TLSv1_1": ssl.PROTOCOL_TLSv1_1, + "TLSv1_2": ssl.PROTOCOL_TLSv1_2, + "TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT, + } + + def __init__(self, cfg): + self._host = cfg.get("host") + self._port = int(cfg["port"]) if cfg.get("port") else None + self._username = cfg.get("username", "") + self._password = cfg.get("password", "") + self._base_dn = (cfg.get("base_dn") or "").strip() + self._connection_type = (cfg.get("connection_type") or "none").lower() + self._ssl_version = cfg.get("ssl_version", "None") + self._fetch_groups = arg_to_bool(cfg.get("fetch_groups", True)) + self._verify = not arg_to_bool(cfg.get("insecure", False)) + self._ldap_server = self._initialize_ldap_server() + self._ldap_server_vendor = cfg.get("ldap_server_vendor", self.AUTO) + if self._ldap_server_vendor == self.AUTO: + self._determine_ldap_vendor_automatically() + self._page_size = int(cfg.get("page_size") or 500) + self._groups_filter_class = (cfg.get("group_filter_class") or "posixGroup").strip() + self._group_identifier_attribute = (cfg.get("group_identifier_attribute") or "gidNumber").strip() + self._member_identifier_attribute = (cfg.get("member_identifier_attribute") or "memberUid").strip() + self._user_filter_class = cfg.get("user_filter_class") or "posixAccount" + self._user_identifier_attribute = cfg.get("user_identifier_attribute") or "uid" + self._custom_attributes = cfg.get("custom_attributes") or "" + + @property + def GROUPS_OBJECT_CLASS(self): + return self._groups_filter_class + + @property + def GROUPS_IDENTIFIER_ATTRIBUTE(self): + return self._group_identifier_attribute + + @property + def GROUPS_MEMBERSHIP_IDENTIFIER_ATTRIBUTE(self): + return self._member_identifier_attribute + + @property + def USER_OBJECT_CLASS(self): + return self._user_filter_class + + @property + def USER_IDENTIFIER_ATTRIBUTE(self): + return self._user_identifier_attribute + + @property + def CUSTOM_ATTRIBUTE(self): + return self._custom_attributes + + def _get_ssl_version(self): + return self.SSL_VERSIONS.get(self._ssl_version) + + def _get_tls_object(self): + if self._verify: + return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=self._get_ssl_version()) + return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, version=self._get_ssl_version(), ciphers=self.CIPHERS_STRING) + + def _initialize_ldap_server(self): + if self._connection_type == "ssl": + return Server(host=self._host, port=self._port, use_ssl=True, tls=self._get_tls_object(), connect_timeout=self.TIMEOUT) + if self._connection_type == "start tls": + return Server(host=self._host, port=self._port, use_ssl=False, tls=self._get_tls_object(), connect_timeout=self.TIMEOUT) + return Server(host=self._host, port=self._port, connect_timeout=self.TIMEOUT) + + def _determine_ldap_vendor_automatically(self): + try: + with Connection(self._ldap_server) as conn: + conn.search(search_base="", search_filter="(objectClass=*)", search_scope=BASE, attributes=[ALL_ATTRIBUTES]) + entry = conn.entries[0] + if "objectClass" in entry and "OpenLDAProotDSE" in entry["objectClass"].value: + self._ldap_server_vendor = self.OPENLDAP + else: + self._ldap_server_vendor = self.ACTIVE_DIRECTORY + except Exception as e: + raise Exception(f"Could not determine the LDAP vendor automatically. Select the vendor manually. Error: {e}") + + @staticmethod + def _parse_ldap_group_entries(ldap_group_entries, groups_identifier_attribute): + return [ + { + "DN": ldap_group.get("dn"), + "Attributes": [{ + "Name": LdapClient.GROUPS_TOKEN, + "Values": [str(ldap_group.get("attributes", {}).get(groups_identifier_attribute))], + }], + } + for ldap_group in ldap_group_entries + ] + + @staticmethod + def _parse_ldap_group_entries_and_referrals(ldap_group_entries): + referrals = [] + entries = [] + for ldap_group in ldap_group_entries: + group_type = ldap_group.get("type") + if group_type == "searchResRef": + referrals.extend(ldap_group.get("uri") or []) + elif group_type == "searchResEntry": + entries.append({ + "DN": ldap_group.get("dn"), + "Attributes": [{ + "Name": LdapClient.GROUPS_TOKEN, + "Values": [str(ldap_group.get("attributes", {}).get(LdapClient.GROUPS_TOKEN))], + }], + }) + return referrals, entries + + def _parse_and_authenticate_ldap_group_entries_and_referrals(self, ldap_group_entries, password): + referrals = [] + entries = [] + for entry in ldap_group_entries: + entry_type = entry.get("type") + if entry_type == "searchResRef": + referrals.extend(entry.get("uri") or []) + elif entry_type == "searchResEntry": + entry_dn = entry.get("dn", "") + entry_attributes = entry.get("attributes", {}) + relevant = [] + for attr in entry_attributes: + attr_value = entry_attributes.get(attr, []) + if attr_value: + if not isinstance(attr_value, list): + attr_value = [str(attr_value)] + relevant.append({"Name": attr, "Values": attr_value}) + entries.append({"DN": entry_dn, "Attributes": relevant}) + self.authenticate_ldap_user(entry_dn, password) + return referrals, entries + + @staticmethod + def _parse_ldap_users_groups_entries(ldap_group_entries): + return [ldap_group.get("dn") for ldap_group in ldap_group_entries] + + @staticmethod + def _build_entry_for_user(user_groups, user_data, mail_attribute, name_attribute, phone_attribute): + attributes = [ + {"Name": LdapClient.GROUPS_MEMBER, "Values": user_groups}, + {"Name": LdapClient.GROUPS_PRIMARY_ID, "Values": user_data["gid_number"]}, + ] + if "name" in user_data: + attributes.append({"Name": name_attribute, "Values": [user_data["name"]]}) + if "email" in user_data: + attributes.append({"Name": mail_attribute, "Values": [user_data["email"]]}) + if "mobile" in user_data: + attributes.append({"Name": phone_attribute, "Values": [user_data["mobile"]]}) + return {"DN": user_data["dn"], "Attributes": attributes} + + @staticmethod + def _is_valid_dn(dn, user_identifier_attribute): + try: + parsed_dn = parse_dn(dn, strip=False) + for attribute_and_value in parsed_dn: + if attribute_and_value[0].lower() == user_identifier_attribute.lower(): + return True, attribute_and_value[1] + raise Exception(f"The {user_identifier_attribute} attribute was not found in the user DN: {dn}") + except LDAPInvalidDnError: + return False, dn + + def _get_formatted_custom_attributes(self): + if not self.CUSTOM_ATTRIBUTE: + return "" + formatted = "" + for att in self.CUSTOM_ATTRIBUTE.split(","): + if len(att.split("=")) != 2: + raise Exception(f'User defined attributes must be of the form "attrA=valA,attrB=valB,...", but got: {self.CUSTOM_ATTRIBUTE}') + formatted += f"({att})" + return formatted + + def _get_ldap_groups_entries_and_referrals_ad(self, ldap_conn, search_filter): + ldap_group_entries = ldap_conn.extend.standard.paged_search( + search_base=self._base_dn, search_filter=search_filter, + attributes=[LdapClient.GROUPS_TOKEN], paged_size=self._page_size, generator=False, + ) + return LdapClient._parse_ldap_group_entries_and_referrals(ldap_group_entries) + + def _fetch_all_groups(self): + auto_bind = self._get_auto_bind_value() + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + if self._ldap_server_vendor == self.ACTIVE_DIRECTORY: + referrals, entries = self._get_ldap_groups_entries_and_referrals_ad( + ldap_conn=ldap_conn, search_filter="(&(objectClass=group)(objectCategory=group))") + return {"Controls": None, "Referrals": referrals, "Entries": entries} + ldap_group_entries = ldap_conn.extend.standard.paged_search( + search_base=self._base_dn, search_filter=f"(objectClass={self.GROUPS_OBJECT_CLASS})", + attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size) + return { + "Controls": None, + "Referrals": ldap_conn.result.get("referrals"), + "Entries": LdapClient._parse_ldap_group_entries(ldap_group_entries, self.GROUPS_IDENTIFIER_ATTRIBUTE), + } + + def _fetch_specific_groups(self, specific_groups): + auto_bind = self._get_auto_bind_value() + dn_list = [group.strip() for group in arg_to_list(specific_groups, separator="#")] + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + if self._ldap_server_vendor == self.ACTIVE_DIRECTORY: + dns_filter = "".join(f"(distinguishedName={dn})" for dn in dn_list) + search_filter = f"(&(objectClass=group)(objectCategory=group)(|{dns_filter}))" + referrals, entries = self._get_ldap_groups_entries_and_referrals_ad( + ldap_conn=ldap_conn, search_filter=search_filter) + return {"Controls": None, "Referrals": referrals, "Entries": entries} + parsed = [] + for dn in dn_list: + ldap_group_entries = ldap_conn.extend.standard.paged_search( + search_base=dn, search_filter=f"(objectClass={self.GROUPS_OBJECT_CLASS})", + attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size, search_scope=BASE) + parsed.append(self._parse_ldap_group_entries(ldap_group_entries, self.GROUPS_IDENTIFIER_ATTRIBUTE)) + return {"Controls": None, "Referrals": ldap_conn.result.get("referrals"), "Entries": parsed} + + @staticmethod + def _get_ad_username(logon_name): + if "\\" in logon_name: + return logon_name.split("\\")[1] + if "@" in logon_name: + return logon_name.split("@")[0] + return logon_name + + @staticmethod + def _has_wildcards_in_user_logon(logon_name): + for wildcard in ("*", "?"): + if wildcard in logon_name: + raise Exception( + f"Authentication failed - wildcards were detected in the user logon name " + f"(input username: '{logon_name}'). Wildcards are not permitted for user authentication.") + + def _get_auto_bind_value(self): + if self._connection_type == "start tls": + return AUTO_BIND_TLS_BEFORE_BIND + return AUTO_BIND_NO_TLS + + def get_ldap_groups(self, specific_group=""): + if not self._fetch_groups and not specific_group: + return {"Controls": None, "Referrals": None, "Entries": []} + return self._fetch_specific_groups(specific_group) if not self._fetch_groups else self._fetch_all_groups() + + def authenticate_ldap_user(self, username, password): + auto_bind = self._get_auto_bind_value() + ldap_conn = Connection(server=self._ldap_server, user=username, password=password, auto_bind=auto_bind) + if ldap_conn.bound: + ldap_conn.unbind() + return "Done" + raise Exception(f"Authentication connection failed (server type: {self._ldap_server_vendor})") + + def search_user_data(self, username, attributes, search_user_by_dn=False): + auto_bind = self._get_auto_bind_value() + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + if search_user_by_dn: + search_filter = f"(&(objectClass={self.USER_OBJECT_CLASS})" + self._get_formatted_custom_attributes() + ")" + ldap_conn.search(search_base=username, search_filter=search_filter, size_limit=1, + attributes=attributes, search_scope=BASE) + else: + custom_attributes = self._get_formatted_custom_attributes() + search_filter = (f"(&(objectClass={self.USER_OBJECT_CLASS})" + f"({self.USER_IDENTIFIER_ATTRIBUTE}={username}){custom_attributes})") + ldap_conn.search(search_base=self._base_dn, search_filter=search_filter, size_limit=1, attributes=attributes) + if not ldap_conn.entries: + raise Exception("LDAP user not found") + entry = ldap_conn.entries[0] + referrals = ldap_conn.result.get("referrals") + if self.GROUPS_IDENTIFIER_ATTRIBUTE not in entry or not entry[self.GROUPS_IDENTIFIER_ATTRIBUTE].value: + raise Exception(f"User's {self.GROUPS_IDENTIFIER_ATTRIBUTE} not found") + return entry, referrals + + def get_user_data(self, username, pull_name, pull_mail, pull_phone, + name_attribute, mail_attribute, phone_attribute, search_user_by_dn=False): + attributes = [self.GROUPS_IDENTIFIER_ATTRIBUTE] + if pull_name: + attributes.append(name_attribute) + if pull_mail: + attributes.append(mail_attribute) + if pull_phone: + attributes.append(phone_attribute) + entry, referrals = self.search_user_data(username, attributes, search_user_by_dn) + user_data = { + "dn": entry.entry_dn, + "gid_number": [str(entry[self.GROUPS_IDENTIFIER_ATTRIBUTE].value)], + "referrals": referrals, + } + if name_attribute in entry and entry[name_attribute].value: + user_data["name"] = entry[name_attribute].value + if mail_attribute in entry and entry[mail_attribute].value: + user_data["email"] = entry[mail_attribute].value + if phone_attribute in entry and entry[phone_attribute].value: + user_data["mobile"] = entry[phone_attribute].value + return user_data + + def get_user_groups(self, user_identifier): + auto_bind = self._get_auto_bind_value() + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + search_filter = (f"(&(objectClass={self.GROUPS_OBJECT_CLASS})" + f"({self.GROUPS_MEMBERSHIP_IDENTIFIER_ATTRIBUTE}={user_identifier}))") + ldap_group_entries = ldap_conn.extend.standard.paged_search( + search_base=self._base_dn, search_filter=search_filter, + attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size) + return LdapClient._parse_ldap_users_groups_entries(ldap_group_entries) + + def authenticate_and_roles_openldap(self, username, password, pull_name=True, pull_mail=True, pull_phone=False, + mail_attribute="mail", name_attribute="name", phone_attribute="mobile"): + search_user_by_dn, user_identifier = LdapClient._is_valid_dn(username, self.USER_IDENTIFIER_ATTRIBUTE) + user_data = self.get_user_data( + username=username, search_user_by_dn=search_user_by_dn, pull_name=pull_name, pull_mail=pull_mail, + pull_phone=pull_phone, mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute) + self.authenticate_ldap_user(user_data["dn"], password) + user_groups = self.get_user_groups(user_identifier) + return { + "Controls": None, + "Referrals": user_data["referrals"], + "Entries": [LdapClient._build_entry_for_user( + user_groups=user_groups, user_data=user_data, + mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute)], + } + + def authenticate_and_roles_active_directory(self, username, password, pull_name=True, pull_mail=True, pull_phone=False, + mail_attribute="mail", name_attribute="name", phone_attribute="mobile"): + ad_username = self._get_ad_username(username) + auto_bind = self._get_auto_bind_value() + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + attributes = [self.GROUPS_MEMBER, self.GROUPS_PRIMARY_ID] + if pull_name: + attributes.append(name_attribute) + if pull_mail: + attributes.append(mail_attribute) + if pull_phone: + attributes.append(phone_attribute) + search_filter = f"(|(sAMAccountName={ad_username})(userPrincipalName={username}))" + ldap_conn_entries = ldap_conn.extend.standard.paged_search( + search_base=self._base_dn, search_filter=search_filter, + attributes=attributes, paged_size=self._page_size, generator=False) + referrals, entries = self._parse_and_authenticate_ldap_group_entries_and_referrals( + ldap_group_entries=ldap_conn_entries, password=password) + if not entries: + raise Exception("LDAP user not found") + return {"Controls": [], "Referrals": referrals, "Entries": entries} + + def authenticate_and_roles(self, username, password, pull_name=True, pull_mail=True, pull_phone=False, + mail_attribute="mail", name_attribute="name", phone_attribute="mobile"): + self._has_wildcards_in_user_logon(username) + if self._ldap_server_vendor == self.ACTIVE_DIRECTORY: + return self.authenticate_and_roles_active_directory( + username=username, password=password, pull_name=pull_name, pull_mail=pull_mail, pull_phone=pull_phone, + mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute) + return self.authenticate_and_roles_openldap( + username=username, password=password, pull_name=pull_name, pull_mail=pull_mail, pull_phone=pull_phone, + mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute) + + def entries_search_command(self, args): + search_params = { + "search_base": args.get("search_base") or self._base_dn, + "search_scope": args.get("search_scope") or SUBTREE, + "search_filter": create_entries_search_filter(args), + "attributes": get_search_attributes(args.get("attributes", "all")), + } + auto_bind = self._get_auto_bind_value() + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + page = arg_to_num(args.get("page")) + if page: + page_size = int(args.get("page_size", 50)) + if page_size > MAX_PAGE_SIZE: + raise Exception("The page size must be less than or equal to 2000") + else: + page = 1 + page_size = int(args.get("limit", 50)) + entries_paged_search(connection=ldap_conn, search_params=search_params, page=page, page_size=page_size) + outputs = [ + {**json.loads(entry.entry_to_json()).get("attributes", {}), "dn": json.loads(entry.entry_to_json()).get("dn")} + for entry in ldap_conn.entries + ] + return {"results": outputs, "count": len(outputs)} + + def ad_authenticate(self, username, password): + self._has_wildcards_in_user_logon(username) + if self._ldap_server_vendor == self.OPENLDAP: + search_user_by_dn, _ = LdapClient._is_valid_dn(username, self.USER_IDENTIFIER_ATTRIBUTE) + entry, _ = self.search_user_data(username, [self.GROUPS_IDENTIFIER_ATTRIBUTE], search_user_by_dn) + username = entry.entry_dn + return self.authenticate_ldap_user(username, password) + + def test_module(self): + self._get_formatted_custom_attributes() + if self._ldap_server_vendor == self.OPENLDAP: + try: + parse_dn(self._username) + except LDAPInvalidDnError: + raise Exception("Invalid credentials input. The bind user must be a full DN.") + self.authenticate_ldap_user(username=self._username, password=self._password) + return "ok" + + + +def run(): + client = LdapClient(S) + result = client.ad_authenticate(I["username"], I["password"]) + out({"result": result, "vendor": client._ldap_server_vendor}) + + +try: + run() +except SystemExit: + raise +except LDAPBindError as e: + fail("Authentication connection failed. Additional details: " + str(e)) +except (LDAPSocketOpenError, LDAPSocketReceiveError, LDAPStartTLSError) as e: + msg = "Failed to connect to the LDAP server. Additional details: " + str(e) + if not arg_to_bool(S.get("insecure", False)): + msg += ' Try the "Trust any certificate" option.' + fail(msg) +except LDAPInvalidPortError: + fail("Invalid LDAP server input. The server must be of the form: ip or ldap://ip") +except LDAPException as e: + fail("LDAP error: " + str(e)) +except Exception as e: + fail(str(e)) diff --git a/integrations/openldap/scripts/ad_authenticate_and_roles.py b/integrations/openldap/scripts/ad_authenticate_and_roles.py new file mode 100644 index 0000000..2429592 --- /dev/null +++ b/integrations/openldap/scripts/ad_authenticate_and_roles.py @@ -0,0 +1,537 @@ +import json, os, sys + +S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) +I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def out(value): + print(json.dumps(value, default=str)) + + +def fail(message, **extra): + payload = {"error": message} + payload.update(extra) + print(json.dumps(payload, default=str)) + sys.exit(1) + + +try: + import ssl + from ldap3 import ( + ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, AUTO_BIND_NO_TLS, + AUTO_BIND_TLS_BEFORE_BIND, BASE, SUBTREE, Connection, Server, Tls, + ) + from ldap3.core.exceptions import ( + LDAPBindError, LDAPException, LDAPInvalidDnError, LDAPInvalidPortError, + LDAPSocketOpenError, LDAPSocketReceiveError, LDAPStartTLSError, + ) + from ldap3.utils.dn import parse_dn +except ImportError as _e: + print(json.dumps({ + "error": "The 'ldap3' Python library is required for the OpenLDAP integration. " + "Install it on the execution host (engine): pip install ldap3", + "detail": str(_e), + })) + sys.exit(1) + +MAX_PAGE_SIZE = 2000 + + +def arg_to_list(v, separator=","): + if not v: + return [] + if isinstance(v, list): + return v + return [x.strip() for x in str(v).split(separator) if x.strip()] + + +def arg_to_bool(v): + if isinstance(v, bool): + return v + return str(v).lower() in ("1", "true", "yes") + + +def arg_to_num(v): + if v is None or v == "": + return None + return int(v) + + +def list_arg_to_ldap_filter(arg, prefix): + items = arg_to_list(arg) + joined = "".join(f"({prefix}={item})" for item in items) + if len(items) > 1: + return f"(&{joined})" + return joined if items else "" + + +def create_entries_search_filter(args): + cn = list_arg_to_ldap_filter(args.get("cn", ""), "cn") + description = list_arg_to_ldap_filter(args.get("description", ""), "description") + object_class = list_arg_to_ldap_filter(args.get("object_class", ""), "objectClass") + uid = list_arg_to_ldap_filter(args.get("uid", ""), "uid") + search_filter = args.get("search_filter", "") + if not any([cn, description, object_class, uid, search_filter]): + return "(objectClass=*)" + return f"(|{cn}{description}{object_class}{uid}{search_filter})" + + +def get_search_attributes(attributes): + if attributes == "all": + return [ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES] + return { + "none": None, + "all_user_attributes": ALL_ATTRIBUTES, + "all_operational_attributes": ALL_OPERATIONAL_ATTRIBUTES, + }.get(attributes, arg_to_list(attributes)) + + +def entries_paged_search(connection, search_params, page, page_size): + if page == 1: + return connection.search(**search_params, paged_size=page_size) + results_to_skip = page_size * (page - 1) + connection.search(**search_params, paged_size=results_to_skip) + cookie = connection.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"] + return connection.search(**search_params, paged_size=page_size, paged_cookie=cookie) + + +class LdapClient: + """LDAP authentication client supporting OpenLDAP and Active Directory.""" + + OPENLDAP = "OpenLDAP" + ACTIVE_DIRECTORY = "Active Directory" + AUTO = "Auto" + GROUPS_TOKEN = "primaryGroupToken" + GROUPS_MEMBER = "memberOf" + GROUPS_PRIMARY_ID = "primaryGroupID" + TIMEOUT = 120 + CIPHERS_STRING = ( + "@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:DH+AESGCM:" + "ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS" + ) + SSL_VERSIONS = { + "None": None, + "TLS": ssl.PROTOCOL_TLS, + "TLSv1": ssl.PROTOCOL_TLSv1, + "TLSv1_1": ssl.PROTOCOL_TLSv1_1, + "TLSv1_2": ssl.PROTOCOL_TLSv1_2, + "TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT, + } + + def __init__(self, cfg): + self._host = cfg.get("host") + self._port = int(cfg["port"]) if cfg.get("port") else None + self._username = cfg.get("username", "") + self._password = cfg.get("password", "") + self._base_dn = (cfg.get("base_dn") or "").strip() + self._connection_type = (cfg.get("connection_type") or "none").lower() + self._ssl_version = cfg.get("ssl_version", "None") + self._fetch_groups = arg_to_bool(cfg.get("fetch_groups", True)) + self._verify = not arg_to_bool(cfg.get("insecure", False)) + self._ldap_server = self._initialize_ldap_server() + self._ldap_server_vendor = cfg.get("ldap_server_vendor", self.AUTO) + if self._ldap_server_vendor == self.AUTO: + self._determine_ldap_vendor_automatically() + self._page_size = int(cfg.get("page_size") or 500) + self._groups_filter_class = (cfg.get("group_filter_class") or "posixGroup").strip() + self._group_identifier_attribute = (cfg.get("group_identifier_attribute") or "gidNumber").strip() + self._member_identifier_attribute = (cfg.get("member_identifier_attribute") or "memberUid").strip() + self._user_filter_class = cfg.get("user_filter_class") or "posixAccount" + self._user_identifier_attribute = cfg.get("user_identifier_attribute") or "uid" + self._custom_attributes = cfg.get("custom_attributes") or "" + + @property + def GROUPS_OBJECT_CLASS(self): + return self._groups_filter_class + + @property + def GROUPS_IDENTIFIER_ATTRIBUTE(self): + return self._group_identifier_attribute + + @property + def GROUPS_MEMBERSHIP_IDENTIFIER_ATTRIBUTE(self): + return self._member_identifier_attribute + + @property + def USER_OBJECT_CLASS(self): + return self._user_filter_class + + @property + def USER_IDENTIFIER_ATTRIBUTE(self): + return self._user_identifier_attribute + + @property + def CUSTOM_ATTRIBUTE(self): + return self._custom_attributes + + def _get_ssl_version(self): + return self.SSL_VERSIONS.get(self._ssl_version) + + def _get_tls_object(self): + if self._verify: + return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=self._get_ssl_version()) + return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, version=self._get_ssl_version(), ciphers=self.CIPHERS_STRING) + + def _initialize_ldap_server(self): + if self._connection_type == "ssl": + return Server(host=self._host, port=self._port, use_ssl=True, tls=self._get_tls_object(), connect_timeout=self.TIMEOUT) + if self._connection_type == "start tls": + return Server(host=self._host, port=self._port, use_ssl=False, tls=self._get_tls_object(), connect_timeout=self.TIMEOUT) + return Server(host=self._host, port=self._port, connect_timeout=self.TIMEOUT) + + def _determine_ldap_vendor_automatically(self): + try: + with Connection(self._ldap_server) as conn: + conn.search(search_base="", search_filter="(objectClass=*)", search_scope=BASE, attributes=[ALL_ATTRIBUTES]) + entry = conn.entries[0] + if "objectClass" in entry and "OpenLDAProotDSE" in entry["objectClass"].value: + self._ldap_server_vendor = self.OPENLDAP + else: + self._ldap_server_vendor = self.ACTIVE_DIRECTORY + except Exception as e: + raise Exception(f"Could not determine the LDAP vendor automatically. Select the vendor manually. Error: {e}") + + @staticmethod + def _parse_ldap_group_entries(ldap_group_entries, groups_identifier_attribute): + return [ + { + "DN": ldap_group.get("dn"), + "Attributes": [{ + "Name": LdapClient.GROUPS_TOKEN, + "Values": [str(ldap_group.get("attributes", {}).get(groups_identifier_attribute))], + }], + } + for ldap_group in ldap_group_entries + ] + + @staticmethod + def _parse_ldap_group_entries_and_referrals(ldap_group_entries): + referrals = [] + entries = [] + for ldap_group in ldap_group_entries: + group_type = ldap_group.get("type") + if group_type == "searchResRef": + referrals.extend(ldap_group.get("uri") or []) + elif group_type == "searchResEntry": + entries.append({ + "DN": ldap_group.get("dn"), + "Attributes": [{ + "Name": LdapClient.GROUPS_TOKEN, + "Values": [str(ldap_group.get("attributes", {}).get(LdapClient.GROUPS_TOKEN))], + }], + }) + return referrals, entries + + def _parse_and_authenticate_ldap_group_entries_and_referrals(self, ldap_group_entries, password): + referrals = [] + entries = [] + for entry in ldap_group_entries: + entry_type = entry.get("type") + if entry_type == "searchResRef": + referrals.extend(entry.get("uri") or []) + elif entry_type == "searchResEntry": + entry_dn = entry.get("dn", "") + entry_attributes = entry.get("attributes", {}) + relevant = [] + for attr in entry_attributes: + attr_value = entry_attributes.get(attr, []) + if attr_value: + if not isinstance(attr_value, list): + attr_value = [str(attr_value)] + relevant.append({"Name": attr, "Values": attr_value}) + entries.append({"DN": entry_dn, "Attributes": relevant}) + self.authenticate_ldap_user(entry_dn, password) + return referrals, entries + + @staticmethod + def _parse_ldap_users_groups_entries(ldap_group_entries): + return [ldap_group.get("dn") for ldap_group in ldap_group_entries] + + @staticmethod + def _build_entry_for_user(user_groups, user_data, mail_attribute, name_attribute, phone_attribute): + attributes = [ + {"Name": LdapClient.GROUPS_MEMBER, "Values": user_groups}, + {"Name": LdapClient.GROUPS_PRIMARY_ID, "Values": user_data["gid_number"]}, + ] + if "name" in user_data: + attributes.append({"Name": name_attribute, "Values": [user_data["name"]]}) + if "email" in user_data: + attributes.append({"Name": mail_attribute, "Values": [user_data["email"]]}) + if "mobile" in user_data: + attributes.append({"Name": phone_attribute, "Values": [user_data["mobile"]]}) + return {"DN": user_data["dn"], "Attributes": attributes} + + @staticmethod + def _is_valid_dn(dn, user_identifier_attribute): + try: + parsed_dn = parse_dn(dn, strip=False) + for attribute_and_value in parsed_dn: + if attribute_and_value[0].lower() == user_identifier_attribute.lower(): + return True, attribute_and_value[1] + raise Exception(f"The {user_identifier_attribute} attribute was not found in the user DN: {dn}") + except LDAPInvalidDnError: + return False, dn + + def _get_formatted_custom_attributes(self): + if not self.CUSTOM_ATTRIBUTE: + return "" + formatted = "" + for att in self.CUSTOM_ATTRIBUTE.split(","): + if len(att.split("=")) != 2: + raise Exception(f'User defined attributes must be of the form "attrA=valA,attrB=valB,...", but got: {self.CUSTOM_ATTRIBUTE}') + formatted += f"({att})" + return formatted + + def _get_ldap_groups_entries_and_referrals_ad(self, ldap_conn, search_filter): + ldap_group_entries = ldap_conn.extend.standard.paged_search( + search_base=self._base_dn, search_filter=search_filter, + attributes=[LdapClient.GROUPS_TOKEN], paged_size=self._page_size, generator=False, + ) + return LdapClient._parse_ldap_group_entries_and_referrals(ldap_group_entries) + + def _fetch_all_groups(self): + auto_bind = self._get_auto_bind_value() + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + if self._ldap_server_vendor == self.ACTIVE_DIRECTORY: + referrals, entries = self._get_ldap_groups_entries_and_referrals_ad( + ldap_conn=ldap_conn, search_filter="(&(objectClass=group)(objectCategory=group))") + return {"Controls": None, "Referrals": referrals, "Entries": entries} + ldap_group_entries = ldap_conn.extend.standard.paged_search( + search_base=self._base_dn, search_filter=f"(objectClass={self.GROUPS_OBJECT_CLASS})", + attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size) + return { + "Controls": None, + "Referrals": ldap_conn.result.get("referrals"), + "Entries": LdapClient._parse_ldap_group_entries(ldap_group_entries, self.GROUPS_IDENTIFIER_ATTRIBUTE), + } + + def _fetch_specific_groups(self, specific_groups): + auto_bind = self._get_auto_bind_value() + dn_list = [group.strip() for group in arg_to_list(specific_groups, separator="#")] + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + if self._ldap_server_vendor == self.ACTIVE_DIRECTORY: + dns_filter = "".join(f"(distinguishedName={dn})" for dn in dn_list) + search_filter = f"(&(objectClass=group)(objectCategory=group)(|{dns_filter}))" + referrals, entries = self._get_ldap_groups_entries_and_referrals_ad( + ldap_conn=ldap_conn, search_filter=search_filter) + return {"Controls": None, "Referrals": referrals, "Entries": entries} + parsed = [] + for dn in dn_list: + ldap_group_entries = ldap_conn.extend.standard.paged_search( + search_base=dn, search_filter=f"(objectClass={self.GROUPS_OBJECT_CLASS})", + attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size, search_scope=BASE) + parsed.append(self._parse_ldap_group_entries(ldap_group_entries, self.GROUPS_IDENTIFIER_ATTRIBUTE)) + return {"Controls": None, "Referrals": ldap_conn.result.get("referrals"), "Entries": parsed} + + @staticmethod + def _get_ad_username(logon_name): + if "\\" in logon_name: + return logon_name.split("\\")[1] + if "@" in logon_name: + return logon_name.split("@")[0] + return logon_name + + @staticmethod + def _has_wildcards_in_user_logon(logon_name): + for wildcard in ("*", "?"): + if wildcard in logon_name: + raise Exception( + f"Authentication failed - wildcards were detected in the user logon name " + f"(input username: '{logon_name}'). Wildcards are not permitted for user authentication.") + + def _get_auto_bind_value(self): + if self._connection_type == "start tls": + return AUTO_BIND_TLS_BEFORE_BIND + return AUTO_BIND_NO_TLS + + def get_ldap_groups(self, specific_group=""): + if not self._fetch_groups and not specific_group: + return {"Controls": None, "Referrals": None, "Entries": []} + return self._fetch_specific_groups(specific_group) if not self._fetch_groups else self._fetch_all_groups() + + def authenticate_ldap_user(self, username, password): + auto_bind = self._get_auto_bind_value() + ldap_conn = Connection(server=self._ldap_server, user=username, password=password, auto_bind=auto_bind) + if ldap_conn.bound: + ldap_conn.unbind() + return "Done" + raise Exception(f"Authentication connection failed (server type: {self._ldap_server_vendor})") + + def search_user_data(self, username, attributes, search_user_by_dn=False): + auto_bind = self._get_auto_bind_value() + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + if search_user_by_dn: + search_filter = f"(&(objectClass={self.USER_OBJECT_CLASS})" + self._get_formatted_custom_attributes() + ")" + ldap_conn.search(search_base=username, search_filter=search_filter, size_limit=1, + attributes=attributes, search_scope=BASE) + else: + custom_attributes = self._get_formatted_custom_attributes() + search_filter = (f"(&(objectClass={self.USER_OBJECT_CLASS})" + f"({self.USER_IDENTIFIER_ATTRIBUTE}={username}){custom_attributes})") + ldap_conn.search(search_base=self._base_dn, search_filter=search_filter, size_limit=1, attributes=attributes) + if not ldap_conn.entries: + raise Exception("LDAP user not found") + entry = ldap_conn.entries[0] + referrals = ldap_conn.result.get("referrals") + if self.GROUPS_IDENTIFIER_ATTRIBUTE not in entry or not entry[self.GROUPS_IDENTIFIER_ATTRIBUTE].value: + raise Exception(f"User's {self.GROUPS_IDENTIFIER_ATTRIBUTE} not found") + return entry, referrals + + def get_user_data(self, username, pull_name, pull_mail, pull_phone, + name_attribute, mail_attribute, phone_attribute, search_user_by_dn=False): + attributes = [self.GROUPS_IDENTIFIER_ATTRIBUTE] + if pull_name: + attributes.append(name_attribute) + if pull_mail: + attributes.append(mail_attribute) + if pull_phone: + attributes.append(phone_attribute) + entry, referrals = self.search_user_data(username, attributes, search_user_by_dn) + user_data = { + "dn": entry.entry_dn, + "gid_number": [str(entry[self.GROUPS_IDENTIFIER_ATTRIBUTE].value)], + "referrals": referrals, + } + if name_attribute in entry and entry[name_attribute].value: + user_data["name"] = entry[name_attribute].value + if mail_attribute in entry and entry[mail_attribute].value: + user_data["email"] = entry[mail_attribute].value + if phone_attribute in entry and entry[phone_attribute].value: + user_data["mobile"] = entry[phone_attribute].value + return user_data + + def get_user_groups(self, user_identifier): + auto_bind = self._get_auto_bind_value() + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + search_filter = (f"(&(objectClass={self.GROUPS_OBJECT_CLASS})" + f"({self.GROUPS_MEMBERSHIP_IDENTIFIER_ATTRIBUTE}={user_identifier}))") + ldap_group_entries = ldap_conn.extend.standard.paged_search( + search_base=self._base_dn, search_filter=search_filter, + attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size) + return LdapClient._parse_ldap_users_groups_entries(ldap_group_entries) + + def authenticate_and_roles_openldap(self, username, password, pull_name=True, pull_mail=True, pull_phone=False, + mail_attribute="mail", name_attribute="name", phone_attribute="mobile"): + search_user_by_dn, user_identifier = LdapClient._is_valid_dn(username, self.USER_IDENTIFIER_ATTRIBUTE) + user_data = self.get_user_data( + username=username, search_user_by_dn=search_user_by_dn, pull_name=pull_name, pull_mail=pull_mail, + pull_phone=pull_phone, mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute) + self.authenticate_ldap_user(user_data["dn"], password) + user_groups = self.get_user_groups(user_identifier) + return { + "Controls": None, + "Referrals": user_data["referrals"], + "Entries": [LdapClient._build_entry_for_user( + user_groups=user_groups, user_data=user_data, + mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute)], + } + + def authenticate_and_roles_active_directory(self, username, password, pull_name=True, pull_mail=True, pull_phone=False, + mail_attribute="mail", name_attribute="name", phone_attribute="mobile"): + ad_username = self._get_ad_username(username) + auto_bind = self._get_auto_bind_value() + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + attributes = [self.GROUPS_MEMBER, self.GROUPS_PRIMARY_ID] + if pull_name: + attributes.append(name_attribute) + if pull_mail: + attributes.append(mail_attribute) + if pull_phone: + attributes.append(phone_attribute) + search_filter = f"(|(sAMAccountName={ad_username})(userPrincipalName={username}))" + ldap_conn_entries = ldap_conn.extend.standard.paged_search( + search_base=self._base_dn, search_filter=search_filter, + attributes=attributes, paged_size=self._page_size, generator=False) + referrals, entries = self._parse_and_authenticate_ldap_group_entries_and_referrals( + ldap_group_entries=ldap_conn_entries, password=password) + if not entries: + raise Exception("LDAP user not found") + return {"Controls": [], "Referrals": referrals, "Entries": entries} + + def authenticate_and_roles(self, username, password, pull_name=True, pull_mail=True, pull_phone=False, + mail_attribute="mail", name_attribute="name", phone_attribute="mobile"): + self._has_wildcards_in_user_logon(username) + if self._ldap_server_vendor == self.ACTIVE_DIRECTORY: + return self.authenticate_and_roles_active_directory( + username=username, password=password, pull_name=pull_name, pull_mail=pull_mail, pull_phone=pull_phone, + mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute) + return self.authenticate_and_roles_openldap( + username=username, password=password, pull_name=pull_name, pull_mail=pull_mail, pull_phone=pull_phone, + mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute) + + def entries_search_command(self, args): + search_params = { + "search_base": args.get("search_base") or self._base_dn, + "search_scope": args.get("search_scope") or SUBTREE, + "search_filter": create_entries_search_filter(args), + "attributes": get_search_attributes(args.get("attributes", "all")), + } + auto_bind = self._get_auto_bind_value() + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + page = arg_to_num(args.get("page")) + if page: + page_size = int(args.get("page_size", 50)) + if page_size > MAX_PAGE_SIZE: + raise Exception("The page size must be less than or equal to 2000") + else: + page = 1 + page_size = int(args.get("limit", 50)) + entries_paged_search(connection=ldap_conn, search_params=search_params, page=page, page_size=page_size) + outputs = [ + {**json.loads(entry.entry_to_json()).get("attributes", {}), "dn": json.loads(entry.entry_to_json()).get("dn")} + for entry in ldap_conn.entries + ] + return {"results": outputs, "count": len(outputs)} + + def ad_authenticate(self, username, password): + self._has_wildcards_in_user_logon(username) + if self._ldap_server_vendor == self.OPENLDAP: + search_user_by_dn, _ = LdapClient._is_valid_dn(username, self.USER_IDENTIFIER_ATTRIBUTE) + entry, _ = self.search_user_data(username, [self.GROUPS_IDENTIFIER_ATTRIBUTE], search_user_by_dn) + username = entry.entry_dn + return self.authenticate_ldap_user(username, password) + + def test_module(self): + self._get_formatted_custom_attributes() + if self._ldap_server_vendor == self.OPENLDAP: + try: + parse_dn(self._username) + except LDAPInvalidDnError: + raise Exception("Invalid credentials input. The bind user must be a full DN.") + self.authenticate_ldap_user(username=self._username, password=self._password) + return "ok" + + + +def run(): + client = LdapClient(S) + result = client.authenticate_and_roles( + username=I["username"], + password=I["password"], + pull_name=arg_to_bool(I.get("attribute-name-pull", True)), + pull_mail=arg_to_bool(I.get("attribute-mail-pull", True)), + pull_phone=arg_to_bool(I.get("attribute-phone-pull", False)), + mail_attribute=I.get("attribute-mail") or "mail", + name_attribute=I.get("attribute-name") or "name", + phone_attribute=I.get("attribute-phone") or "mobile", + ) + out(result) + + +try: + run() +except SystemExit: + raise +except LDAPBindError as e: + fail("Authentication connection failed. Additional details: " + str(e)) +except (LDAPSocketOpenError, LDAPSocketReceiveError, LDAPStartTLSError) as e: + msg = "Failed to connect to the LDAP server. Additional details: " + str(e) + if not arg_to_bool(S.get("insecure", False)): + msg += ' Try the "Trust any certificate" option.' + fail(msg) +except LDAPInvalidPortError: + fail("Invalid LDAP server input. The server must be of the form: ip or ldap://ip") +except LDAPException as e: + fail("LDAP error: " + str(e)) +except Exception as e: + fail(str(e)) diff --git a/integrations/openldap/scripts/ad_entries_search.py b/integrations/openldap/scripts/ad_entries_search.py new file mode 100644 index 0000000..2bf7079 --- /dev/null +++ b/integrations/openldap/scripts/ad_entries_search.py @@ -0,0 +1,527 @@ +import json, os, sys + +S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) +I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def out(value): + print(json.dumps(value, default=str)) + + +def fail(message, **extra): + payload = {"error": message} + payload.update(extra) + print(json.dumps(payload, default=str)) + sys.exit(1) + + +try: + import ssl + from ldap3 import ( + ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, AUTO_BIND_NO_TLS, + AUTO_BIND_TLS_BEFORE_BIND, BASE, SUBTREE, Connection, Server, Tls, + ) + from ldap3.core.exceptions import ( + LDAPBindError, LDAPException, LDAPInvalidDnError, LDAPInvalidPortError, + LDAPSocketOpenError, LDAPSocketReceiveError, LDAPStartTLSError, + ) + from ldap3.utils.dn import parse_dn +except ImportError as _e: + print(json.dumps({ + "error": "The 'ldap3' Python library is required for the OpenLDAP integration. " + "Install it on the execution host (engine): pip install ldap3", + "detail": str(_e), + })) + sys.exit(1) + +MAX_PAGE_SIZE = 2000 + + +def arg_to_list(v, separator=","): + if not v: + return [] + if isinstance(v, list): + return v + return [x.strip() for x in str(v).split(separator) if x.strip()] + + +def arg_to_bool(v): + if isinstance(v, bool): + return v + return str(v).lower() in ("1", "true", "yes") + + +def arg_to_num(v): + if v is None or v == "": + return None + return int(v) + + +def list_arg_to_ldap_filter(arg, prefix): + items = arg_to_list(arg) + joined = "".join(f"({prefix}={item})" for item in items) + if len(items) > 1: + return f"(&{joined})" + return joined if items else "" + + +def create_entries_search_filter(args): + cn = list_arg_to_ldap_filter(args.get("cn", ""), "cn") + description = list_arg_to_ldap_filter(args.get("description", ""), "description") + object_class = list_arg_to_ldap_filter(args.get("object_class", ""), "objectClass") + uid = list_arg_to_ldap_filter(args.get("uid", ""), "uid") + search_filter = args.get("search_filter", "") + if not any([cn, description, object_class, uid, search_filter]): + return "(objectClass=*)" + return f"(|{cn}{description}{object_class}{uid}{search_filter})" + + +def get_search_attributes(attributes): + if attributes == "all": + return [ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES] + return { + "none": None, + "all_user_attributes": ALL_ATTRIBUTES, + "all_operational_attributes": ALL_OPERATIONAL_ATTRIBUTES, + }.get(attributes, arg_to_list(attributes)) + + +def entries_paged_search(connection, search_params, page, page_size): + if page == 1: + return connection.search(**search_params, paged_size=page_size) + results_to_skip = page_size * (page - 1) + connection.search(**search_params, paged_size=results_to_skip) + cookie = connection.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"] + return connection.search(**search_params, paged_size=page_size, paged_cookie=cookie) + + +class LdapClient: + """LDAP authentication client supporting OpenLDAP and Active Directory.""" + + OPENLDAP = "OpenLDAP" + ACTIVE_DIRECTORY = "Active Directory" + AUTO = "Auto" + GROUPS_TOKEN = "primaryGroupToken" + GROUPS_MEMBER = "memberOf" + GROUPS_PRIMARY_ID = "primaryGroupID" + TIMEOUT = 120 + CIPHERS_STRING = ( + "@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:DH+AESGCM:" + "ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS" + ) + SSL_VERSIONS = { + "None": None, + "TLS": ssl.PROTOCOL_TLS, + "TLSv1": ssl.PROTOCOL_TLSv1, + "TLSv1_1": ssl.PROTOCOL_TLSv1_1, + "TLSv1_2": ssl.PROTOCOL_TLSv1_2, + "TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT, + } + + def __init__(self, cfg): + self._host = cfg.get("host") + self._port = int(cfg["port"]) if cfg.get("port") else None + self._username = cfg.get("username", "") + self._password = cfg.get("password", "") + self._base_dn = (cfg.get("base_dn") or "").strip() + self._connection_type = (cfg.get("connection_type") or "none").lower() + self._ssl_version = cfg.get("ssl_version", "None") + self._fetch_groups = arg_to_bool(cfg.get("fetch_groups", True)) + self._verify = not arg_to_bool(cfg.get("insecure", False)) + self._ldap_server = self._initialize_ldap_server() + self._ldap_server_vendor = cfg.get("ldap_server_vendor", self.AUTO) + if self._ldap_server_vendor == self.AUTO: + self._determine_ldap_vendor_automatically() + self._page_size = int(cfg.get("page_size") or 500) + self._groups_filter_class = (cfg.get("group_filter_class") or "posixGroup").strip() + self._group_identifier_attribute = (cfg.get("group_identifier_attribute") or "gidNumber").strip() + self._member_identifier_attribute = (cfg.get("member_identifier_attribute") or "memberUid").strip() + self._user_filter_class = cfg.get("user_filter_class") or "posixAccount" + self._user_identifier_attribute = cfg.get("user_identifier_attribute") or "uid" + self._custom_attributes = cfg.get("custom_attributes") or "" + + @property + def GROUPS_OBJECT_CLASS(self): + return self._groups_filter_class + + @property + def GROUPS_IDENTIFIER_ATTRIBUTE(self): + return self._group_identifier_attribute + + @property + def GROUPS_MEMBERSHIP_IDENTIFIER_ATTRIBUTE(self): + return self._member_identifier_attribute + + @property + def USER_OBJECT_CLASS(self): + return self._user_filter_class + + @property + def USER_IDENTIFIER_ATTRIBUTE(self): + return self._user_identifier_attribute + + @property + def CUSTOM_ATTRIBUTE(self): + return self._custom_attributes + + def _get_ssl_version(self): + return self.SSL_VERSIONS.get(self._ssl_version) + + def _get_tls_object(self): + if self._verify: + return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=self._get_ssl_version()) + return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, version=self._get_ssl_version(), ciphers=self.CIPHERS_STRING) + + def _initialize_ldap_server(self): + if self._connection_type == "ssl": + return Server(host=self._host, port=self._port, use_ssl=True, tls=self._get_tls_object(), connect_timeout=self.TIMEOUT) + if self._connection_type == "start tls": + return Server(host=self._host, port=self._port, use_ssl=False, tls=self._get_tls_object(), connect_timeout=self.TIMEOUT) + return Server(host=self._host, port=self._port, connect_timeout=self.TIMEOUT) + + def _determine_ldap_vendor_automatically(self): + try: + with Connection(self._ldap_server) as conn: + conn.search(search_base="", search_filter="(objectClass=*)", search_scope=BASE, attributes=[ALL_ATTRIBUTES]) + entry = conn.entries[0] + if "objectClass" in entry and "OpenLDAProotDSE" in entry["objectClass"].value: + self._ldap_server_vendor = self.OPENLDAP + else: + self._ldap_server_vendor = self.ACTIVE_DIRECTORY + except Exception as e: + raise Exception(f"Could not determine the LDAP vendor automatically. Select the vendor manually. Error: {e}") + + @staticmethod + def _parse_ldap_group_entries(ldap_group_entries, groups_identifier_attribute): + return [ + { + "DN": ldap_group.get("dn"), + "Attributes": [{ + "Name": LdapClient.GROUPS_TOKEN, + "Values": [str(ldap_group.get("attributes", {}).get(groups_identifier_attribute))], + }], + } + for ldap_group in ldap_group_entries + ] + + @staticmethod + def _parse_ldap_group_entries_and_referrals(ldap_group_entries): + referrals = [] + entries = [] + for ldap_group in ldap_group_entries: + group_type = ldap_group.get("type") + if group_type == "searchResRef": + referrals.extend(ldap_group.get("uri") or []) + elif group_type == "searchResEntry": + entries.append({ + "DN": ldap_group.get("dn"), + "Attributes": [{ + "Name": LdapClient.GROUPS_TOKEN, + "Values": [str(ldap_group.get("attributes", {}).get(LdapClient.GROUPS_TOKEN))], + }], + }) + return referrals, entries + + def _parse_and_authenticate_ldap_group_entries_and_referrals(self, ldap_group_entries, password): + referrals = [] + entries = [] + for entry in ldap_group_entries: + entry_type = entry.get("type") + if entry_type == "searchResRef": + referrals.extend(entry.get("uri") or []) + elif entry_type == "searchResEntry": + entry_dn = entry.get("dn", "") + entry_attributes = entry.get("attributes", {}) + relevant = [] + for attr in entry_attributes: + attr_value = entry_attributes.get(attr, []) + if attr_value: + if not isinstance(attr_value, list): + attr_value = [str(attr_value)] + relevant.append({"Name": attr, "Values": attr_value}) + entries.append({"DN": entry_dn, "Attributes": relevant}) + self.authenticate_ldap_user(entry_dn, password) + return referrals, entries + + @staticmethod + def _parse_ldap_users_groups_entries(ldap_group_entries): + return [ldap_group.get("dn") for ldap_group in ldap_group_entries] + + @staticmethod + def _build_entry_for_user(user_groups, user_data, mail_attribute, name_attribute, phone_attribute): + attributes = [ + {"Name": LdapClient.GROUPS_MEMBER, "Values": user_groups}, + {"Name": LdapClient.GROUPS_PRIMARY_ID, "Values": user_data["gid_number"]}, + ] + if "name" in user_data: + attributes.append({"Name": name_attribute, "Values": [user_data["name"]]}) + if "email" in user_data: + attributes.append({"Name": mail_attribute, "Values": [user_data["email"]]}) + if "mobile" in user_data: + attributes.append({"Name": phone_attribute, "Values": [user_data["mobile"]]}) + return {"DN": user_data["dn"], "Attributes": attributes} + + @staticmethod + def _is_valid_dn(dn, user_identifier_attribute): + try: + parsed_dn = parse_dn(dn, strip=False) + for attribute_and_value in parsed_dn: + if attribute_and_value[0].lower() == user_identifier_attribute.lower(): + return True, attribute_and_value[1] + raise Exception(f"The {user_identifier_attribute} attribute was not found in the user DN: {dn}") + except LDAPInvalidDnError: + return False, dn + + def _get_formatted_custom_attributes(self): + if not self.CUSTOM_ATTRIBUTE: + return "" + formatted = "" + for att in self.CUSTOM_ATTRIBUTE.split(","): + if len(att.split("=")) != 2: + raise Exception(f'User defined attributes must be of the form "attrA=valA,attrB=valB,...", but got: {self.CUSTOM_ATTRIBUTE}') + formatted += f"({att})" + return formatted + + def _get_ldap_groups_entries_and_referrals_ad(self, ldap_conn, search_filter): + ldap_group_entries = ldap_conn.extend.standard.paged_search( + search_base=self._base_dn, search_filter=search_filter, + attributes=[LdapClient.GROUPS_TOKEN], paged_size=self._page_size, generator=False, + ) + return LdapClient._parse_ldap_group_entries_and_referrals(ldap_group_entries) + + def _fetch_all_groups(self): + auto_bind = self._get_auto_bind_value() + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + if self._ldap_server_vendor == self.ACTIVE_DIRECTORY: + referrals, entries = self._get_ldap_groups_entries_and_referrals_ad( + ldap_conn=ldap_conn, search_filter="(&(objectClass=group)(objectCategory=group))") + return {"Controls": None, "Referrals": referrals, "Entries": entries} + ldap_group_entries = ldap_conn.extend.standard.paged_search( + search_base=self._base_dn, search_filter=f"(objectClass={self.GROUPS_OBJECT_CLASS})", + attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size) + return { + "Controls": None, + "Referrals": ldap_conn.result.get("referrals"), + "Entries": LdapClient._parse_ldap_group_entries(ldap_group_entries, self.GROUPS_IDENTIFIER_ATTRIBUTE), + } + + def _fetch_specific_groups(self, specific_groups): + auto_bind = self._get_auto_bind_value() + dn_list = [group.strip() for group in arg_to_list(specific_groups, separator="#")] + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + if self._ldap_server_vendor == self.ACTIVE_DIRECTORY: + dns_filter = "".join(f"(distinguishedName={dn})" for dn in dn_list) + search_filter = f"(&(objectClass=group)(objectCategory=group)(|{dns_filter}))" + referrals, entries = self._get_ldap_groups_entries_and_referrals_ad( + ldap_conn=ldap_conn, search_filter=search_filter) + return {"Controls": None, "Referrals": referrals, "Entries": entries} + parsed = [] + for dn in dn_list: + ldap_group_entries = ldap_conn.extend.standard.paged_search( + search_base=dn, search_filter=f"(objectClass={self.GROUPS_OBJECT_CLASS})", + attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size, search_scope=BASE) + parsed.append(self._parse_ldap_group_entries(ldap_group_entries, self.GROUPS_IDENTIFIER_ATTRIBUTE)) + return {"Controls": None, "Referrals": ldap_conn.result.get("referrals"), "Entries": parsed} + + @staticmethod + def _get_ad_username(logon_name): + if "\\" in logon_name: + return logon_name.split("\\")[1] + if "@" in logon_name: + return logon_name.split("@")[0] + return logon_name + + @staticmethod + def _has_wildcards_in_user_logon(logon_name): + for wildcard in ("*", "?"): + if wildcard in logon_name: + raise Exception( + f"Authentication failed - wildcards were detected in the user logon name " + f"(input username: '{logon_name}'). Wildcards are not permitted for user authentication.") + + def _get_auto_bind_value(self): + if self._connection_type == "start tls": + return AUTO_BIND_TLS_BEFORE_BIND + return AUTO_BIND_NO_TLS + + def get_ldap_groups(self, specific_group=""): + if not self._fetch_groups and not specific_group: + return {"Controls": None, "Referrals": None, "Entries": []} + return self._fetch_specific_groups(specific_group) if not self._fetch_groups else self._fetch_all_groups() + + def authenticate_ldap_user(self, username, password): + auto_bind = self._get_auto_bind_value() + ldap_conn = Connection(server=self._ldap_server, user=username, password=password, auto_bind=auto_bind) + if ldap_conn.bound: + ldap_conn.unbind() + return "Done" + raise Exception(f"Authentication connection failed (server type: {self._ldap_server_vendor})") + + def search_user_data(self, username, attributes, search_user_by_dn=False): + auto_bind = self._get_auto_bind_value() + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + if search_user_by_dn: + search_filter = f"(&(objectClass={self.USER_OBJECT_CLASS})" + self._get_formatted_custom_attributes() + ")" + ldap_conn.search(search_base=username, search_filter=search_filter, size_limit=1, + attributes=attributes, search_scope=BASE) + else: + custom_attributes = self._get_formatted_custom_attributes() + search_filter = (f"(&(objectClass={self.USER_OBJECT_CLASS})" + f"({self.USER_IDENTIFIER_ATTRIBUTE}={username}){custom_attributes})") + ldap_conn.search(search_base=self._base_dn, search_filter=search_filter, size_limit=1, attributes=attributes) + if not ldap_conn.entries: + raise Exception("LDAP user not found") + entry = ldap_conn.entries[0] + referrals = ldap_conn.result.get("referrals") + if self.GROUPS_IDENTIFIER_ATTRIBUTE not in entry or not entry[self.GROUPS_IDENTIFIER_ATTRIBUTE].value: + raise Exception(f"User's {self.GROUPS_IDENTIFIER_ATTRIBUTE} not found") + return entry, referrals + + def get_user_data(self, username, pull_name, pull_mail, pull_phone, + name_attribute, mail_attribute, phone_attribute, search_user_by_dn=False): + attributes = [self.GROUPS_IDENTIFIER_ATTRIBUTE] + if pull_name: + attributes.append(name_attribute) + if pull_mail: + attributes.append(mail_attribute) + if pull_phone: + attributes.append(phone_attribute) + entry, referrals = self.search_user_data(username, attributes, search_user_by_dn) + user_data = { + "dn": entry.entry_dn, + "gid_number": [str(entry[self.GROUPS_IDENTIFIER_ATTRIBUTE].value)], + "referrals": referrals, + } + if name_attribute in entry and entry[name_attribute].value: + user_data["name"] = entry[name_attribute].value + if mail_attribute in entry and entry[mail_attribute].value: + user_data["email"] = entry[mail_attribute].value + if phone_attribute in entry and entry[phone_attribute].value: + user_data["mobile"] = entry[phone_attribute].value + return user_data + + def get_user_groups(self, user_identifier): + auto_bind = self._get_auto_bind_value() + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + search_filter = (f"(&(objectClass={self.GROUPS_OBJECT_CLASS})" + f"({self.GROUPS_MEMBERSHIP_IDENTIFIER_ATTRIBUTE}={user_identifier}))") + ldap_group_entries = ldap_conn.extend.standard.paged_search( + search_base=self._base_dn, search_filter=search_filter, + attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size) + return LdapClient._parse_ldap_users_groups_entries(ldap_group_entries) + + def authenticate_and_roles_openldap(self, username, password, pull_name=True, pull_mail=True, pull_phone=False, + mail_attribute="mail", name_attribute="name", phone_attribute="mobile"): + search_user_by_dn, user_identifier = LdapClient._is_valid_dn(username, self.USER_IDENTIFIER_ATTRIBUTE) + user_data = self.get_user_data( + username=username, search_user_by_dn=search_user_by_dn, pull_name=pull_name, pull_mail=pull_mail, + pull_phone=pull_phone, mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute) + self.authenticate_ldap_user(user_data["dn"], password) + user_groups = self.get_user_groups(user_identifier) + return { + "Controls": None, + "Referrals": user_data["referrals"], + "Entries": [LdapClient._build_entry_for_user( + user_groups=user_groups, user_data=user_data, + mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute)], + } + + def authenticate_and_roles_active_directory(self, username, password, pull_name=True, pull_mail=True, pull_phone=False, + mail_attribute="mail", name_attribute="name", phone_attribute="mobile"): + ad_username = self._get_ad_username(username) + auto_bind = self._get_auto_bind_value() + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + attributes = [self.GROUPS_MEMBER, self.GROUPS_PRIMARY_ID] + if pull_name: + attributes.append(name_attribute) + if pull_mail: + attributes.append(mail_attribute) + if pull_phone: + attributes.append(phone_attribute) + search_filter = f"(|(sAMAccountName={ad_username})(userPrincipalName={username}))" + ldap_conn_entries = ldap_conn.extend.standard.paged_search( + search_base=self._base_dn, search_filter=search_filter, + attributes=attributes, paged_size=self._page_size, generator=False) + referrals, entries = self._parse_and_authenticate_ldap_group_entries_and_referrals( + ldap_group_entries=ldap_conn_entries, password=password) + if not entries: + raise Exception("LDAP user not found") + return {"Controls": [], "Referrals": referrals, "Entries": entries} + + def authenticate_and_roles(self, username, password, pull_name=True, pull_mail=True, pull_phone=False, + mail_attribute="mail", name_attribute="name", phone_attribute="mobile"): + self._has_wildcards_in_user_logon(username) + if self._ldap_server_vendor == self.ACTIVE_DIRECTORY: + return self.authenticate_and_roles_active_directory( + username=username, password=password, pull_name=pull_name, pull_mail=pull_mail, pull_phone=pull_phone, + mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute) + return self.authenticate_and_roles_openldap( + username=username, password=password, pull_name=pull_name, pull_mail=pull_mail, pull_phone=pull_phone, + mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute) + + def entries_search_command(self, args): + search_params = { + "search_base": args.get("search_base") or self._base_dn, + "search_scope": args.get("search_scope") or SUBTREE, + "search_filter": create_entries_search_filter(args), + "attributes": get_search_attributes(args.get("attributes", "all")), + } + auto_bind = self._get_auto_bind_value() + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + page = arg_to_num(args.get("page")) + if page: + page_size = int(args.get("page_size", 50)) + if page_size > MAX_PAGE_SIZE: + raise Exception("The page size must be less than or equal to 2000") + else: + page = 1 + page_size = int(args.get("limit", 50)) + entries_paged_search(connection=ldap_conn, search_params=search_params, page=page, page_size=page_size) + outputs = [ + {**json.loads(entry.entry_to_json()).get("attributes", {}), "dn": json.loads(entry.entry_to_json()).get("dn")} + for entry in ldap_conn.entries + ] + return {"results": outputs, "count": len(outputs)} + + def ad_authenticate(self, username, password): + self._has_wildcards_in_user_logon(username) + if self._ldap_server_vendor == self.OPENLDAP: + search_user_by_dn, _ = LdapClient._is_valid_dn(username, self.USER_IDENTIFIER_ATTRIBUTE) + entry, _ = self.search_user_data(username, [self.GROUPS_IDENTIFIER_ATTRIBUTE], search_user_by_dn) + username = entry.entry_dn + return self.authenticate_ldap_user(username, password) + + def test_module(self): + self._get_formatted_custom_attributes() + if self._ldap_server_vendor == self.OPENLDAP: + try: + parse_dn(self._username) + except LDAPInvalidDnError: + raise Exception("Invalid credentials input. The bind user must be a full DN.") + self.authenticate_ldap_user(username=self._username, password=self._password) + return "ok" + + + +def run(): + client = LdapClient(S) + out(client.entries_search_command(I)) + + +try: + run() +except SystemExit: + raise +except LDAPBindError as e: + fail("Authentication connection failed. Additional details: " + str(e)) +except (LDAPSocketOpenError, LDAPSocketReceiveError, LDAPStartTLSError) as e: + msg = "Failed to connect to the LDAP server. Additional details: " + str(e) + if not arg_to_bool(S.get("insecure", False)): + msg += ' Try the "Trust any certificate" option.' + fail(msg) +except LDAPInvalidPortError: + fail("Invalid LDAP server input. The server must be of the form: ip or ldap://ip") +except LDAPException as e: + fail("LDAP error: " + str(e)) +except Exception as e: + fail(str(e)) diff --git a/integrations/openldap/scripts/ad_groups.py b/integrations/openldap/scripts/ad_groups.py new file mode 100644 index 0000000..11e767d --- /dev/null +++ b/integrations/openldap/scripts/ad_groups.py @@ -0,0 +1,527 @@ +import json, os, sys + +S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) +I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def out(value): + print(json.dumps(value, default=str)) + + +def fail(message, **extra): + payload = {"error": message} + payload.update(extra) + print(json.dumps(payload, default=str)) + sys.exit(1) + + +try: + import ssl + from ldap3 import ( + ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, AUTO_BIND_NO_TLS, + AUTO_BIND_TLS_BEFORE_BIND, BASE, SUBTREE, Connection, Server, Tls, + ) + from ldap3.core.exceptions import ( + LDAPBindError, LDAPException, LDAPInvalidDnError, LDAPInvalidPortError, + LDAPSocketOpenError, LDAPSocketReceiveError, LDAPStartTLSError, + ) + from ldap3.utils.dn import parse_dn +except ImportError as _e: + print(json.dumps({ + "error": "The 'ldap3' Python library is required for the OpenLDAP integration. " + "Install it on the execution host (engine): pip install ldap3", + "detail": str(_e), + })) + sys.exit(1) + +MAX_PAGE_SIZE = 2000 + + +def arg_to_list(v, separator=","): + if not v: + return [] + if isinstance(v, list): + return v + return [x.strip() for x in str(v).split(separator) if x.strip()] + + +def arg_to_bool(v): + if isinstance(v, bool): + return v + return str(v).lower() in ("1", "true", "yes") + + +def arg_to_num(v): + if v is None or v == "": + return None + return int(v) + + +def list_arg_to_ldap_filter(arg, prefix): + items = arg_to_list(arg) + joined = "".join(f"({prefix}={item})" for item in items) + if len(items) > 1: + return f"(&{joined})" + return joined if items else "" + + +def create_entries_search_filter(args): + cn = list_arg_to_ldap_filter(args.get("cn", ""), "cn") + description = list_arg_to_ldap_filter(args.get("description", ""), "description") + object_class = list_arg_to_ldap_filter(args.get("object_class", ""), "objectClass") + uid = list_arg_to_ldap_filter(args.get("uid", ""), "uid") + search_filter = args.get("search_filter", "") + if not any([cn, description, object_class, uid, search_filter]): + return "(objectClass=*)" + return f"(|{cn}{description}{object_class}{uid}{search_filter})" + + +def get_search_attributes(attributes): + if attributes == "all": + return [ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES] + return { + "none": None, + "all_user_attributes": ALL_ATTRIBUTES, + "all_operational_attributes": ALL_OPERATIONAL_ATTRIBUTES, + }.get(attributes, arg_to_list(attributes)) + + +def entries_paged_search(connection, search_params, page, page_size): + if page == 1: + return connection.search(**search_params, paged_size=page_size) + results_to_skip = page_size * (page - 1) + connection.search(**search_params, paged_size=results_to_skip) + cookie = connection.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"] + return connection.search(**search_params, paged_size=page_size, paged_cookie=cookie) + + +class LdapClient: + """LDAP authentication client supporting OpenLDAP and Active Directory.""" + + OPENLDAP = "OpenLDAP" + ACTIVE_DIRECTORY = "Active Directory" + AUTO = "Auto" + GROUPS_TOKEN = "primaryGroupToken" + GROUPS_MEMBER = "memberOf" + GROUPS_PRIMARY_ID = "primaryGroupID" + TIMEOUT = 120 + CIPHERS_STRING = ( + "@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:DH+AESGCM:" + "ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS" + ) + SSL_VERSIONS = { + "None": None, + "TLS": ssl.PROTOCOL_TLS, + "TLSv1": ssl.PROTOCOL_TLSv1, + "TLSv1_1": ssl.PROTOCOL_TLSv1_1, + "TLSv1_2": ssl.PROTOCOL_TLSv1_2, + "TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT, + } + + def __init__(self, cfg): + self._host = cfg.get("host") + self._port = int(cfg["port"]) if cfg.get("port") else None + self._username = cfg.get("username", "") + self._password = cfg.get("password", "") + self._base_dn = (cfg.get("base_dn") or "").strip() + self._connection_type = (cfg.get("connection_type") or "none").lower() + self._ssl_version = cfg.get("ssl_version", "None") + self._fetch_groups = arg_to_bool(cfg.get("fetch_groups", True)) + self._verify = not arg_to_bool(cfg.get("insecure", False)) + self._ldap_server = self._initialize_ldap_server() + self._ldap_server_vendor = cfg.get("ldap_server_vendor", self.AUTO) + if self._ldap_server_vendor == self.AUTO: + self._determine_ldap_vendor_automatically() + self._page_size = int(cfg.get("page_size") or 500) + self._groups_filter_class = (cfg.get("group_filter_class") or "posixGroup").strip() + self._group_identifier_attribute = (cfg.get("group_identifier_attribute") or "gidNumber").strip() + self._member_identifier_attribute = (cfg.get("member_identifier_attribute") or "memberUid").strip() + self._user_filter_class = cfg.get("user_filter_class") or "posixAccount" + self._user_identifier_attribute = cfg.get("user_identifier_attribute") or "uid" + self._custom_attributes = cfg.get("custom_attributes") or "" + + @property + def GROUPS_OBJECT_CLASS(self): + return self._groups_filter_class + + @property + def GROUPS_IDENTIFIER_ATTRIBUTE(self): + return self._group_identifier_attribute + + @property + def GROUPS_MEMBERSHIP_IDENTIFIER_ATTRIBUTE(self): + return self._member_identifier_attribute + + @property + def USER_OBJECT_CLASS(self): + return self._user_filter_class + + @property + def USER_IDENTIFIER_ATTRIBUTE(self): + return self._user_identifier_attribute + + @property + def CUSTOM_ATTRIBUTE(self): + return self._custom_attributes + + def _get_ssl_version(self): + return self.SSL_VERSIONS.get(self._ssl_version) + + def _get_tls_object(self): + if self._verify: + return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=self._get_ssl_version()) + return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, version=self._get_ssl_version(), ciphers=self.CIPHERS_STRING) + + def _initialize_ldap_server(self): + if self._connection_type == "ssl": + return Server(host=self._host, port=self._port, use_ssl=True, tls=self._get_tls_object(), connect_timeout=self.TIMEOUT) + if self._connection_type == "start tls": + return Server(host=self._host, port=self._port, use_ssl=False, tls=self._get_tls_object(), connect_timeout=self.TIMEOUT) + return Server(host=self._host, port=self._port, connect_timeout=self.TIMEOUT) + + def _determine_ldap_vendor_automatically(self): + try: + with Connection(self._ldap_server) as conn: + conn.search(search_base="", search_filter="(objectClass=*)", search_scope=BASE, attributes=[ALL_ATTRIBUTES]) + entry = conn.entries[0] + if "objectClass" in entry and "OpenLDAProotDSE" in entry["objectClass"].value: + self._ldap_server_vendor = self.OPENLDAP + else: + self._ldap_server_vendor = self.ACTIVE_DIRECTORY + except Exception as e: + raise Exception(f"Could not determine the LDAP vendor automatically. Select the vendor manually. Error: {e}") + + @staticmethod + def _parse_ldap_group_entries(ldap_group_entries, groups_identifier_attribute): + return [ + { + "DN": ldap_group.get("dn"), + "Attributes": [{ + "Name": LdapClient.GROUPS_TOKEN, + "Values": [str(ldap_group.get("attributes", {}).get(groups_identifier_attribute))], + }], + } + for ldap_group in ldap_group_entries + ] + + @staticmethod + def _parse_ldap_group_entries_and_referrals(ldap_group_entries): + referrals = [] + entries = [] + for ldap_group in ldap_group_entries: + group_type = ldap_group.get("type") + if group_type == "searchResRef": + referrals.extend(ldap_group.get("uri") or []) + elif group_type == "searchResEntry": + entries.append({ + "DN": ldap_group.get("dn"), + "Attributes": [{ + "Name": LdapClient.GROUPS_TOKEN, + "Values": [str(ldap_group.get("attributes", {}).get(LdapClient.GROUPS_TOKEN))], + }], + }) + return referrals, entries + + def _parse_and_authenticate_ldap_group_entries_and_referrals(self, ldap_group_entries, password): + referrals = [] + entries = [] + for entry in ldap_group_entries: + entry_type = entry.get("type") + if entry_type == "searchResRef": + referrals.extend(entry.get("uri") or []) + elif entry_type == "searchResEntry": + entry_dn = entry.get("dn", "") + entry_attributes = entry.get("attributes", {}) + relevant = [] + for attr in entry_attributes: + attr_value = entry_attributes.get(attr, []) + if attr_value: + if not isinstance(attr_value, list): + attr_value = [str(attr_value)] + relevant.append({"Name": attr, "Values": attr_value}) + entries.append({"DN": entry_dn, "Attributes": relevant}) + self.authenticate_ldap_user(entry_dn, password) + return referrals, entries + + @staticmethod + def _parse_ldap_users_groups_entries(ldap_group_entries): + return [ldap_group.get("dn") for ldap_group in ldap_group_entries] + + @staticmethod + def _build_entry_for_user(user_groups, user_data, mail_attribute, name_attribute, phone_attribute): + attributes = [ + {"Name": LdapClient.GROUPS_MEMBER, "Values": user_groups}, + {"Name": LdapClient.GROUPS_PRIMARY_ID, "Values": user_data["gid_number"]}, + ] + if "name" in user_data: + attributes.append({"Name": name_attribute, "Values": [user_data["name"]]}) + if "email" in user_data: + attributes.append({"Name": mail_attribute, "Values": [user_data["email"]]}) + if "mobile" in user_data: + attributes.append({"Name": phone_attribute, "Values": [user_data["mobile"]]}) + return {"DN": user_data["dn"], "Attributes": attributes} + + @staticmethod + def _is_valid_dn(dn, user_identifier_attribute): + try: + parsed_dn = parse_dn(dn, strip=False) + for attribute_and_value in parsed_dn: + if attribute_and_value[0].lower() == user_identifier_attribute.lower(): + return True, attribute_and_value[1] + raise Exception(f"The {user_identifier_attribute} attribute was not found in the user DN: {dn}") + except LDAPInvalidDnError: + return False, dn + + def _get_formatted_custom_attributes(self): + if not self.CUSTOM_ATTRIBUTE: + return "" + formatted = "" + for att in self.CUSTOM_ATTRIBUTE.split(","): + if len(att.split("=")) != 2: + raise Exception(f'User defined attributes must be of the form "attrA=valA,attrB=valB,...", but got: {self.CUSTOM_ATTRIBUTE}') + formatted += f"({att})" + return formatted + + def _get_ldap_groups_entries_and_referrals_ad(self, ldap_conn, search_filter): + ldap_group_entries = ldap_conn.extend.standard.paged_search( + search_base=self._base_dn, search_filter=search_filter, + attributes=[LdapClient.GROUPS_TOKEN], paged_size=self._page_size, generator=False, + ) + return LdapClient._parse_ldap_group_entries_and_referrals(ldap_group_entries) + + def _fetch_all_groups(self): + auto_bind = self._get_auto_bind_value() + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + if self._ldap_server_vendor == self.ACTIVE_DIRECTORY: + referrals, entries = self._get_ldap_groups_entries_and_referrals_ad( + ldap_conn=ldap_conn, search_filter="(&(objectClass=group)(objectCategory=group))") + return {"Controls": None, "Referrals": referrals, "Entries": entries} + ldap_group_entries = ldap_conn.extend.standard.paged_search( + search_base=self._base_dn, search_filter=f"(objectClass={self.GROUPS_OBJECT_CLASS})", + attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size) + return { + "Controls": None, + "Referrals": ldap_conn.result.get("referrals"), + "Entries": LdapClient._parse_ldap_group_entries(ldap_group_entries, self.GROUPS_IDENTIFIER_ATTRIBUTE), + } + + def _fetch_specific_groups(self, specific_groups): + auto_bind = self._get_auto_bind_value() + dn_list = [group.strip() for group in arg_to_list(specific_groups, separator="#")] + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + if self._ldap_server_vendor == self.ACTIVE_DIRECTORY: + dns_filter = "".join(f"(distinguishedName={dn})" for dn in dn_list) + search_filter = f"(&(objectClass=group)(objectCategory=group)(|{dns_filter}))" + referrals, entries = self._get_ldap_groups_entries_and_referrals_ad( + ldap_conn=ldap_conn, search_filter=search_filter) + return {"Controls": None, "Referrals": referrals, "Entries": entries} + parsed = [] + for dn in dn_list: + ldap_group_entries = ldap_conn.extend.standard.paged_search( + search_base=dn, search_filter=f"(objectClass={self.GROUPS_OBJECT_CLASS})", + attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size, search_scope=BASE) + parsed.append(self._parse_ldap_group_entries(ldap_group_entries, self.GROUPS_IDENTIFIER_ATTRIBUTE)) + return {"Controls": None, "Referrals": ldap_conn.result.get("referrals"), "Entries": parsed} + + @staticmethod + def _get_ad_username(logon_name): + if "\\" in logon_name: + return logon_name.split("\\")[1] + if "@" in logon_name: + return logon_name.split("@")[0] + return logon_name + + @staticmethod + def _has_wildcards_in_user_logon(logon_name): + for wildcard in ("*", "?"): + if wildcard in logon_name: + raise Exception( + f"Authentication failed - wildcards were detected in the user logon name " + f"(input username: '{logon_name}'). Wildcards are not permitted for user authentication.") + + def _get_auto_bind_value(self): + if self._connection_type == "start tls": + return AUTO_BIND_TLS_BEFORE_BIND + return AUTO_BIND_NO_TLS + + def get_ldap_groups(self, specific_group=""): + if not self._fetch_groups and not specific_group: + return {"Controls": None, "Referrals": None, "Entries": []} + return self._fetch_specific_groups(specific_group) if not self._fetch_groups else self._fetch_all_groups() + + def authenticate_ldap_user(self, username, password): + auto_bind = self._get_auto_bind_value() + ldap_conn = Connection(server=self._ldap_server, user=username, password=password, auto_bind=auto_bind) + if ldap_conn.bound: + ldap_conn.unbind() + return "Done" + raise Exception(f"Authentication connection failed (server type: {self._ldap_server_vendor})") + + def search_user_data(self, username, attributes, search_user_by_dn=False): + auto_bind = self._get_auto_bind_value() + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + if search_user_by_dn: + search_filter = f"(&(objectClass={self.USER_OBJECT_CLASS})" + self._get_formatted_custom_attributes() + ")" + ldap_conn.search(search_base=username, search_filter=search_filter, size_limit=1, + attributes=attributes, search_scope=BASE) + else: + custom_attributes = self._get_formatted_custom_attributes() + search_filter = (f"(&(objectClass={self.USER_OBJECT_CLASS})" + f"({self.USER_IDENTIFIER_ATTRIBUTE}={username}){custom_attributes})") + ldap_conn.search(search_base=self._base_dn, search_filter=search_filter, size_limit=1, attributes=attributes) + if not ldap_conn.entries: + raise Exception("LDAP user not found") + entry = ldap_conn.entries[0] + referrals = ldap_conn.result.get("referrals") + if self.GROUPS_IDENTIFIER_ATTRIBUTE not in entry or not entry[self.GROUPS_IDENTIFIER_ATTRIBUTE].value: + raise Exception(f"User's {self.GROUPS_IDENTIFIER_ATTRIBUTE} not found") + return entry, referrals + + def get_user_data(self, username, pull_name, pull_mail, pull_phone, + name_attribute, mail_attribute, phone_attribute, search_user_by_dn=False): + attributes = [self.GROUPS_IDENTIFIER_ATTRIBUTE] + if pull_name: + attributes.append(name_attribute) + if pull_mail: + attributes.append(mail_attribute) + if pull_phone: + attributes.append(phone_attribute) + entry, referrals = self.search_user_data(username, attributes, search_user_by_dn) + user_data = { + "dn": entry.entry_dn, + "gid_number": [str(entry[self.GROUPS_IDENTIFIER_ATTRIBUTE].value)], + "referrals": referrals, + } + if name_attribute in entry and entry[name_attribute].value: + user_data["name"] = entry[name_attribute].value + if mail_attribute in entry and entry[mail_attribute].value: + user_data["email"] = entry[mail_attribute].value + if phone_attribute in entry and entry[phone_attribute].value: + user_data["mobile"] = entry[phone_attribute].value + return user_data + + def get_user_groups(self, user_identifier): + auto_bind = self._get_auto_bind_value() + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + search_filter = (f"(&(objectClass={self.GROUPS_OBJECT_CLASS})" + f"({self.GROUPS_MEMBERSHIP_IDENTIFIER_ATTRIBUTE}={user_identifier}))") + ldap_group_entries = ldap_conn.extend.standard.paged_search( + search_base=self._base_dn, search_filter=search_filter, + attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size) + return LdapClient._parse_ldap_users_groups_entries(ldap_group_entries) + + def authenticate_and_roles_openldap(self, username, password, pull_name=True, pull_mail=True, pull_phone=False, + mail_attribute="mail", name_attribute="name", phone_attribute="mobile"): + search_user_by_dn, user_identifier = LdapClient._is_valid_dn(username, self.USER_IDENTIFIER_ATTRIBUTE) + user_data = self.get_user_data( + username=username, search_user_by_dn=search_user_by_dn, pull_name=pull_name, pull_mail=pull_mail, + pull_phone=pull_phone, mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute) + self.authenticate_ldap_user(user_data["dn"], password) + user_groups = self.get_user_groups(user_identifier) + return { + "Controls": None, + "Referrals": user_data["referrals"], + "Entries": [LdapClient._build_entry_for_user( + user_groups=user_groups, user_data=user_data, + mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute)], + } + + def authenticate_and_roles_active_directory(self, username, password, pull_name=True, pull_mail=True, pull_phone=False, + mail_attribute="mail", name_attribute="name", phone_attribute="mobile"): + ad_username = self._get_ad_username(username) + auto_bind = self._get_auto_bind_value() + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + attributes = [self.GROUPS_MEMBER, self.GROUPS_PRIMARY_ID] + if pull_name: + attributes.append(name_attribute) + if pull_mail: + attributes.append(mail_attribute) + if pull_phone: + attributes.append(phone_attribute) + search_filter = f"(|(sAMAccountName={ad_username})(userPrincipalName={username}))" + ldap_conn_entries = ldap_conn.extend.standard.paged_search( + search_base=self._base_dn, search_filter=search_filter, + attributes=attributes, paged_size=self._page_size, generator=False) + referrals, entries = self._parse_and_authenticate_ldap_group_entries_and_referrals( + ldap_group_entries=ldap_conn_entries, password=password) + if not entries: + raise Exception("LDAP user not found") + return {"Controls": [], "Referrals": referrals, "Entries": entries} + + def authenticate_and_roles(self, username, password, pull_name=True, pull_mail=True, pull_phone=False, + mail_attribute="mail", name_attribute="name", phone_attribute="mobile"): + self._has_wildcards_in_user_logon(username) + if self._ldap_server_vendor == self.ACTIVE_DIRECTORY: + return self.authenticate_and_roles_active_directory( + username=username, password=password, pull_name=pull_name, pull_mail=pull_mail, pull_phone=pull_phone, + mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute) + return self.authenticate_and_roles_openldap( + username=username, password=password, pull_name=pull_name, pull_mail=pull_mail, pull_phone=pull_phone, + mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute) + + def entries_search_command(self, args): + search_params = { + "search_base": args.get("search_base") or self._base_dn, + "search_scope": args.get("search_scope") or SUBTREE, + "search_filter": create_entries_search_filter(args), + "attributes": get_search_attributes(args.get("attributes", "all")), + } + auto_bind = self._get_auto_bind_value() + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + page = arg_to_num(args.get("page")) + if page: + page_size = int(args.get("page_size", 50)) + if page_size > MAX_PAGE_SIZE: + raise Exception("The page size must be less than or equal to 2000") + else: + page = 1 + page_size = int(args.get("limit", 50)) + entries_paged_search(connection=ldap_conn, search_params=search_params, page=page, page_size=page_size) + outputs = [ + {**json.loads(entry.entry_to_json()).get("attributes", {}), "dn": json.loads(entry.entry_to_json()).get("dn")} + for entry in ldap_conn.entries + ] + return {"results": outputs, "count": len(outputs)} + + def ad_authenticate(self, username, password): + self._has_wildcards_in_user_logon(username) + if self._ldap_server_vendor == self.OPENLDAP: + search_user_by_dn, _ = LdapClient._is_valid_dn(username, self.USER_IDENTIFIER_ATTRIBUTE) + entry, _ = self.search_user_data(username, [self.GROUPS_IDENTIFIER_ATTRIBUTE], search_user_by_dn) + username = entry.entry_dn + return self.authenticate_ldap_user(username, password) + + def test_module(self): + self._get_formatted_custom_attributes() + if self._ldap_server_vendor == self.OPENLDAP: + try: + parse_dn(self._username) + except LDAPInvalidDnError: + raise Exception("Invalid credentials input. The bind user must be a full DN.") + self.authenticate_ldap_user(username=self._username, password=self._password) + return "ok" + + + +def run(): + client = LdapClient(S) + out(client.get_ldap_groups(I.get("specific-groups") or "")) + + +try: + run() +except SystemExit: + raise +except LDAPBindError as e: + fail("Authentication connection failed. Additional details: " + str(e)) +except (LDAPSocketOpenError, LDAPSocketReceiveError, LDAPStartTLSError) as e: + msg = "Failed to connect to the LDAP server. Additional details: " + str(e) + if not arg_to_bool(S.get("insecure", False)): + msg += ' Try the "Trust any certificate" option.' + fail(msg) +except LDAPInvalidPortError: + fail("Invalid LDAP server input. The server must be of the form: ip or ldap://ip") +except LDAPException as e: + fail("LDAP error: " + str(e)) +except Exception as e: + fail(str(e)) diff --git a/integrations/openldap/scripts/test_connection.py b/integrations/openldap/scripts/test_connection.py new file mode 100644 index 0000000..2526668 --- /dev/null +++ b/integrations/openldap/scripts/test_connection.py @@ -0,0 +1,527 @@ +import json, os, sys + +S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) +I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def out(value): + print(json.dumps(value, default=str)) + + +def fail(message, **extra): + payload = {"error": message} + payload.update(extra) + print(json.dumps(payload, default=str)) + sys.exit(1) + + +try: + import ssl + from ldap3 import ( + ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, AUTO_BIND_NO_TLS, + AUTO_BIND_TLS_BEFORE_BIND, BASE, SUBTREE, Connection, Server, Tls, + ) + from ldap3.core.exceptions import ( + LDAPBindError, LDAPException, LDAPInvalidDnError, LDAPInvalidPortError, + LDAPSocketOpenError, LDAPSocketReceiveError, LDAPStartTLSError, + ) + from ldap3.utils.dn import parse_dn +except ImportError as _e: + print(json.dumps({ + "error": "The 'ldap3' Python library is required for the OpenLDAP integration. " + "Install it on the execution host (engine): pip install ldap3", + "detail": str(_e), + })) + sys.exit(1) + +MAX_PAGE_SIZE = 2000 + + +def arg_to_list(v, separator=","): + if not v: + return [] + if isinstance(v, list): + return v + return [x.strip() for x in str(v).split(separator) if x.strip()] + + +def arg_to_bool(v): + if isinstance(v, bool): + return v + return str(v).lower() in ("1", "true", "yes") + + +def arg_to_num(v): + if v is None or v == "": + return None + return int(v) + + +def list_arg_to_ldap_filter(arg, prefix): + items = arg_to_list(arg) + joined = "".join(f"({prefix}={item})" for item in items) + if len(items) > 1: + return f"(&{joined})" + return joined if items else "" + + +def create_entries_search_filter(args): + cn = list_arg_to_ldap_filter(args.get("cn", ""), "cn") + description = list_arg_to_ldap_filter(args.get("description", ""), "description") + object_class = list_arg_to_ldap_filter(args.get("object_class", ""), "objectClass") + uid = list_arg_to_ldap_filter(args.get("uid", ""), "uid") + search_filter = args.get("search_filter", "") + if not any([cn, description, object_class, uid, search_filter]): + return "(objectClass=*)" + return f"(|{cn}{description}{object_class}{uid}{search_filter})" + + +def get_search_attributes(attributes): + if attributes == "all": + return [ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES] + return { + "none": None, + "all_user_attributes": ALL_ATTRIBUTES, + "all_operational_attributes": ALL_OPERATIONAL_ATTRIBUTES, + }.get(attributes, arg_to_list(attributes)) + + +def entries_paged_search(connection, search_params, page, page_size): + if page == 1: + return connection.search(**search_params, paged_size=page_size) + results_to_skip = page_size * (page - 1) + connection.search(**search_params, paged_size=results_to_skip) + cookie = connection.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"] + return connection.search(**search_params, paged_size=page_size, paged_cookie=cookie) + + +class LdapClient: + """LDAP authentication client supporting OpenLDAP and Active Directory.""" + + OPENLDAP = "OpenLDAP" + ACTIVE_DIRECTORY = "Active Directory" + AUTO = "Auto" + GROUPS_TOKEN = "primaryGroupToken" + GROUPS_MEMBER = "memberOf" + GROUPS_PRIMARY_ID = "primaryGroupID" + TIMEOUT = 120 + CIPHERS_STRING = ( + "@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:DH+AESGCM:" + "ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS" + ) + SSL_VERSIONS = { + "None": None, + "TLS": ssl.PROTOCOL_TLS, + "TLSv1": ssl.PROTOCOL_TLSv1, + "TLSv1_1": ssl.PROTOCOL_TLSv1_1, + "TLSv1_2": ssl.PROTOCOL_TLSv1_2, + "TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT, + } + + def __init__(self, cfg): + self._host = cfg.get("host") + self._port = int(cfg["port"]) if cfg.get("port") else None + self._username = cfg.get("username", "") + self._password = cfg.get("password", "") + self._base_dn = (cfg.get("base_dn") or "").strip() + self._connection_type = (cfg.get("connection_type") or "none").lower() + self._ssl_version = cfg.get("ssl_version", "None") + self._fetch_groups = arg_to_bool(cfg.get("fetch_groups", True)) + self._verify = not arg_to_bool(cfg.get("insecure", False)) + self._ldap_server = self._initialize_ldap_server() + self._ldap_server_vendor = cfg.get("ldap_server_vendor", self.AUTO) + if self._ldap_server_vendor == self.AUTO: + self._determine_ldap_vendor_automatically() + self._page_size = int(cfg.get("page_size") or 500) + self._groups_filter_class = (cfg.get("group_filter_class") or "posixGroup").strip() + self._group_identifier_attribute = (cfg.get("group_identifier_attribute") or "gidNumber").strip() + self._member_identifier_attribute = (cfg.get("member_identifier_attribute") or "memberUid").strip() + self._user_filter_class = cfg.get("user_filter_class") or "posixAccount" + self._user_identifier_attribute = cfg.get("user_identifier_attribute") or "uid" + self._custom_attributes = cfg.get("custom_attributes") or "" + + @property + def GROUPS_OBJECT_CLASS(self): + return self._groups_filter_class + + @property + def GROUPS_IDENTIFIER_ATTRIBUTE(self): + return self._group_identifier_attribute + + @property + def GROUPS_MEMBERSHIP_IDENTIFIER_ATTRIBUTE(self): + return self._member_identifier_attribute + + @property + def USER_OBJECT_CLASS(self): + return self._user_filter_class + + @property + def USER_IDENTIFIER_ATTRIBUTE(self): + return self._user_identifier_attribute + + @property + def CUSTOM_ATTRIBUTE(self): + return self._custom_attributes + + def _get_ssl_version(self): + return self.SSL_VERSIONS.get(self._ssl_version) + + def _get_tls_object(self): + if self._verify: + return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=self._get_ssl_version()) + return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, version=self._get_ssl_version(), ciphers=self.CIPHERS_STRING) + + def _initialize_ldap_server(self): + if self._connection_type == "ssl": + return Server(host=self._host, port=self._port, use_ssl=True, tls=self._get_tls_object(), connect_timeout=self.TIMEOUT) + if self._connection_type == "start tls": + return Server(host=self._host, port=self._port, use_ssl=False, tls=self._get_tls_object(), connect_timeout=self.TIMEOUT) + return Server(host=self._host, port=self._port, connect_timeout=self.TIMEOUT) + + def _determine_ldap_vendor_automatically(self): + try: + with Connection(self._ldap_server) as conn: + conn.search(search_base="", search_filter="(objectClass=*)", search_scope=BASE, attributes=[ALL_ATTRIBUTES]) + entry = conn.entries[0] + if "objectClass" in entry and "OpenLDAProotDSE" in entry["objectClass"].value: + self._ldap_server_vendor = self.OPENLDAP + else: + self._ldap_server_vendor = self.ACTIVE_DIRECTORY + except Exception as e: + raise Exception(f"Could not determine the LDAP vendor automatically. Select the vendor manually. Error: {e}") + + @staticmethod + def _parse_ldap_group_entries(ldap_group_entries, groups_identifier_attribute): + return [ + { + "DN": ldap_group.get("dn"), + "Attributes": [{ + "Name": LdapClient.GROUPS_TOKEN, + "Values": [str(ldap_group.get("attributes", {}).get(groups_identifier_attribute))], + }], + } + for ldap_group in ldap_group_entries + ] + + @staticmethod + def _parse_ldap_group_entries_and_referrals(ldap_group_entries): + referrals = [] + entries = [] + for ldap_group in ldap_group_entries: + group_type = ldap_group.get("type") + if group_type == "searchResRef": + referrals.extend(ldap_group.get("uri") or []) + elif group_type == "searchResEntry": + entries.append({ + "DN": ldap_group.get("dn"), + "Attributes": [{ + "Name": LdapClient.GROUPS_TOKEN, + "Values": [str(ldap_group.get("attributes", {}).get(LdapClient.GROUPS_TOKEN))], + }], + }) + return referrals, entries + + def _parse_and_authenticate_ldap_group_entries_and_referrals(self, ldap_group_entries, password): + referrals = [] + entries = [] + for entry in ldap_group_entries: + entry_type = entry.get("type") + if entry_type == "searchResRef": + referrals.extend(entry.get("uri") or []) + elif entry_type == "searchResEntry": + entry_dn = entry.get("dn", "") + entry_attributes = entry.get("attributes", {}) + relevant = [] + for attr in entry_attributes: + attr_value = entry_attributes.get(attr, []) + if attr_value: + if not isinstance(attr_value, list): + attr_value = [str(attr_value)] + relevant.append({"Name": attr, "Values": attr_value}) + entries.append({"DN": entry_dn, "Attributes": relevant}) + self.authenticate_ldap_user(entry_dn, password) + return referrals, entries + + @staticmethod + def _parse_ldap_users_groups_entries(ldap_group_entries): + return [ldap_group.get("dn") for ldap_group in ldap_group_entries] + + @staticmethod + def _build_entry_for_user(user_groups, user_data, mail_attribute, name_attribute, phone_attribute): + attributes = [ + {"Name": LdapClient.GROUPS_MEMBER, "Values": user_groups}, + {"Name": LdapClient.GROUPS_PRIMARY_ID, "Values": user_data["gid_number"]}, + ] + if "name" in user_data: + attributes.append({"Name": name_attribute, "Values": [user_data["name"]]}) + if "email" in user_data: + attributes.append({"Name": mail_attribute, "Values": [user_data["email"]]}) + if "mobile" in user_data: + attributes.append({"Name": phone_attribute, "Values": [user_data["mobile"]]}) + return {"DN": user_data["dn"], "Attributes": attributes} + + @staticmethod + def _is_valid_dn(dn, user_identifier_attribute): + try: + parsed_dn = parse_dn(dn, strip=False) + for attribute_and_value in parsed_dn: + if attribute_and_value[0].lower() == user_identifier_attribute.lower(): + return True, attribute_and_value[1] + raise Exception(f"The {user_identifier_attribute} attribute was not found in the user DN: {dn}") + except LDAPInvalidDnError: + return False, dn + + def _get_formatted_custom_attributes(self): + if not self.CUSTOM_ATTRIBUTE: + return "" + formatted = "" + for att in self.CUSTOM_ATTRIBUTE.split(","): + if len(att.split("=")) != 2: + raise Exception(f'User defined attributes must be of the form "attrA=valA,attrB=valB,...", but got: {self.CUSTOM_ATTRIBUTE}') + formatted += f"({att})" + return formatted + + def _get_ldap_groups_entries_and_referrals_ad(self, ldap_conn, search_filter): + ldap_group_entries = ldap_conn.extend.standard.paged_search( + search_base=self._base_dn, search_filter=search_filter, + attributes=[LdapClient.GROUPS_TOKEN], paged_size=self._page_size, generator=False, + ) + return LdapClient._parse_ldap_group_entries_and_referrals(ldap_group_entries) + + def _fetch_all_groups(self): + auto_bind = self._get_auto_bind_value() + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + if self._ldap_server_vendor == self.ACTIVE_DIRECTORY: + referrals, entries = self._get_ldap_groups_entries_and_referrals_ad( + ldap_conn=ldap_conn, search_filter="(&(objectClass=group)(objectCategory=group))") + return {"Controls": None, "Referrals": referrals, "Entries": entries} + ldap_group_entries = ldap_conn.extend.standard.paged_search( + search_base=self._base_dn, search_filter=f"(objectClass={self.GROUPS_OBJECT_CLASS})", + attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size) + return { + "Controls": None, + "Referrals": ldap_conn.result.get("referrals"), + "Entries": LdapClient._parse_ldap_group_entries(ldap_group_entries, self.GROUPS_IDENTIFIER_ATTRIBUTE), + } + + def _fetch_specific_groups(self, specific_groups): + auto_bind = self._get_auto_bind_value() + dn_list = [group.strip() for group in arg_to_list(specific_groups, separator="#")] + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + if self._ldap_server_vendor == self.ACTIVE_DIRECTORY: + dns_filter = "".join(f"(distinguishedName={dn})" for dn in dn_list) + search_filter = f"(&(objectClass=group)(objectCategory=group)(|{dns_filter}))" + referrals, entries = self._get_ldap_groups_entries_and_referrals_ad( + ldap_conn=ldap_conn, search_filter=search_filter) + return {"Controls": None, "Referrals": referrals, "Entries": entries} + parsed = [] + for dn in dn_list: + ldap_group_entries = ldap_conn.extend.standard.paged_search( + search_base=dn, search_filter=f"(objectClass={self.GROUPS_OBJECT_CLASS})", + attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size, search_scope=BASE) + parsed.append(self._parse_ldap_group_entries(ldap_group_entries, self.GROUPS_IDENTIFIER_ATTRIBUTE)) + return {"Controls": None, "Referrals": ldap_conn.result.get("referrals"), "Entries": parsed} + + @staticmethod + def _get_ad_username(logon_name): + if "\\" in logon_name: + return logon_name.split("\\")[1] + if "@" in logon_name: + return logon_name.split("@")[0] + return logon_name + + @staticmethod + def _has_wildcards_in_user_logon(logon_name): + for wildcard in ("*", "?"): + if wildcard in logon_name: + raise Exception( + f"Authentication failed - wildcards were detected in the user logon name " + f"(input username: '{logon_name}'). Wildcards are not permitted for user authentication.") + + def _get_auto_bind_value(self): + if self._connection_type == "start tls": + return AUTO_BIND_TLS_BEFORE_BIND + return AUTO_BIND_NO_TLS + + def get_ldap_groups(self, specific_group=""): + if not self._fetch_groups and not specific_group: + return {"Controls": None, "Referrals": None, "Entries": []} + return self._fetch_specific_groups(specific_group) if not self._fetch_groups else self._fetch_all_groups() + + def authenticate_ldap_user(self, username, password): + auto_bind = self._get_auto_bind_value() + ldap_conn = Connection(server=self._ldap_server, user=username, password=password, auto_bind=auto_bind) + if ldap_conn.bound: + ldap_conn.unbind() + return "Done" + raise Exception(f"Authentication connection failed (server type: {self._ldap_server_vendor})") + + def search_user_data(self, username, attributes, search_user_by_dn=False): + auto_bind = self._get_auto_bind_value() + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + if search_user_by_dn: + search_filter = f"(&(objectClass={self.USER_OBJECT_CLASS})" + self._get_formatted_custom_attributes() + ")" + ldap_conn.search(search_base=username, search_filter=search_filter, size_limit=1, + attributes=attributes, search_scope=BASE) + else: + custom_attributes = self._get_formatted_custom_attributes() + search_filter = (f"(&(objectClass={self.USER_OBJECT_CLASS})" + f"({self.USER_IDENTIFIER_ATTRIBUTE}={username}){custom_attributes})") + ldap_conn.search(search_base=self._base_dn, search_filter=search_filter, size_limit=1, attributes=attributes) + if not ldap_conn.entries: + raise Exception("LDAP user not found") + entry = ldap_conn.entries[0] + referrals = ldap_conn.result.get("referrals") + if self.GROUPS_IDENTIFIER_ATTRIBUTE not in entry or not entry[self.GROUPS_IDENTIFIER_ATTRIBUTE].value: + raise Exception(f"User's {self.GROUPS_IDENTIFIER_ATTRIBUTE} not found") + return entry, referrals + + def get_user_data(self, username, pull_name, pull_mail, pull_phone, + name_attribute, mail_attribute, phone_attribute, search_user_by_dn=False): + attributes = [self.GROUPS_IDENTIFIER_ATTRIBUTE] + if pull_name: + attributes.append(name_attribute) + if pull_mail: + attributes.append(mail_attribute) + if pull_phone: + attributes.append(phone_attribute) + entry, referrals = self.search_user_data(username, attributes, search_user_by_dn) + user_data = { + "dn": entry.entry_dn, + "gid_number": [str(entry[self.GROUPS_IDENTIFIER_ATTRIBUTE].value)], + "referrals": referrals, + } + if name_attribute in entry and entry[name_attribute].value: + user_data["name"] = entry[name_attribute].value + if mail_attribute in entry and entry[mail_attribute].value: + user_data["email"] = entry[mail_attribute].value + if phone_attribute in entry and entry[phone_attribute].value: + user_data["mobile"] = entry[phone_attribute].value + return user_data + + def get_user_groups(self, user_identifier): + auto_bind = self._get_auto_bind_value() + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + search_filter = (f"(&(objectClass={self.GROUPS_OBJECT_CLASS})" + f"({self.GROUPS_MEMBERSHIP_IDENTIFIER_ATTRIBUTE}={user_identifier}))") + ldap_group_entries = ldap_conn.extend.standard.paged_search( + search_base=self._base_dn, search_filter=search_filter, + attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size) + return LdapClient._parse_ldap_users_groups_entries(ldap_group_entries) + + def authenticate_and_roles_openldap(self, username, password, pull_name=True, pull_mail=True, pull_phone=False, + mail_attribute="mail", name_attribute="name", phone_attribute="mobile"): + search_user_by_dn, user_identifier = LdapClient._is_valid_dn(username, self.USER_IDENTIFIER_ATTRIBUTE) + user_data = self.get_user_data( + username=username, search_user_by_dn=search_user_by_dn, pull_name=pull_name, pull_mail=pull_mail, + pull_phone=pull_phone, mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute) + self.authenticate_ldap_user(user_data["dn"], password) + user_groups = self.get_user_groups(user_identifier) + return { + "Controls": None, + "Referrals": user_data["referrals"], + "Entries": [LdapClient._build_entry_for_user( + user_groups=user_groups, user_data=user_data, + mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute)], + } + + def authenticate_and_roles_active_directory(self, username, password, pull_name=True, pull_mail=True, pull_phone=False, + mail_attribute="mail", name_attribute="name", phone_attribute="mobile"): + ad_username = self._get_ad_username(username) + auto_bind = self._get_auto_bind_value() + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + attributes = [self.GROUPS_MEMBER, self.GROUPS_PRIMARY_ID] + if pull_name: + attributes.append(name_attribute) + if pull_mail: + attributes.append(mail_attribute) + if pull_phone: + attributes.append(phone_attribute) + search_filter = f"(|(sAMAccountName={ad_username})(userPrincipalName={username}))" + ldap_conn_entries = ldap_conn.extend.standard.paged_search( + search_base=self._base_dn, search_filter=search_filter, + attributes=attributes, paged_size=self._page_size, generator=False) + referrals, entries = self._parse_and_authenticate_ldap_group_entries_and_referrals( + ldap_group_entries=ldap_conn_entries, password=password) + if not entries: + raise Exception("LDAP user not found") + return {"Controls": [], "Referrals": referrals, "Entries": entries} + + def authenticate_and_roles(self, username, password, pull_name=True, pull_mail=True, pull_phone=False, + mail_attribute="mail", name_attribute="name", phone_attribute="mobile"): + self._has_wildcards_in_user_logon(username) + if self._ldap_server_vendor == self.ACTIVE_DIRECTORY: + return self.authenticate_and_roles_active_directory( + username=username, password=password, pull_name=pull_name, pull_mail=pull_mail, pull_phone=pull_phone, + mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute) + return self.authenticate_and_roles_openldap( + username=username, password=password, pull_name=pull_name, pull_mail=pull_mail, pull_phone=pull_phone, + mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute) + + def entries_search_command(self, args): + search_params = { + "search_base": args.get("search_base") or self._base_dn, + "search_scope": args.get("search_scope") or SUBTREE, + "search_filter": create_entries_search_filter(args), + "attributes": get_search_attributes(args.get("attributes", "all")), + } + auto_bind = self._get_auto_bind_value() + with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn: + page = arg_to_num(args.get("page")) + if page: + page_size = int(args.get("page_size", 50)) + if page_size > MAX_PAGE_SIZE: + raise Exception("The page size must be less than or equal to 2000") + else: + page = 1 + page_size = int(args.get("limit", 50)) + entries_paged_search(connection=ldap_conn, search_params=search_params, page=page, page_size=page_size) + outputs = [ + {**json.loads(entry.entry_to_json()).get("attributes", {}), "dn": json.loads(entry.entry_to_json()).get("dn")} + for entry in ldap_conn.entries + ] + return {"results": outputs, "count": len(outputs)} + + def ad_authenticate(self, username, password): + self._has_wildcards_in_user_logon(username) + if self._ldap_server_vendor == self.OPENLDAP: + search_user_by_dn, _ = LdapClient._is_valid_dn(username, self.USER_IDENTIFIER_ATTRIBUTE) + entry, _ = self.search_user_data(username, [self.GROUPS_IDENTIFIER_ATTRIBUTE], search_user_by_dn) + username = entry.entry_dn + return self.authenticate_ldap_user(username, password) + + def test_module(self): + self._get_formatted_custom_attributes() + if self._ldap_server_vendor == self.OPENLDAP: + try: + parse_dn(self._username) + except LDAPInvalidDnError: + raise Exception("Invalid credentials input. The bind user must be a full DN.") + self.authenticate_ldap_user(username=self._username, password=self._password) + return "ok" + + +def run(): + client = LdapClient(S) + client.test_module() + out({"ok": True, "vendor": client._ldap_server_vendor}) + + +try: + run() +except SystemExit: + raise +except LDAPBindError as e: + fail("Authentication connection failed. Additional details: " + str(e)) +except (LDAPSocketOpenError, LDAPSocketReceiveError, LDAPStartTLSError) as e: + msg = "Failed to connect to the LDAP server. Additional details: " + str(e) + if not arg_to_bool(S.get("insecure", False)): + msg += ' Try the "Trust any certificate" option.' + fail(msg) +except LDAPInvalidPortError: + fail("Invalid LDAP server input. The server must be of the form: ip or ldap://ip") +except LDAPException as e: + fail("LDAP error: " + str(e)) +except Exception as e: + fail(str(e))