From d64dc56d9dc3e9ba334147505b6dd998a05ae9a6 Mon Sep 17 00:00:00 2001 From: Ajit Awekar Date: Tue, 30 Jun 2026 17:51:09 +0530 Subject: [PATCH 5/5] Add LDAP account-existence check to credential validation For LDAP-authenticated sessions, re-run the configured search filter on each validation cycle and terminate the session once the account no longer exists in the directory. Search+bind only; simple-bind sessions are treated as valid. --- src/backend/libpq/auth-validate-methods.c | 37 ++++ src/backend/libpq/auth-validate.c | 2 + src/backend/libpq/auth.c | 173 ++++++++++++++++++ src/include/libpq/auth-validate.h | 1 + src/include/libpq/auth.h | 9 + src/test/ldap/meson.build | 1 + .../ldap/t/004_ldap_continuous_validation.pl | 138 ++++++++++++++ 7 files changed, 361 insertions(+) create mode 100644 src/test/ldap/t/004_ldap_continuous_validation.pl diff --git a/src/backend/libpq/auth-validate-methods.c b/src/backend/libpq/auth-validate-methods.c index 7039b3201b8..e8dc2eddf2c 100644 --- a/src/backend/libpq/auth-validate-methods.c +++ b/src/backend/libpq/auth-validate-methods.c @@ -20,6 +20,7 @@ #include "postgres.h" #include "catalog/pg_authid.h" +#include "libpq/auth.h" #include "libpq/auth-validate-methods.h" #include "libpq/auth-validate.h" #include "libpq/libpq-be.h" @@ -32,6 +33,7 @@ static bool validate_oauth_credentials(void); static bool validate_cert_credentials(void); static bool validate_gss_credentials(void); +static bool validate_ldap_credentials(void); /* * Initialize validation methods @@ -48,6 +50,7 @@ InitializeValidationMethods(void) RegisterCredentialValidator(CVT_OAUTH, validate_oauth_credentials); RegisterCredentialValidator(CVT_CERT, validate_cert_credentials); RegisterCredentialValidator(CVT_GSS, validate_gss_credentials); + RegisterCredentialValidator(CVT_LDAP, validate_ldap_credentials); } /* @@ -167,3 +170,37 @@ validate_gss_credentials(void) return true; } + +/* + * Validate LDAP credentials. + * + * Unlike OAuth/cert/GSS, an LDAP-authenticated session retains no credential + * with an intrinsic expiry: the password presented at connection time is used + * for the bind and then discarded. What we can re-check is whether the + * account still exists (and still satisfies the configured search filter) in + * the directory. This is delegated to CheckLDAPCredentialValidity(), which + * re-binds with the configured search credentials and re-runs the search + * filter; see the comment there for the details and limitations. + * + * Note that this check, by its nature, performs a network round-trip to the + * LDAP server on every validation cycle, so a larger + * credential_validation_interval is advisable for LDAP-authenticated sessions. + * + * Returns false if the user is gone from the directory, true otherwise. + */ +static bool +validate_ldap_credentials(void) +{ +#ifdef USE_LDAP + Port *port = MyProcPort; + + if (port == NULL) + return true; + + /* The session is no longer valid once the LDAP account disappears */ + if (!CheckLDAPCredentialValidity(port)) + return false; +#endif + + return true; +} diff --git a/src/backend/libpq/auth-validate.c b/src/backend/libpq/auth-validate.c index e39a33b71a9..2ccd03066c0 100644 --- a/src/backend/libpq/auth-validate.c +++ b/src/backend/libpq/auth-validate.c @@ -50,6 +50,8 @@ UserAuthToValidationType(UserAuth auth_method) return CVT_CERT; case uaGSS: return CVT_GSS; + case uaLDAP: + return CVT_LDAP; default: /* * No method-specific validator for other auth methods. Password diff --git a/src/backend/libpq/auth.c b/src/backend/libpq/auth.c index 2af5615e54a..fe166320536 100644 --- a/src/backend/libpq/auth.c +++ b/src/backend/libpq/auth.c @@ -146,6 +146,7 @@ static int CheckBSDAuth(Port *port, char *user); #endif static int CheckLDAPAuth(Port *port); +static void save_ldap_validation_params(Port *port); /* LDAP_OPT_DIAGNOSTIC_MESSAGE is the newer spelling */ #ifndef LDAP_OPT_DIAGNOSTIC_MESSAGE @@ -156,6 +157,15 @@ static int CheckLDAPAuth(Port *port); static char *dummy_ldap_password_mutator(char *input); auth_password_hook_typ ldap_password_hook = dummy_ldap_password_mutator; +/* + * Session-lifetime snapshot of the pg_hba LDAP settings, taken at + * authentication time and used later by CheckLDAPCredentialValidity(). The + * HbaLine that port->hba points at lives in PostmasterContext, which is + * destroyed before the backend enters its main command loop, so the settings + * needed to re-validate the session must be copied somewhere longer-lived. + */ +static HbaLine *ldap_validation_hba = NULL; + #endif /* USE_LDAP */ /*---------------------------------------------------------------- @@ -2662,6 +2672,12 @@ CheckLDAPAuth(Port *port) /* Save the original bind DN as the authenticated identity. */ set_authn_id(port, fulluser); + /* + * Snapshot the LDAP settings while port->hba is still valid, so that + * continuous credential validation can re-check the account later. + */ + save_ldap_validation_params(port); + ldap_unbind(ldap); pfree(passwd); pfree(fulluser); @@ -2669,6 +2685,163 @@ CheckLDAPAuth(Port *port) return STATUS_OK; } +/* + * Copy the LDAP-related pg_hba settings needed for credential re-validation + * into TopMemoryContext, where they survive for the life of the session. See + * the comment on ldap_validation_hba above for why this is necessary. + */ +static void +save_ldap_validation_params(Port *port) +{ + HbaLine *src = port->hba; + HbaLine *dst; + + dst = (HbaLine *) MemoryContextAllocZero(TopMemoryContext, sizeof(HbaLine)); + + dst->ldaptls = src->ldaptls; + dst->ldapport = src->ldapport; + dst->ldapscope = src->ldapscope; + +#define LDAP_SAVE_STR(field) \ + dst->field = src->field ? MemoryContextStrdup(TopMemoryContext, src->field) : NULL + + LDAP_SAVE_STR(ldapscheme); + LDAP_SAVE_STR(ldapserver); + LDAP_SAVE_STR(ldapbinddn); + LDAP_SAVE_STR(ldapbindpasswd); + LDAP_SAVE_STR(ldapsearchattribute); + LDAP_SAVE_STR(ldapsearchfilter); + LDAP_SAVE_STR(ldapbasedn); + +#undef LDAP_SAVE_STR + + ldap_validation_hba = dst; +} + +/* + * Re-validate an LDAP-authenticated session for continuous credential + * validation. + * + * An LDAP session keeps no credential with an intrinsic expiry (the user's + * password is discarded right after the bind), so "still valid" is defined + * here as: the user still exists in the directory and still satisfies the + * configured search filter. We re-bind with the configured search + * credentials (ldapbinddn / ldapbindpasswd) and re-run the same filter that + * was used at authentication time: + * + * - exactly one matching entry -> still valid + * - no matching entry -> the account has been deleted, moved out of + * the base DN, or no longer matches the + * filter (e.g. a filter that excludes + * disabled accounts) -> no longer valid + * + * This requires search+bind mode (ldapbasedn set): in simple-bind mode there + * are no retained credentials to bind with, so there is nothing to re-check + * and the session is treated as valid. Likewise, any operational failure + * (cannot connect, bind fails, search errors, ambiguous result) is treated + * conservatively as "still valid", so that a transient directory or network + * problem does not tear down established sessions; only a definitive "user is + * gone" result (zero entries) terminates the session. + * + * Returns true if the session should be considered still valid, false if the + * LDAP account is gone. + */ +bool +CheckLDAPCredentialValidity(Port *port) +{ + HbaLine *hba = ldap_validation_hba; + Port vport; + LDAP *ldap; + int r; + char *filter; + LDAPMessage *search_message = NULL; + char *attributes[] = {LDAP_NO_ATTRS, NULL}; + int count; + char *c; + + /* + * Only search+bind mode can be re-validated; simple bind (ldapprefix / + * ldapsuffix) retains no credentials with which to query the directory. + * A missing snapshot means this is not a search+bind LDAP session. + */ + if (hba == NULL || hba->ldapbasedn == NULL) + return true; + + /* + * As in CheckLDAPAuth, refuse user names that could inject filter syntax. + * Such a name could not have authenticated in the first place, so just + * treat it as still valid rather than risk a malformed search. + */ + for (c = port->user_name; *c; c++) + { + if (*c == '*' || *c == '(' || *c == ')' || *c == '\\' || *c == '/') + return true; + } + + /* + * InitializeLDAPConnection() reads its settings from port->hba, but the + * real port->hba has been freed by now (see ldap_validation_hba). Point a + * throwaway Port at our session-lifetime snapshot instead. + */ + vport = *port; + vport.hba = hba; + + if (InitializeLDAPConnection(&vport, &ldap) == STATUS_ERROR) + return true; /* error already logged; fail open */ + + /* Bind with the search credentials (anonymous if none configured). */ + r = ldap_simple_bind_s(ldap, + hba->ldapbinddn ? hba->ldapbinddn : "", + hba->ldapbindpasswd ? ldap_password_hook(hba->ldapbindpasswd) : ""); + if (r != LDAP_SUCCESS) + { + ereport(LOG, + (errmsg("could not perform LDAP bind for credential validation of ldapbinddn \"%s\": %s", + hba->ldapbinddn ? hba->ldapbinddn : "", + ldap_err2string(r)), + errdetail_for_ldap(ldap))); + ldap_unbind(ldap); + return true; + } + + /* Build the same filter that was used at authentication time. */ + if (hba->ldapsearchfilter) + filter = FormatSearchFilter(hba->ldapsearchfilter, port->user_name); + else if (hba->ldapsearchattribute) + filter = psprintf("(%s=%s)", hba->ldapsearchattribute, port->user_name); + else + filter = psprintf("(uid=%s)", port->user_name); + + r = ldap_search_s(ldap, hba->ldapbasedn, hba->ldapscope, + filter, attributes, 0, &search_message); + + if (r != LDAP_SUCCESS) + { + ereport(LOG, + (errmsg("could not search LDAP for filter \"%s\" during credential validation: %s", + filter, ldap_err2string(r)), + errdetail_for_ldap(ldap))); + if (search_message != NULL) + ldap_msgfree(search_message); + ldap_unbind(ldap); + pfree(filter); + return true; /* operational error; fail open */ + } + + count = ldap_count_entries(ldap, search_message); + + ldap_msgfree(search_message); + ldap_unbind(ldap); + pfree(filter); + + /* + * Zero entries is a definitive "user is gone"; terminate the session. An + * ambiguous (>1) result should not happen for an account that + * authenticated earlier, so treat it conservatively as still valid. + */ + return (count != 0); +} + /* * Add a detail error message text to the current error if one can be * constructed from the LDAP 'diagnostic message'. diff --git a/src/include/libpq/auth-validate.h b/src/include/libpq/auth-validate.h index 5a0e1df4dcf..aaea4a28e8e 100644 --- a/src/include/libpq/auth-validate.h +++ b/src/include/libpq/auth-validate.h @@ -22,6 +22,7 @@ typedef enum CredentialValidationType CVT_OAUTH = 0, /* OAuth bearer token authentication */ CVT_CERT, /* TLS client certificate authentication */ CVT_GSS, /* GSSAPI/Kerberos authentication */ + CVT_LDAP, /* LDAP authentication */ CVT_COUNT /* Total number of credential validation types */ } CredentialValidationType; diff --git a/src/include/libpq/auth.h b/src/include/libpq/auth.h index be23c4ca3e4..faa75e35202 100644 --- a/src/include/libpq/auth.h +++ b/src/include/libpq/auth.h @@ -51,4 +51,13 @@ typedef char *(*auth_password_hook_typ) (char *input); /* Default LDAP password mutator hook, can be overridden by a shared library */ extern PGDLLIMPORT auth_password_hook_typ ldap_password_hook; +#ifdef USE_LDAP +/* + * Re-check that an LDAP-authenticated session's account is still valid in the + * directory. Used by continuous credential validation. Returns true if the + * session is still considered valid, false if the account is gone. + */ +extern bool CheckLDAPCredentialValidity(Port *port); +#endif + #endif /* AUTH_H */ diff --git a/src/test/ldap/meson.build b/src/test/ldap/meson.build index d8961e6c8d7..bdad7cb8409 100644 --- a/src/test/ldap/meson.build +++ b/src/test/ldap/meson.build @@ -9,6 +9,7 @@ tests += { 't/001_auth.pl', 't/002_bindpasswd.pl', 't/003_ldap_connection_param_lookup.pl', + 't/004_ldap_continuous_validation.pl', ], 'env': { 'with_ldap': ldap.found() ? 'yes' : 'no', diff --git a/src/test/ldap/t/004_ldap_continuous_validation.pl b/src/test/ldap/t/004_ldap_continuous_validation.pl new file mode 100644 index 00000000000..6cce03cb043 --- /dev/null +++ b/src/test/ldap/t/004_ldap_continuous_validation.pl @@ -0,0 +1,138 @@ + +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +# Test continuous credential validation for LDAP-authenticated sessions. +# +# Unlike OAuth/cert/GSS, an LDAP session retains no credential with an +# intrinsic expiry: the password presented at connection time is used for the +# bind and then discarded. With credential_validation_enabled, the backend +# should instead periodically re-check that the authenticated account still +# exists in the directory (and still matches the configured search filter), and +# terminate the session once it is gone. +# +# This test (search+bind mode) opens an LDAP-authenticated session, deletes the +# user's entry from the directory while the session sits idle, and then +# verifies that the next command is rejected with a FATAL "session credentials +# have expired". +# +# Like 001_auth.pl this requires a full OpenLDAP installation; see the README +# for details. + +use strict; +use warnings FATAL => 'all'; + +use FindBin; +use lib "$FindBin::RealBin/.."; + +use LdapServer; +use PostgreSQL::Test::Utils; +use PostgreSQL::Test::Cluster; +use Test::More; + +if ($ENV{with_ldap} ne 'yes') +{ + plan skip_all => 'LDAP not supported by this build'; +} +elsif (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\bldap\b/) +{ + plan skip_all => + 'Potentially unsafe test LDAP not enabled in PG_TEST_EXTRA'; +} +elsif (!$LdapServer::setup) +{ + plan skip_all => $LdapServer::setup_error; +} + +# Timing parameters. credential_validation_interval has a 5s minimum, so sit +# idle comfortably longer than that (and longer than the interval) to be sure +# the validation timer has fired before the next command is issued. +my $validation_interval = 5; +my $idle_seconds = 12; + +my $username = 'test1'; +my $user_dn = "uid=$username,dc=example,dc=net"; + +note "setting up LDAP server"; + +my $ldap_rootpw = 'secret'; +my $ldap = LdapServer->new($ldap_rootpw, 'anonymous'); # use anonymous auth +$ldap->ldapadd_file('authdata.ldif'); +$ldap->ldapsetpw($user_dn, 'secret1'); + +my ($ldap_server, $ldap_port, $ldap_basedn, $ldap_rootdn, $ldap_pwfile, + $ldap_url) + = $ldap->prop(qw(server port basedn rootdn pwfile url)); + +note "setting up PostgreSQL instance"; + +my $node = PostgreSQL::Test::Cluster->new('node'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq{ +listen_addresses = '127.0.0.1' +log_connections = all +log_min_messages = debug2 +lc_messages = 'C' +credential_validation_enabled = on +credential_validation_interval = $validation_interval +}); +$node->start; + +$node->safe_psql('postgres', 'CREATE USER test1;'); + +unlink($node->data_dir . '/pg_hba.conf'); +$node->append_conf('pg_hba.conf', + qq{host all all 127.0.0.1/32 ldap ldapserver=$ldap_server ldapport=$ldap_port ldapbasedn="$ldap_basedn" ldapsearchfilter="(uid=\$username)"} +); +$node->restart; + +note "running tests"; + +# Open an LDAP-authenticated session, confirm it is live, then delete the +# user's directory entry and sit idle past the validation interval before +# issuing another command. +# +# The deletion and the idle wait are done from a client-side "\!" shell step, +# so the backend waits at a command boundary -- exactly where the validation +# timer's pending flag is acted upon when the next command arrives. By then +# the account is gone, so the re-search finds no entry and the session is +# terminated. +my $connstr = + $node->connstr('postgres') . " user=$username host=127.0.0.1 sslmode=disable"; + +my $delete_and_wait = + "ldapdelete -x -H $ldap_url -D '$ldap_rootdn' -y $ldap_pwfile '$user_dn' && sleep $idle_seconds"; + +my $script = + "SELECT 'session-live';\n" + . "\\! $delete_and_wait\n" + . "SELECT 'after-expiry';\n"; + +local $ENV{PGPASSWORD} = 'secret1'; + +my ($ret, $stdout, $stderr) = $node->psql( + 'postgres', $script, + connstr => $connstr, + extra_params => ['-w']); + +isnt($ret, 0, 'LDAP session is terminated once the account is gone'); +like( + $stdout, + qr/session-live/, + 'session is live before the LDAP account is removed'); +unlike( + $stdout, + qr/after-expiry/, + 'command after the account is removed does not return a result'); +like( + $stderr, + qr/session credentials have expired/, + 'session terminated with the credential-expiry FATAL'); + +# The session must have actually authenticated via LDAP for this to be a +# meaningful test of LDAP credential validation. +ok( $node->log_contains( + qq{connection authenticated: identity="$user_dn" method=ldap}), + 'session was authenticated with LDAP'); + +done_testing(); -- 2.52.0