diff --git a/rhodecode/authentication/plugins/auth_ldap.py b/rhodecode/authentication/plugins/auth_ldap.py index 5ba7866e..74a6c4fd 100644 --- a/rhodecode/authentication/plugins/auth_ldap.py +++ b/rhodecode/authentication/plugins/auth_ldap.py @@ -21,28 +21,22 @@ RhodeCode authentication plugin for LDAP """ import logging -import traceback import colander + +from rhodecode.authentication.plugins.services.ldap_dao import LdapDao from rhodecode.translation import _ -from rhodecode.authentication.base import RhodeCodeExternalAuthPlugin, AuthLdapBase, hybrid_property +from rhodecode.authentication.base import RhodeCodeExternalAuthPlugin, hybrid_property from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase, TwoFactorAuthnPluginSettingsSchemaMixin from rhodecode.authentication.routes import AuthnPluginResourceBase from rhodecode.lib.colander_utils import strip_whitespace -from rhodecode.lib.exceptions import LdapConnectionError, LdapUsernameError, LdapPasswordError, LdapImportError +from rhodecode.lib.exceptions import LdapUsernameError, LdapPasswordError, LdapImportError from rhodecode.lib.str_utils import safe_str from rhodecode.model.db import User -from rhodecode.model.validators import Missing + log = logging.getLogger(__name__) -try: - import ldap -except ImportError: - # means that python-ldap is not installed, we use Missing object to mark - # ldap lib is Missing - ldap = Missing - class LdapError(Exception): pass @@ -61,188 +55,6 @@ class LdapAuthnResource(AuthnPluginResourceBase): pass -class AuthLdap(AuthLdapBase): - default_tls_cert_dir = "/etc/openldap/cacerts" - - scope_labels = { - ldap.SCOPE_BASE: "SCOPE_BASE", - ldap.SCOPE_ONELEVEL: "SCOPE_ONELEVEL", - ldap.SCOPE_SUBTREE: "SCOPE_SUBTREE", - } - - def __init__( - self, - server, - base_dn, - port=389, - bind_dn="", - bind_pass="", - tls_kind="PLAIN", - tls_reqcert="DEMAND", - tls_cert_file=None, - tls_cert_dir=None, - ldap_version=3, - search_scope="SUBTREE", - attr_login="uid", - ldap_filter="", - timeout=None, - ): - if ldap == Missing: - raise LdapImportError("Missing or incompatible ldap library") - - self.debug = False - self.timeout = timeout or 60 * 5 - self.ldap_version = ldap_version - self.ldap_server_type = "ldap" - - self.TLS_KIND = tls_kind - - if self.TLS_KIND == "LDAPS": - port = port or 636 - self.ldap_server_type += "s" - - OPT_X_TLS_DEMAND = 2 - self.TLS_REQCERT = getattr(ldap, "OPT_X_TLS_%s" % tls_reqcert, OPT_X_TLS_DEMAND) - self.TLS_CERT_FILE = tls_cert_file or "" - self.TLS_CERT_DIR = tls_cert_dir or self.default_tls_cert_dir - - # split server into list - self.SERVER_ADDRESSES = self._get_server_list(server) - self.LDAP_SERVER_PORT = port - - # USE FOR READ ONLY BIND TO LDAP SERVER - self.attr_login = attr_login - - self.LDAP_BIND_DN = safe_str(bind_dn) - self.LDAP_BIND_PASS = safe_str(bind_pass) - - self.SEARCH_SCOPE = getattr(ldap, "SCOPE_%s" % search_scope) - self.BASE_DN = safe_str(base_dn) - self.LDAP_FILTER = safe_str(ldap_filter) - - def _get_ldap_conn(self): - if self.debug: - ldap.set_option(ldap.OPT_DEBUG_LEVEL, 255) - - if self.TLS_CERT_FILE and hasattr(ldap, "OPT_X_TLS_CACERTFILE"): - ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, self.TLS_CERT_FILE) - - elif hasattr(ldap, "OPT_X_TLS_CACERTDIR"): - ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, self.TLS_CERT_DIR) - - if self.TLS_KIND != "PLAIN": - ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, self.TLS_REQCERT) - - ldap.set_option(ldap.OPT_REFERRALS, ldap.OPT_OFF) - ldap.set_option(ldap.OPT_RESTART, ldap.OPT_ON) - - # init connection now - ldap_servers = self._build_servers(self.ldap_server_type, self.SERVER_ADDRESSES, self.LDAP_SERVER_PORT) - log.debug("initializing LDAP connection to:%s", ldap_servers) - ldap_conn = ldap.initialize(ldap_servers) - ldap_conn.set_option(ldap.OPT_NETWORK_TIMEOUT, self.timeout) - ldap_conn.set_option(ldap.OPT_TIMEOUT, self.timeout) - ldap_conn.timeout = self.timeout - - if self.ldap_version == 2: - ldap_conn.protocol = ldap.VERSION2 - else: - ldap_conn.protocol = ldap.VERSION3 - - if self.TLS_KIND == "START_TLS": - ldap_conn.start_tls_s() - - if self.LDAP_BIND_DN and self.LDAP_BIND_PASS: - log.debug("Trying simple_bind with password and given login DN: %r", self.LDAP_BIND_DN) - ldap_conn.simple_bind_s(self.LDAP_BIND_DN, self.LDAP_BIND_PASS) - log.debug("simple_bind successful") - return ldap_conn - - def fetch_attrs_from_simple_bind(self, ldap_conn, dn, username, password): - scope = ldap.SCOPE_BASE - scope_label = self.scope_labels.get(scope) - ldap_filter = "(objectClass=*)" - - try: - log.debug( - "Trying authenticated search bind with dn: %r SCOPE: %s (and filter: %s)", dn, scope_label, ldap_filter - ) - ldap_conn.simple_bind_s(dn, safe_str(password)) - response = ldap_conn.search_ext_s(dn, scope, ldap_filter, attrlist=["*", "+"]) - - if not response: - log.error("search bind returned empty results: %r", response) - return {} - else: - _dn, attrs = response[0] - return attrs - - except ldap.INVALID_CREDENTIALS: - log.debug("LDAP rejected password for user '%s': %s, org_exc:", username, dn, exc_info=True) - - def authenticate_ldap(self, username, password): - """ - Authenticate a user via LDAP and return his/her LDAP properties. - - Raises AuthenticationError if the credentials are rejected, or - EnvironmentError if the LDAP server can't be reached. - - :param username: username - :param password: password - """ - - uid = self.get_uid(username, self.SERVER_ADDRESSES) - user_attrs = {} - dn = "" - - self.validate_password(username, password) - self.validate_username(username) - scope_label = self.scope_labels.get(self.SEARCH_SCOPE) - - ldap_conn = None - try: - ldap_conn = self._get_ldap_conn() - filter_ = "(&{}({}={}))".format(self.LDAP_FILTER, self.attr_login, username) - log.debug("Authenticating %r filter %s and scope: %s", self.BASE_DN, filter_, scope_label) - - ldap_objects = ldap_conn.search_ext_s(self.BASE_DN, self.SEARCH_SCOPE, filter_, attrlist=["*", "+"]) - - if not ldap_objects: - log.debug("No matching LDAP objects for authentication of UID:'%s' username:(%s)", uid, username) - raise ldap.NO_SUCH_OBJECT() - - log.debug("Found %s matching ldap object[s], trying to authenticate on each one now...", len(ldap_objects)) - for dn, _attrs in ldap_objects: - if dn is None: - continue - - user_attrs = self.fetch_attrs_from_simple_bind(ldap_conn, dn, username, password) - - if user_attrs: - log.debug("Got authenticated user attributes from DN:%s", dn) - break - else: - raise LdapPasswordError(f"Failed to authenticate user `{username}` with given password") - - except ldap.NO_SUCH_OBJECT: - log.debug("LDAP says no such user '%s' (%s), org_exc:", uid, username, exc_info=True) - raise LdapUsernameError("Unable to find user") - except ldap.SERVER_DOWN: - org_exc = traceback.format_exc() - raise LdapConnectionError("LDAP can't access authentication server, org_exc:%s" % org_exc) - finally: - if ldap_conn: - log.debug("ldap: connection release") - try: - ldap_conn.unbind_s() - except Exception: - # for any reason this can raise exception we must catch it - # to not crush the server - pass - - return dn, user_attrs - - class LdapSettingsSchema(TwoFactorAuthnPluginSettingsSchemaMixin, AuthnPluginSettingsSchemaBase): tls_kind_choices = ["PLAIN", "LDAPS", "START_TLS"] tls_reqcert_choices = ["NEVER", "ALLOW", "TRY", "DEMAND", "HARD"] @@ -338,7 +150,7 @@ class LdapSettingsSchema(TwoFactorAuthnPluginSettingsSchemaMixin, AuthnPluginSet ) tls_cert_dir = colander.SchemaNode( colander.String(), - default=AuthLdap.default_tls_cert_dir, + default=LdapDao.default_tls_cert_dir, description=_( "This specifies the path of a directory that contains individual CA certificates in separate files." ), @@ -420,6 +232,38 @@ class LdapSettingsSchema(TwoFactorAuthnPluginSettingsSchemaMixin, AuthnPluginSet title=_("Last Name Attribute"), widget="string", ) + sync_active_directory_users = colander.SchemaNode( + colander.Bool(), + default=False, + description=_( + "A cron job that periodically retrieves all users from an LDAP-based Active Directory server and syncs " + "them with the RhodeCode database.\n" + "Note: This feature is specific to Active Directory. Enabling it for other types of LDAP servers will have no effect." + ), + missing=False, + preparer=strip_whitespace, + title=_("Sync Active Directory Users"), + widget="bool", + ) + + +def get_ldap_args(settings: dict): + return { + "server": settings.get("host", ""), + "base_dn": settings.get("base_dn", ""), + "port": settings.get("port"), + "bind_dn": settings.get("dn_user"), + "bind_pass": settings.get("dn_pass"), + "tls_kind": settings.get("tls_kind"), + "tls_reqcert": settings.get("tls_reqcert"), + "tls_cert_file": settings.get("tls_cert_file"), + "tls_cert_dir": settings.get("tls_cert_dir"), + "search_scope": settings.get("search_scope"), + "attr_login": settings.get("attr_login"), + "ldap_version": 3, + "ldap_filter": settings.get("filter"), + "timeout": settings.get("timeout"), + } class RhodeCodeAuthPlugin(RhodeCodeExternalAuthPlugin): @@ -509,30 +353,15 @@ class RhodeCodeAuthPlugin(RhodeCodeExternalAuthPlugin): log.debug("Empty username or password skipping...") return None - ldap_args = { - "server": settings.get("host", ""), - "base_dn": settings.get("base_dn", ""), - "port": settings.get("port"), - "bind_dn": settings.get("dn_user"), - "bind_pass": settings.get("dn_pass"), - "tls_kind": settings.get("tls_kind"), - "tls_reqcert": settings.get("tls_reqcert"), - "tls_cert_file": settings.get("tls_cert_file"), - "tls_cert_dir": settings.get("tls_cert_dir"), - "search_scope": settings.get("search_scope"), - "attr_login": settings.get("attr_login"), - "ldap_version": 3, - "ldap_filter": settings.get("filter"), - "timeout": settings.get("timeout"), - } + ldap_args = get_ldap_args(settings) ldap_attrs = self.try_dynamic_binding(username, password, ldap_args) log.debug("Checking for ldap authentication.") try: - auth_ldap = AuthLdap(**ldap_args) - (user_dn, ldap_attrs) = auth_ldap.authenticate_ldap(username, password) + ldap_dao = LdapDao(**ldap_args) + (user_dn, ldap_attrs) = ldap_dao.authenticate_ldap(username, password) log.debug("Got ldap DN response %s", user_dn) def get_ldap_attr(k) -> str: diff --git a/rhodecode/authentication/plugins/services/__init__.py b/rhodecode/authentication/plugins/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rhodecode/authentication/plugins/services/ldap_dao.py b/rhodecode/authentication/plugins/services/ldap_dao.py new file mode 100644 index 00000000..7a53e961 --- /dev/null +++ b/rhodecode/authentication/plugins/services/ldap_dao.py @@ -0,0 +1,226 @@ +import logging +import traceback +from dataclasses import dataclass +from typing import List, Optional + +from rhodecode.model.validators import Missing +from rhodecode.lib.exceptions import LdapConnectionError, LdapUsernameError, LdapPasswordError, LdapImportError +from rhodecode.authentication.base import AuthLdapBase +from rhodecode.lib.str_utils import safe_str + +try: + import ldap +except ImportError: + # means that python-ldap is not installed, we use Missing object to mark + # ldap lib is Missing + ldap = Missing + + +@dataclass +class UserType: + DEFAULT: str = "DEFAULT" + ACTIVE_DIRECTORY: str = "ACTIVE_DIRECTORY" + + +class LdapDao(AuthLdapBase): + default_tls_cert_dir = "/etc/openldap/cacerts" + + scope_labels = { + ldap.SCOPE_BASE: "SCOPE_BASE", + ldap.SCOPE_ONELEVEL: "SCOPE_ONELEVEL", + ldap.SCOPE_SUBTREE: "SCOPE_SUBTREE", + } + + def __init__( + self, + server, + base_dn, + port=389, + bind_dn="", + bind_pass="", + tls_kind="PLAIN", + tls_reqcert="DEMAND", + tls_cert_file=None, + tls_cert_dir=None, + ldap_version=3, + search_scope="SUBTREE", + attr_login="uid", + ldap_filter="", + timeout=None, + ): + if ldap == Missing: + raise LdapImportError("Missing or incompatible ldap library") + + self.log = logging.getLogger(LdapDao.__name__) + + self.debug = False + self.timeout = timeout or 60 * 5 + self.ldap_version = ldap_version + self.ldap_server_type = "ldap" + + self.TLS_KIND = tls_kind + + if self.TLS_KIND == "LDAPS": + port = port or 636 + self.ldap_server_type += "s" + + OPT_X_TLS_DEMAND = 2 + self.TLS_REQCERT = getattr(ldap, "OPT_X_TLS_%s" % tls_reqcert, OPT_X_TLS_DEMAND) + self.TLS_CERT_FILE = tls_cert_file or "" + self.TLS_CERT_DIR = tls_cert_dir or self.default_tls_cert_dir + + # split server into list + self.SERVER_ADDRESSES = self._get_server_list(server) + self.LDAP_SERVER_PORT = port + + # USE FOR READ ONLY BIND TO LDAP SERVER + self.attr_login = attr_login + + self.LDAP_BIND_DN = safe_str(bind_dn) + self.LDAP_BIND_PASS = safe_str(bind_pass) + + self.SEARCH_SCOPE = getattr(ldap, "SCOPE_%s" % search_scope) + self.BASE_DN = safe_str(base_dn) + self.LDAP_FILTER = safe_str(ldap_filter) + + def _get_ldap_conn(self): + if self.debug: + ldap.set_option(ldap.OPT_DEBUG_LEVEL, 255) + + if self.TLS_CERT_FILE and hasattr(ldap, "OPT_X_TLS_CACERTFILE"): + ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, self.TLS_CERT_FILE) + + elif hasattr(ldap, "OPT_X_TLS_CACERTDIR"): + ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, self.TLS_CERT_DIR) + + if self.TLS_KIND != "PLAIN": + ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, self.TLS_REQCERT) + + ldap.set_option(ldap.OPT_REFERRALS, ldap.OPT_OFF) + ldap.set_option(ldap.OPT_RESTART, ldap.OPT_ON) + + # init connection now + ldap_servers = self._build_servers(self.ldap_server_type, self.SERVER_ADDRESSES, self.LDAP_SERVER_PORT) + self.log.debug("initializing LDAP connection to:%s", ldap_servers) + ldap_conn = ldap.initialize(ldap_servers) + ldap_conn.set_option(ldap.OPT_NETWORK_TIMEOUT, self.timeout) + ldap_conn.set_option(ldap.OPT_TIMEOUT, self.timeout) + ldap_conn.timeout = self.timeout + + if self.ldap_version == 2: + ldap_conn.protocol = ldap.VERSION2 + else: + ldap_conn.protocol = ldap.VERSION3 + + if self.TLS_KIND == "START_TLS": + ldap_conn.start_tls_s() + + if self.LDAP_BIND_DN and self.LDAP_BIND_PASS: + self.log.debug("Trying simple_bind with password and given login DN: %r", self.LDAP_BIND_DN) + ldap_conn.simple_bind_s(self.LDAP_BIND_DN, self.LDAP_BIND_PASS) + self.log.debug("simple_bind successful") + return ldap_conn + + def fetch_all(self, ldap_filter: str = "(objectClass=*)", attributes: List[str] = None) -> Optional[List[dict]]: + # TODO: add pagination + ldap_conn = None + try: + if attributes is None: + attributes = ["*", "+"] + + ldap_conn = self._get_ldap_conn() + self.log.debug("fetching users for DN: %s", self.BASE_DN) + ldap_objects = ldap_conn.search_ext_s(self.BASE_DN, self.SEARCH_SCOPE, ldap_filter, attrlist=attributes) + return [attrs for _, attrs in ldap_objects] + except Exception as e: + self.log.error("Error fetching users for DN: %s. Error: %s", self.BASE_DN, str(e)) + return None + finally: + self._releease_connection(ldap_conn) + + def _releease_connection(self, ldap_conn): + if ldap_conn: + self.log.debug("ldap: connection release") + try: + ldap_conn.unbind_s() + except Exception as e: + # for any reason this can raise exception we must catch it + # to not crush the server + self.log.warning("unbind_s failed, error: %s", str(e)) + + def _fetch_attrs_from_simple_bind(self, ldap_conn, dn, username, password): + scope = ldap.SCOPE_BASE + scope_label = self.scope_labels.get(scope) + ldap_filter = "(objectClass=*)" + + try: + self.log.debug( + "Trying authenticated search bind with dn: %r SCOPE: %s (and filter: %s)", dn, scope_label, ldap_filter + ) + ldap_conn.simple_bind_s(dn, safe_str(password)) + response = ldap_conn.search_ext_s(dn, scope, ldap_filter, attrlist=["*", "+"]) + + if not response: + self.log.error("search bind returned empty results: %r", response) + return {} + else: + _dn, attrs = response[0] + return attrs + + except ldap.INVALID_CREDENTIALS: + self.log.debug("LDAP rejected password for user '%s': %s, org_exc:", username, dn, exc_info=True) + + def authenticate_ldap(self, username, password): + """ + Authenticate a user via LDAP and return his/her LDAP properties. + + Raises AuthenticationError if the credentials are rejected, or + EnvironmentError if the LDAP server can't be reached. + + :param username: username + :param password: password + """ + + uid = self.get_uid(username, self.SERVER_ADDRESSES) + + self.validate_password(username, password) + self.validate_username(username) + scope_label = self.scope_labels.get(self.SEARCH_SCOPE) + + ldap_conn = None + try: + ldap_conn = self._get_ldap_conn() + filter_ = "(&{}({}={}))".format(self.LDAP_FILTER, self.attr_login, username) + self.log.debug("Authenticating %r filter %s and scope: %s", self.BASE_DN, filter_, scope_label) + + ldap_objects = ldap_conn.search_ext_s(self.BASE_DN, self.SEARCH_SCOPE, filter_, attrlist=["*", "+"]) + + if not ldap_objects: + self.log.debug("No matching LDAP objects for authentication of UID:'%s' username:(%s)", uid, username) + raise ldap.NO_SUCH_OBJECT() + + self.log.debug( + "Found %s matching ldap object[s], trying to authenticate on each one now...", len(ldap_objects) + ) + for dn, _attrs in ldap_objects: + if dn is None: + continue + + user_attrs = self._fetch_attrs_from_simple_bind(ldap_conn, dn, username, password) + + if user_attrs: + self.log.debug("Got authenticated user attributes from DN:%s", dn) + break + else: + raise LdapPasswordError(f"Failed to authenticate user `{username}` with given password") + + except ldap.NO_SUCH_OBJECT: + self.log.debug("LDAP says no such user '%s' (%s), org_exc:", uid, username, exc_info=True) + raise LdapUsernameError("Unable to find user") + except ldap.SERVER_DOWN: + org_exc = traceback.format_exc() + raise LdapConnectionError("LDAP can't access authentication server, org_exc:%s" % org_exc) + finally: + self._releease_connection(ldap_conn) + + return dn, user_attrs