From 2f3c8959274a53fb6027cd666c5e942c6ab68ed2 Mon Sep 17 00:00:00 2001
From: Jacob Champion <pchampion@vmware.com>
Date: Mon, 13 Dec 2021 15:39:16 -0800
Subject: [PATCH 4/4] ldapmap: implement SASL binding

Add the "ldapsaslmechs" HBA option, which tells the ldapmap query to use
SASL binding (instead of simple username/password binding) and specifies
the SASL mechanism list that is accepted. This allows the DBA to further
lock down the LDAP connection and even get rid of bind passwords
altogether.

This new feature is gated on the existence of the <sasl/sasl.h> header,
which we need in order to implement the SASL callback API. (No new
runtime dependencies are needed.)

The currently tested mechanisms are

- EXTERNAL, which authenticates using a TLS client certificate, and

- SCRAM-SHA-1, which performs mutual password authentication without
  ever sending the password over the connection. (Other SCRAM-*
  mechanisms should work too but I haven't tested them.)

TODOs:

- This seems like it would be useful for the ldap auth method, too.

- I reuse ldapbinddn for the SASL auth name, but it's probably not
  actually a DN in practice (and it's certainly not a DN for the test
  case I provided). Maybe make a new HBA option?
---
 configure                   |  12 +++
 configure.ac                |   1 +
 src/backend/libpq/hba.c     | 165 +++++++++++++++++++++++++++++++++---
 src/include/libpq/hba.h     |   1 +
 src/include/pg_config.h.in  |   3 +
 src/test/ldap/authdata.ldif |   7 ++
 src/test/ldap/t/001_auth.pl |  35 +++++++-
 7 files changed, 210 insertions(+), 14 deletions(-)

diff --git a/configure b/configure
index 3b19105328..e5ac7f441d 100755
--- a/configure
+++ b/configure
@@ -13993,6 +13993,18 @@ else
   as_fn_error $? "header file <ldap.h> is required for LDAP" "$LINENO" 5
 fi
 
+done
+
+     for ac_header in sasl/sasl.h
+do :
+  ac_fn_c_check_header_mongrel "$LINENO" "sasl/sasl.h" "ac_cv_header_sasl_sasl_h" "$ac_includes_default"
+if test "x$ac_cv_header_sasl_sasl_h" = xyes; then :
+  cat >>confdefs.h <<_ACEOF
+#define HAVE_SASL_SASL_H 1
+_ACEOF
+
+fi
+
 done
 
      { $as_echo "$as_me:${as_lineno-$LINENO}: checking for compatible LDAP implementation" >&5
diff --git a/configure.ac b/configure.ac
index e77d4dcf2d..c1f0048b0f 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1523,6 +1523,7 @@ if test "$with_ldap" = yes ; then
   if test "$PORTNAME" != "win32"; then
      AC_CHECK_HEADERS(ldap.h, [],
                       [AC_MSG_ERROR([header file <ldap.h> is required for LDAP])])
+     AC_CHECK_HEADERS(sasl/sasl.h)
      PGAC_LDAP_SAFE
   else
      AC_CHECK_HEADERS(winldap.h, [],
diff --git a/src/backend/libpq/hba.c b/src/backend/libpq/hba.c
index 57440107be..7516a9681e 100644
--- a/src/backend/libpq/hba.c
+++ b/src/backend/libpq/hba.c
@@ -51,6 +51,9 @@
 #else
 #define LDAP_DEPRECATED 1
 #include <ldap.h>
+#if HAVE_SASL_SASL_H
+#include <sasl/sasl.h> /* header-only dependency for sasl_interact_t */
+#endif
 #endif
 #endif
 
@@ -1712,6 +1715,10 @@ parse_hba_line(TokenizedLine *tok_line, int elevel)
 		{
 			badopt = "ldapbindpasswd";
 		}
+		else if (parsedline->ldapsaslmechs)
+		{
+			badopt = "ldapsaslmechs";
+		}
 
 		if (badopt)
 		{
@@ -2004,6 +2011,10 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 	{
 		hbaline->ldapbindpasswd = pstrdup(val);
 	}
+	else if (strcmp(name, "ldapsaslmechs") == 0)
+	{
+		hbaline->ldapsaslmechs = pstrdup(val);
+	}
 	else if (strcmp(name, "ldapsearchattribute") == 0)
 	{
 		REQUIRE_AUTH_OPTION(uaLDAP, "ldapsearchattribute", "ldap");
@@ -3273,6 +3284,146 @@ errdetail_for_ldap(LDAP *ldap)
 	return 0;
 }
 
+#if HAVE_SASL_SASL_H
+
+struct sasl_ctx
+{
+	const HbaLine  *hba;
+};
+
+/*
+ * Callback for ldap_sasl_interactive_bind_s(). libsasl asks us for various
+ * authentication parameters, and we fill them in.
+ */
+static int
+sasl_interaction(LDAP *ldap, unsigned flags, void *vctx, void *sasl_interact)
+{
+	struct sasl_ctx *ctx = vctx;
+	sasl_interact_t *prompt = sasl_interact;
+
+	while (true)
+	{
+		switch (prompt->id)
+		{
+			case SASL_CB_LIST_END:
+				return LDAP_SUCCESS;
+
+			case SASL_CB_USER:
+				/*
+				 * This is the authzid; we leave it empty/default.
+				 */
+				prompt->result = prompt->defresult;
+				break;
+
+			case SASL_CB_AUTHNAME:
+				/*
+				 * The username for the authentication; this is our bind DN.
+				 */
+				if (!ctx->hba->ldapbinddn)
+				{
+					ereport(LOG,
+							errmsg("SASL mechanism requires ldapbinddn"));
+					return LDAP_LOCAL_ERROR;
+				}
+
+				prompt->result = ctx->hba->ldapbinddn;
+				prompt->len = strlen(prompt->result);
+
+				break;
+
+			case SASL_CB_PASS:
+				/*
+				 * The password.
+				 */
+				if (!ctx->hba->ldapbindpasswd)
+				{
+					ereport(LOG,
+							errmsg("SASL mechanism requires ldapbindpasswd"));
+					return LDAP_LOCAL_ERROR;
+				}
+
+				prompt->result = ctx->hba->ldapbindpasswd;
+				prompt->len = strlen(prompt->result);
+
+				break;
+
+			default:
+				ereport(LOG,
+						errmsg("SASL interaction type 0x%lX (%s) is unimplemented",
+							   prompt->id, prompt->challenge));
+				return LDAP_LOCAL_ERROR;
+		}
+
+		++prompt;
+	}
+
+	/* unreachable */
+	return LDAP_LOCAL_ERROR;
+}
+
+#endif /* HAVE_SASL_SASL_H */
+
+/*
+ * Performs either a simple or a SASL bind over the LDAP connection, depending
+ * on the HBA settings.
+ */
+static bool
+bind_ldap(const HbaLine *hba, LDAP *ldap, const char *server_name)
+{
+	int			rc;
+#if HAVE_SASL_SASL_H
+	struct sasl_ctx ctx = {0};
+#endif
+
+	if (!(hba->ldapsaslmechs && hba->ldapsaslmechs[0]))
+	{
+		/* Use a simple bind. */
+		rc = ldap_simple_bind_s(ldap,
+								hba->ldapbinddn ? hba->ldapbinddn : "",
+								hba->ldapbindpasswd ? hba->ldapbindpasswd : "");
+		if (rc != LDAP_SUCCESS)
+		{
+			ereport(LOG,
+					(errmsg("could not perform initial LDAP bind for ldapbinddn \"%s\" on server \"%s\": %s",
+							hba->ldapbinddn ? hba->ldapbinddn : "",
+							server_name,
+							ldap_err2string(rc)),
+					 errdetail_for_ldap(ldap)));
+		}
+
+		return (rc == LDAP_SUCCESS);
+	}
+
+#if HAVE_SASL_SASL_H
+	/* DBA has asked for a SASL bind. */
+	ctx.hba = hba;
+
+	rc = ldap_sasl_interactive_bind_s(ldap,
+									  NULL, /* DN is ignored for SASL */
+									  hba->ldapsaslmechs,
+									  NULL, NULL, /* server/client controls */
+									  LDAP_SASL_QUIET, /* don't prompt */
+									  sasl_interaction,
+									  &ctx);
+	if (rc != LDAP_SUCCESS)
+	{
+		ereport(LOG,
+				(errmsg("could not perform SASL bind on server \"%s\": %s",
+						server_name,
+						ldap_err2string(rc)),
+				 errdetail_for_ldap(ldap)));
+	}
+
+	return (rc == LDAP_SUCCESS);
+
+#else
+	ereport(LOG,
+			(errmsg("this build does not support LDAP SASL binding")));
+	return false;
+
+#endif /* HAVE_SASL_SASL_H */
+}
+
 /*
  * Returns a palloc'd list of pointers to role names returned by the LDAP query
  * contained in ldapurl. Callers must list_free_deep() the return value even if
@@ -3339,19 +3490,9 @@ query_ldap_roles(const Port *port, LDAPURLDesc *ldapurl, List **roles)
 		}
 	}
 
-	rc = ldap_simple_bind_s(ldap,
-							port->hba->ldapbinddn ? port->hba->ldapbinddn : "",
-							port->hba->ldapbindpasswd ? port->hba->ldapbindpasswd : "");
-	if (rc != LDAP_SUCCESS)
-	{
-		ereport(LOG,
-				(errmsg("could not perform initial LDAP bind for ldapbinddn \"%s\" on server \"%s\": %s",
-						port->hba->ldapbinddn ? port->hba->ldapbinddn : "",
-						server,
-						ldap_err2string(rc)),
-				 errdetail_for_ldap(ldap)));
+	/* Bind using our HBA settings. */
+	if (!bind_ldap(port->hba, ldap, server))
 		goto cleanup;
-	}
 
 	rc = ldap_search_s(ldap,
 					   ldapurl->lud_dn,
diff --git a/src/include/libpq/hba.h b/src/include/libpq/hba.h
index a9c709f9b8..c701cb72a7 100644
--- a/src/include/libpq/hba.h
+++ b/src/include/libpq/hba.h
@@ -101,6 +101,7 @@ typedef struct HbaLine
 	int			ldapport;
 	char	   *ldapbinddn;
 	char	   *ldapbindpasswd;
+	char	   *ldapsaslmechs;
 	char	   *ldapsearchattribute;
 	char	   *ldapsearchfilter;
 	char	   *ldapbasedn;
diff --git a/src/include/pg_config.h.in b/src/include/pg_config.h.in
index 7525c16597..5479b2d952 100644
--- a/src/include/pg_config.h.in
+++ b/src/include/pg_config.h.in
@@ -488,6 +488,9 @@
 /* Define to 1 if you have the `rl_variable_bind' function. */
 #undef HAVE_RL_VARIABLE_BIND
 
+/* Define to 1 if you have the <sasl/sasl.h> header file. */
+#undef HAVE_SASL_SASL_H
+
 /* Define to 1 if you have the <security/pam_appl.h> header file. */
 #undef HAVE_SECURITY_PAM_APPL_H
 
diff --git a/src/test/ldap/authdata.ldif b/src/test/ldap/authdata.ldif
index a3e296f3e2..0a32042d85 100644
--- a/src/test/ldap/authdata.ldif
+++ b/src/test/ldap/authdata.ldif
@@ -33,3 +33,10 @@ homeDirectory: /home/test2
 mail: test2@example.net
 postgresRole: test0
 postgresRole: test2@example.net
+
+# For SCRAM auth only; the rootpw should take care of the rest.
+dn: cn=Manager,dc=example,dc=net
+objectClass: inetOrgPerson
+uid: Manager
+sn: Lastname
+cn: Manager
diff --git a/src/test/ldap/t/001_auth.pl b/src/test/ldap/t/001_auth.pl
index 6467a6c4af..c296c746a1 100644
--- a/src/test/ldap/t/001_auth.pl
+++ b/src/test/ldap/t/001_auth.pl
@@ -9,7 +9,7 @@ use Test::More;
 
 if ($ENV{with_ldap} eq 'yes')
 {
-	plan tests => 56;
+	plan tests => 61;
 }
 else
 {
@@ -192,7 +192,7 @@ $node->safe_psql('postgres', 'CREATE USER test0;');
 $node->safe_psql('postgres', 'CREATE USER test1;');
 $node->safe_psql('postgres', 'CREATE USER "test2@example.net";');
 
-my @databases = ( 'anon', 'noattrs', 'badmap', 'starttls', 'bindpw' );
+my @databases = ( 'anon', 'noattrs', 'badmap', 'starttls', 'bindpw', 'bindscram', 'bindcert' );
 foreach my $db (@databases)
 {
 	$node->safe_psql('postgres', "CREATE DATABASE $db");
@@ -445,6 +445,8 @@ hostssl  noattrs   all   all      cert    ldapmap=noattrs
 hostssl  badmap    all   all      cert    ldapmap=badmap
 hostssl  starttls  all   all      cert    ldapmap=ldap ldaptls=1
 hostssl  bindpw    all   all      cert    ldapmap=ldap ldaptls=1 ldapbinddn="$ldap_rootdn" ldapbindpasswd="$ldap_rootpw"
+hostssl  bindscram all   all      cert    ldapmap=ldap ldaptls=1 ldapsaslmechs=scram-sha-1 ldapbinddn=Manager ldapbindpasswd="$ldap_rootpw"
+hostssl  bindcert  all   all      cert    ldapmap=ldap ldaptls=1 ldapsaslmechs=external
 });
 
 unlink($node->data_dir . '/pg_ident.conf');
@@ -570,6 +572,31 @@ $node->connect_ok(
 	"$common_connstr dbname=bindpw user=test0",
 	"ldapmap works with bind password");
 
+note 'LDAP ident mapping with SCRAM binding';
+
+$node->connect_fails(
+	"$common_connstr dbname=bindscram user=test0",
+	"ldapmap can't perform SCRAM authentication without server setup",
+	log_like => [
+		qr/could not perform SASL bind on server .*: Invalid credentials/,
+		qr/user not found: no secret in database/,
+	]);
+
+# Map the SCRAM-specific authentication DN to our root user.
+append_to_file(
+	$slapd_conf,
+	qq{
+authz-regexp
+	uid=Manager,cn=SCRAM-SHA-1,cn=auth
+	cn=Manager,dc=example,dc=net
+});
+
+restart_slapd($ldaps_url);
+
+$node->connect_ok(
+	"$common_connstr dbname=bindscram user=test0",
+	"ldapmap works with SCRAM authentication to LDAP server");
+
 note 'LDAP ident mapping with client certificate';
 
 # Set up a certificate for the root user.
@@ -609,5 +636,9 @@ $node->connect_ok(
 	"$common_connstr dbname=bindpw user=test0",
 	"ldapmap works with bind certificate");
 
+$node->connect_ok(
+	"$common_connstr dbname=bindcert user=test0",
+	"ldapmap works with client certificate authentication");
+
 note 'LDAP group ident mapping';
 # TODO
-- 
2.25.1

