diff --git a/.gitignore b/.gitignore index 4beb292..74efbd2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,11 +8,13 @@ __pycache__/ # Distribution / packaging .Python -env/ build/ develop-eggs/ dist/ downloads/ +.idea/ +.DS_Store +.vscode/ eggs/ .eggs/ lib/ @@ -20,9 +22,13 @@ lib64/ parts/ sdist/ var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ *.egg-info/ .installed.cfg *.egg +MANIFEST # PyInstaller # Usually these files are written by a python script from a template @@ -37,13 +43,16 @@ pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ +.nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml -*,cover +*.cover +*.py,cover .hypothesis/ +.pytest_cache/ # Translations *.mo @@ -51,6 +60,16 @@ coverage.xml # Django stuff: *.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy # Sphinx documentation docs/_build/ @@ -58,10 +77,69 @@ docs/_build/ # PyBuilder target/ -# pyenv python configuration file +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv .python-version -misc/ -.pytest_cache/ -htmlcov/ -.vscode/ \ No newline at end of file +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# ldap +*.ldif + +# ruff +.ruff_cache + +# certs +*.pem +*.crt + +# Dev stuff +dev diff --git a/aioldap3.py b/aioldap3.py index 1a7f77e..6cc997d 100644 --- a/aioldap3.py +++ b/aioldap3.py @@ -255,6 +255,8 @@ from types import TracebackType from typing import Any, AsyncGenerator, Callable, Literal, cast +import gssapi +import gssapi.exceptions from ldap3.operation.add import add_operation from ldap3.operation.bind import bind_operation, bind_response_to_dict_fast from ldap3.operation.delete import delete_operation @@ -671,11 +673,20 @@ def __init__( server: Server, user: str | None = None, password: str | None = None, + sasl_mechanism: str | None = None, + cred_store: dict[bytes | str, bytes | str] | None = None, + cred_token: bytes | None = None, loop: asyncio.AbstractEventLoop | None = None, ) -> None: """Set server, user and pw.""" self._responses: dict[str, LDAPResponse] = {} self._msg_id = 0 + + self._sasl_in_progress = False + self._sasl_mechanism = sasl_mechanism + self._cred_store = cred_store + self._cred_token = cred_token + self.loop = loop or asyncio.get_running_loop() self.server = server @@ -725,6 +736,151 @@ def _next_msg_id(self) -> int: self._msg_id += 1 return self._msg_id + async def sasl_bind(self) -> LDAPResponse: + """Perform SASL bind.""" + logger.debug(f"start SASL BIND operation to {self.server.host}") + if not self._sasl_in_progress: + self._sasl_in_progress = True + try: + if self._sasl_mechanism == "GSSAPI": + result = await self.sasl_gssapi() + else: + raise LDAPBindError("Unsupported SASL mechanism") + finally: + self._sasl_in_progress = False + + logger.debug(f"done SASL BIND operation to {self.server.host}") + + return result + + def init_gssapi_credentials( + self, + name: gssapi.Name, + usage: str, + store: dict[bytes | str, bytes | str] | None, + ) -> gssapi.Credentials: + """Initialize GSSAPI credentials.""" + return gssapi.Credentials( + name=name, + usage=usage, + store=store, + ) + + async def sasl_gssapi(self) -> LDAPResponse: + """Perform SASL GSSAPI bind using the Kerberos v5 mechanism.""" + target_name = gssapi.Name( + "ldap@" + self.server.host, gssapi.NameType.hostbased_service + ) + + if self._cred_token: + creds = gssapi.Credentials(token=self._cred_token) + else: + creds = await self.loop.run_in_executor( + None, + self.init_gssapi_credentials, + gssapi.Name(self.bind_dn), + "initiate", + self._cred_store, + ) + + ctx = gssapi.SecurityContext( + name=target_name, + mech=gssapi.MechType.kerberos, + creds=creds, + ) + + self._msg_id = 0 + + in_token = None + try: + while True: + logger.debug("Sending SASL token") + out_token = await self.loop.run_in_executor( + None, + ctx.step, + in_token, + ) + if out_token is None: + out_token = b"" + result = await self.send_sasl_negotiation(out_token) + in_token = result.data["saslCreds"] + try: + if ctx.complete: + break + except gssapi.exceptions.MissingContextError: + pass + + unwrapped_token = ctx.unwrap(in_token) + client_security_layers = self.proccess_end_token( + unwrapped_token.message + ) + out_token = ctx.wrap(bytes(client_security_layers), False) + return await self.send_sasl_negotiation(out_token.message) + except gssapi.exceptions.GSSError as exc: + await self.abort_sasl_negotiation() + raise LDAPBindError(f"LDAP GSSAPI error: {exc}") from exc + + async def abort_sasl_negotiation(self) -> None: + """Abort the SASL negotiation.""" + bind_req = bind_operation( + version=self.server.version, + authentication="SASL", + name=None, + password=None, + sasl_mechanism="", + sasl_credentials=None, + ) + + ldap_msg = LDAPClientProtocol.encapsulate_ldap_message( + self._next_msg_id, "bindRequest", bind_req + ) + + resp = self._proto.send(ldap_msg) + + await resp.wait() + + async def send_sasl_negotiation(self, payload: bytes) -> LDAPResponse: + """Send SASL negotiation data to the server.""" + bind_req = bind_operation( + version=self.server.version, + authentication="SASL", + name=None, + password=None, + sasl_mechanism="GSSAPI", + sasl_credentials=payload, + ) + + # Generate ASN1 form of LDAP bind request + ldap_msg = LDAPClientProtocol.encapsulate_ldap_message( + self._next_msg_id, "bindRequest", bind_req + ) + + resp = self._proto.send(ldap_msg) + await resp.wait() + + return resp + + def proccess_end_token(self, token: bytes) -> bytearray: + """Process the response we got at the end of our SASL negotiation.""" + if len(token) != 4: + raise LDAPBindError("Incorrect token length") + + server_security_layers = token[0] + if not isinstance(server_security_layers, int): + server_security_layers = ord(server_security_layers) # type: ignore + + if server_security_layers in (0, 1) and token[1:] != b"\x00\x00\x00": + raise LDAPBindError( + "Server max buffer size must be 0 if no security layer" + ) + if not (server_security_layers & 1): + raise LDAPBindError( + "Server requires a security layer, but this is not implemented" + ) + + client_security_layers = bytearray([1, 0, 0, 0]) + return client_security_layers + async def bind( self, bind_dn: str | None = None, @@ -786,6 +942,14 @@ async def bind( ntlm_client, ) + elif method == "SASL" and self._sasl_mechanism == "GSSAPI": + resp = await self.sasl_bind() + + if resp.data["result"] != 0: + raise LDAPBindError("Invalid Credentials") + + self._proto.is_bound = True + return else: raise LDAPBindError("Unsupported Authentication Method") @@ -1027,25 +1191,13 @@ async def unbind(self) -> None: async def start_tls(self, ctx: ssl.SSLContext | None = None) -> None: """Start tls protocol.""" - if hasattr(self, "_proto") or self._proto.transport.is_closing(): + if not hasattr(self, "_proto") or self._proto.transport.is_closing(): self._socket, self._proto = await self.loop.create_connection( lambda: LDAPClientProtocol(self.loop), self.server.host, self.server.port, ) - # Get SSL context from server obj, if - # it wasnt provided, it'll be the default one - - resp = await self.extended("1.3.6.1.4.1.1466.20037") - - if resp.data["description"] != "success": - raise LDAPStartTlsError( - "Server doesnt want us to use TLS. {}".format( - resp.data.get("message") - ) - ) - await self._proto.start_tls( ctx or cast(ssl.SSLContext, self.server.ssl_context) ) diff --git a/poetry.lock b/poetry.lock index 0522037..878fb22 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "colorama" @@ -78,6 +78,17 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli"] +[[package]] +name = "decorator" +version = "5.2.1" +description = "Decorators for Humans" +optional = false +python-versions = ">=3.8" +files = [ + {file = "decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a"}, + {file = "decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360"}, +] + [[package]] name = "exceptiongroup" version = "1.2.0" @@ -92,6 +103,43 @@ files = [ [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "gssapi" +version = "1.9.0" +description = "Python GSSAPI Wrapper" +optional = false +python-versions = ">=3.8" +files = [ + {file = "gssapi-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:261e00ac426d840055ddb2199f4989db7e3ce70fa18b1538f53e392b4823e8f1"}, + {file = "gssapi-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:14a1ae12fdf1e4c8889206195ba1843de09fe82587fa113112887cd5894587c6"}, + {file = "gssapi-1.9.0-cp310-cp310-win32.whl", hash = "sha256:2a9c745255e3a810c3e8072e267b7b302de0705f8e9a0f2c5abc92fe12b9475e"}, + {file = "gssapi-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:dfc1b4c0bfe9f539537601c9f187edc320daf488f694e50d02d0c1eb37416962"}, + {file = "gssapi-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:67d9be5e34403e47fb5749d5a1ad4e5a85b568e6a9add1695edb4a5b879f7560"}, + {file = "gssapi-1.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11e9b92cef11da547fc8c210fa720528fd854038504103c1b15ae2a89dce5fcd"}, + {file = "gssapi-1.9.0-cp311-cp311-win32.whl", hash = "sha256:6c5f8a549abd187687440ec0b72e5b679d043d620442b3637d31aa2766b27cbe"}, + {file = "gssapi-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:59e1a1a9a6c5dc430dc6edfcf497f5ca00cf417015f781c9fac2e85652cd738f"}, + {file = "gssapi-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b66a98827fbd2864bf8993677a039d7ba4a127ca0d2d9ed73e0ef4f1baa7fd7f"}, + {file = "gssapi-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2bddd1cc0c9859c5e0fd96d4d88eb67bd498fdbba45b14cdccfe10bfd329479f"}, + {file = "gssapi-1.9.0-cp312-cp312-win32.whl", hash = "sha256:10134db0cf01bd7d162acb445762dbcc58b5c772a613e17c46cf8ad956c4dfec"}, + {file = "gssapi-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:e28c7d45da68b7e36ed3fb3326744bfe39649f16e8eecd7b003b082206039c76"}, + {file = "gssapi-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cea344246935b5337e6f8a69bb6cc45619ab3a8d74a29fcb0a39fd1e5843c89c"}, + {file = "gssapi-1.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a5786bd9fcf435bd0c87dc95ae99ad68cefcc2bcc80c71fef4cb0ccdfb40f1e"}, + {file = "gssapi-1.9.0-cp313-cp313-win32.whl", hash = "sha256:c99959a9dd62358e370482f1691e936cb09adf9a69e3e10d4f6a097240e9fd28"}, + {file = "gssapi-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:a2e43f50450e81fe855888c53df70cdd385ada979db79463b38031710a12acd9"}, + {file = "gssapi-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c0e378d62b2fc352ca0046030cda5911d808a965200f612fdd1d74501b83e98f"}, + {file = "gssapi-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b74031c70864d04864b7406c818f41be0c1637906fb9654b06823bcc79f151dc"}, + {file = "gssapi-1.9.0-cp38-cp38-win32.whl", hash = "sha256:f2f3a46784d8127cc7ef10d3367dedcbe82899ea296710378ccc9b7cefe96f4c"}, + {file = "gssapi-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:a81f30cde21031e7b1f8194a3eea7285e39e551265e7744edafd06eadc1c95bc"}, + {file = "gssapi-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cbc93fdadd5aab9bae594538b2128044b8c5cdd1424fe015a465d8a8a587411a"}, + {file = "gssapi-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5b2a3c0a9beb895942d4b8e31f515e52c17026e55aeaa81ee0df9bbfdac76098"}, + {file = "gssapi-1.9.0-cp39-cp39-win32.whl", hash = "sha256:060b58b455d29ab8aca74770e667dca746264bee660ac5b6a7a17476edc2c0b8"}, + {file = "gssapi-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:11c9fe066edb0fa0785697eb0cecf2719c7ad1d9f2bf27be57b647a617bcfaa5"}, + {file = "gssapi-1.9.0.tar.gz", hash = "sha256:f468fac8f3f5fca8f4d1ca19e3cd4d2e10bd91074e7285464b22715d13548afe"}, +] + +[package.dependencies] +decorator = "*" + [[package]] name = "iniconfig" version = "2.0.0" @@ -322,4 +370,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.8.1" -content-hash = "674dc342c6dfd05e5a529a5b75a4379ebded3ffb69f7a85c3090042e71ae5910" +content-hash = "6830da8c832853427041625449f4089307dd859cff6bc5cd3bd2d501ff274b55" diff --git a/pyproject.toml b/pyproject.toml index 853822c..da53035 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ packages = [ [tool.poetry.dependencies] python = "^3.8.1" +gssapi = "^1.9.0" ldap3 = "^2.9.1" [tool.poetry.group.dev] @@ -35,7 +36,6 @@ requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [tool.mypy] -plugins = ["sqlalchemy.ext.mypy.plugin", "pydantic.mypy"] ignore_missing_imports = true platform = "linux" disallow_untyped_defs = true