egroupware/api/src/Auth/Saml.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'!");
}
}
}
}
}