From 2435882d82cc91f0b4ac73bf6149fbacf08a547c Mon Sep 17 00:00:00 2001
From: Thomas Munro <thomas.munro@gmail.com>
Date: Sat, 31 Dec 2022 14:41:57 +1300
Subject: [PATCH v2 1/4] Add simple test for RADIUS authentication.

This is similar to the existing tests for other authentication methods.
It requires FreeRADIUS to be installed, and opens ports that may be
considered insecure, so users have to opt in with PG_EXTRA_TESTS=radius.

Reviewed-by: Heikki Linnakangas <hlinnaka@iki.fi>
Reviewed-by: Michael Paquier <michael@paquier.xyz>
Discussion: https://postgr.es/m/CA%2BhUKGLRSPTOC_ygx4_sJjWeKOkOpWGCBCJiRq8cPNuMisuzgw%40mail.gmail.com
---
 doc/src/sgml/regress.sgml               |  11 ++
 src/test/authentication/README          |  12 ++
 src/test/authentication/meson.build     |   1 +
 src/test/authentication/t/007_radius.pl | 187 ++++++++++++++++++++++++
 4 files changed, 211 insertions(+)
 create mode 100644 src/test/authentication/t/007_radius.pl

diff --git a/doc/src/sgml/regress.sgml b/doc/src/sgml/regress.sgml
index d1042e02228..b9dc28b8dea 100644
--- a/doc/src/sgml/regress.sgml
+++ b/doc/src/sgml/regress.sgml
@@ -284,6 +284,17 @@ make check-world PG_TEST_EXTRA='kerberos ldap ssl load_balance libpq_encryption'
      </listitem>
     </varlistentry>
 
+    <varlistentry>
+     <term><literal>radius</literal></term>
+     <listitem>
+      <para>
+       Runs the test <filename>src/test/authentication/t/007_radius.pl</filename>.
+       This requires a <productname>FreeRADIUS</productname> installation and
+       opens UDP listen sockets.
+      </para>
+     </listitem>
+    </varlistentry>
+
     <varlistentry>
      <term><literal>ssl</literal></term>
      <listitem>
diff --git a/src/test/authentication/README b/src/test/authentication/README
index 602280a0713..13d7a7f4d84 100644
--- a/src/test/authentication/README
+++ b/src/test/authentication/README
@@ -26,3 +26,15 @@ Either way, this test initializes, starts, and stops a test Postgres
 cluster.
 
 See src/test/perl/README for more info about running these tests.
+
+Requirements
+============
+
+The RADIUS test is skipped unless "radius" is listed in PG_TEXT_EXTRA, because
+it requires a FreeRADIUS installation and opens extra ports.  FreeRADIUS can be
+installed with:
+
+Debian: apt-get install freeradius
+Homebrew: brew install freeradius-server
+FreeBSD: pkg install freeradius3
+MacPorts: port install freeradius
diff --git a/src/test/authentication/meson.build b/src/test/authentication/meson.build
index 8f5688dcc13..59da3e64169 100644
--- a/src/test/authentication/meson.build
+++ b/src/test/authentication/meson.build
@@ -12,6 +12,7 @@ tests += {
       't/004_file_inclusion.pl',
       't/005_sspi.pl',
       't/006_login_trigger.pl',
+      't/007_radius.pl',
     ],
   },
 }
