From cb31a261878a7b7e839c9503418831cf23f3be08 Mon Sep 17 00:00:00 2001 From: Nathan Bossart Date: Mon, 2 Feb 2026 10:07:05 -0600 Subject: [PATCH v13 1/1] Add password expiration warnings. This commit adds a new parameter called parameter_expiration_warning_threshold that controls when the server begins emitting imminent-password-expiration warnings upon successful password authentication. By default, this parameter is set to 7 days, but this functionality can be disabled by setting it to 0. This patch also introduces a new "connection warning" infrastructure that can be reused elsewhere. For example, we may want to emit warnings about the use of MD5 passwords for a couple of releases before removing MD5 password support. Author: Gilles Darold Reviewed-by: Japin Li Reviewed-by: songjinzhou Reviewed-by: liu xiaohui Reviewed-by: Yuefei Shi Reviewed-by: Steven Niu Reviewed-by: Soumya S Murali Reviewed-by: Euler Taveira Reviewed-by: Zsolt Parragi Discussion: https://postgr.es/m/129bcfbf-47a6-e58a-190a-62fc21a17d03%40migops.com --- doc/src/sgml/config.sgml | 22 ++++++ src/backend/libpq/crypt.c | 57 +++++++++++++-- src/backend/utils/init/postinit.c | 72 +++++++++++++++++++ src/backend/utils/misc/guc_parameters.dat | 10 +++ src/backend/utils/misc/postgresql.conf.sample | 3 +- src/include/libpq/crypt.h | 3 + src/include/libpq/libpq-be.h | 9 +++ src/test/authentication/t/001_password.pl | 34 +++++++++ 8 files changed, 204 insertions(+), 6 deletions(-) diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml index 5560b95ee60..5b9ab15bc92 100644 --- a/doc/src/sgml/config.sgml +++ b/doc/src/sgml/config.sgml @@ -1157,6 +1157,28 @@ include_dir 'conf.d' + + password_expiration_warning_threshold (integer) + + password_expiration_warning_threshold configuration parameter + + + + + When this parameter is greater than zero, the server will emit a + WARNING upon successful password authentication if + less than this amount of time remains until the authenticated role's + password expires. Note that a role's password only expires if a date + was specified in a VALID UNTIL clause for + CREATE ROLE or ALTER ROLE. If + this value is specified without units, it is taken as minutes. The + default is 7 days. This parameter can only set in the + postgresql.conf file or on the server command + line. + + + + md5_password_warnings (boolean) diff --git a/src/backend/libpq/crypt.c b/src/backend/libpq/crypt.c index 52722060451..3d86eb9a245 100644 --- a/src/backend/libpq/crypt.c +++ b/src/backend/libpq/crypt.c @@ -19,11 +19,16 @@ #include "common/md5.h" #include "common/scram-common.h" #include "libpq/crypt.h" +#include "libpq/libpq-be.h" #include "libpq/scram.h" #include "utils/builtins.h" +#include "utils/memutils.h" #include "utils/syscache.h" #include "utils/timestamp.h" +/* Time before password expiration warnings. */ +int password_expiration_warning_threshold = 10080; + /* Enables deprecation warnings for MD5 passwords. */ bool md5_password_warnings = true; @@ -71,13 +76,55 @@ get_role_password(const char *role, const char **logdetail) ReleaseSysCache(roleTup); /* - * Password OK, but check to be sure we are not past rolvaliduntil + * Password OK, but check to be sure we are not past rolvaliduntil or + * password_expiration_warning_threshold. */ - if (!isnull && vuntil < GetCurrentTimestamp()) + if (!isnull) { - *logdetail = psprintf(_("User \"%s\" has an expired password."), - role); - return NULL; + TimestampTz expire_time = vuntil - GetCurrentTimestamp(); + + /* + * If we're past rolvaliduntil, the connection attempt should fail, so + * update logdetail and return NULL. + */ + if (expire_time < 0) + { + *logdetail = psprintf(_("User \"%s\" has an expired password."), + role); + return NULL; + } + + /* + * If we're past the warning threshold, the connection attempt should + * succeed, but we still want to emit a warning. To do so, we queue + * the warning message using StoreConnectionWarning() so that it will + * be emitted at the end of InitPostgres(), and we return normally. + */ + if (expire_time / USECS_PER_MINUTE < password_expiration_warning_threshold) + { + MemoryContext oldcontext; + TimestampTz days; + TimestampTz hours; + TimestampTz minutes; + char *warning; + char *detail; + + oldcontext = MemoryContextSwitchTo(TopMemoryContext); + + days = expire_time / USECS_PER_DAY; + hours = (expire_time % USECS_PER_DAY) / USECS_PER_HOUR; + minutes = (expire_time % USECS_PER_HOUR) / USECS_PER_MINUTE; + + warning = pstrdup(_("role password will expire soon")); + detail = psprintf(_("The password for role \"%s\" will expire in " + INT64_FORMAT " day(s), " INT64_FORMAT + " hour(s), " INT64_FORMAT " minute(s)."), + role, days, hours, minutes); + + StoreConnectionWarning(warning, detail); + + MemoryContextSwitchTo(oldcontext); + } } return shadow_pass; diff --git a/src/backend/utils/init/postinit.c b/src/backend/utils/init/postinit.c index 3f401faf3de..f9a963c0a47 100644 --- a/src/backend/utils/init/postinit.c +++ b/src/backend/utils/init/postinit.c @@ -70,6 +70,8 @@ #include "utils/syscache.h" #include "utils/timeout.h" +static bool ConnectionWarningsEmitted = false; + static HeapTuple GetDatabaseTuple(const char *dbname); static HeapTuple GetDatabaseTupleByOid(Oid dboid); static void PerformAuthentication(Port *port); @@ -85,6 +87,7 @@ static void ClientCheckTimeoutHandler(void); static bool ThereIsAtLeastOneRole(void); static void process_startup_options(Port *port, bool am_superuser); static void process_settings(Oid databaseid, Oid roleid); +static void EmitConnectionWarnings(void); /*** InitPostgres support ***/ @@ -1232,6 +1235,9 @@ InitPostgres(const char *in_dbname, Oid dboid, /* close the transaction we started above */ if (!bootstrap) CommitTransactionCommand(); + + /* send any WARNINGs we've accumulated during initialization */ + EmitConnectionWarnings(); } /* @@ -1446,3 +1452,69 @@ ThereIsAtLeastOneRole(void) return result; } + +/* + * Stores a warning message to be sent at the end of InitPostgres(). "detail" + * can be NULL, but "msg" cannot. + * + * NB: Caller should ensure the strings are allocated in a long-lived context + * like TopMemoryContext. + */ +void +StoreConnectionWarning(char *msg, char *detail) +{ + MemoryContext oldcontext; + + Assert(msg); + + if (ConnectionWarningsEmitted) + elog(ERROR, "StoreConnectionWarning() called after EmitConnectionWarnings()"); + + oldcontext = MemoryContextSwitchTo(TopMemoryContext); + + MyProcPort->warning_msgs = lappend(MyProcPort->warning_msgs, msg); + MyProcPort->warning_details = lappend(MyProcPort->warning_details, detail); + + MemoryContextSwitchTo(oldcontext); +} + +/* + * Sends the warning messages saved for the end of InitPostgres() and frees the + * strings and lists. + * + * NB: This can only be called once per backend. + */ +static void +EmitConnectionWarnings(void) +{ + ListCell *lc_msg; + ListCell *lc_detail; + + if (ConnectionWarningsEmitted) + elog(ERROR, "EmitConnectionWarnings() called more than once"); + else + ConnectionWarningsEmitted = true; + + if (MyProcPort == NULL) + return; + + forboth(lc_msg, MyProcPort->warning_msgs, + lc_detail, MyProcPort->warning_details) + { + char *msg = (char *) lfirst(lc_msg); + char *detail = (char *) lfirst(lc_detail); + + ereport(WARNING, + (errmsg("%s", msg), + detail ? errdetail("%s", detail) : 0)); + + pfree(msg); + if (detail) + pfree(detail); + } + + list_free(MyProcPort->warning_msgs); + list_free(MyProcPort->warning_details); + MyProcPort->warning_msgs = NIL; + MyProcPort->warning_details = NIL; +} diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat index f0260e6e412..6add1f37b4e 100644 --- a/src/backend/utils/misc/guc_parameters.dat +++ b/src/backend/utils/misc/guc_parameters.dat @@ -2242,6 +2242,16 @@ options => 'password_encryption_options', }, +{ name => 'password_expiration_warning_threshold', type => 'int', context => 'PGC_SIGHUP', group => 'CONN_AUTH_AUTH', + short_desc => 'Time before password expiration warnings.', + long_desc => '0 means not to emit these warnings.', + flags => 'GUC_UNIT_MIN', + variable => 'password_expiration_warning_threshold', + boot_val => '10080', + min => '0', + max => 'INT_MAX', +}, + { name => 'plan_cache_mode', type => 'enum', context => 'PGC_USERSET', group => 'QUERY_TUNING_OTHER', short_desc => 'Controls the planner\'s selection of custom or generic plan.', long_desc => 'Prepared statements can have custom and generic plans, and the planner will attempt to choose which is better. This can be set to override the default behavior.', diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample index c4f92fcdac8..6575a37405c 100644 --- a/src/backend/utils/misc/postgresql.conf.sample +++ b/src/backend/utils/misc/postgresql.conf.sample @@ -96,7 +96,8 @@ #authentication_timeout = 1min # 1s-600s #password_encryption = scram-sha-256 # scram-sha-256 or (deprecated) md5 #scram_iterations = 4096 -#md5_password_warnings = on # display md5 deprecation warnings? +#password_expiration_warning_threshold = 7d # time before expiration warnings +#md5_password_warnings = on # display md5 deprecation warnings? #oauth_validator_libraries = '' # comma-separated list of trusted validator modules # GSSAPI using Kerberos diff --git a/src/include/libpq/crypt.h b/src/include/libpq/crypt.h index f01886e1098..081817972d5 100644 --- a/src/include/libpq/crypt.h +++ b/src/include/libpq/crypt.h @@ -25,6 +25,9 @@ */ #define MAX_ENCRYPTED_PASSWORD_LEN (512) +/* Time before password expiration warnings. */ +extern PGDLLIMPORT int password_expiration_warning_threshold; + /* Enables deprecation warnings for MD5 passwords. */ extern PGDLLIMPORT bool md5_password_warnings; diff --git a/src/include/libpq/libpq-be.h b/src/include/libpq/libpq-be.h index 921b2daa4ff..fa382261fb1 100644 --- a/src/include/libpq/libpq-be.h +++ b/src/include/libpq/libpq-be.h @@ -238,6 +238,13 @@ typedef struct Port char *raw_buf; ssize_t raw_buf_consumed, raw_buf_remaining; + + /* + * Content of warning messages to send to the client upon successful + * authentication. + */ + List *warning_msgs; + List *warning_details; } Port; /* @@ -367,4 +374,6 @@ extern int pq_setkeepalivesinterval(int interval, Port *port); extern int pq_setkeepalivescount(int count, Port *port); extern int pq_settcpusertimeout(int timeout, Port *port); +extern void StoreConnectionWarning(char *msg, char *detail); + #endif /* LIBPQ_BE_H */ diff --git a/src/test/authentication/t/001_password.pl b/src/test/authentication/t/001_password.pl index f4d65ba7bae..0ec9aa9f4e8 100644 --- a/src/test/authentication/t/001_password.pl +++ b/src/test/authentication/t/001_password.pl @@ -68,8 +68,24 @@ $node->init; $node->append_conf('postgresql.conf', "log_connections = on\n"); # Needed to allow connect_fails to inspect postmaster log: $node->append_conf('postgresql.conf', "log_min_messages = debug2"); +$node->append_conf('postgresql.conf', "password_expiration_warning_threshold = '1100d'"); $node->start; +# Set up roles for password_expiration_warning_threshold test +my $current_year = 1900 + ${ [ localtime(time) ] }[5]; +my $expire_year = $current_year - 1; +$node->safe_psql( + 'postgres', + "CREATE ROLE expired LOGIN VALID UNTIL '$expire_year-01-01' PASSWORD 'pass'"); +$expire_year = $current_year + 2; +$node->safe_psql( + 'postgres', + "CREATE ROLE expiration_warnings LOGIN VALID UNTIL '$expire_year-01-01' PASSWORD 'pass'"); +$expire_year = $current_year + 5; +$node->safe_psql( + 'postgres', + "CREATE ROLE no_warnings LOGIN VALID UNTIL '$expire_year-01-01' PASSWORD 'pass'"); + # Test behavior of log_connections GUC # # There wasn't another test file where these tests obviously fit, and we don't @@ -531,6 +547,24 @@ $node->connect_fails( qr/authentication method requirement "!password,!md5,!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication/ ); +# Test password_expiration_warning_threshold +$node->connect_fails( + "user=expired dbname=postgres", + "connection fails due to expired password", + expected_stderr => + qr/password authentication failed for user "expired"/ +); +$node->connect_ok( + "user=expiration_warnings dbname=postgres", + "connection succeeds with password expiration warning", + expected_stderr => + qr/role password will expire soon/ +); +$node->connect_ok( + "user=no_warnings dbname=postgres", + "connection succeeds with no password expiration warning" +); + # Test SYSTEM_USER <> NULL with parallel workers. $node->safe_psql( 'postgres', -- 2.50.1 (Apple Git-155)