From ec937a5af1440b56c0c51bf4149324955b2ec469 Mon Sep 17 00:00:00 2001 From: Philipp Mundhenk Date: Sun, 14 Jan 2024 14:39:23 +0100 Subject: [PATCH 1/3] implemented ProxyAuth --- plugins/proxy-auth/LICENSE | 20 ++++ plugins/proxy-auth/index.php | 175 +++++++++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 plugins/proxy-auth/LICENSE create mode 100644 plugins/proxy-auth/index.php diff --git a/plugins/proxy-auth/LICENSE b/plugins/proxy-auth/LICENSE new file mode 100644 index 000000000..172df18fd --- /dev/null +++ b/plugins/proxy-auth/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2023 Philipp Mundhenk + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/plugins/proxy-auth/index.php b/plugins/proxy-auth/index.php new file mode 100644 index 000000000..1d8bbfe79 --- /dev/null +++ b/plugins/proxy-auth/index.php @@ -0,0 +1,175 @@ +addPartHook('ProxyAuth', 'ServiceProxyAuth'); + $this->addHook('login.credentials', 'MapEmailAddress'); + } + + /* by https://gist.github.com/tott/7684443 */ + /** + * Check if a given ip is in a network + * @param string $ip IP to check in IPV4 format eg. 127.0.0.1 + * @param string $range IP/CIDR netmask eg. 127.0.0.0/24, also 127.0.0.1 is accepted and /32 assumed + * @return boolean true if the ip is in this range / false if not. + */ + private function ip_in_range( $ip, $range ) { + if ( strpos( $range, '/' ) == false ) { + $range .= '/32'; + } + // $range is in IP/CIDR format eg 127.0.0.1/24 + list( $range, $netmask ) = explode( '/', $range, 2 ); + $range_decimal = ip2long( $range ); + $ip_decimal = ip2long( $ip ); + $wildcard_decimal = pow( 2, ( 32 - $netmask ) ) - 1; + $netmask_decimal = ~ $wildcard_decimal; + return ( ( $ip_decimal & $netmask_decimal ) == ( $range_decimal & $netmask_decimal ) ); + } + + public function MapEmailAddress(string &$sEmail, string &$sLogin, string &$sPassword) + { + $oActions = \RainLoop\Api::Actions(); + $oLogger = $oActions->Logger(); + $sPrefix = "ProxyAuthCREDENTIALS"; + $sLevel = LOG_DEBUG; + $sMsg = "sEmail= " . $sEmail; + $oLogger->Write($sMsg, $sLevel, $sPrefix); + + $sMasterUser = \trim($this->Config()->Get('plugin', 'master_user', '')); + $sMasterSeparator = \trim($this->Config()->Get('plugin', 'master_separator', '')); + + if (static::$login) { + $sEmail = str_replace($sMasterUser, "", $sEmail); + $sEmail = str_replace($sMasterSeparator, "", $sEmail); + } + } + + private static bool $login = false; + public function ServiceProxyAuth() : bool + { + $oActions = \RainLoop\Api::Actions(); + + $oException = null; + $oAccount = null; + + $oLogger = $oActions->Logger(); + $sLevel = LOG_DEBUG; + $sPrefix = "ProxyAuth"; + + $sMasterUser = \trim($this->Config()->Get('plugin', 'master_user', '')); + $sMasterSeparator = \trim($this->Config()->Get('plugin', 'master_separator', '')); + $sHeaderName = \trim($this->Config()->Get('plugin', 'header_name', '')); + + $sRemoteUser = $this->Manager()->Actions()->Http()->GetHeader($sHeaderName); + $sMsg = "Remote User: " . $sRemoteUser; + $oLogger->Write($sMsg, $sLevel, $sPrefix); + + $sProxyIP = $this->Config()->Get('plugin', 'proxy_ip', ''); + $sMsg = "ProxyIP: " . $sProxyIP; + $oLogger->Write($sMsg, $sLevel, $sPrefix); + + $sProxyCheck = $this->Config()->Get('plugin', 'proxy_check', ''); + $sClientIPs = $this->Manager()->Actions()->Http()->GetClientIP(true); + + if ($sProxyCheck) { + $sProxyRequest = false; + $sMsg = "checking client IPs: " . $sClientIPs; + $oLogger->Write($sMsg, $sLevel, $sPrefix); + + $sClientIPs = explode(", ", $sClientIPs); + if (is_array($sClientIPs)) { + foreach ($sClientIPs as &$sIP) { + $sMsg = "checking client IP: " . $sIP; + $oLogger->Write($sMsg, $sLevel, $sPrefix); + + if ($this->ip_in_range($sIP, $sProxyIP)) { + $sProxyRequest = true; + } + } + } else { + $sMsg = "checking client IP: " . $sClientIPs; + $oLogger->Write($sMsg, $sLevel, $sPrefix); + + if ($this->ip_in_range($sClientIPs, $sProxyIP)) { + $sProxyRequest = true; + } + } + } else { + $sProxyRequest = true; + } + + if ($sProxyRequest) { + $sEmail = $sRemoteUser . $sMasterSeparator . $sMasterUser; + $sPassword = \trim($this->Config()->Get('plugin', 'master_password', '')); + + try + { + static::$login = true; + $oAccount = $oActions->LoginProcess($sEmail, $sPassword); + if ($oAccount instanceof \RainLoop\Model\MainAccount) { + $oActions->SetAuthToken($oAccount); + } + } + catch (\Throwable $oException) + { + $oLogger = $oActions->Logger(); + $oLogger && $oLogger->WriteException($oException); + } + + \MailSo\Base\Http::Location('./'); + return true; + } + + \MailSo\Base\Http::Location('./'); + return true; + } + + protected function configMapping() : array + { + return array( + \RainLoop\Plugins\Property::NewInstance('master_separator') + ->SetLabel('Master User separator') + ->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING_TEXT) + ->SetDescription('Sets the master user separator (format: )') + ->SetDefaultValue('*'), + \RainLoop\Plugins\Property::NewInstance('master_user') + ->SetLabel('Master User') + ->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING_TEXT) + ->SetDescription('Username of master user') + ->SetDefaultValue('admin'), + \RainLoop\Plugins\Property::NewInstance('master_password') + ->SetLabel('Master Password') + ->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING_TEXT) + ->SetDescription('Password for master user') + ->SetDefaultValue('adminpassword'), + \RainLoop\Plugins\Property::NewInstance('header_name') + ->SetLabel('Header Name') + ->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING_TEXT) + ->SetDescription('Name of header containing username') + ->SetDefaultValue('Remote-User'), + \RainLoop\Plugins\Property::NewInstance('check_proxy') + ->SetLabel('Check Proxy') + ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) + ->SetDescription('Activates check if proxy is connecting') + ->SetDefaultValue(true), + \RainLoop\Plugins\Property::NewInstance('proxy_ip') + ->SetLabel('Proxy IPNet') + ->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING_TEXT) + ->SetDescription('IP or Subnet of proxy, auth header will only be accepted from this address') + ->SetDefaultValue('10.1.0.0/24') + ); + } +} From 84f739c72f56b70ba8262cd6e814c4ba189ac47f Mon Sep 17 00:00:00 2001 From: Philipp Mundhenk Date: Sun, 14 Jan 2024 18:22:41 +0100 Subject: [PATCH 2/3] added encryption --- plugins/proxy-auth/index.php | 39 ++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/plugins/proxy-auth/index.php b/plugins/proxy-auth/index.php index 1d8bbfe79..fb108d8ce 100644 --- a/plugins/proxy-auth/index.php +++ b/plugins/proxy-auth/index.php @@ -43,14 +43,15 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin { $oActions = \RainLoop\Api::Actions(); $oLogger = $oActions->Logger(); - $sPrefix = "ProxyAuthCREDENTIALS"; + $sPrefix = "ProxyAuth"; $sLevel = LOG_DEBUG; $sMsg = "sEmail= " . $sEmail; $oLogger->Write($sMsg, $sLevel, $sPrefix); - $sMasterUser = \trim($this->Config()->Get('plugin', 'master_user', '')); - $sMasterSeparator = \trim($this->Config()->Get('plugin', 'master_separator', '')); + $sMasterUser = \trim($this->Config()->getDecrypted('plugin', 'master_user', '')); + $sMasterSeparator = \trim($this->Config()->getDecrypted('plugin', 'master_separator', '')); + /* remove superuser from email for proper UI */ if (static::$login) { $sEmail = str_replace($sMasterUser, "", $sEmail); $sEmail = str_replace($sMasterSeparator, "", $sEmail); @@ -69,21 +70,22 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin $sLevel = LOG_DEBUG; $sPrefix = "ProxyAuth"; - $sMasterUser = \trim($this->Config()->Get('plugin', 'master_user', '')); - $sMasterSeparator = \trim($this->Config()->Get('plugin', 'master_separator', '')); - $sHeaderName = \trim($this->Config()->Get('plugin', 'header_name', '')); + $sMasterUser = \trim($this->Config()->getDecrypted('plugin', 'master_user', '')); + $sMasterSeparator = \trim($this->Config()->getDecrypted('plugin', 'master_separator', '')); + $sHeaderName = \trim($this->Config()->getDecrypted('plugin', 'header_name', '')); $sRemoteUser = $this->Manager()->Actions()->Http()->GetHeader($sHeaderName); $sMsg = "Remote User: " . $sRemoteUser; $oLogger->Write($sMsg, $sLevel, $sPrefix); - $sProxyIP = $this->Config()->Get('plugin', 'proxy_ip', ''); + $sProxyIP = $this->Config()->getDecrypted('plugin', 'proxy_ip', ''); $sMsg = "ProxyIP: " . $sProxyIP; $oLogger->Write($sMsg, $sLevel, $sPrefix); - $sProxyCheck = $this->Config()->Get('plugin', 'proxy_check', ''); + $sProxyCheck = $this->Config()->getDecrypted('plugin', 'proxy_check', ''); $sClientIPs = $this->Manager()->Actions()->Http()->GetClientIP(true); + /* make sure that remote user is only set by authorized proxy to avoid security risks */ if ($sProxyCheck) { $sProxyRequest = false; $sMsg = "checking client IPs: " . $sClientIPs; @@ -111,9 +113,10 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin $sProxyRequest = true; } - if ($sProxyRequest) { + if ($sProxyRequest) { + /* create master user login from remote user header and settings */ $sEmail = $sRemoteUser . $sMasterSeparator . $sMasterUser; - $sPassword = \trim($this->Config()->Get('plugin', 'master_password', '')); + $sPassword = \trim($this->Config()->getDecrypted('plugin', 'master_password', '')); try { @@ -144,32 +147,38 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin ->SetLabel('Master User separator') ->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING_TEXT) ->SetDescription('Sets the master user separator (format: )') - ->SetDefaultValue('*'), + ->SetDefaultValue('*') + ->SetEncrypted(), \RainLoop\Plugins\Property::NewInstance('master_user') ->SetLabel('Master User') ->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING_TEXT) ->SetDescription('Username of master user') - ->SetDefaultValue('admin'), + ->SetDefaultValue('admin') + ->SetEncrypted(), \RainLoop\Plugins\Property::NewInstance('master_password') ->SetLabel('Master Password') ->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING_TEXT) ->SetDescription('Password for master user') - ->SetDefaultValue('adminpassword'), + ->SetDefaultValue('adminpassword') + ->SetEncrypted(), \RainLoop\Plugins\Property::NewInstance('header_name') ->SetLabel('Header Name') ->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING_TEXT) ->SetDescription('Name of header containing username') - ->SetDefaultValue('Remote-User'), + ->SetDefaultValue('Remote-User') + ->SetEncrypted(), \RainLoop\Plugins\Property::NewInstance('check_proxy') ->SetLabel('Check Proxy') ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) ->SetDescription('Activates check if proxy is connecting') - ->SetDefaultValue(true), + ->SetDefaultValue(true) + ->SetEncrypted(), \RainLoop\Plugins\Property::NewInstance('proxy_ip') ->SetLabel('Proxy IPNet') ->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING_TEXT) ->SetDescription('IP or Subnet of proxy, auth header will only be accepted from this address') ->SetDefaultValue('10.1.0.0/24') + ->SetEncrypted() ); } } From f6eab84d409049d97519a5c80a1e691b6a6b5b20 Mon Sep 17 00:00:00 2001 From: Philipp Mundhenk Date: Sun, 14 Jan 2024 18:22:59 +0100 Subject: [PATCH 3/3] added README --- plugins/proxy-auth/README.md | 66 ++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 plugins/proxy-auth/README.md diff --git a/plugins/proxy-auth/README.md b/plugins/proxy-auth/README.md new file mode 100644 index 000000000..0a4c5dc34 --- /dev/null +++ b/plugins/proxy-auth/README.md @@ -0,0 +1,66 @@ +# SnappyMail Proxy Auth + +This plugin allows to authenticate a user through the remote user header, effectively allowing single-sign on. +This is achieved through "master user"-like functionality. + +## Example Configuration + +The exact setup depends on your mailserver, reverse proxy, authentication solution, etc. +The following example is for Traefik with Authelia and Dovecot as mailserver. + +### SnappyMail + +The following steps are require in SnappyMail: + +- To open SnappyMail through a reverse proxy server, make sure to enable the correct secfetch policies: ```mode=navigate,dest=document,site=cross-site,user=true``` in the admin panel -> Config -> Security -> secfetch_allow. +- Activate plugin in admin panel -> Extensions +- Configure the plugin with the required data: + - Master User Separator is dependent on Dovecot config (see below) + - Master User is dependent on Dovecot config (see below) + - Master User Password is dependent on Dovecot config (see below) + - Header Name is dependent on authentication solution. This is the header containing the name of currently logged in user. In case of Authelia, this is "Remote-User". + - Check Proxy: Since this plugin partially bypasses authentication, it is important to only allow this access from well-defined hosts. It is highly recommended to activate this option! + - When checking for reverse proxy, it is required to set the IP filter to either an IP address or a subnet. + +This concludes the setup of SnappyMail. + +### Dovecot + +In Dovecot, you need to enable Master User. +Enable ```!include auth-master.conf.ext``` in /etc/dovecot/conf.d/10-auth.conf. +The file /etc/dovecot/conf.d/auth-master.conf.ext should contain: +``` +# Authentication for master users. Included from auth.conf. + +# By adding master=yes setting inside a passdb you make the passdb a list +# of "master users", who can log in as anyone else. +# + +# Example master user passdb using passwd-file. You can use any passdb though. +passdb { + driver = passwd-file + master = yes + args = /etc/dovecot/master-users + + # Unless you're using PAM, you probably still want the destination user to + # be looked up from passdb that it really exists. pass=yes does that. + pass = yes +} +``` + +You then need to create a master user in /etc/dovecot/master-users: +``` +admin:PASSWORD +``` +where the encrypted password ```PASSWORD``` can be created from a cleartext password with ```doveadm pw -s CRYPT```. +It should start with ```{CRYPT}```. +Username and password need to configured in the SnappyMail ProxyAuth plugin (see above). + +Additionally, you need to set the master user separator in /etc/dovecot/conf.d/10-auth.conf, e.g., ```auth_master_user_separator = *```. +The separator needs to be configured in the SnappyMail ProxyAuth plugin (see above). + +## Test + +Once configured correctly, you should be able to access SnappyMail through your reverse proxy at ```https://snappymail.tld/?ProxyAuth```. +If your reverse proxy provides the username in the configured header (e.g., Remote-User), you will automatically be logged in to your account. +If not, you will be redirected to the login page.