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" }, ] Loading
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" }, ]