Skip to content

Commit

Permalink
Merge pull request #14849 from mahendrapaipuri/auth_ldap3
Browse files Browse the repository at this point in the history
Add LDAP authenticator variant using `ldap3` package
  • Loading branch information
nsoranzo authored Jan 9, 2023
2 parents 441f8c8 + c9487b8 commit 5826b42
Show file tree
Hide file tree
Showing 5 changed files with 195 additions and 2 deletions.
6 changes: 6 additions & 0 deletions doc/source/admin/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ Galaxy is distributed with a plugin-driven authentication framework for which th
just one (and the default plugin). This framework can be used to allow Galaxy to delegate authentication to
an LDAP server, an Active Directory server, or to PAM.

Currently, we provide two variants of the LDAP authenticator namely, `ldap` and `ldap3`. Both are identical implementations that use different Python modules for binding and making queries to a LDAP server. The authenticator
`ldap` is based on the [python-ldap](https://www.python-ldap.org/) module which is a wrapper around the
OpenLDAP's client library `libldap`. Currently, `python-ldap` does not provide pre-built Python wheel packages and hence, the OpenLDAP client libraries are needed to install `python-ldap` on the Galaxy server.

On the otherhand, `ldap3` is a pure-Python implementation of the OpenLDAP client library and has no external dependencies. This package can be installed out-of-the-box, so we recommend to use it when the OpenLDAP client libraries are not available on the Galaxy server.

These same mechanisms can also be configured by proxies serving Galaxy (e.g. nginx or Apache), but configuring them
within Galaxy allows users to use the Galaxy UI for logging in instead of relying on a proxy.

Expand Down
183 changes: 182 additions & 1 deletion lib/galaxy/auth/providers/ldap_ad.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,17 @@
Created on 15/07/2014
@author: Andrew Robinson
Modification on 24/10/2022
Addition of LDAP3 auth provider using the ldap3 module. The original LDAP auth provider uses the python-ldap library which
has external dependencies like openldap client libs. ldap3 is a pure Python LDAP v3 client library.
@author: Mahendra Paipuri, CNRS
"""

import logging
from urllib.parse import urlparse

from galaxy.exceptions import ConfigurationError
from galaxy.security.validate_user_input import transform_publicname
Expand All @@ -20,6 +28,12 @@
ldap = None
ldap_import_exc = exc

try:
import ldap3
except ImportError as exc:
ldap3 = None
ldap3_import_exc = exc

log = logging.getLogger(__name__)


Expand Down Expand Up @@ -282,11 +296,178 @@ def authenticate_user(self, user, password, options, request):
return self.authenticate(user.email, user.username, password, options, request)[0]


class LDAP3(LDAP):
"""LDAP auth provider using ldap3 module"""

plugin_type = "ldap3"

def __init__(self):
super().__init__()
# Initialise server and autobind objects
self.server = None
self.auto_bind = None
# LDAP over TLS bool
self.ldap_tls = None

def get_server(self, options, params):
# Get server URL
server_url = _get_subs(options, "server", params)
# Check if server URL has scheme
# If no scheme is provided, assume it to be ldap
if "ldap" not in server_url:
server_url = "ldaps://{server_url}".format(server_url=server_url)
# Check if TLS is available
if server_url.startswith("ldaps://"):
self.ldap_tls = True
else:
self.ldap_tls = False
# Get server address and port
url_obj = urlparse(server_url)
server_address = url_obj.hostname
try:
server_port = int(url_obj.port)
except TypeError:
# If port is not specified use standard port numbers based on TLS
if self.ldap_tls:
server_port = 636
else:
server_port = 389
# Create server object
self.server = ldap3.Server(server_address, port=server_port, use_ssl=self.ldap_tls, get_info=ldap3.ALL)
# Set auto_bind
self.auto_bind = ldap3.AUTO_BIND_NO_TLS if self.ldap_tls else ldap3.AUTO_BIND_TLS_BEFORE_BIND

def ldap_search(self, email, username, options):
config_ok, failure_mode = self.check_config(username, email, options)
if ldap3 is None:
raise RuntimeError("Failed to load LDAP3 module: %s", str(ldap3_import_exc))

if not config_ok:
return failure_mode, None

params = {"email": email, "username": username}

if "search-fields" in options:
try:
# Initialise server object
self.get_server(options, params)
if "search-user" in options:
conn = ldap3.Connection(
self.server,
user=_get_subs(options, "search-user", params),
password=_get_subs(options, "search-password", params),
auto_bind=self.auto_bind,
)
else:
conn = ldap3.Connection(self.server, auto_bind=self.auto_bind)
# Use StartTLS if LDAP connection is not over TLS
if not self.ldap_tls:
conn.start_tls()
# setup search
attributes = {_.strip().format(**params) for _ in options["search-fields"].split(",")}
if "search-memberof-filter" in options:
attributes.add("memberOf")
conn.search(
search_base=_get_subs(options, "search-base", params),
search_scope=ldap3.SUBTREE,
search_filter=_get_subs(options, "search-filter", params),
attributes=attributes,
time_limit=60,
size_limit=1,
)
response = conn.response
# Unbind connection
conn.unbind()

# parse results
if len(response) == 0 or "attributes" not in response[0].keys():
log.warning("LDAP3 authenticate: search returned no results")
return (failure_mode, None)
dn = response[0]["dn"]
attrs = response[0]["attributes"]
log.debug("LDAP3 authenticate: dn is %s", dn)
log.debug("LDAP3 authenticate: search attributes are %s", attrs)
for attr in attributes:
if self.role_search_attribute and attr == self.role_search_attribute[1:-1]: # strip curly brackets
# keep role names as list
params[self.role_search_option] = [unicodify(_) for _ in attrs[attr]]
elif attr == "memberOf":
params[attr] = [unicodify(_) for _ in attrs[attr]]
elif attr in attrs:
params[attr] = unicodify(attrs[attr][0])
else:
params[attr] = ""

if self.auto_create_roles_or_groups and self.role_search_option not in params:
raise ConfigurationError(
"Missing or mismatching LDAP parameters for %s. Make sure the %s is "
"included in the 'search-fields'." % (self.role_search_option, self.role_search_attribute)
)
params["dn"] = dn
except Exception:
log.exception("LDAP3 authenticate: search exception")
return (failure_mode, None)

return failure_mode, params

def _authenticate(self, params, options):
"""
Do the actual authentication by binding as the user to check their credentials
"""
try:
# Initialise server object
self.get_server(options, params)
conn = ldap3.Connection(
self.server,
user=_get_subs(options, "bind-user", params),
password=_get_subs(options, "bind-password", params),
auto_bind=self.auto_bind,
)
# Use StartTLS if LDAP connection is not over TLS
if not self.ldap_tls:
conn.start_tls()
try:
whoami = conn.extend.standard.who_am_i()
# Unbind connection
conn.unbind()
except ldap3.LDAPExtensionError:
# The "Who am I?" extended operation is not supported by this LDAP server
pass
else:
if whoami is None:
raise RuntimeError("LDAP3 authenticate: anonymous bind")
if not options["redact_username_in_logs"]:
log.debug("LDAP3 authenticate: whoami is %s", whoami)
except Exception as e:
log.info("LDAP3 authenticate: bind exception: %s", unicodify(e))
return False
log.debug("LDAP3 authentication successful")
return True


class ActiveDirectory(LDAP):
"""Effectively just an alias for LDAP auth, but may contain active directory specific
logic in the future."""

plugin_type = "activedirectory"


__all__ = ("LDAP", "ActiveDirectory")
__all__ = ("LDAP", "LDAP3", "ActiveDirectory")

if __name__ == "__main__":
# Instantiate LDAP3 class
c = LDAP3()
# Define options
options = {
"server": "ipa.demo1.freeipa.org",
"bind-user": "{dn}",
"bind-password": "{password}",
"search-fields": "uid",
"search-filter": "(uid={username})",
"search-base": "cn=users,cn=accounts,dc=demo1,dc=freeipa,dc=org",
"redact_username_in_logs": False,
"auto-register-username": "{uid}",
"auto-register-email": "{uid}@example.com",
}
# Test method
print(c.authenticate("[email protected]", "admin", "Secret123", options, None))
4 changes: 3 additions & 1 deletion lib/galaxy/config/sample/auth_conf.xml.sample
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@
Options and values should match those from the python-ldap
documentation.
The following example allows connecting to ldaps:// (SSL/TLS)
when self-signed certificates are used -->
when self-signed certificates are used
These options are valid only if ldap provider is used.
For ldap3 none of these options are used-->
<!-- <ldap-options>OPT_X_TLS_REQUIRE_CERT=OPT_X_TLS_ALLOW</ldap-options>
-->
<!-- Whether unregistered users should use their LDAP username
Expand Down
3 changes: 3 additions & 0 deletions lib/galaxy/dependencies/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,9 @@ def check_statsd(self):
def check_python_ldap(self):
return "ldap" in self.authenticators or "activedirectory" in self.authenticators

def check_ldap3(self):
return "ldap3" in self.authenticators

def check_python_pam(self):
return "PAM" in self.authenticators

Expand Down
1 change: 1 addition & 0 deletions lib/galaxy/dependencies/conditional-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ statsd
azure-storage==0.32.0
python-irodsclient==1.1.3
python-ldap==3.4.0
ldap3==2.9.1
python-pam
galaxycloudrunner
pkce
Expand Down

0 comments on commit 5826b42

Please sign in to comment.