- Guard all session.save() calls with hasattr checks for CookieSession compatibility (CookieSession auto-saves via response callbacks) - Guard beaker-specific _set_cookie_expires and _update_cookie_out in login views - Defer ldap scope_labels to property to avoid class-level AttributeError when python-ldap is not installed - Add vcsserver stub package to allow imports without full vcsserver - Add rc_license stub for CE edition (no license management)
225 lines
8.1 KiB
Python
225 lines
8.1 KiB
Python
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
|
|
|
|
|
|
class LdapDao(AuthLdapBase):
|
|
default_tls_cert_dir = "/etc/openldap/cacerts"
|
|
|
|
@staticmethod
|
|
def _scope_labels():
|
|
return {
|
|
ldap.SCOPE_BASE: "SCOPE_BASE",
|
|
ldap.SCOPE_ONELEVEL: "SCOPE_ONELEVEL",
|
|
ldap.SCOPE_SUBTREE: "SCOPE_SUBTREE",
|
|
}
|
|
|
|
@property
|
|
def scope_labels(self):
|
|
return self._scope_labels()
|
|
|
|
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]]:
|
|
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
|