-
Tim van Dijen authoredcaa3a215
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
<?php
declare(strict_types=1);
namespace SimpleSAML\Module\admin\Controller;
use Exception;
use SimpleSAML\Auth;
use SimpleSAML\Configuration;
use SimpleSAML\HTTP\RunnableResponse;
use SimpleSAML\Locale\Translate;
use SimpleSAML\Logger;
use SimpleSAML\Metadata\MetaDataStorageHandler;
use SimpleSAML\Metadata\SAMLBuilder;
use SimpleSAML\Metadata\SAMLParser;
use SimpleSAML\Metadata\Signer;
use SimpleSAML\Module;
use SimpleSAML\Module\adfs\IdP\ADFS as ADFS_IdP;
use SimpleSAML\Module\saml\IdP\SAML1 as SAML1_IdP;
use SimpleSAML\Module\saml\IdP\SAML2 as SAML2_IdP;
use SimpleSAML\Utils;
use SimpleSAML\XHTML\Template;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\VarExporter\VarExporter;
use Webmozart\Assert\Assert;
/**
* Controller class for the admin module.
*
* This class serves the federation views available in the module.
*
* @package SimpleSAML\Module\admin
*/
class Federation
{
/** @var \SimpleSAML\Configuration */
protected $config;
/** @var MetaDataStorageHandler */
protected $mdHandler;
/** @var Menu */
protected $menu;
/**
* FederationController constructor.
*
* @param \SimpleSAML\Configuration $config The configuration to use.
*/
public function __construct(Configuration $config)
{
$this->config = $config;
$this->menu = new Menu();
$this->mdHandler = MetaDataStorageHandler::getMetadataHandler();
}
/**
* Display the federation page.
*
* @return \SimpleSAML\XHTML\Template
* @throws \SimpleSAML\Error\Exception
* @throws \SimpleSAML\Error\Exception
*/
public function main(): Template
{
Utils\Auth::requireAdmin();
// initialize basic metadata array
$hostedSPs = $this->getHostedSP();
$hostedIdPs = $this->getHostedIdP();
$entries = [
'hosted' => array_merge($hostedSPs, $hostedIdPs),
'remote' => [
'saml20-idp-remote' => !empty($hostedSPs) ? $this->mdHandler->getList('saml20-idp-remote', true) : [],
'saml20-sp-remote' => $this->config->getBoolean('enable.saml20-idp', false) === true
? $this->mdHandler->getList('saml20-sp-remote', true) : [],
'adfs-sp-remote' => ($this->config->getBoolean('enable.adfs-idp', false) === true) &&
Module::isModuleEnabled('adfs') ? $this->mdHandler->getList('adfs-sp-remote', true) : [],
],
];
// initialize template and language
$t = new Template($this->config, 'admin:federation.twig');
$language = $t->getTranslator()->getLanguage()->getLanguage();
$defaultLang = $this->config->getString('language.default', 'en');
// process hosted entities
foreach ($entries['hosted'] as $index => $entity) {
if (isset($entity['name']) && is_string($entity['name'])) {
// if the entity has no internationalized name, fake it
$entries['hosted'][$index]['name'] = [$language => $entity['name']];
}
}
// clean up empty remote entries
foreach ($entries['remote'] as $key => $value) {
if (empty($value)) {
unset($entries['remote'][$key]);
}
}
$translators = [
'name' => 'name_translated',
'descr' => 'descr_translated',
'OrganizationDisplayName' => 'organizationdisplayname_translated',
];
foreach ($entries['remote'] as $key => $set) {
foreach ($set as $entityid => $entity) {
foreach ($translators as $old => $new) {
if (isset($entity[$old][$language])) {
$entries['remote'][$key][$entityid][$new] = $entity[$old][$language];
} elseif (isset($entity[$old][$defaultLang])) {
$entries['remote'][$key][$entityid][$new] = $entity[$old][$defaultLang];
} elseif (isset($entity[$old]['en'])) {
$entries['remote'][$key][$entityid][$new] = $entity[$old]['en'];
} elseif (isset($entries['remote'][$key][$entityid][$old])) {
$old_entry = $entries['remote'][$key][$entityid][$old];
$entries['remote'][$key][$entityid][$new] = is_array($old_entry) ? $entityid : $old_entry;
}
}
}
}
$t->data = [
'links' => [
[
'href' => Module::getModuleURL('admin/federation/metadata-converter'),
'text' => Translate::noop('XML to SimpleSAMLphp metadata converter'),
]
],
'entries' => $entries,
'mdtype' => [
'saml20-sp-remote' => Translate::noop('SAML 2.0 SP metadata'),
'saml20-sp-hosted' => Translate::noop('SAML 2.0 SP metadata'),
'saml20-idp-remote' => Translate::noop('SAML 2.0 IdP metadata'),
'saml20-idp-hosted' => Translate::noop('SAML 2.0 IdP metadata'),
'adfs-sp-remote' => Translate::noop('ADFS SP metadata'),
'adfs-sp-hosted' => Translate::noop('ADFS SP metadata'),
'adfs-idp-remote' => Translate::noop('ADFS IdP metadata'),
'adfs-idp-hosted' => Translate::noop('ADFS IdP metadata'),
],
'logouturl' => Utils\Auth::getAdminLogoutURL(),
];
Module::callHooks('federationpage', $t);
Assert::isInstanceOf($t, Template::class);
$this->menu->addOption('logout', $t->data['logouturl'], Translate::noop('Log out'));
return $this->menu->insert($t);
}
/**
* Get a list of the hosted IdP entities, including SAML 2, SAML 1.1 and ADFS.
*
* @return array
* @throws \Exception
*/
private function getHostedIdP(): array
{
$entities = [];
// SAML 2
if ($this->config->getBoolean('enable.saml20-idp', false)) {
try {
$idps = $this->mdHandler->getList('saml20-idp-hosted');
$saml2entities = [];
if (count($idps) > 1) {
foreach ($idps as $index => $idp) {
$idp['url'] = Module::getModuleURL('saml/2/idp/metadata/' . $idp['auth']);
$idp['metadata-set'] = 'saml20-idp-hosted';
$idp['metadata-index'] = $index;
$idp['metadata_array'] = SAML2_IdP::getHostedMetadata($idp['entityid']);
$saml2entities[] = $idp;
}
} else {
$saml2entities['saml20-idp'] = $this->mdHandler->getMetaDataCurrent('saml20-idp-hosted');
$saml2entities['saml20-idp']['url'] = Utils\HTTP::getBaseURL() . 'saml2/idp/metadata.php';
$saml2entities['saml20-idp']['metadata_array'] = SAML2_IdP::getHostedMetadata(
$this->mdHandler->getMetaDataCurrentEntityID('saml20-idp-hosted')
);
}
foreach ($saml2entities as $index => $entity) {
$builder = new SAMLBuilder($entity['entityid']);
$builder->addMetadataIdP20($entity['metadata_array']);
$builder->addOrganizationInfo($entity['metadata_array']);
foreach ($entity['metadata_array']['contacts'] as $contact) {
$builder->addContact($contact['contactType'], $contact);
}
$entity['metadata'] = Signer::sign(
$builder->getEntityDescriptorText(),
$entity['metadata_array'],
'SAML 2 IdP'
);
$entities[$index] = $entity;
}
} catch (\Exception $e) {
Logger::error('Federation: Error loading saml20-idp: ' . $e->getMessage());
}
}
// ADFS
if ($this->config->getBoolean('enable.adfs-idp', false) && Module::isModuleEnabled('adfs')) {
try {
$idps = $this->mdHandler->getList('adfs-idp-hosted');
$adfsentities = [];
if (count($idps) > 1) {
foreach ($idps as $index => $idp) {
$idp['url'] = Module::getModuleURL('adfs/idp/metadata/' . $idp['auth']);
$idp['metadata-set'] = 'adfs-idp-hosted';
$idp['metadata-index'] = $index;
$idp['metadata_array'] = ADFS_IdP::getHostedMetadata($idp['entityid']);
$adfsentities[] = $idp;
}
} else {
$adfsentities['adfs-idp'] = $this->mdHandler->getMetaDataCurrent('adfs-idp-hosted');
$adfsentities['adfs-idp']['url'] = Module::getModuleURL('adfs/idp/metadata.php');
$adfsentities['adfs-idp']['metadata_array'] = ADFS_IdP::getHostedMetadata(
$this->mdHandler->getMetaDataCurrentEntityID('adfs-idp-hosted')
);
}
foreach ($adfsentities as $index => $entity) {
$builder = new SAMLBuilder($entity['entityid']);
$builder->addSecurityTokenServiceType($entity['metadata_array']);
$builder->addOrganizationInfo($entity['metadata_array']);
foreach ($entity['metadata_array']['contacts'] as $contact) {
$builder->addContact($contact['contactType'], $contact);
}
$entity['metadata'] = Signer::sign(
$builder->getEntityDescriptorText(),
$entity['metadata_array'],
'ADFS IdP'
);
$entities[$index] = $entity;
}
} catch (\Exception $e) {
Logger::error('Federation: Error loading adfs-idp: ' . $e->getMessage());
}
}
// process certificate information and dump the metadata array
foreach ($entities as $index => $entity) {
$entities[$index]['type'] = $entity['metadata-set'];
foreach ($entity['metadata_array']['keys'] as $kidx => $key) {
$key['url'] = Module::getModuleURL(
'admin/federation/cert',
[
'set' => $entity['metadata-set'],
'idp' => $entity['metadata-index'],
'prefix' => $key['prefix'],
]
);
$key['name'] = 'idp';
unset($entity['metadata_array']['keys'][$kidx]['prefix']);
$entities[$index]['certificates'][] = $key;
}
// only one key, reduce
if (count($entity['metadata_array']['keys']) === 1) {
$cert = array_pop($entity['metadata_array']['keys']);
$entity['metadata_array']['certData'] = $cert['X509Certificate'];
unset($entity['metadata_array']['keys']);
}
$entities[$index]['metadata_array'] = VarExporter::export($entity['metadata_array']);
}
return $entities;
}
/**
* Get an array of entities describing the local SP instances.
*
* @return array
* @throws \SimpleSAML\Error\Exception If OrganizationName is set for an SP instance but OrganizationURL is not.
*/
private function getHostedSP(): array
{
$entities = [];
/** @var \SimpleSAML\Module\saml\Auth\Source\SP $source */
foreach (Auth\Source::getSourcesOfType('saml:SP') as $source) {
$metadata = $source->getHostedMetadata();
if (isset($metadata['keys'])) {
$certificates = $metadata['keys'];
if (count($metadata['keys']) === 1) {
$cert = array_pop($metadata['keys']);
$metadata['certData'] = $cert['X509Certificate'];
unset($metadata['keys']);
}
} else {
$certificates = [];
}
// get the name
$name = $source->getMetadata()->getLocalizedString(
'name',
$source->getMetadata()->getLocalizedString('OrganizationDisplayName', $source->getAuthId())
);
$builder = new SAMLBuilder($source->getEntityId());
$builder->addMetadataSP20($metadata, $source->getSupportedProtocols());
$builder->addOrganizationInfo($metadata);
$xml = $builder->getEntityDescriptorText(true);
// sanitize the resulting array
unset($metadata['UIInfo']);
unset($metadata['metadata-set']);
unset($metadata['entityid']);
// sanitize the attributes array to remove friendly names
if (isset($metadata['attributes']) && is_array($metadata['attributes'])) {
$metadata['attributes'] = array_values($metadata['attributes']);
}
// sign the metadata if enabled
$xml = Signer::sign($xml, $source->getMetadata()->toArray(), 'SAML 2 SP');
$entities[] = [
'authid' => $source->getAuthId(),
'entityid' => $source->getEntityId(),
'type' => 'saml20-sp-hosted',
'url' => $source->getMetadataURL(),
'name' => $name,
'metadata' => $xml,
'metadata_array' => VarExporter::export($metadata),
'certificates' => $certificates,
];
}
return $entities;
}
/**
* Metadata converter
*
* @param Request $request The current request.
*
* @return \SimpleSAML\XHTML\Template
*/
public function metadataConverter(Request $request): Template
{
Utils\Auth::requireAdmin();
if ($xmlfile = $request->files->get('xmlfile')) {
$xmldata = trim(file_get_contents($xmlfile->getPathname()));
} elseif ($xmldata = $request->request->get('xmldata')) {
$xmldata = trim($xmldata);
}
$error = null;
if (!empty($xmldata)) {
Utils\XML::checkSAMLMessage($xmldata, 'saml-meta');
$entities = null;
try {
$entities = SAMLParser::parseDescriptorsString($xmldata);
} catch (Exception $e) {
$error = $e->getMessage();
}
if ($entities !== null) {
// get all metadata for the entities
foreach ($entities as &$entity) {
$entity = [
'saml20-sp-remote' => $entity->getMetadata20SP(),
'saml20-idp-remote' => $entity->getMetadata20IdP(),
];
}
// transpose from $entities[entityid][type] to $output[type][entityid]
$output = Utils\Arrays::transpose($entities);
// merge all metadata of each type to a single string which should be added to the corresponding file
foreach ($output as $type => &$entities) {
$text = '';
foreach ($entities as $entityId => $entityMetadata) {
if ($entityMetadata === null) {
continue;
}
/**
* remove the entityDescriptor element because it is unused,
* and only makes the output harder to read
*/
unset($entityMetadata['entityDescriptor']);
$text .= '$metadata[' . var_export($entityId, true) . '] = '
. VarExporter::export($entityMetadata) . ";\n";
}
$entities = $text;
}
}
} else {
$xmldata = '';
$output = [];
}
$t = new Template($this->config, 'admin:metadata_converter.twig');
$t->data = [
'logouturl' => Utils\Auth::getAdminLogoutURL(),
'xmldata' => $xmldata,
'output' => $output,
'error' => $error,
];
$this->menu->addOption('logout', $t->data['logouturl'], Translate::noop('Log out'));
return $this->menu->insert($t);
}
/**
* Download a certificate for a given entity.
*
* @param Request $request The current request.
*
* @return Response PEM-encoded certificate.
*/
public function downloadCert(Request $request): Response
{
Utils\Auth::requireAdmin();
$set = $request->get('set');
$prefix = $request->get('prefix');
if ($set === 'saml20-sp-hosted') {
$sourceID = $request->get('source');
/**
* The second argument ensures non-nullable return-value
* @var \SimpleSAML\Module\saml\Auth\Source\SP $source
*/
$source = \SimpleSAML\Auth\Source::getById($sourceID, Module\saml\Auth\Source\SP::class);
$mdconfig = $source->getMetadata();
} else {
$entityID = $request->get('entity');
$mdconfig = $this->mdHandler->getMetaDataConfig($entityID, $set);
}
/** @var array $certInfo Second param ensures non-nullable return-value */
$certInfo = Utils\Crypto::loadPublicKey($mdconfig, true, $prefix);
$response = new Response($certInfo['PEM']);
$disposition = $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
'cert.pem'
);
$response->headers->set('Content-Disposition', $disposition);
$response->headers->set('Content-Type', 'application/x-pem-file');
return $response;
}
/**
* Show remote entity metadata
*
* @param Request $request The current request.
*
* @return Response
*/
public function showRemoteEntity(Request $request): Response
{
Utils\Auth::requireAdmin();
$entityId = $request->get('entityid');
$set = $request->get('set');
$metadata = $this->mdHandler->getMetaData($entityId, $set);
$t = new Template($this->config, 'admin:show_metadata.twig');
$t->data['entityid'] = $entityId;
$t->data['metadata'] = VarExporter::export($metadata);
return $t;
}
}