Verified Commit 0c053aa3 authored by Jakob Moser's avatar Jakob Moser
Browse files

Make it a Python library

parent 606a3afb
Loading
Loading
Loading
Loading

.python-version

0 → 100644
+1 −0
Original line number Diff line number Diff line
3.12

pyproject.toml

0 → 100644
+20 −0
Original line number Diff line number Diff line
[project]
name = "ldap"
version = "0.1.0"
authors = [
  { name="Jakob Moser", email="moser@cl.uni-heidelberg.de" },
]
description = "An opinionated wrapper for the excellent ldap3 library, making ldap auth easy."
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "ldap3>=2.9.1",
]

[project.urls]
Homepage = "https://gitlab.cl.uni-heidelberg.de/moser/ldap"
Issues = "https://gitlab.cl.uni-heidelberg.de/moser/ldap/-/issues"

[build-system]
requires = ["hatchling >= 1.26"]
build-backend = "hatchling.build"

src/ldap/Directory.py

0 → 100644
+99 −0
Original line number Diff line number Diff line
import ssl

from ldap3 import Tls, Server, Connection
from ldap3.core.exceptions import LDAPBindError, LDAPPasswordIsMandatoryError
from ldap3.utils.dn import escape_rdn


def is_valid_server_url(url: str) -> bool:
    return url.startswith("ldaps://") or url.startswith("ldap://")


TLS_CONFIGURATION = Tls(validate=ssl.CERT_REQUIRED)


class Directory:
    """
    A (user information) directory (= database), accessible via the Lightweight Directory Access Protocol (LDAP).
    Provides a few methods to do things you probably want to do with user information.

    For a detailed security consideration, please see:
    https://stackoverflow.com/questions/73681632/how-to-safely-authenticate-a-user-using-ldap/

    Taken from an older (internal) project of mine, codex:
    https://gitlab.cl.uni-heidelberg.de/fachschaft/codex/-/blob/master/ldap/LdapDatabase.py?ref_type=heads
    """

    def __init__(self, url: str, base_dn: str):
        """
        Creates the LdapDatabase instance. Does not establish any connections whatsoever.

        :param url: A ldap server url (must use either ldaps:// or ldap:// scheme)
        :param base_dn: The distinguished name below which to look for users (e.g. "ou=employees,dc=example,dc=com")
        """
        if not is_valid_server_url(url):
            raise ValueError("ldap_server_url must begin with ldaps:// or ldap://")

        self.ldap_server_url = url
        self.base_dn = base_dn

        # Note: The URL scheme (ldap:// or ldaps://) has precedence over the parameter "use_ssl"
        # (see https://ldap3.readthedocs.io/en/latest/server.html). As we always require a scheme to be present, this
        # parameter is superfluous. However, setting it to true means that if there ever is a case where no scheme is
        # specified (e.g. if someone ever deletes the scheme-enforcing code above), the connection will fall back to
        # use SSL, and I believe that is a good way for things to be.
        self.__server = Server(
            self.ldap_server_url, use_ssl=True, tls=TLS_CONFIGURATION
        )

    def is_valid(self, username: str, password: str) -> bool:
        """
        Return if the given username and password are valid credentials.

        Iff true is returned, the user that provided these credentials can be treated as successfully
        authenticated.

        :param username: An (untrusted!) username string
        :param password: An (untrusted!) password string
        :return: If username and password are valid
        """
        user_dn = self._get_user_dn(username)

        try:
            with Connection(self.__server, user=user_dn, password=password):
                # This will implicitly perform a bind (as, by default, lazy=False). If it succeeds, the user was
                # authenticated against the LDAP server, meaning the credentials are valid.
                # Binding actually seems to be enough, see e.g. https://auth0.com/blog/using-ldap-with-c-sharp/
                # (section "Validating user credentials using bind") and https://stackoverflow.com/q/28575359.
                return True
        except LDAPBindError:
            return False
        except LDAPPasswordIsMandatoryError:
            # There is a (I believe deprecated) bind method with a username, but without a password
            # (see https://stackoverflow.com/a/3515564/). This is not permissible by the library, which will throw
            # this exception. We then return False.
            return False

    def _get_user_dn(self, common_name: str) -> str:
        """
        Get the distinguished name of a user, given only its common name (= username, in our case)
        :param common_name: An (untrusted!) username string
        :return: The distinguished name of the user (by interpreting "cn=<common_name>" as RDN below self.base_dn)
        """

        # The common_name provided here is untrusted, meaning it can contain arbitrary (malicious) input.
        # Is this a problem? I don't know.
        #
        # When building a filter or search query, it definitely would be:
        # - https://cheatsheetseries.owasp.org/cheatsheets/LDAP_Injection_Prevention_Cheat_Sheet.html
        # - https://stackoverflow.com/q/47397341
        # This is a problem because untrusted input is processed in a trusted context.
        #
        # However, when binding, one could argue that the untrusted input is also only processed in an untrusted
        # context - anyone could go and make a bind request with a malicious username directly to the ldap server.
        # So we can probably expect the LDAP server to deal with malicious usernames of any kind.
        #
        # Or can we? The documentation (https://ldap3.readthedocs.io/en/latest/connection.html) recommends using
        # escape_rdn() for the username, so we do this, better safe than sorry.

        return f"cn={escape_rdn(common_name)},{self.base_dn}"

src/ldap/__init__.py

0 → 100644
+3 −0
Original line number Diff line number Diff line
from .Directory import Directory

__all__ = ["Directory"]

uv.lock

0 → 100644
+35 −0
Original line number Diff line number Diff line
version = 1
revision = 3
requires-python = ">=3.12"

[[package]]
name = "ldap"
version = "0.1.0"
source = { editable = "." }
dependencies = [
    { name = "ldap3" },
]

[package.metadata]
requires-dist = [{ name = "ldap3", specifier = ">=2.9.1" }]

[[package]]
name = "ldap3"
version = "2.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/ac/96bd5464e3edbc61595d0d69989f5d9969ae411866427b2500a8e5b812c0/ldap3-2.9.1.tar.gz", hash = "sha256:f3e7fc4718e3f09dda568b57100095e0ce58633bcabbed8667ce3f8fbaa4229f", size = 398830, upload-time = "2021-07-18T06:34:21.786Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/4e/f6/71d6ec9f18da0b2201287ce9db6afb1a1f637dedb3f0703409558981c723/ldap3-2.9.1-py2.py3-none-any.whl", hash = "sha256:5869596fc4948797020d3f03b7939da938778a0f9e2009f7a072ccf92b8e8d70", size = 432192, upload-time = "2021-07-18T06:34:12.905Z" },
]

[[package]]
name = "pyasn1"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" },
]