feature: initial implementation of LDAP/AD sync

This commit is contained in:
ievgenii vdovenko 2025-07-11 16:04:18 +02:00
parent 5a67c042e2
commit b783ff6689
3 changed files with 267 additions and 212 deletions

View file

@ -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:

View file

@ -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