mirror of
https://github.com/EGroupware/egroupware.git
synced 2025-05-18 11:40:46 +02:00
877 lines
31 KiB
PHP
877 lines
31 KiB
PHP
<?php
|
|
/**
|
|
* EGroupware API - Authentication via SAML, Shibboleth or everything supported by SimpleSAMLphp
|
|
*
|
|
* @link https://www.egroupware.org
|
|
* @link https://simplesamlphp.org/docs/stable/
|
|
* @license http://opensource.org/licenses/lgpl-license.php LGPL - GNU Lesser General Public License
|
|
* @package api
|
|
* @subpackage authentication
|
|
*/
|
|
|
|
namespace EGroupware\Api\Auth;
|
|
|
|
use EGroupware\Api;
|
|
use SimpleSAML;
|
|
use EGroupware\Api\Exception;
|
|
|
|
/**
|
|
* Authentication based on SAML, Shibboleth or everything supported by SimpleSAMLphp
|
|
*
|
|
* SimpleSAMLphp is installed together with EGroupware and a default configuration is created in EGroupware
|
|
* files subdirectory "saml" eg. when you first store it's configuration in Setup > Configuration > SAML/Shibboleth
|
|
*
|
|
* Storing setup configuration modifies the following files:
|
|
* a) $files_dir/saml/config.php
|
|
* b) $files_dir/saml/authsources.php (only "default-sp" is used currently)
|
|
* c) $files_dir/saml/metadata/*
|
|
* d) $files_dir/saml/cert/*
|
|
* Modification is only on certain values, everything else can be edited to suit your needs.
|
|
*
|
|
* Initially also a key-pair is generated as $files_dir/saml/cert/saml.{pem,crt}.
|
|
* If you want or have to use a different certificate, best replace these with your files (they are referenced multiple times!).
|
|
* They must stay in the files directory and can NOT be symlinks to eg. /etc, as only files dir is mounted into the container!
|
|
*
|
|
* Authentication / configuration can be tested independent of EGroupware by using https://example.org/egroupware/saml/
|
|
* with the "admin" user and password stored in cleartext in $files_dir/saml/config.php under 'auth.adminpassword'.
|
|
*
|
|
* There are basically three possible scenarios currently supported:
|
|
* a) a single IdP and SAML configured as authentication method
|
|
* --> gives full SSO (login page is never displayed, it directly redirects to the IdP)
|
|
* b) one or multiple IdP, a discovery label and another authentication type eg. SQL configured
|
|
* --> uses the login page for local accounts plus a button or selectbox (depending on number of IdPs) to start SAML login
|
|
* c) multiple IdP and SAML configured as authentication method
|
|
* --> SimpleSAML discovery/selection page with a checkbox to remember the selection (SSO after first selection)
|
|
*
|
|
* EGroupware understands assertions / attributes send after authentication in the following ways:
|
|
* - as "urn:uuid:<oid>" uri as used eg. in the DFN federation
|
|
* - as LDAP attribute name send eg. by Univention IdP (either lower cased or in matching camelCase)
|
|
*
|
|
* SAML support in EGroupware automatically downgrades session cookies to SameSite=Lax, as our default SameSite=Strict
|
|
* does NOT work with SAML redirects from an IdP in a different domain (browser simply ignores the session cookies)!
|
|
*
|
|
* Please note: the following SimpleSAMLphp WARNING can be safely ignored (as EGroupware shares the session with it):
|
|
* There is already a PHP session with the same name as SimpleSAMLphp's session, or the
|
|
* 'session.phpsession.cookiename' configuration option is not set. Make sure to set SimpleSAMLphp's cookie name
|
|
* with a value not used by any other applications.
|
|
*/
|
|
class Saml implements BackendSSO
|
|
{
|
|
/**
|
|
* Which entry in authsources.php to use.
|
|
*
|
|
* Setup > configuration always modifies "default-sp"
|
|
*
|
|
* A different SP can be configured via header.inc.php by adding at the end:
|
|
*
|
|
* EGroupware\Api\Auth\Saml::$auth_source = "other-sp";
|
|
*/
|
|
static public $auth_source = 'default-sp';
|
|
|
|
/**
|
|
* Constructor
|
|
*/
|
|
function __construct()
|
|
{
|
|
// ensure we have (at least) a default configuration
|
|
self::checkDefaultConfig();
|
|
}
|
|
|
|
/**
|
|
* Authentication against SAML
|
|
*
|
|
* @param string $username username of account to authenticate
|
|
* @param string $passwd corresponding password
|
|
* @param string $passwd_type ='text' 'text' for cleartext passwords (default)
|
|
* @return boolean true if successful authenticated, false otherwise
|
|
*/
|
|
function authenticate($username, $passwd, $passwd_type='text')
|
|
{
|
|
// login (redirects to IdP)
|
|
$as = new SimpleSAML\Auth\Simple(self::$auth_source);
|
|
$as->requireAuth();
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* changes password in SAML
|
|
*
|
|
* @param string $old_passwd must be cleartext or empty to not to be checked
|
|
* @param string $new_passwd must be cleartext
|
|
* @param int $account_id =0 account id of user whose passwd should be changed
|
|
* @return boolean true if password successful changed, false otherwise
|
|
*/
|
|
function change_password($old_passwd, $new_passwd, $account_id=0)
|
|
{
|
|
/* Not allowed */
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Some urn:oid constants for common attributes
|
|
*/
|
|
const eduPersonPricipalName = 'urn:oid:1.3.6.1.4.1.5923.1.1.1.6';
|
|
const eduPersonUniqueId = 'urn:oid:1.3.6.1.4.1.5923.1.1.1.13';
|
|
const emailAddress = 'urn:oid:0.9.2342.19200300.100.1.3';
|
|
const uid = 'urn:oid:0.9.2342.19200300.100.1.1';
|
|
const firstName = 'urn:oid:2.5.4.42';
|
|
const lastName = 'urn:oid:2.5.4.4';
|
|
|
|
/**
|
|
* Attempt SSO login
|
|
*
|
|
* @return string sessionid on successful login, null otherwise
|
|
*/
|
|
function login()
|
|
{
|
|
// login (redirects to IdP)
|
|
$as = new SimpleSAML\Auth\Simple(self::$auth_source);
|
|
$as->requireAuth(preg_match('|^https://|', $_REQUEST['idp']) ?
|
|
['saml:idp' => $_REQUEST['idp']] : []);
|
|
|
|
/* cleanup session for EGroupware: currently NOT used as we share the session with SimpleSAMLphp
|
|
$session = SimpleSAML\Session::getSessionFromRequest();
|
|
$session->cleanup();*/
|
|
|
|
// get attributes for (automatic) account creation
|
|
$attrs = $as->getAttributes();
|
|
if (!$attrs || empty($username = self::samlAttr(null, $attrs)))
|
|
{
|
|
throw new \Exception('Got NO '.(!$attrs ? 'attributes' : 'username attribute').' from SAML: '.json_encode($attrs));
|
|
}
|
|
|
|
// check if user already exists
|
|
if (!$GLOBALS['egw']->accounts->name2id($username, 'account_lid', 'u'))
|
|
{
|
|
if (($existing = $this->checkJoin($_GET['login'], $_GET['passwd'], $username)) ||
|
|
($existing = $this->checkReplaceUsername($username)))
|
|
{
|
|
$username = $this->updateJoinedAccount($existing, $attrs);
|
|
}
|
|
else
|
|
{
|
|
// fail if auto-creation of authenticated users is NOT configured
|
|
if (empty($GLOBALS['egw_info']['server']['auto_create_acct']))
|
|
{
|
|
return null;
|
|
}
|
|
$GLOBALS['auto_create_acct'] = [
|
|
'firstname' => self::samlAttr(self::firstName, $attrs),
|
|
'lastname' => self::samlAttr(self::lastName, $attrs),
|
|
'email' => self::samlAttr(self::emailAddress, $attrs),
|
|
];
|
|
}
|
|
}
|
|
|
|
// check affiliation / group to add or remove
|
|
self::checkAffiliation($username, $attrs, $GLOBALS['auto_create_acct']);
|
|
|
|
// Set SameSite attribute for cookies, as SAML redirect does NOT work with samesite=Strict,
|
|
// it requires as least Lax, otherwise the browser will ignore the session cookies set in Session::create()!
|
|
if ($GLOBALS['egw_info']['server']['cookie_samesite_attribute'] === 'Strict')
|
|
{
|
|
$GLOBALS['egw_info']['server']['cookie_samesite_attribute'] = 'Lax';
|
|
}
|
|
// return user session
|
|
return $GLOBALS['egw']->session->create($username, null, null, false, false);
|
|
}
|
|
|
|
/**
|
|
* Check if joining a SAML account with an existing accounts is enabled and user specified correct credentials
|
|
*
|
|
* @param string $login login-name entered by user
|
|
* @param string $password password entered by user
|
|
* @param string $username SAML username
|
|
* @return string|null|false existing user-name to join or
|
|
* null if no joining configured or missing credentials or user does not exist or
|
|
* false if authentication with given credentials failed
|
|
*/
|
|
private function checkJoin($login, $password, $username)
|
|
{
|
|
// check SAML username is stored in account_description and we have a matching account
|
|
if ($GLOBALS['egw_info']['server']['saml_join'] === 'description' &&
|
|
($account_id = $GLOBALS['egw']->accounts->name2id($username, 'account_description', 'u')))
|
|
{
|
|
return Api\Accounts::id2name($account_id);
|
|
}
|
|
|
|
// check join configuration and if user specified credentials
|
|
if (empty($GLOBALS['egw_info']['server']['saml_join']) || empty($login) || empty($password))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
$backend = Api\Auth::backend($GLOBALS['egw_info']['server']['auth_type'] ?: 'sql', false);
|
|
if (!$backend->authenticate($login, $password))
|
|
{
|
|
return false;
|
|
}
|
|
return $login;
|
|
}
|
|
|
|
/**
|
|
* Update joined account, if configured
|
|
*
|
|
* @param string $account_lid existing account_lid
|
|
* @param array $attrs saml attributes incl. SAML username
|
|
* @return string username to use
|
|
*/
|
|
private function updateJoinedAccount($account_lid, array $attrs)
|
|
{
|
|
if (empty($GLOBALS['egw_info']['server']['saml_join']))
|
|
{
|
|
return $account_lid;
|
|
}
|
|
$account = $update = $GLOBALS['egw']->accounts->read($account_lid);
|
|
|
|
switch($GLOBALS['egw_info']['server']['saml_join'])
|
|
{
|
|
case 'usernameemail':
|
|
if (!empty(self::samlAttr(self::emailAddress, $attrs)))
|
|
{
|
|
unset($update['account_email']); // force email update
|
|
}
|
|
// fall through
|
|
case 'username':
|
|
$update['account_lid'] = self::samlAttr(null, $attrs);
|
|
break;
|
|
|
|
case 'description':
|
|
$update['account_description'] = self::samlAttr(null, $attrs);
|
|
break;
|
|
}
|
|
// update other attributes
|
|
foreach([
|
|
'account_email' => self::emailAddress,
|
|
'account_firstname' => self::firstName,
|
|
'account_lastname' => self::lastName,
|
|
] as $name => $oid)
|
|
{
|
|
if (!empty($value = self::samlAttr($oid, $attrs)) && ($name !== 'account_email' || empty($update['account_email'])))
|
|
{
|
|
$update[$name] = $value;
|
|
}
|
|
}
|
|
// update account if necessary
|
|
if ($account != $update)
|
|
{
|
|
// notify user about successful update of existing account and evtl. updated account-name
|
|
if ($GLOBALS['egw']->accounts->save($update))
|
|
{
|
|
$msg = lang('Your account has been updated with new data from your identity provider.');
|
|
if ($account['account_lid'] !== $update['account_lid'])
|
|
{
|
|
$msg .= "\n".lang("Please remember to use '%1' as username for local login's from now on!", $update['account_lid']);
|
|
// rename home directory
|
|
Api\Vfs::$is_root = true;
|
|
Api\Vfs::rename('/home/'.$account['account_lid'], '/home/'.$update['account_lid']);
|
|
Api\Vfs::$is_root = false;
|
|
}
|
|
Api\Framework::message($msg, 'notice');
|
|
}
|
|
else
|
|
{
|
|
Api\Framework::message(lang('Updating your account with new data from your identity provider failed!'), 'error');
|
|
}
|
|
}
|
|
return $update['account_lid'];
|
|
}
|
|
|
|
/**
|
|
* Check if some replacement is configured to match SAML usernames to existing ones
|
|
*
|
|
* @param string $username SAML username
|
|
* @return string|null existing username or null if not found
|
|
*/
|
|
private function checkReplaceUsername($username)
|
|
{
|
|
if (empty($GLOBALS['egw_info']['server']['saml_replace']))
|
|
{
|
|
return null;
|
|
}
|
|
$replace = $GLOBALS['egw_info']['server']['saml_replace'];
|
|
$with = $GLOBALS['egw_info']['server']['saml_replace_with'] ?? '';
|
|
$replaced = $replace[0] === '/' ? preg_replace($replace, $with, $username) : str_replace($replace, $with, $username);
|
|
|
|
if (empty($replaced) || !$GLOBALS['egw']->accounts->name2id($replaced, 'account_lid', 'u'))
|
|
{
|
|
return null;
|
|
}
|
|
return $replaced;
|
|
}
|
|
|
|
/**
|
|
* Logout SSO system
|
|
*/
|
|
function logout()
|
|
{
|
|
$as = new SimpleSAML\Auth\Simple(self::$auth_source);
|
|
if ($as->isAuthenticated()) $as->logout();
|
|
}
|
|
|
|
/**
|
|
* Return (which) parts of session needed by current auth backend
|
|
*
|
|
* If this returns any key(s), the session is NOT destroyed by Api\Session::destroy,
|
|
* just everything but the keys is removed.
|
|
*
|
|
* @return array of needed keys in session
|
|
*/
|
|
function needSession()
|
|
{
|
|
return ['SimpleSAMLphp_SESSION', Api\Session::EGW_APPSESSION_VAR]; // Auth stores backend via Cache::setSession()
|
|
}
|
|
|
|
const IDP_DISPLAY_NAME = 'OrganizationDisplayName';
|
|
|
|
/**
|
|
* Display a IdP selection / discovery
|
|
*
|
|
* Will be displayed if IdP(s) are added in setup and a discovery label is specified.
|
|
*
|
|
* @return string|null html to display in login page or null to disable the selection
|
|
*/
|
|
static public function discovery()
|
|
{
|
|
if (empty($GLOBALS['egw_info']['server']['saml_discovery']) ||
|
|
!($metadata = self::metadata()))
|
|
{
|
|
return null;
|
|
}
|
|
//error_log(__METHOD__."() metadata=".json_encode($metadata));
|
|
$lang = Api\Translation::$userlang;
|
|
$select = ['' => $GLOBALS['egw_info']['server']['saml_discovery']];
|
|
foreach($metadata as $idp => $data)
|
|
{
|
|
$select[$idp] = $data[self::IDP_DISPLAY_NAME][$lang] ?: $data[self::IDP_DISPLAY_NAME]['en'];
|
|
}
|
|
return count($metadata) > 1 ?
|
|
Api\Html::select('auth=saml', '', $select, true, 'class="onChangeSubmit"') :
|
|
Api\Html::input('auth=saml', $GLOBALS['egw_info']['server']['saml_discovery'], 'submit', 'formmethod="get"');
|
|
}
|
|
|
|
/**
|
|
* @return array IdP => metadata pairs
|
|
*/
|
|
static public function metadata($files_dir=null)
|
|
{
|
|
$metadata = [];
|
|
if (file_exists($file = ($files_dir ?: $GLOBALS['egw_info']['server']['files_dir']).'/saml/metadata/saml20-idp-remote.php'))
|
|
{
|
|
include $file;
|
|
}
|
|
return $metadata;
|
|
}
|
|
|
|
const ASYNC_JOB_ID = 'saml_metadata_refresh';
|
|
|
|
/**
|
|
* Hook called when setup configuration is being stored:
|
|
* - updating SimpleSAMLphp config files
|
|
* - creating/removing cron job to refresh metadata
|
|
*
|
|
* @param array $location key "newsettings" with reference to changed settings from setup > configuration
|
|
* @throws \Exception for errors
|
|
*/
|
|
public static function setupConfig(array $location)
|
|
{
|
|
$config =& $location['newsettings'];
|
|
|
|
if (empty($config['saml_idp'])) return; // nothing to do, if no idp defined
|
|
|
|
if (file_exists($config['files_dir'].'/saml/config.php'))
|
|
{
|
|
self::updateConfig($config);
|
|
}
|
|
self::checkDefaultConfig($config);
|
|
// config files are PHP files and EGroupware contaier does not check timestamps
|
|
if (function_exists('opcache_reset')) opcache_reset();
|
|
|
|
// install or remove async job to refresh metadata
|
|
static $freq2times = [
|
|
'daily' => ['min' => 4, 'hour' => 4], // daily at 4:04am
|
|
'weekly' => ['min' => 4, 'hour' => 4, 'dow' => 5], // Saturdays as 4:04am
|
|
];
|
|
$async = new Api\Asyncservice();
|
|
if (isset($freq2times[$config['saml_metadata_refresh']]) &&
|
|
preg_match('|^https://|', $config['saml_metadata']))
|
|
{
|
|
$async->set_timer($freq2times[$config['saml_metadata_refresh']], self::ASYNC_JOB_ID, self::class.'::refreshMetadata');
|
|
}
|
|
else
|
|
{
|
|
$async->cancel_timer(self::ASYNC_JOB_ID);
|
|
}
|
|
|
|
// only refresh metadata if we have to, or request by user
|
|
if ($config['saml_metadata_refresh'] !== 'no')
|
|
{
|
|
$metadata = self::metadata($config['files_dir']);
|
|
$idps = self::splitIdP($config['saml_idp']);
|
|
foreach($idps as $idp)
|
|
{
|
|
if (!isset($metadata[$idp]))
|
|
{
|
|
$metadata = [];
|
|
break;
|
|
}
|
|
}
|
|
if (count($metadata) !== count($idps) || $config['saml_metadata_refresh'] === 'now')
|
|
{
|
|
self::refreshMetadata($config);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Split multiple IdP
|
|
*
|
|
* @param string $config
|
|
* @return string[]
|
|
*/
|
|
private static function splitIdP($config)
|
|
{
|
|
return preg_split('/[\n\r ]+/', trim($config)) ?: [];
|
|
}
|
|
|
|
/**
|
|
* Refresh metadata
|
|
*
|
|
* @param array|null $config defaults to $GLOBALS['egw_info']['server']
|
|
* @throws \Exception
|
|
*/
|
|
public static function refreshMetadata(array $config=null)
|
|
{
|
|
if (!isset($config)) $config = $GLOBALS['egw_info']['server'];
|
|
$old_config = Api\Config::read('phpgwapi');
|
|
|
|
$saml_config = $config['files_dir'].'/saml';
|
|
SimpleSAML\Configuration::setConfigDir($saml_config);
|
|
|
|
$source = [
|
|
'src' => $config['saml_metadata'],
|
|
// only read/configure our idp(s), the whole thing can be huge
|
|
'whitelist' => self::splitIdP($config['saml_idp']),
|
|
];
|
|
if (!empty($config['saml_certificate']))
|
|
{
|
|
$cert = $saml_config.'/cert/'.basename(parse_url($config['saml_certificate'], PHP_URL_PATH));
|
|
if ((!file_exists($cert) || $config['saml_certificate'] !== $old_config['saml_certificate']) &&
|
|
(!($content = file_get_contents($config['saml_certificate'])) ||
|
|
!file_put_contents($cert, $content)))
|
|
{
|
|
throw new \Exception("Could not load certificate from $config[saml_certificate]!");
|
|
}
|
|
$source['certificate'] = $cert;
|
|
}
|
|
$metaloader = new SimpleSAML\Module\metarefresh\MetaLoader();
|
|
$metaloader->loadSource($source);
|
|
$metaloader->writeMetadataFiles($saml_config.'/metadata');
|
|
|
|
// metadata files are PHP files and EGroupware contaier does not check timestamps
|
|
if (function_exists('opcache_reset')) opcache_reset();
|
|
}
|
|
|
|
/**
|
|
* Update config files
|
|
*
|
|
* @param array $config
|
|
*/
|
|
public static function updateConfig(array $config)
|
|
{
|
|
// some Api classes require the config in $GLOBALS['egw_info']['server']
|
|
$GLOBALS['egw_info']['server']['webserver_url'] = $config['webserver_url'];
|
|
$GLOBALS['egw_info']['server']['usecookies'] = true;
|
|
$config['baseurlpath'] = Api\Framework::getUrl(Api\Egw::link('/saml/'));
|
|
$config['username_oid'] = [self::usernameOid($config)];
|
|
$config['attribute_oids'] = [
|
|
'eduPersonPricipalName' => self::eduPersonPricipalName,
|
|
'eduPersonUniqueId' => self::eduPersonUniqueId,
|
|
'emailAddress' => self::emailAddress,
|
|
'uid' => self::uid,
|
|
'firstName' => self::firstName,
|
|
'lastName' => self::lastName,
|
|
];
|
|
if (!in_array(self::usernameOid($config), $config['attribute_oids']))
|
|
{
|
|
$config['attribute_oids']['customOid'] = self::usernameOid($config);
|
|
}
|
|
// if multiple IdP's are configured, do NOT specify one to let user select
|
|
if (count(self::splitIdP($config['saml_idp'])) > 1)
|
|
{
|
|
unset($config['saml_idp']);
|
|
}
|
|
else
|
|
{
|
|
$config['saml_idp'] = trim($config['saml_idp']);
|
|
}
|
|
// update config.php and default-sp in authsources.php
|
|
foreach([
|
|
'authsources.php' => [
|
|
'saml_idp' => "/('default-sp' => *\\[.*?'idp' => *).*?$/ms",
|
|
'saml_sp' => "/('default-sp' => *\\[.*?'name' => *\\[.*?'en' => *).*?$/ms",
|
|
'attribute_oids' => "/('default-sp' => *\\[.*?'attributes' => *)\\[.*?\\],$/ms",
|
|
'username_oid' => "/('default-sp' => *\\[.*?'attributes.required' => *)\\[.*?\\],$/ms",
|
|
],
|
|
'config.php' => [
|
|
'baseurlpath' => "/('baseurlpath' => *).*?$/ms",
|
|
'saml_contact_name' => "/('technicalcontact_name' => *).*?$/ms",
|
|
'saml_contact_email' => "/('technicalcontact_email' => *).*?$/ms",
|
|
]
|
|
] as $file => $replacements)
|
|
{
|
|
if (file_exists($path = $config['files_dir'] . '/saml/'.$file) &&
|
|
($content = file_get_contents($path)))
|
|
{
|
|
foreach($replacements as $conf => $reg_exp)
|
|
{
|
|
$content = preg_replace($reg_exp, '$1' . (is_array($config[$conf]) ?
|
|
self::quoteArray($config[$conf]) : self::quote($config[$conf])) . ',', $content);
|
|
}
|
|
if (!file_put_contents($path, $content))
|
|
{
|
|
throw new \Exception("Failed to update '$path'!");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param string|null $str
|
|
* @param string $empty=null default value, if $str is empty
|
|
* @return string
|
|
*/
|
|
private static function quote($str, $empty=null)
|
|
{
|
|
return $str || isset($empty) ? "'".addslashes($str ?: $empty)."'" : 'null';
|
|
}
|
|
|
|
/**
|
|
* @param array $arr
|
|
* @param null $empty
|
|
* @return string
|
|
*/
|
|
private static function quoteArray($arr, $empty=null)
|
|
{
|
|
$str = "[\n";
|
|
foreach($arr as $key => $val)
|
|
{
|
|
$str .= "\t\t";
|
|
if (!is_int($key)) $str .= self::quote($key).'=>';
|
|
$str .= self::quote($val, $empty).",\n";
|
|
}
|
|
$str .= "\t]";
|
|
return $str;
|
|
}
|
|
|
|
/**
|
|
* Get the urn:oid of the username
|
|
*
|
|
* @param array|null $config
|
|
* @return string
|
|
*/
|
|
private static function usernameOid(array $config=null)
|
|
{
|
|
if (!isset($config)) $config = $GLOBALS['egw_info']['server'];
|
|
|
|
switch($config['saml_username'])
|
|
{
|
|
case 'eduPersonPrincipalName':
|
|
return self::eduPersonPricipalName;
|
|
case 'eduPersonUniqueId':
|
|
return self::eduPersonUniqueId;
|
|
case 'emailAddress':
|
|
return self::emailAddress;
|
|
case 'uid':
|
|
return self::uid;
|
|
case 'customOid':
|
|
return !empty($config['saml_username_oid']) ? 'urn:oid:'.$config['saml_username_oid'] : self::emailAddress;
|
|
}
|
|
return self::emailAddress;
|
|
}
|
|
|
|
/**
|
|
* Get SAML attribute by name, taking into account that some IdP, like eg. Univention, use not the oid but the LDAP name
|
|
*
|
|
* @param string $name attribute name or null for the configured username
|
|
* @param array $attrs as returned by SimpleSAML\Auth\Simple::getAttributes()
|
|
* @param ?array $config default use config from $GLOBALS['egw_info']['server']
|
|
* @return string[]|string|null
|
|
*/
|
|
private static function samlAttr($name, array $attrs, array $config=null)
|
|
{
|
|
if (!isset($config)) $config = $GLOBALS['egw_info']['server'];
|
|
|
|
switch($name ?: $config['saml_username'] ?? 'emailAddress')
|
|
{
|
|
case 'emailAddress':
|
|
case 'mailPrimaryAddress':
|
|
case self::emailAddress:
|
|
$keys = [self::emailAddress, 'emailAddress', 'mailPrimaryAddress'];
|
|
break;
|
|
case 'eduPersonPrincipalName':
|
|
case self::eduPersonPricipalName:
|
|
$keys = [self::eduPersonPricipalName, 'eduPersonPrincipalName'];
|
|
break;
|
|
case 'eduPersonUniqueId':
|
|
case self::eduPersonUniqueId:
|
|
$keys = [self::eduPersonUniqueId, 'eduPersonUniqueId'];
|
|
break;
|
|
case 'uid':
|
|
case self::uid:
|
|
$keys = [self::uid, 'uid'];
|
|
break;
|
|
case 'sn':
|
|
case 'surname':
|
|
case self::lastName:
|
|
$keys = [self::lastName, 'sn', 'surname'];
|
|
break;
|
|
case 'givenName':
|
|
case self::firstName:
|
|
$keys = [self::firstName, 'givenName'];
|
|
break;
|
|
case 'customOid':
|
|
$keys = ['urn:oid:'.$config['saml_username_oid'], $config['saml_username_oid']];
|
|
break;
|
|
}
|
|
foreach($keys as $key)
|
|
{
|
|
if (isset($attrs[$key]) || isset($attrs[$key = strtolower($key)]))
|
|
{
|
|
return count($attrs[$key]) === 1 ? current($attrs[$key]) : $attrs[$key];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* eduPersonAffiliation attribute
|
|
*/
|
|
const eduPersonAffiliation = 'urn:oid:1.3.6.1.4.1.5923.1.1.1.1';
|
|
|
|
/**
|
|
* Check if a group is specified depending on an affiliation attribute
|
|
*
|
|
* @param string $username
|
|
* @param array $attrs
|
|
* @param ?array& $auto_create_acct reference to $GLOBALS['auto_create_acct'] for not existing accounts
|
|
* @param array|null $config
|
|
*/
|
|
private function checkAffiliation($username, array $attrs, array &$auto_create_acct=null, array $config=null)
|
|
{
|
|
if (!isset($config)) $config = $GLOBALS['egw_info']['server'];
|
|
|
|
// check if affiliation is configured and attribute returned by IdP
|
|
$attr = $config['saml_affiliation'] === 'eduPersonAffiliation' ? self::eduPersonAffiliation : $config['saml_affiliation_oid'];
|
|
if (!empty($attr) && !empty($affiliation = self::samlAttr($attr, $attrs)) && !empty($config['saml_affiliation_group']) && !empty($config['saml_affiliation_values']) &&
|
|
($gid = $GLOBALS['egw']->accounts->name2id($config['saml_affiliation_group'], 'account_lid', 'g')))
|
|
{
|
|
if (!isset($auto_create_acct) && ($accout_id = $GLOBALS['egw']->accounts->name2id($username, 'account_lid', 'u')))
|
|
{
|
|
$memberships = $GLOBALS['egw']->accounts->memberships($accout_id, true);
|
|
}
|
|
// check if attribute matches given values to add the extra membership
|
|
if (array_intersect($affiliation, preg_split('/, */', $config['saml_affiliation_values'])))
|
|
{
|
|
if (isset($auto_create_acct))
|
|
{
|
|
$auto_create_acct['add_group'] = $gid;
|
|
}
|
|
elseif ($accout_id && !in_array($gid, $memberships))
|
|
{
|
|
$memberships[] = $gid;
|
|
$GLOBALS['egw']->accounts->set_memberships($memberships, $accout_id);
|
|
}
|
|
}
|
|
// remove membership, if it's set
|
|
elseif ($accout_id && ($key = array_search($gid, $memberships, false)) !== false)
|
|
{
|
|
unset($memberships[$key]);
|
|
$GLOBALS['egw']->accounts->set_memberships($memberships, $accout_id);
|
|
}
|
|
}
|
|
error_log(__METHOD__."('$username', ".json_encode($attrs).", ".json_encode($auto_create_acct).") attr=$attr, gid=$gid --> account_id=$accout_id, memberships=".json_encode($memberships));
|
|
}
|
|
|
|
/**
|
|
* Create simpleSAMLphp default configuration
|
|
*
|
|
* @param array $config=null default $GLOBALS['egw_info']['server']
|
|
* @throws Exception
|
|
*/
|
|
public static function checkDefaultConfig(array $config=null)
|
|
{
|
|
if (!isset($config)) $config = $GLOBALS['egw_info']['server'];
|
|
|
|
// some Api classes require the config in $GLOBALS['egw_info']['server']
|
|
$GLOBALS['egw_info']['server']['webserver_url'] = $config['webserver_url'];
|
|
$GLOBALS['egw_info']['server']['usecookies'] = true;
|
|
|
|
// use "saml" subdirectory of EGroupware files directory as simpleSAMLphp config-directory
|
|
$config_dir = $config['files_dir'].'/saml';
|
|
if (!file_exists($config_dir) && !mkdir($config_dir))
|
|
{
|
|
throw new Exception("Can't create SAML config directory '$config_dir'!");
|
|
}
|
|
SimpleSAML\Configuration::setConfigDir($config_dir);
|
|
|
|
// check if all necessary directories exist, if not create them
|
|
foreach(['cert', 'log', 'data', 'metadata', 'tmp'] as $dir)
|
|
{
|
|
if (!file_exists($config_dir.'/'.$dir) && !mkdir($config_dir.'/'.$dir, 0700, true))
|
|
{
|
|
throw new Exception("Can't create $dir-directory '$config_dir/$dir'!");
|
|
}
|
|
}
|
|
|
|
// create a key-pair, if not existing
|
|
$cert_dir = $config_dir.'/cert';
|
|
$private_key_path = $cert_dir.'/saml.pem';
|
|
$public_key_path = $cert_dir.'/saml.crt';
|
|
|
|
if (!file_exists($private_key_path) || !file_exists($public_key_path) ||
|
|
!preg_match('/^-----BEGIN CERTIFICATE-----$/m', file_get_contents($public_key_path)))
|
|
{
|
|
$config = [
|
|
"digest_alg" => "sha512",
|
|
"private_key_bits" => 2048,
|
|
"private_key_type" => OPENSSL_KEYTYPE_RSA,
|
|
];
|
|
// Read or generate the private key
|
|
if ((!file_exists($private_key_path) ||
|
|
($pkey = openssl_pkey_get_private(file_get_contents($private_key_path))) === false) &&
|
|
($pkey = openssl_pkey_new($config)) === false)
|
|
{
|
|
throw new Exception('Error generating key-pair!');
|
|
}
|
|
|
|
// generate CSR and self-sign it
|
|
if (($csr = openssl_csr_new([
|
|
'commonName' => Api\Header\Http::host(),
|
|
], $pkey, $config)) === false ||
|
|
($cert = openssl_csr_sign($csr, null, $pkey, 3650, $config)) === false)
|
|
{
|
|
throw new Exception('Error self-signing cert!');
|
|
}
|
|
|
|
// Extract the public key from $res to $pubKey
|
|
if (openssl_x509_export_to_file($cert, $public_key_path) === false ||
|
|
// Extract the private key
|
|
openssl_pkey_export_to_file($pkey, $private_key_path) === false) // ToDo: db-password as passphrase
|
|
{
|
|
throw new Exception('Error storing key-pair!');
|
|
}
|
|
|
|
// fix permisions to only allow webserver access
|
|
chmod($public_key_path, 0600);
|
|
chmod($private_key_path, 0600);
|
|
}
|
|
|
|
// create a default configuration
|
|
if (!file_exists($config_dir.'/config.php') || filesize($config_dir.'/config.php') < 1000)
|
|
{
|
|
$simplesaml_dir = EGW_SERVER_ROOT.'/vendor/simplesamlphp/simplesamlphp';
|
|
|
|
foreach(glob($simplesaml_dir.'/config-templates/*.php') as $path)
|
|
{
|
|
switch($file=basename($path))
|
|
{
|
|
case 'config.php':
|
|
$cookie_domain = Api\Session::getCookieDomain($cookie_path, $cookie_secure);
|
|
$replacements = [
|
|
'$config = [' => <<<EOF
|
|
// SimpleSAMLphp does NOT honor X-Forwarded-* headers
|
|
// and solution mentioned in docs to just set baseurlpath to correct https-URL does NOT work in all cases
|
|
if (isset(\$_SERVER['HTTP_X_FORWARDED_PROTO']) && \$_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https') {
|
|
\$_SERVER['HTTPS'] = 'on';
|
|
\$_SERVER['SERVER_PORT'] = '443';
|
|
}
|
|
|
|
\$config = [
|
|
EOF
|
|
,
|
|
"'baseurlpath' => 'simplesaml/'," => "'baseurlpath' => '".Api\Framework::getUrl(Api\Egw::link('/saml/'))."',",
|
|
"'timezone' => null," => "'timezone' => 'Europe/Berlin',", // ToDo: use default prefs
|
|
"'secretsalt' => 'defaultsecretsalt'," => "'secretsalt' => '".Api\Auth::randomstring(32)."',",
|
|
"'auth.adminpassword' => '123'," => "'auth.adminpassword' => '".Api\Auth::randomstring(12)."',",
|
|
"'admin.protectindexpage' => false," => "'admin.protectindexpage' => true,",
|
|
"'certdir' => 'cert/'," => "'certdir' => __DIR__.'/cert/',",
|
|
"'loggingdir' => 'log/'," => "'loggingdir' => __DIR__.'/log/',",
|
|
"'datadir' => 'data/'," => "'datadir' => __DIR__.'/data/',",
|
|
"'tempdir' => '/tmp/simplesaml'," => "'tempdir' => __DIR__.'/tmp',",
|
|
"'metadatadir' => 'metadata'," => "'metadatadir' => __DIR__.'/metadata',",
|
|
"'logging.handler' => 'syslog'," => "'logging.handler' => 'errorlog',",
|
|
"'technicalcontact_name' => 'Administrator'" =>
|
|
"'technicalcontact_name' => ".self::quote($config['saml_contact_name'], 'Administrator'),
|
|
"'technicalcontact_email' => 'na@example.org'" =>
|
|
"'technicalcontact_email' => ".self::quote($config['saml_contact_email'], 'na@example.org'),
|
|
"'metadata.sign.privatekey' => null," => "'metadata.sign.privatekey' => 'saml.pem',",
|
|
//"'metadata.sign.privatekey_pass' => null," => "",
|
|
"'metadata.sign.certificate' => null," => "'metadata.sign.certificate' => 'saml.crt',",
|
|
//"'metadata.sign.algorithm' => null," => "",
|
|
// we have to use EGroupware session/cookie parameters
|
|
"'session.cookie.name' => 'SimpleSAMLSessionID'," => "'session.cookie.name' => 'sessionid',",
|
|
"'session.cookie.path' => '/'," => "'session.cookie.path' => '$cookie_path',",
|
|
"'session.cookie.domain' => null," => "'session.cookie.domain' => '.$cookie_domain',",
|
|
"'session.cookie.secure' => false," => "'session.cookie.secure' => ".($cookie_secure ? 'true' : 'false').',',
|
|
"'session.phpsession.cookiename' => 'SimpleSAML'," => "'session.phpsession.cookiename' => 'sessionid',",
|
|
];
|
|
break;
|
|
|
|
case 'authsources.php':
|
|
$replacements = [
|
|
"'idp' => null," => "'idp' => ".self::quote(
|
|
count(self::splitIdP($config['saml_idp'])) <= 1 ? trim($config['saml_idp']) : null).',',
|
|
"'discoURL' => null," => "'discoURL' => null,\n\n".
|
|
// add our private and public keys
|
|
"\t'privatekey' => 'saml.pem',\n\n".
|
|
"\t// to include certificate in metadata\n".
|
|
"\t'certificate' => 'saml.crt',\n\n".
|
|
"\t// new certificates for rotation: add new, wait for IdP sync, swap old and new, wait, comment again\n".
|
|
"\t//'new_privatekey' => 'new-saml.pem',\n".
|
|
"\t//'new_certificate' => 'new-saml.crt',\n\n".
|
|
"\t// logout is NOT signed by default, but signature is required from the uni-kl.de IdP for logout\n".
|
|
"\t'sign.logout' => true,\n\n".
|
|
"\t'name' => [\n".
|
|
"\t\t'en' => ".self::quote($config['saml_sp'] ?: 'EGroupware').",\n".
|
|
"\t],\n\n".
|
|
"\t'attributes' => [\n".
|
|
"\t\t'eduPersonPricipalName' => '".self::eduPersonPricipalName."',\n".
|
|
"\t\t'eduPersonUniqueId' => '".self::eduPersonUniqueId."',\n".
|
|
"\t\t'emailAddress' => '".self::emailAddress."',\n".
|
|
"\t\t'firstName' => '".self::firstName."',\n".
|
|
"\t\t'lastName' => '".self::lastName."',\n".
|
|
"\t],\n".
|
|
"\t'attributes.required' => [".self::quote(self::usernameOid($config))."],",
|
|
];
|
|
break;
|
|
|
|
default:
|
|
unset($replacements);
|
|
if (!copy($path, $config_dir.'/'.$file))
|
|
{
|
|
throw new Exception("Can't copy SAML config file '$config_dir/$file'!");
|
|
}
|
|
break;
|
|
}
|
|
if (isset($replacements) &&
|
|
!file_put_contents($config_dir.'/'.$file,
|
|
$c=strtr($t=file_get_contents($path), $replacements)))
|
|
{
|
|
header('Content-Type: text/plain');
|
|
echo "<pre>template:\n$t\n\nconfig:\n$c\n</pre>\n";
|
|
throw new Exception("Can't write SAML config file '$config_dir/config.php'!");
|
|
}
|
|
}
|
|
foreach(glob($simplesaml_dir.'/metadata-templates/*.php') as $path)
|
|
{
|
|
$dest = $config_dir . '/metadata/' . basename($path);
|
|
if (!copy($path, $dest))
|
|
{
|
|
throw new Exception("Can't copy SAML metadata file '$dest'!");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} |