From 0c385144f5b116617b46485d44446b9bf12cdb31 Mon Sep 17 00:00:00 2001 From: James Robinson Date: Thu, 28 Sep 2023 15:25:07 +0100 Subject: [PATCH 1/5] :see_no_evil: Add pycache to gitignore --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index e69f6fd..f5a9e25 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + # OS files .DS_Store From c0a5346354a9f52cddeb98ccee6722197e062aee Mon Sep 17 00:00:00 2001 From: James Robinson Date: Thu, 28 Sep 2023 15:46:23 +0100 Subject: [PATCH 2/5] :wrench: Require domain as a command line argument --- apricot/apricot_server.py | 6 +++++- apricot/ldap/oauth_ldap_tree.py | 3 +-- apricot/oauth/microsoft_entra_client.py | 18 ++---------------- apricot/oauth/oauth_client.py | 16 +++++++++------- run.py | 15 +++++++++------ 5 files changed, 26 insertions(+), 32 deletions(-) diff --git a/apricot/apricot_server.py b/apricot/apricot_server.py index ed27993..4d3978d 100644 --- a/apricot/apricot_server.py +++ b/apricot/apricot_server.py @@ -16,6 +16,7 @@ def __init__( backend: OAuthBackend, client_id: str, client_secret: str, + domain: str, port: int, **kwargs: Any, ) -> None: @@ -25,7 +26,10 @@ def __init__( # Initialize the appropriate OAuth client try: oauth_client = OAuthClientMap[backend]( - client_id=client_id, client_secret=client_secret, **kwargs + client_id=client_id, + client_secret=client_secret, + domain=domain, + **kwargs, ) except Exception as exc: msg = f"Could not construct an OAuth client for the '{backend}' backend." diff --git a/apricot/ldap/oauth_ldap_tree.py b/apricot/ldap/oauth_ldap_tree.py index 6eb81e5..11adfd8 100644 --- a/apricot/ldap/oauth_ldap_tree.py +++ b/apricot/ldap/oauth_ldap_tree.py @@ -20,9 +20,8 @@ def __init__(self, oauth_client: OAuthClient) -> None: self.oauth_client = oauth_client # Create a root node for the tree - root_dn = "DC=" + self.oauth_client.domain().replace(".", ",DC=") self.root = self.build_root( - dn=root_dn, attributes={"objectClass": ["dcObject"]} + dn=self.oauth_client.root_dn, attributes={"objectClass": ["dcObject"]} ) # Add OUs for users and groups groups_ou = self.root.add_child( diff --git a/apricot/oauth/microsoft_entra_client.py b/apricot/oauth/microsoft_entra_client.py index ee6ac59..148163f 100644 --- a/apricot/oauth/microsoft_entra_client.py +++ b/apricot/oauth/microsoft_entra_client.py @@ -9,12 +9,9 @@ class MicrosoftEntraClient(OAuthClient): def __init__( self, - client_id: str, - client_secret: str, entra_tenant_id: str, **kwargs: Any, ): - del kwargs # consume any unused arguments redirect_uri = "urn:ietf:wg:oauth:2.0:oob" # this is the "no redirect" URL scopes = ["https://graph.microsoft.com/.default"] # this is the default scope token_url = ( @@ -22,20 +19,9 @@ def __init__( ) self.tenant_id = entra_tenant_id super().__init__( - client_id=client_id, - client_secret=client_secret, - redirect_uri=redirect_uri, - scopes=scopes, - token_url=token_url, + redirect_uri=redirect_uri, scopes=scopes, token_url=token_url, **kwargs ) - def domain(self) -> str: - users = self.users() - domains = {str(user["domain"][0]) for user in users} - if len(domains) > 1: - domains = {domain for domain in domains if "onmicrosoft.com" not in domain} - return sorted(domains)[0] - def extract_token(self, json_response: JSONDict) -> str: return str(json_response["access_token"]) @@ -68,7 +54,7 @@ def users(self) -> list[LDAPAttributeDict]: f"https://graph.microsoft.com/v1.0/users/{user_dict['id']}/memberOf" ) attributes["memberOf"] = [ - group["displayName"] + f"CN={group['displayName']},OU=groups,{self.root_dn}" for group in group_memberships["value"] if group["displayName"] ] diff --git a/apricot/oauth/oauth_client.py b/apricot/oauth/oauth_client.py index a8f1c95..3f1f24b 100644 --- a/apricot/oauth/oauth_client.py +++ b/apricot/oauth/oauth_client.py @@ -20,10 +20,15 @@ def __init__( self, client_id: str, client_secret: str, + domain: str, redirect_uri: str, scopes: list[str], token_url: str, ) -> None: + # Set attributes + self.client_secret = client_secret + self.domain = domain + self.token_url = token_url # Allow token scope to not match requested scope. (Other auth libraries allow # this, but Requests-OAuthlib raises exception on scope mismatch by default.) os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1" # noqa: S105 @@ -40,9 +45,6 @@ def __init__( client_id=client_id, scope=scopes, redirect_uri=redirect_uri ) ) - # Store client secret and token URL - self.client_secret = client_secret - self.token_url = token_url # Request a new bearer token json_response = self.session_application.fetch_token( token_url=self.token_url, @@ -51,10 +53,6 @@ def __init__( ) self.bearer_token = self.extract_token(json_response) - @abstractmethod - def domain(self) -> str: - pass - @abstractmethod def extract_token(self, json_response: JSONDict) -> str: pass @@ -67,6 +65,10 @@ def groups(self) -> list[LDAPAttributeDict]: def users(self) -> list[LDAPAttributeDict]: pass + @property + def root_dn(self) -> str: + return "DC=" + self.domain.replace(".", ",DC=") + def query(self, url: str) -> dict[str, Any]: result = self.session_application.request( method="GET", diff --git a/run.py b/run.py index cd1142f..1d9e7dd 100644 --- a/run.py +++ b/run.py @@ -8,12 +8,15 @@ prog="Apricot", description="Apricot is a proxy for delegating LDAP requests to an OpenID Connect backend.", ) - parser.add_argument("-b", "--backend", type=OAuthBackend, help="Which OAuth backend to use") - parser.add_argument("-p", "--port", type=int, default=8080, help="Port to run on") - parser.add_argument("-i", "--client-id", type=str, help="OAuth client ID") - parser.add_argument("-s", "--client-secret", type=str, help="OAuth client secret") - parser.add_argument("-t", "--entra-tenant-id", type=str, help="Microsoft Entra tenant id") - + # Common options needed for all backends + parser.add_argument("-b", "--backend", type=OAuthBackend, help="Which OAuth backend to use.") + parser.add_argument("-d", "--domain", type=str, help="Which domain users belong to.") + parser.add_argument("-p", "--port", type=int, default=8080, help="Port to run on.") + parser.add_argument("-i", "--client-id", type=str, help="OAuth client ID.") + parser.add_argument("-s", "--client-secret", type=str, help="OAuth client secret.") + # Options for Microsoft Entra backend + parser.add_argument("-t", "--entra-tenant-id", type=str, help="Microsoft Entra tenant ID.", required=False) + # Parse arguments args = parser.parse_args() # Create the Apricot server From 94554a989d53b9a7edd7ecf7c1b16804d010e8a3 Mon Sep 17 00:00:00 2001 From: James Robinson Date: Thu, 28 Sep 2023 16:46:46 +0100 Subject: [PATCH 3/5] :sparkles: Add ReadOnlyLDAPServer which raises exceptions when handling operations that would change the LDAP tree --- apricot/ldap/oauth_ldap_entry.py | 7 +- apricot/ldap/oauth_ldap_server_factory.py | 4 +- apricot/ldap/read_only_ldap_server.py | 111 ++++++++++++++++++++++ 3 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 apricot/ldap/read_only_ldap_server.py diff --git a/apricot/ldap/oauth_ldap_entry.py b/apricot/ldap/oauth_ldap_entry.py index c8d73ee..50eae6d 100644 --- a/apricot/ldap/oauth_ldap_entry.py +++ b/apricot/ldap/oauth_ldap_entry.py @@ -41,7 +41,12 @@ def __str__(self) -> str: output = bytes(self.toWire()).decode("utf-8") for child in self._children.values(): try: - output += f"\n- {child!s}" + # Indent children by two spaces + indent = " " + output += ( + f"{indent}{str(child).strip()}".replace("\n", f"\n{indent}") + + "\n\n" + ) except TypeError: pass return output diff --git a/apricot/ldap/oauth_ldap_server_factory.py b/apricot/ldap/oauth_ldap_server_factory.py index fc2dcf3..9794d1c 100644 --- a/apricot/ldap/oauth_ldap_server_factory.py +++ b/apricot/ldap/oauth_ldap_server_factory.py @@ -1,14 +1,14 @@ -from ldaptor.protocols.ldap.ldapserver import LDAPServer from twisted.internet.interfaces import IAddress from twisted.internet.protocol import Protocol, ServerFactory from apricot.oauth import OAuthClient from .oauth_ldap_tree import OAuthLDAPTree +from .read_only_ldap_server import ReadOnlyLDAPServer class OAuthLDAPServerFactory(ServerFactory): - protocol = LDAPServer + protocol = ReadOnlyLDAPServer def __init__(self, oauth_client: OAuthClient): """ diff --git a/apricot/ldap/read_only_ldap_server.py b/apricot/ldap/read_only_ldap_server.py new file mode 100644 index 0000000..92c00d4 --- /dev/null +++ b/apricot/ldap/read_only_ldap_server.py @@ -0,0 +1,111 @@ +from typing import Callable + +from ldaptor.interfaces import ILDAPEntry +from ldaptor.protocols.ldap.ldaperrors import LDAPProtocolError +from ldaptor.protocols.ldap.ldapserver import LDAPServer +from ldaptor.protocols.pureldap import ( + LDAPBindRequest, + LDAPControl, + LDAPSearchResultDone, + LDAPSearchResultEntry, +) +from twisted.internet import defer + + +class ReadOnlyLDAPServer(LDAPServer): + def getRootDSE( # noqa: N802 + self, + request: LDAPBindRequest, + reply: Callable[[LDAPSearchResultEntry], None] | None, + ) -> LDAPSearchResultDone: + """Handle an LDAP Root RSE request""" + return super().getRootDSE(request, reply) + + def handle_LDAPAddRequest( # noqa: N802 + self, + request: LDAPBindRequest, + controls: LDAPControl | None, + reply: Callable[..., None] | None, + ) -> defer.Deferred[ILDAPEntry]: + """Refuse to handle an LDAP add request""" + id((request, controls, reply)) # ignore unused arguments + msg = "ReadOnlyLDAPServer will not handle LDAP add requests" + raise LDAPProtocolError(msg) + + def handle_LDAPBindRequest( # noqa: N802 + self, + request: LDAPBindRequest, + controls: LDAPControl | None, + reply: Callable[..., None] | None, + ) -> defer.Deferred[ILDAPEntry]: + """Handle an LDAP bind request""" + return super().handle_LDAPBindRequest(request, controls, reply) + + def handle_LDAPCompareRequest( # noqa: N802 + self, + request: LDAPBindRequest, + controls: LDAPControl | None, + reply: Callable[..., None] | None, + ) -> defer.Deferred[ILDAPEntry]: + """Handle an LDAP compare request""" + return super().handle_LDAPCompareRequest(request, controls, reply) + + def handle_LDAPDelRequest( # noqa: N802 + self, + request: LDAPBindRequest, + controls: LDAPControl | None, + reply: Callable[..., None] | None, + ) -> defer.Deferred[ILDAPEntry]: + """Refuse to handle an LDAP delete request""" + id((request, controls, reply)) # ignore unused arguments + msg = "ReadOnlyLDAPServer will not handle LDAP delete requests" + raise LDAPProtocolError(msg) + + def handle_LDAPExtendedRequest( # noqa: N802 + self, + request: LDAPBindRequest, + controls: LDAPControl | None, + reply: Callable[..., None] | None, + ) -> defer.Deferred[ILDAPEntry]: + """Handle an LDAP extended request""" + return super().handle_LDAPExtendedRequest(request, controls, reply) + + def handle_LDAPModifyDNRequest( # noqa: N802 + self, + request: LDAPBindRequest, + controls: LDAPControl | None, + reply: Callable[..., None] | None, + ) -> defer.Deferred[ILDAPEntry]: + """Refuse to handle an LDAP modify DN request""" + id((request, controls, reply)) # ignore unused arguments + msg = "ReadOnlyLDAPServer will not handle LDAP modify DN requests" + raise LDAPProtocolError(msg) + + def handle_LDAPModifyRequest( # noqa: N802 + self, + request: LDAPBindRequest, + controls: LDAPControl | None, + reply: Callable[..., None] | None, + ) -> defer.Deferred[ILDAPEntry]: + """Refuse to handle an LDAP modify request""" + id((request, controls, reply)) # ignore unused arguments + msg = "ReadOnlyLDAPServer will not handle LDAP modify requests" + raise LDAPProtocolError(msg) + + def handle_LDAPUnbindRequest( # noqa: N802 + self, + request: LDAPBindRequest, + controls: LDAPControl | None, + reply: Callable[..., None] | None, + ) -> None: + """Handle an LDAP unbind request""" + super().handle_LDAPUnbindRequest(request, controls, reply) + + def handle_LDAPSearchRequest( # noqa: N802 + self, + request: LDAPBindRequest, + controls: LDAPControl | None, + reply: Callable[[LDAPSearchResultEntry], None] | None, + ) -> defer.Deferred[ILDAPEntry]: + """Handle an LDAP search request""" + return super().handle_LDAPSearchRequest(request, controls, reply) From 0e0532360ae7a454ffd404c8d22da1b8f52d3475 Mon Sep 17 00:00:00 2001 From: James Robinson Date: Thu, 28 Sep 2023 16:53:34 +0100 Subject: [PATCH 4/5] :memo: Improve description of LDAP output --- README.md | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c38ccee..2c051d5 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,42 @@ The name is a slightly tortured acronym for: LD**A**P **pr**oxy for Open**I**D * Start the `Apricot` server on port 8080 by running: ```bash -python run.py --client-id "" --client-secret "" --backend --port 8080 +python run.py --client-id "" --client-secret "" --backend "" --port 8080 --domain "" +``` + +This will create an LDAP tree that looks like this: + +``` +dn: DC= +objectClass: dcObject + +dn: OU=users,DC= +objectClass: organizationalUnit +ou: users + +dn: OU=groups,DC= +objectClass: organizationalUnit +ou: groups +``` + +Each user will have an entry like + +``` +dn: CN=,OU=users,DC= +objectClass: organizationalPerson +objectClass: person +objectClass: top +objectClass: user + +``` + +Each group will have an entry like + +``` +dn: CN=,OU=groups,DC= +objectClass: group +objectClass: top + ``` ## OpenID Connect From 0eaab0173bfcc5f05018600cca6b25d93b5a465d Mon Sep 17 00:00:00 2001 From: James Robinson Date: Thu, 28 Sep 2023 19:15:30 +0100 Subject: [PATCH 5/5] :memo: Add ldif syntax highlighting --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2c051d5..46e6c99 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ python run.py --client-id "" --client-secret " objectClass: dcObject @@ -28,7 +28,7 @@ ou: groups Each user will have an entry like -``` +```ldif dn: CN=,OU=users,DC= objectClass: organizationalPerson objectClass: person @@ -39,7 +39,7 @@ objectClass: user Each group will have an entry like -``` +```ldif dn: CN=,OU=groups,DC= objectClass: group objectClass: top