diff --git a/src/test/authentication/t/007_radius.pl b/src/test/authentication/t/007_radius.pl
new file mode 100644
index 00000000000..c683d1d5579
--- /dev/null
+++ b/src/test/authentication/t/007_radius.pl
@@ -0,0 +1,187 @@
+
+# Copyright (c) 2021-2024, PostgreSQL Global Development Group
+
+use strict;
+use warnings;
+use PostgreSQL::Test::Utils;
+use PostgreSQL::Test::Cluster;
+use Test::More;
+
+my $radiusd_dir = "${PostgreSQL::Test::Utils::tmp_check}/radiusd_data";
+my $radiusd_conf = "radiusd.conf";
+my $radiusd_users = "users.txt";
+my $radiusd_prefix;
+my $radiusd;
+
+if ($ENV{PG_TEST_EXTRA} !~ /\bradius\b/)
+{
+	plan skip_all => 'radius not enabled in PG_TEST_EXTRA';
+}
+elsif ($^O eq 'freebsd')
+{
+	$radiusd = '/usr/local/sbin/radiusd';
+}
+elsif ($^O eq 'linux' && -f '/usr/sbin/freeradius')
+{
+	$radiusd = '/usr/sbin/freeradius';
+}
+elsif ($^O eq 'linux')
+{
+	$radiusd = '/usr/sbin/radiusd';
+}
+elsif ($^O eq 'darwin' && -d '/opt/local')
+{
+	# typical path for MacPorts
+	$radiusd = '/opt/local/sbin/radiusd';
+	$radiusd_prefix = '/opt/local';
+}
+elsif ($^O eq 'darwin' && -d '/opt/homebrew')
+{
+	# typical path for Homebrew on ARM
+	$radiusd = '/opt/homebrew/bin/radiusd';
+	$radiusd_prefix = '/opt/homebrew';
+}
+elsif ($^O eq 'darwin' && -d '/usr/local')
+{
+	# typical path for Homebrew on Intel
+	$radiusd = '/usr/local/bin/radiusd';
+	$radiusd_prefix = '/usr/local';
+}
+
+if (!defined($radiusd) || ! -f $radiusd)
+{
+	plan skip_all =>
+	  "radius tests not supported on $^O or dependencies not installed";
+}
+
+sub make_random_string
+{
+	my $length = 8 + int(rand(16));
+	my $s = "";
+	$s .= chr(ord("A") + rand(26)) for 1..$length;
+	return $s;
+}
+
+my $shared_secret = make_random_string();
+my $password = make_random_string();
+
+note "setting up radiusd";
+
+my $radius_port = PostgreSQL::Test::Cluster::get_free_port();
+
+mkdir $radiusd_dir or die "cannot create $radiusd_dir";
+
+append_to_file("$radiusd_dir/$radiusd_users",
+	qq{test2 Cleartext-Password := "$password"});
+
+my $conf = qq{
+client default {
+  ipaddr = "127.0.0.1"
+  secret = "$shared_secret"
+}
+
+modules {
+  files {
+    filename = "$radiusd_dir/users.txt"
+  }
+  pap {
+  }
+}
+
+server default {
+  listen {
+    type   = "auth"
+    ipv4addr = "127.0.0.1"
+    port = "$radius_port"
+  }
+  authenticate {
+    Auth-Type PAP {
+      pap
+    }
+  }
+  authorize {
+    files
+    pap
+  }
+}
+
+log {
+  destination = "files"
+  localstatedir = "$radiusd_dir"
+  logdir = "$radiusd_dir"
+  file = "$radiusd_dir/radius.log"
+}
+
+pidfile = "$radiusd_dir/radiusd.pid"
+};
+
+if ($radiusd_prefix)
+{
+	$conf .= "prefix=\"$radiusd_prefix\"\n";
+}
+
+append_to_file("$radiusd_dir/$radiusd_conf", $conf);
+
+system_or_bail $radiusd, '-xx', '-d', $radiusd_dir;
+
+END
+{
+	kill 'INT', `cat $radiusd_dir/radiusd.pid`
+	  if -f "$radiusd_dir/radiusd.pid";
+}
+
+note "setting up PostgreSQL instance";
+
+my $node = PostgreSQL::Test::Cluster->new('node');
+$node->init;
+$node->append_conf('postgresql.conf', "log_connections = on\n");
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE USER test1;');
+$node->safe_psql('postgres', 'CREATE USER test2;');
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf',
+	qq{
+local all test2 radius radiusservers="127.0.0.1" radiussecrets="$shared_secret" radiusports="$radius_port"
+}
+);
+$node->restart;
+
+note "running tests";
+
+sub test_access
+{
+	local $Test::Builder::Level = $Test::Builder::Level + 1;
+
+	my ($node, $role, $expected_res, $test_name, %params) = @_;
+	my $connstr = "user=$role";
+
+	if ($expected_res eq 0)
+	{
+		$node->connect_ok($connstr, $test_name, %params);
+	}
+	else
+	{
+		# No checks of the error message, only the status code.
+		$node->connect_fails($connstr, $test_name, %params);
+	}
+}
+
+$ENV{"PGPASSWORD"} = 'wrong';
+test_access(
+	$node, 'test1', 2,
+	'authentication fails if user not found in RADIUS',
+	log_unlike => [qr/connection authenticated:/]);
+test_access(
+	$node, 'test2', 2,
+	'authentication fails with wrong password',
+	log_unlike => [qr/connection authenticated:/]);
+
+$ENV{"PGPASSWORD"} = $password;
+test_access($node, 'test2', 0, 'authentication succeeds with right password',
+	log_like =>
+	  [qr/connection authenticated: identity="test2" method=radius/],);
+
+done_testing();
-- 
2.46.0

