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