From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id BC3A51FF39B for ; Tue, 7 May 2024 10:48:14 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 06C0C9CFE; Tue, 7 May 2024 10:48:18 +0200 (CEST) From: Markus Frank To: pmg-devel@lists.proxmox.com Date: Tue, 7 May 2024 10:47:41 +0200 Message-Id: <20240507084745.8025-4-m.frank@proxmox.com> X-Mailer: git-send-email 2.39.2 In-Reply-To: <20240507084745.8025-1-m.frank@proxmox.com> References: <20240507084745.8025-1-m.frank@proxmox.com> MIME-Version: 1.0 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.029 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: [pmg-devel] [PATCH pmg-api v2 3/7] config: add plugin system for realms & add openid type realms X-BeenThere: pmg-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Mail Gateway development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pmg-devel-bounces@lists.proxmox.com Sender: "pmg-devel" To differentiate between usernames, the realm is also stored in the user.conf file. Old config file syntax can be read, but will be overwritten after a change. Utils generates a list of valid realm names, including any newly added realms, to ensure proper validation of a specified realm name. Signed-off-by: Markus Frank --- src/Makefile | 3 + src/PMG/AccessControl.pm | 31 ++++++ src/PMG/Auth/OIDC.pm | 99 +++++++++++++++++++ src/PMG/Auth/PMG.pm | 28 ++++++ src/PMG/Auth/Plugin.pm | 193 +++++++++++++++++++++++++++++++++++++ src/PMG/RESTEnvironment.pm | 14 +++ src/PMG/UserConfig.pm | 25 +++-- src/PMG/Utils.pm | 29 ++++-- 8 files changed, 406 insertions(+), 16 deletions(-) create mode 100755 src/PMG/Auth/OIDC.pm create mode 100755 src/PMG/Auth/PMG.pm create mode 100755 src/PMG/Auth/Plugin.pm diff --git a/src/Makefile b/src/Makefile index 8e49a10..4140698 100644 --- a/src/Makefile +++ b/src/Makefile @@ -164,6 +164,9 @@ LIBSOURCES = \ PMG/API2/ACMEPlugin.pm \ PMG/API2/NodeConfig.pm \ PMG/API2.pm \ + PMG/Auth/Plugin.pm \ + PMG/Auth/OIDC.pm \ + PMG/Auth/PMG.pm \ SOURCES = ${LIBSOURCES} ${CLI_BINARIES} ${TEMPLATES_FILES} ${CONF_MANS} ${CLI_MANS} ${SERVICE_MANS} ${SERVICE_UNITS} ${TIMER_UNITS} pmg-sources.list pmg-initramfs.conf diff --git a/src/PMG/AccessControl.pm b/src/PMG/AccessControl.pm index e12d7cf..b8c6cd9 100644 --- a/src/PMG/AccessControl.pm +++ b/src/PMG/AccessControl.pm @@ -13,6 +13,14 @@ use PMG::LDAPConfig; use PMG::LDAPSet; use PMG::TFAConfig; +use PMG::Auth::Plugin; +use PMG::Auth::OIDC; +use PMG::Auth::PMG; + +PMG::Auth::OIDC->register(); +PMG::Auth::PMG->register(); +PMG::Auth::Plugin->init(); + sub normalize_path { my $path = shift; @@ -38,6 +46,7 @@ sub authenticate_user : prototype($$$) { ($username, $ruid, $realm) = PMG::Utils::verify_username($username); + my $realm_regex = PMG::Utils::valid_pmg_realm_regex(); if ($realm eq 'pam') { die "invalid pam user (only root allowed)\n" if $ruid ne 'root'; authenticate_pam_user($ruid, $password); @@ -53,6 +62,8 @@ sub authenticate_user : prototype($$$) { return ($pmail . '@quarantine', undef); } die "ldap login failed\n"; + } elsif ($realm =~ m!(${realm_regex})!) { + # nothing to do for self-defined realms } else { die "no such realm '$realm'\n"; } @@ -79,6 +90,7 @@ sub set_user_password { ($username, $ruid, $realm) = PMG::Utils::verify_username($username); + my $realm_regex = PMG::Utils::valid_pmg_realm_regex(); if ($realm eq 'pam') { die "invalid pam user (only root allowed)\n" if $ruid ne 'root'; @@ -92,6 +104,8 @@ sub set_user_password { } elsif ($realm eq 'pmg') { PMG::UserConfig->set_user_password($username, $password); + } elsif ($realm =~ m!(${realm_regex})!) { + # nothing to do for self-defined realms } else { die "no such realm '$realm'\n"; } @@ -106,6 +120,7 @@ sub check_user_enabled { ($username, $ruid, $realm) = PMG::Utils::verify_username($username, 1); + my $realm_regex = PMG::Utils::valid_pmg_realm_regex(); if ($realm && $ruid) { if ($realm eq 'pam') { return 'root' if $ruid eq 'root'; @@ -115,6 +130,10 @@ sub check_user_enabled { return $data->{role} if $data && $data->{enable}; } elsif ($realm eq 'quarantine') { return 'quser'; + } elsif ($realm =~ m!(${realm_regex})!) { + my $usercfg = PMG::UserConfig->new(); + my $data = $usercfg->lookup_user_data($username, $noerr); + return $data->{role} if $data && $data->{enable}; } } @@ -123,6 +142,18 @@ sub check_user_enabled { return undef; } +sub check_user_exist { + my ($usercfg, $username, $noerr) = @_; + + $username = PMG::Utils::verify_username($username, $noerr); + return undef if !$username; + return $usercfg->{$username} if $usercfg && $usercfg->{$username}; + + die "no such user ('$username')\n" if !$noerr; + + return undef; +} + sub authenticate_pam_user { my ($username, $password) = @_; diff --git a/src/PMG/Auth/OIDC.pm b/src/PMG/Auth/OIDC.pm new file mode 100755 index 0000000..3bb758b --- /dev/null +++ b/src/PMG/Auth/OIDC.pm @@ -0,0 +1,99 @@ +package PMG::Auth::OIDC; + +use strict; +use warnings; + +use PVE::Tools; +use PMG::Auth::Plugin; + +use base qw(PMG::Auth::Plugin); + +sub type { + return 'oidc'; +} + +sub properties { + return { + 'issuer-url' => { + description => "OpenID Connect Issuer Url", + type => 'string', + maxLength => 256, + }, + 'client-id' => { + description => "OpenID Connect Client ID", + type => 'string', + maxLength => 256, + }, + 'client-key' => { + description => "OpenID Connect Client Key", + type => 'string', + optional => 1, + maxLength => 256, + }, + autocreate => { + description => "Automatically create users if they do not exist.", + optional => 1, + type => 'boolean', + default => 0, + }, + 'username-claim' => { + description => "OpenID Connect claim used to generate the unique username.", + type => 'string', + optional => 1, + }, + prompt => { + description => "Specifies whether the Authorization Server prompts the End-User for" + ." reauthentication and consent.", + type => 'string', + pattern => '(?:none|login|consent|select_account|\S+)', # \S+ is the extension variant + optional => 1, + }, + scopes => { + description => "Specifies the scopes (user details) that should be authorized and" + ." returned, for example 'email' or 'profile'.", + type => 'string', # format => 'some-safe-id-list', # FIXME: TODO + default => "email profile", + optional => 1, + }, + 'acr-values' => { + description => "Specifies the Authentication Context Class Reference values that the" + ."Authorization Server is being requested to use for the Auth Request.", + type => 'string', # format => 'some-safe-id-list', # FIXME: TODO + optional => 1, + }, + default => { + description => "Use this as default realm", + type => 'boolean', + optional => 1, + }, + comment => { + description => "Description.", + type => 'string', + optional => 1, + maxLength => 4096, + }, + }; +} + +sub options { + return { + 'issuer-url' => {}, + 'client-id' => {}, + 'client-key' => { optional => 1 }, + autocreate => { optional => 1 }, + 'username-claim' => { optional => 1, fixed => 1 }, + prompt => { optional => 1 }, + scopes => { optional => 1 }, + 'acr-values' => { optional => 1 }, + default => { optional => 1 }, + comment => { optional => 1 }, + }; +} + +sub authenticate_user { + my ($class, $config, $realm, $username, $password) = @_; + + die "OpenID realm does not allow password verification.\n"; +} + +1; diff --git a/src/PMG/Auth/PMG.pm b/src/PMG/Auth/PMG.pm new file mode 100755 index 0000000..0eb136c --- /dev/null +++ b/src/PMG/Auth/PMG.pm @@ -0,0 +1,28 @@ +package PMG::Auth::PMG; + +use strict; +use warnings; + +use PMG::Auth::Plugin; + +use base qw(PMG::Auth::Plugin); + +sub type { + return 'pmg'; +} + +sub properties { + return { + tfa => PVE::JSONSchema::get_standard_option('tfa'), + }; +} + +sub options { + return { + default => { optional => 1 }, + comment => { optional => 1 }, + tfa => { optional => 1 }, + }; +} + +1; diff --git a/src/PMG/Auth/Plugin.pm b/src/PMG/Auth/Plugin.pm new file mode 100755 index 0000000..dc88aff --- /dev/null +++ b/src/PMG/Auth/Plugin.pm @@ -0,0 +1,193 @@ +package PMG::Auth::Plugin; + +use strict; +use warnings; + +use Digest::SHA; +use Encode; + +use PMG::Utils; +use PVE::INotify; +use PVE::JSONSchema qw(get_standard_option); +use PVE::Schema::Auth; +use PVE::SectionConfig; +use PVE::Tools; + +use base qw(PVE::SectionConfig); + +my $domainconfigfile = "realms.cfg"; +my $lockfile = "/var/lock/realms.lck"; + +sub read_realms_conf { + my ($filename, $fh) = @_; + + my $raw; + $raw = do { local $/ = undef; <$fh> } if defined($fh); + + return PMG::Auth::Plugin->parse_config($filename, $raw); +} + +sub write_realms_conf { + my ($filename, $fh, $cfg) = @_; + + my $raw = PMG::Auth::Plugin->write_config($filename, $cfg); + + PVE::Tools::safe_print($filename, $fh, $raw); +} + +PVE::INotify::register_file( + $domainconfigfile, + "/etc/pmg/realms.cfg", + \&read_realms_conf, + \&write_realms_conf, + undef, + always_call_parser => 1, +); + +sub lock_domain_config { + my ($code, $errmsg) = @_; + + PVE::Tools::lock_file($lockfile, undef, $code); + if (my $err = $@) { + $errmsg ? die "$errmsg: $err" : die $err; + } +} + +my $realm_regex = qr/[A-Za-z][A-Za-z0-9\.\-_]+/; + +sub pmg_verify_realm { + my ($realm, $noerr) = @_; + + if ($realm !~ m/^${realm_regex}$/) { + return undef if $noerr; + die "value does not look like a valid realm\n"; + } + return $realm; +} + +my $defaultData = { + propertyList => { + type => { description => "Realm type." }, + realm => get_standard_option('realm'), + }, +}; + +sub private { + return $defaultData; +} + +sub parse_section_header { + my ($class, $line) = @_; + + if ($line =~ m/^(\S+):\s*(\S+)\s*$/) { + my ($type, $realm) = (lc($1), $2); + my $errmsg = undef; # set if you want to skip whole section + eval { pmg_verify_realm($realm); }; + $errmsg = $@ if $@; + my $config = {}; # to return additional attributes + return ($type, $realm, $errmsg, $config); + } + return undef; +} + +sub parse_config { + my ($class, $filename, $raw) = @_; + + my $cfg = $class->SUPER::parse_config($filename, $raw); + + my $default; + foreach my $realm (keys %{$cfg->{ids}}) { + my $data = $cfg->{ids}->{$realm}; + # make sure there is only one default marker + if ($data->{default}) { + if ($default) { + delete $data->{default}; + } else { + $default = $realm; + } + } + + if ($data->{comment}) { + $data->{comment} = PVE::Tools::decode_text($data->{comment}); + } + + } + + # add default domains + $cfg->{ids}->{pmg}->{type} = 'pmg'; # force type + $cfg->{ids}->{pmg}->{comment} = "Proxmox Mail Gateway authentication server" + if !$cfg->{ids}->{pmg}->{comment}; + + return $cfg; +}; + +sub write_config { + my ($class, $filename, $cfg) = @_; + + foreach my $realm (keys %{$cfg->{ids}}) { + my $data = $cfg->{ids}->{$realm}; + if ($data->{comment}) { + $data->{comment} = PVE::Tools::encode_text($data->{comment}); + } + } + + $class->SUPER::write_config($filename, $cfg); +} + +sub authenticate_user { + my ($class, $config, $realm, $username, $password) = @_; + + die "overwrite me"; +} + +sub store_password { + my ($class, $config, $realm, $username, $password) = @_; + + my $type = $class->type(); + + die "can't set password on auth type '$type'\n"; +} + +sub delete_user { + my ($class, $config, $realm, $username) = @_; + + # do nothing by default +} + +# called during addition of realm (before the new domain config got written) +# `password` is moved to %param to avoid writing it out to the config +# die to abort addition if there are (grave) problems +# NOTE: runs in a domain config *locked* context +sub on_add_hook { + my ($class, $realm, $config, %param) = @_; + # do nothing by default +} + +# called during domain configuration update (before the updated domain config got +# written). `password` is moved to %param to avoid writing it out to the config +# die to abort the update if there are (grave) problems +# NOTE: runs in a domain config *locked* context +sub on_update_hook { + my ($class, $realm, $config, %param) = @_; + # do nothing by default +} + +# called during deletion of realms (before the new domain config got written) +# and if the activate check on addition fails, to cleanup all storage traces +# which on_add_hook may have created. +# die to abort deletion if there are (very grave) problems +# NOTE: runs in a domain config *locked* context +sub on_delete_hook { + my ($class, $realm, $config) = @_; + # do nothing by default +} + +# called during addition and updates of realms (before the new domain config gets written) +# die to abort addition/update in case the connection/bind fails +# NOTE: runs in a domain config *locked* context +sub check_connection { + my ($class, $realm, $config, %param) = @_; + # do nothing by default +} + +1; diff --git a/src/PMG/RESTEnvironment.pm b/src/PMG/RESTEnvironment.pm index 3875720..f6ff449 100644 --- a/src/PMG/RESTEnvironment.pm +++ b/src/PMG/RESTEnvironment.pm @@ -88,6 +88,20 @@ sub get_role { return $self->{role}; } +sub check_user_enabled { + my ($self, $user, $noerr) = @_; + + my $cfg = $self->{usercfg}; + return PMG::AccessControl::check_user_enabled($cfg, $user, $noerr); +} + +sub check_user_exist { + my ($self, $user, $noerr) = @_; + + my $cfg = $self->{usercfg}; + return PMG::AccessControl::check_user_exist($cfg, $user, $noerr); +} + sub check_node_is_master { my ($self, $noerr); diff --git a/src/PMG/UserConfig.pm b/src/PMG/UserConfig.pm index b9a83a7..fe6d2c8 100644 --- a/src/PMG/UserConfig.pm +++ b/src/PMG/UserConfig.pm @@ -80,7 +80,7 @@ my $schema = { realm => { description => "Authentication realm.", type => 'string', - enum => ['pam', 'pmg'], + format => 'pmg-realm', default => 'pmg', optional => 1, }, @@ -219,10 +219,13 @@ sub read_user_conf { (?(?:[^:]*)) : $/x ) { + my @username_parts = split('@', $+{userid}); + my $username = $username_parts[0]; + my $realm = defined($username_parts[1]) ? $username_parts[1] : "pmg"; my $d = { - username => $+{userid}, - userid => $+{userid} . '@pmg', - realm => 'pmg', + username => $username, + userid => $username . '@' . $realm, + realm => $realm, enable => $+{enable} || 0, expire => $+{expire} || 0, role => $+{role}, @@ -235,8 +238,9 @@ sub read_user_conf { eval { $verify_entry->($d); $cfg->{$d->{userid}} = $d; - die "role 'root' is reserved\n" - if $d->{role} eq 'root' && $d->{userid} ne 'root@pmg'; + if ($d->{role} eq 'root' && $d->{userid} !~ /^root@(pmg|pam)$/) { + die "role 'root' is reserved\n"; + } }; if (my $err = $@) { warn "$filename: $err"; @@ -274,22 +278,23 @@ sub write_user_conf { $verify_entry->($d); $cfg->{$d->{userid}} = $d; + my $realm_regex = PMG::Utils::valid_pmg_realm_regex(); if ($d->{userid} ne 'root@pam') { die "role 'root' is reserved\n" if $d->{role} eq 'root'; die "unable to add users for realm '$d->{realm}'\n" - if $d->{realm} && $d->{realm} ne 'pmg'; + if $d->{realm} && $d->{realm} !~ m!(${realm_regex})!; } my $line; if ($userid eq 'root@pam') { - $line = 'root:'; + $line = 'root@pam:'; $d->{crypt_pass} = '', $d->{expire} = '0', $d->{role} = 'root'; } else { - next if $userid !~ m/^(?.+)\@pmg$/; - $line = "$+{username}:"; + next if $userid !~ m/^(?.+)\@(${realm_regex})$/; + $line = "$d->{userid}:"; } for my $k (qw(enable expire crypt_pass role email firstname lastname keys)) { diff --git a/src/PMG/Utils.pm b/src/PMG/Utils.pm index 5d9ded4..844eb23 100644 --- a/src/PMG/Utils.pm +++ b/src/PMG/Utils.pm @@ -49,12 +49,30 @@ postgres_admin_cmd try_decode_utf8 ); -my $valid_pmg_realms = ['pam', 'pmg', 'quarantine']; +my $user_regex = qr![^\s:/]+!; + +sub valid_pmg_realm_regex { + my $cfg = PVE::INotify::read_file('realms.cfg'); + my $ids = $cfg->{ids}; + my $realms = ['pam', 'quarantine', sort keys $cfg->{ids}->%* ]; + return join('|', @$realms); +} + +sub is_valid_realm { + my ($realm) = @_; + return 0 if !$realm; + return 1 if $realm eq 'pam' || $realm eq 'quarantine'; # built-in ones + + my $cfg = PVE::INotify::read_file('realms.cfg'); + return exists($cfg->{ids}->{$realm}) ? 1 : 0; +} + +PVE::JSONSchema::register_format('pmg-realm', \&is_valid_realm); PVE::JSONSchema::register_standard_option('realm', { description => "Authentication domain ID", type => 'string', - enum => $valid_pmg_realms, + format => 'pmg-realm', maxLength => 32, }); @@ -82,16 +100,15 @@ sub verify_username { die "user name '$username' is too short\n" if !$noerr; return undef; } - if ($len > 64) { - die "user name '$username' is too long ($len > 64)\n" if !$noerr; + if ($len > 128) { + die "user name '$username' is too long ($len > 128)\n" if !$noerr; return undef; } # we only allow a limited set of characters. Colons aren't allowed, because we store usernames # with colon separated lists! slashes aren't allowed because it is used as pve API delimiter # also see "man useradd" - my $realm_list = join('|', @$valid_pmg_realms); - if ($username =~ m!^([^\s:/]+)\@(${realm_list})$!) { + if ($username =~ m!^(${user_regex})\@([A-Za-z][A-Za-z0-9\.\-_]+)$!) { return wantarray ? ($username, $1, $2) : $username; } -- 2.39.2 _______________________________________________ pmg-devel mailing list pmg-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pmg-devel