menu = new Menu(); $this->mdHandler = MetaDataStorageHandler::getMetadataHandler(); $this->authUtils = new Utils\Auth(); $this->cryptoUtils = new Utils\Crypto(); } /** * Inject the \SimpleSAML\Auth\Source dependency. * * @param \SimpleSAML\Auth\Source $authSource */ public function setAuthSource(Auth\Source $authSource): void { $this->authSource = $authSource; } /** * Inject the \SimpleSAML\Utils\Auth dependency. * * @param \SimpleSAML\Utils\Auth $authUtils */ public function setAuthUtils(Utils\Auth $authUtils): void { $this->authUtils = $authUtils; } /** * Inject the \SimpleSAML\Metadata\MetadataStorageHandler dependency. * * @param \SimpleSAML\Metadata\MetaDataStorageHandler $mdHandler */ public function setMetadataStorageHandler(MetadataStorageHandler $mdHandler): void { $this->mdHandler = $mdHandler; } /** * Display the federation page. * * @param \Symfony\Component\HttpFoundation\Request $request * @return \SimpleSAML\XHTML\Template * @throws \SimpleSAML\Error\Exception * @throws \SimpleSAML\Error\Exception */ public function main(/** @scrutinizer ignore-unused */ Request $request): Template { $this->authUtils->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->getOptionalBoolean('enable.saml20-idp', false) === true ? $this->mdHandler->getList('saml20-sp-remote', true) : [], 'adfs-sp-remote' => ($this->config->getOptionalBoolean('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(); // 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]); } } $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' => $this->authUtils->getAdminLogoutURL(), ]; Module::callHooks('federationpage', $t); Assert::isInstanceOf($t, Template::class); $this->menu->addOption('logout', $t->data['logouturl'], Translate::noop('Log out')); /** @psalm-var \SimpleSAML\XHTML\Template $t */ return $this->menu->insert($t); } /** * Get a list of the hosted IdP entities, including SAML 2 and ADFS. * * @return array * @throws \Exception */ private function getHostedIdP(): array { $entities = []; // SAML 2 if ($this->config->getOptionalBoolean('enable.saml20-idp', false)) { try { $idps = $this->mdHandler->getList('saml20-idp-hosted'); $saml2entities = []; $httpUtils = new Utils\HTTP(); $metadataBase = Module::getModuleURL('saml/idp/metadata'); if (count($idps) > 1) { $selfHost = $httpUtils->getSelfHostWithPath(); foreach ($idps as $index => $idp) { if (isset($idp['host']) && $idp['host'] !== '__DEFAULT__') { $mdHostBase = str_replace('://' . $selfHost . '/', '://' . $idp['host'] . '/', $metadataBase); } else { $mdHostBase = $metadataBase; } $idp['url'] = $mdHostBase . '?idpentityid=' . urlencode($idp['entityid']); $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'] = $metadataBase; $saml2entities['saml20-idp']['metadata_array'] = SAML2_IdP::getHostedMetadata( $this->mdHandler->getMetaDataCurrentEntityID('saml20-idp-hosted'), ); } foreach ($saml2entities as $index => $entity) { Assert::validURI($entity['entityid']); Assert::maxLength( $entity['entityid'], C::SAML2INT_ENTITYID_MAX_LENGTH, sprintf('The entityID cannot be longer than %d characters.', C::SAML2INT_ENTITYID_MAX_LENGTH), ); $builder = new SAMLBuilder($entity['entityid']); $builder->addMetadataIdP20($entity['metadata_array']); $builder->addOrganizationInfo($entity['metadata_array']); $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->getOptionalBoolean('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/?idpentityid=' . urlencode($idp['entityid'])); $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) { Assert::validURI($entity['entityid']); Assert::maxLength( $entity['entityid'], C::SAML2INT_ENTITYID_MAX_LENGTH, sprintf('The entityID cannot be longer than %d characters.', C::SAML2INT_ENTITYID_MAX_LENGTH), ); $builder = new SAMLBuilder($entity['entityid']); $builder->addSecurityTokenServiceType($entity['metadata_array']); $builder->addOrganizationInfo($entity['metadata_array']); if (isset($entity['metadata_array']['contacts'])) { foreach ($entity['metadata_array']['contacts'] as $contact) { $builder->addContact(Utils\Config\Metadata::getContact($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'], 'entity' => $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 ($this->authSource::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()->getOptionalLocalizedString( 'name', $source->getMetadata()->getOptionalLocalizedString( 'OrganizationDisplayName', ['en' => $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['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 \Symfony\Component\HttpFoundation\Request $request The current request. * * @return \SimpleSAML\XHTML\Template */ public function metadataConverter(Request $request): Template { $this->authUtils->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)) { $xmlUtils = new Utils\XML(); $xmlUtils->checkSAMLMessage($xmldata, 'saml-meta'); try { $entities = SAMLParser::parseDescriptorsString($xmldata); } catch (Exception $e) { $entities = null; $error = $e->getMessage(); } $output = []; 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] $arrayUtils = new Utils\Arrays(); $output = $arrayUtils->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']); /** * Remove any expire from the metadata. This is not so useful * for manually converted metadata and frequently gives rise * to unexpected results when copy-pased statically. */ unset($entityMetadata['expire']); $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' => $this->authUtils->getAdminLogoutURL(), 'xmldata' => $xmldata, 'output' => $output, 'error' => $error, 'upload' => boolval(ini_get('file_uploads')), ]; $this->menu->addOption('logout', $t->data['logouturl'], Translate::noop('Log out')); return $this->menu->insert($t); } /** * Download a certificate for a given entity. * * @param \Symfony\Component\HttpFoundation\Request $request The current request. * * @return \Symfony\Component\HttpFoundation\Response PEM-encoded certificate. */ public function downloadCert(Request $request): Response { $this->authUtils->requireAdmin(); $set = $request->query->get('set'); $prefix = $request->query->get('prefix', ''); if ($set === 'saml20-sp-hosted') { $sourceID = $request->query->get('source'); /** * The second argument ensures non-nullable return-value * @var \SimpleSAML\Module\saml\Auth\Source\SP $source */ $source = $this->authSource::getById($sourceID, Module\saml\Auth\Source\SP::class); $mdconfig = $source->getMetadata(); } else { $entityID = $request->query->get('entity'); $mdconfig = $this->mdHandler->getMetaDataConfig($entityID, $set); } /** @var array $certInfo Second param ensures non-nullable return-value */ $certInfo = $this->cryptoUtils->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 \Symfony\Component\HttpFoundation\Request $request The current request. * * @return \SimpleSAML\XHTML\Template */ public function showRemoteEntity(Request $request): Template { $this->authUtils->requireAdmin(); $entityId = $request->query->get('entityid'); $set = $request->query->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; } }