From 6537fa58cc97237e14116a4a0a3ac8ab3f0dd6b6 Mon Sep 17 00:00:00 2001 From: Julian Rother <julian@jrother.eu> Date: Tue, 9 Mar 2021 17:09:49 +0100 Subject: [PATCH] More authentication mechanisms and some documentation --- db.py | 17 ++++- server.py | 209 +++++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 199 insertions(+), 27 deletions(-) diff --git a/db.py b/db.py index 50e08a8..a1424cc 100644 --- a/db.py +++ b/db.py @@ -1,3 +1,5 @@ +import socket +import struct from crypt import crypt from ssl import SSLContext, SSLSocket @@ -7,7 +9,7 @@ from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.hybrid import hybrid_property from ldap import SearchScope, FilterAnd, FilterOr, FilterNot, FilterEqual, FilterPresent from server import LDAPRequestHandler, LDAPInvalidCredentials, LDAPInsufficientAccessRights, LDAPConfidentialityRequired -from socketserver import ForkingTCPServer +import socketserver from dn import parse_dn, build_dn Base = declarative_base() @@ -298,7 +300,7 @@ class Group(Base): Base.metadata.create_all(engine) staticobjs = StaticSearchEvaluator() -staticobjs.add(dn='', attributes={'objectClass': 'top', 'supportedSASLMechanisms': ['PLAIN', 'ANONYMOUS', 'EXTERNAL', 'SCRAM', 'DIGEST-MD5', 'CRAM-MD5', 'NTLM']}) +staticobjs.add(dn='', attributes={'objectClass': 'top', 'supportedSASLMechanisms': ['EXTERNAL']}) #'PLAIN', 'ANONYMOUS', 'EXTERNAL', 'SCRAM', 'DIGEST-MD5', 'CRAM-MD5', 'NTLM']}) usereval = SQLSearchEvaluator( model=User, @@ -355,6 +357,14 @@ class RequestHandler(LDAPRequestHandler): raise LDAPInvalidCredentials() return user + def do_bind_sasl_external(self, authzid=None): + ucred = struct.Struct('III') + data = self.request.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, ucred.size) + print('EXTERNAL', data, authzid) + pid, uid, gid = ucred.unpack(data) + print('EXTERNAL', pid, uid, gid) + return None, None + def do_search(self, baseobj, scope, filter): #if self.bind_object is None: # raise LDAPInsufficientAccessRights() @@ -362,4 +372,5 @@ class RequestHandler(LDAPRequestHandler): yield from usereval(baseobj, scope, filter) yield from groupeval(baseobj, scope, filter) -ForkingTCPServer(('127.0.0.1', 1337), RequestHandler).serve_forever() +#socketserver.ForkingTCPServer(('127.0.0.1', 1337), RequestHandler).serve_forever() +socketserver.UnixStreamServer('/tmp/ldapd.sock', RequestHandler).serve_forever() diff --git a/server.py b/server.py index 2a65f50..dc8050e 100644 --- a/server.py +++ b/server.py @@ -63,13 +63,14 @@ class LDAPRequestHandler(BaseRequestHandler): # Resume ongoing SASL dialog if self.bind_sasl_state and isinstance(auth, SaslCredentials) \ and auth.mechanism == self.bind_sasl_state[0]: - iterator = self.bind_sasl_state[1] + mechanism, iterator = self.bind_sasl_state + self.bind_sasl_state = None resp_code = LDAPResultCode.saslBindInProgress try: resp = iterator.send(auth.credentials) + self.bind_sasl_state = (mechanism, iterator) except StopIteration as e: resp_code = LDAPResultCode.success - self.bind_sasl_state = None self.bind_object, resp = e.value self.send_msg(LDAPMessage(req.messageID, BindResponse(resp_code, serverSaslCreds=resp))) return @@ -88,36 +89,192 @@ class LDAPRequestHandler(BaseRequestHandler): resp_code = LDAPResultCode.saslBindInProgress try: resp = next(iterator) + self.bind_sasl_state = (auth.mechanism, iterator) except StopIteration as e: resp_code = LDAPResultCode.success - self.bind_sasl_state = None self.bind_object, resp = e.value self.send_msg(LDAPMessage(req.messageID, BindResponse(resp_code, serverSaslCreds=resp))) - self.bind_sasl_state = (auth.mechanism, iterator) else: raise LDAPAuthMethodNotSupported() - def do_bind(self, user, password): + def do_bind_simple(self, dn='', password=b''): + '''Do LDAP BIND with simple authentication + + :param dn: Distinguished name of object to be authenticated or empty + :type dn: str + :param password: Password, may be empty + :type password: bytes + + :returns: Bind object + :rtype: obj + + Delegates implementation to `do_bind_simple_anonymous`, `do_bind_simple_unauthenticated` + or `do_bind_simple_authenticated` according to RFC 4513.''' + if not dn and not password: + return self.do_bind_simple_anonymous() + if not password: + return self.do_bind_simple_unauthenticated(dn) + return self.do_bind_simple_unauthenticated(dn, password) + + def do_bind_simple_anonymous(self): + '''Do LDAP BIND with simple anonymous authentication (RFC 4513 5.1.1.) + + :raises LDAPError: if authentication failed + + :returns: Bind object on success + :rtype: obj + + Calld by `do_bind_simple()`. Always returns None.''' + return None + + def do_bind_simple_unauthenticated(self, dn): + '''Do LDAP BIND with simple unauthenticated authentication (RFC 4513 5.1.2.) + + :param dn: Distinguished name of the object to be authenticated + :type dn: str + + :raises LDAPError: if authentication failed + + :returns: Bind object on success + :rtype: obj + + Calld by `do_bind_simple()`. The default implementation always raises an + `LDAPInvalidCredentials` exception.''' + raise LDAPInvalidCredentials() + + def do_bind_simple_authenticated(self, dn, password): + '''Do LDAP BIND with simple name/password authentication (RFC 4513 5.1.3.) + + :param dn: Distinguished name of the object to be authenticated + :type dn: str + :param password: Password for object + :type dn: bytes + + :raises LDAPError: if authentication failed + + :returns: Bind object on success + :rtype: obj + + Calld by `do_bind_simple()`. The default implementation always raises an + `LDAPInvalidCredentials` exception.''' + raise LDAPInvalidCredentials() - def do_bind_sasl(self, mechanism, credentials): + def do_bind_sasl(self, mechanism, credentials=None, dn=None): + '''Do LDAP BIND with SASL authentication (RFC 4513 and 4422) + + :param mechanism: Name of the selected SASL mechanism + :type mechanism: str + :param credentials: Initial client response + :type credentials: bytes, optional + :param dn: Distinguished name in LDAP BIND request, should be ignored for + SASL authentication + :type dn: str, optional + + :returns: Bind object and final server challenge, only returns on success + :rtype: Tuple (obj, bytes/None) + + The call only returns if authentication succeeded. In any other case, + an appropriate `LDAPError` is raised. + + Some SASL methods require additional challenge-response round trips. These + can be achieved with the `yield` statement: + + > client_response = yield server_challenge + + Generally all server challenges and client responses can always be absent + (indicated by None), empty (empty bytes object) or consist of any number + of bytes. Whether a challenge or response may or must be absent or present + is defined by the individual SASL mechanism. + + IANA list of SASL mechansims: https://www.iana.org/assignments/sasl-mechanisms/sasl-mechanisms.xhtml + ''' + if not mechanism: + # Request to abort current negotiation (RFC4513 5.2.1.2) + raise LDAPAuthMethodNotSupported() + if mechanism == 'ANONYMOUS': + if credentials is not None: + credentials = credentials.decode() + return self.do_bind_sasl_anonymous(trace_info=credentials), None + if mechanism == 'PLAIN': + if credentials is None: + raise LDAPProtocolError('Unsupported protocol version') + authzid, authcid, password = credentials.split(b'\0', 2) + return do_bind_sasl_plain(authcid.decode(), password.decode(), authzid.decode() or None), None + if mechanism == 'EXTERNAL': + if credentials is not None: + credentials = credentials.decode() + return self.do_bind_sasl_external(authzid=credentials), None if mechanism == 'DIGEST-MD5': - return self.do_bind_sasl_digest_md5(mechanism, credentials) + return self.do_bind_sasl_digest_md5(credentials) + raise LDAPAuthMethodNotSupported() + + def do_bind_sasl_anonymous(self, trace_info=None): + '''Do LDAP BIND with SASL "ANONYMOUS" mechanism (RFC 4505) + + :param trace_info: Trace information, either an email address or an + opaque string that does not contain the '@' character + :type trace_info: str, optional + + :raises LDAPError: if authentication failed + + :returns: Bind object on success + :rtype: obj + + Calld by `do_bind_sasl()`. The default implementation raises an + `LDAPAuthMethodNotSupported` exception.''' + raise LDAPAuthMethodNotSupported() + + def do_bind_sasl_plain(self, identity, password, authzid=None): + '''Do LDAP BIND with SASL "PLAIN" mechanism (RFC 4616) + + :param identity: Authentication identity (authcid) + :type identity: str + :param password: Password (passwd) + :type password: str + :param authzid: Authorization identity + :type authzid: str, optional + + :raises LDAPError: if authentication failed + + :returns: Bind object on success + :rtype: obj + + Calld by `do_bind_sasl()`. The default implementation raises an + `LDAPAuthMethodNotSupported` exception.''' raise LDAPAuthMethodNotSupported() - def do_bind_sasl_digest_md5_password(self, username, realm): - return None, 'foo' + def do_bind_sasl_external(self, authzid=None): + '''Do LDAP BIND with SASL "EXTERNAL" mechanism (RFC 4422 and 4513) + + :param authzid: Authorization identity + :type authzid: str, optional + + :raises LDAPError: if authentication failed + + :returns: Bind object on success + :rtype: obj + + EXTERNAL is commonly used for TLS client certificate authentication or + system user based authentication on UNIX sockets. + + Calld by `do_bind_sasl()`. The default implementation raises an + `LDAPAuthMethodNotSupported` exception.''' + raise LDAPAuthMethodNotSupported() + + def do_bind_sasl_digest_md5_password(self, username, realm, authzid=None): # should return (bind_obj, password string) for username raise LDAPAuthMethodNotSupported() - def do_bind_sasl_digest_md5_pwdigest(self, username, realm, charset): + def do_bind_sasl_digest_md5_pwdigest(self, username, realm, charset='utf-8', authzid=None): # charset is either 'utf-8' or 'latin_1', it should only affect username and password - bind_obj, password = self.do_bind_sasl_digest_md5_password(username, realm) + bind_obj, password = self.do_bind_sasl_digest_md5_password(username, realm, authzid=authzid) ctx = hashlib.md5() ctx.update(username.encode(charset) + b':' + realm + b':' + password.encode(charset)) return bind_obj, ctx.digest() - def do_bind_sasl_digest_md5(self, mechanism, credentials): + def do_bind_sasl_digest_md5(self, credentials=None): + # Defined by RFC2831 and RFC2829, obsoleted by RFC6331 nonce = secrets.token_urlsafe(1024).encode() challenge = b'nonce="%s",charset="utf-8",algorithm="md5-sess"'%(nonce) resp = yield challenge @@ -149,7 +306,7 @@ class LDAPRequestHandler(BaseRequestHandler): expected_response = md5digest(key + b':' + data) if expected_response != response: raise LDAPInvalidCredentials() - # We don't support subsequent authentication so according to RFC 2829 the + # We don't support subsequent authentication so according to RFC2829 the # serverSaslCreds field in our response should be absent and we should # return (bind_obj, None). But this seems to confuse some clients (e.g. # openldap's ldapsearch) so we return serverSaslCreds with rspauth instead. @@ -166,6 +323,21 @@ class LDAPRequestHandler(BaseRequestHandler): self.send_msg(LDAPMessage(req.messageID, SearchResultDone(LDAPResultCode.success))) def do_search(self, baseobj, scope, filter): + '''Do LDAP SEARCH operation + + :param baseobj: Distinguished name of the LDAP entry relative to which the search is + to be performed + :type baseobj: str + :param scope: Search scope + :type scope: `SearchScope` + :param filter: Filter object + :type filter: `Filter` + + :raises LDAPError: on error + + :returns: Iterable of dn, attributes tuples + + The default implementation always returns an empty list.''' return [] def handle_unbind(self, req): @@ -242,17 +414,6 @@ class LDAPRequestHandler(BaseRequestHandler): AbandonRequest: (None, None), ExtendedRequest: (self.handle_extended, ExtendedResponse), } - - responses = { - BindRequest: BindResponse, - SearchRequest: SearchResultDone, - ModifyRequest: ModifyResponse, - AddRequest: AddResponse, - DelRequest: DelResponse, - ModifyDNRequest: ModifyDNResponse, - CompareRequest: CompareResponse, - ExtendedRequest: ExtendedResponse, - } handler, response = msgtypes.get(shallowmsg.protocolOpType, (None, None)) try: if handler is None: -- GitLab