557 lines
21 KiB
PHP
557 lines
21 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace SimpleSAML\Module\admin\Controller;
|
|
|
|
use Exception;
|
|
use SAML2\Constants as C;
|
|
use SimpleSAML\Assert\Assert;
|
|
use SimpleSAML\Auth;
|
|
use SimpleSAML\Configuration;
|
|
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\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 function array_merge;
|
|
use function array_pop;
|
|
use function array_values;
|
|
use function boolval;
|
|
use function count;
|
|
use function file_get_contents;
|
|
use function ini_get;
|
|
use function is_array;
|
|
use function sprintf;
|
|
use function str_replace;
|
|
use function trim;
|
|
use function var_export;
|
|
|
|
/**
|
|
* Controller class for the admin module.
|
|
*
|
|
* This class serves the federation views available in the module.
|
|
*
|
|
* @package SimpleSAML\Module\admin
|
|
*/
|
|
class Federation
|
|
{
|
|
/**
|
|
* @var \SimpleSAML\Auth\Source|string
|
|
* @psalm-var \SimpleSAML\Auth\Source|class-string
|
|
*/
|
|
protected $authSource = Auth\Source::class;
|
|
|
|
/** @var \SimpleSAML\Utils\Auth */
|
|
protected Utils\Auth $authUtils;
|
|
|
|
/** @var \SimpleSAML\Utils\Crypto */
|
|
protected Utils\Crypto $cryptoUtils;
|
|
|
|
/** @var \SimpleSAML\Metadata\MetaDataStorageHandler */
|
|
protected MetadataStorageHandler $mdHandler;
|
|
|
|
/** @var \SimpleSAML\Module\admin\Controller\Menu */
|
|
protected Menu $menu;
|
|
|
|
|
|
/**
|
|
* FederationController constructor.
|
|
*
|
|
* @param \SimpleSAML\Configuration $config The configuration to use.
|
|
*/
|
|
public function __construct(
|
|
protected Configuration $config,
|
|
) {
|
|
$this->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;
|
|
}
|
|
}
|