-
Thijs Kinkhorst authored6298d32e
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\Configuration;
use SimpleSAML\HTTP\RunnableResponse;
use SimpleSAML\Locale\Translate;
use SimpleSAML\Metadata\MetaDataStorageHandler;
use SimpleSAML\Module;
use SimpleSAML\Session;
use SimpleSAML\Utils;
use SimpleSAML\XHTML\Template;
use Symfony\Component\HttpFoundation\Request;
/**
* Controller class for the admin module.
*
* This class serves the configuration views available in the module.
*
* @package SimpleSAML\Module\admin
*/
class Config
{
public const LATEST_VERSION_STATE_KEY = 'core:latest_simplesamlphp_version';
public const RELEASES_API = 'https://api.github.com/repos/simplesamlphp/simplesamlphp/releases/latest';
/** @var \SimpleSAML\Configuration */
protected Configuration $config;
/** @var \SimpleSAML\Utils\Auth */
protected Utils\Auth $authUtils;
/** @var \SimpleSAML\Utils\HTTP */
protected Utils\HTTP $httpUtils;
/** @var \SimpleSAML\Module\admin\Controller\Menu */
protected Menu $menu;
/** @var \SimpleSAML\Session */
protected Session $session;
/**
* ConfigController constructor.
*
* @param \SimpleSAML\Configuration $config The configuration to use.
* @param \SimpleSAML\Session $session The current user session.
*/
public function __construct(Configuration $config, Session $session)
{
$this->config = $config;
$this->session = $session;
$this->menu = new Menu();
$this->authUtils = new Utils\Auth();
$this->httpUtils = new Utils\HTTP();
}
/**
* Inject the \SimpleSAML\Utils\Auth dependency.
*
* @param \SimpleSAML\Utils\Auth $authUtils
*/
public function setAuthUtils(Utils\Auth $authUtils): void
{
$this->authUtils = $authUtils;
}
/**
* Display basic diagnostic information on hostname, port and protocol.
*
* @param \Symfony\Component\HttpFoundation\Request $request The current request.
*
* @return \SimpleSAML\XHTML\Template
*/
public function diagnostics(Request $request): Template
{
$this->authUtils->requireAdmin();
$t = new Template($this->config, 'admin:diagnostics.twig');
$t->data = [
'remaining' => $this->session->getAuthData('admin', 'Expire') - time(),
'logouturl' => $this->authUtils->getAdminLogoutURL(),
'items' => [
'HTTP_HOST' => [$request->getHost()],
'HTTPS' => $request->isSecure() ? ['on'] : [],
'SERVER_PROTOCOL' => [$request->getProtocolVersion()],
'getBaseURL()' => [$this->httpUtils->getBaseURL()],
'getSelfHost()' => [$this->httpUtils->getSelfHost()],
'getSelfHostWithNonStandardPort()' => [$this->httpUtils->getSelfHostWithNonStandardPort()],
'getSelfURLHost()' => [$this->httpUtils->getSelfURLHost()],
'getSelfURLNoQuery()' => [$this->httpUtils->getSelfURLNoQuery()],
'getSelfHostWithPath()' => [$this->httpUtils->getSelfHostWithPath()],
'getSelfURL()' => [$this->httpUtils->getSelfURL()],
],
];
$this->menu->addOption('logout', $t->data['logouturl'], Translate::noop('Log out'));
return $this->menu->insert($t);
}
/**
* Display the main admin page.
*
* @param \Symfony\Component\HttpFoundation\Request $request The current request.
*
* @return \SimpleSAML\XHTML\Template
*/
public function main(/** @scrutinizer ignore-unused */ Request $request): Template
{
$this->authUtils->requireAdmin();
$t = new Template($this->config, 'admin:config.twig');
$t->data = [
'warnings' => $this->getWarnings(),
'directory' => $this->config->getBaseDir(),
'version' => $this->config->getVersion(),
'links' => [
[
'href' => Module::getModuleURL('admin/diagnostics'),
'text' => Translate::noop('Diagnostics on hostname, port and protocol')
],
[
'href' => Module::getModuleURL('admin/phpinfo'),
'text' => Translate::noop('Information on your PHP installation')
]
],
'enablematrix' => [
'saml20idp' => $this->config->getOptionalBoolean('enable.saml20-idp', false),
],
'funcmatrix' => $this->getPrerequisiteChecks(),
'logouturl' => $this->authUtils->getAdminLogoutURL(),
'modulelist' => $this->getModuleList(),
];
Module::callHooks('configpage', $t);
$this->menu->addOption('logout', $this->authUtils->getAdminLogoutURL(), Translate::noop('Log out'));
return $this->menu->insert($t);
}
/**
* @return array
*/
protected function getModuleList(): array
{
$modules = Module::getModules();
$modulestates = [];
foreach ($modules as $module) {
$modulestates[$module] = Module::isModuleEnabled($module);
}
ksort($modulestates);
return $modulestates;
}
/**
* Display the output of phpinfo().
*
* @param \Symfony\Component\HttpFoundation\Request $request The current request.
*
* @return \SimpleSAML\HTTP\RunnableResponse
*/
public function phpinfo(/** @scrutinizer ignore-unused */ Request $request): RunnableResponse
{
$this->authUtils->requireAdmin();
return new RunnableResponse('phpinfo');
}
/**
* Perform a list of checks on the current installation, and return the results as an array.
*
* The elements in the array returned are also arrays with the following keys:
*
* - required: Whether this prerequisite is mandatory or not. One of "required" or "optional".
* - descr: A translatable text that describes the prerequisite. If the text uses parameters, the value must be an
* array where the first value is the text to translate, and the second is a hashed array containing the
* parameters needed to properly translate the text.
* - enabled: True if the prerequisite is met, false otherwise.
*
* @return array
*/
protected function getPrerequisiteChecks(): array
{
$matrix = [
[
'required' => 'required',
'descr' => [
Translate::noop('PHP %minimum% or newer is needed. You are running: %current%'),
[
'%minimum%' => '7.4',
'%current%' => explode('-', phpversion())[0]
]
],
'enabled' => version_compare(phpversion(), '7.4', '>=')
]
];
$store = $this->config->getOptionalString('store.type', null);
// check dependencies used via normal functions
$functions = [
'time' => [
'required' => 'required',
'descr' => [
'required' => Translate::noop('Date/Time Extension'),
]
],
'hash' => [
'required' => 'required',
'descr' => [
'required' => Translate::noop('Hashing function'),
]
],
'gzinflate' => [
'required' => 'required',
'descr' => [
'required' => Translate::noop('ZLib'),
]
],
'openssl_sign' => [
'required' => 'required',
'descr' => [
'required' => Translate::noop('OpenSSL'),
]
],
'dom_import_simplexml' => [
'required' => 'required',
'descr' => [
'required' => Translate::noop('XML DOM'),
]
],
'preg_match' => [
'required' => 'required',
'descr' => [
'required' => Translate::noop('Regular expression support'),
]
],
'intl_get_error_code' => [
'required' => 'required',
'descr' => [
'required' => Translate::noop('PHP intl extension'),
]
],
'json_decode' => [
'required' => 'required',
'descr' => [
'required' => Translate::noop('JSON support'),
]
],
'class_implements' => [
'required' => 'required',
'descr' => [
'required' => Translate::noop('Standard PHP library (SPL)'),
]
],
'mb_strlen' => [
'required' => 'required',
'descr' => [
'required' => Translate::noop('Multibyte String extension'),
]
],
'curl_init' => [
'required' => $this->config->getOptionalBoolean('admin.checkforupdates', true) ? 'required' : 'optional',
'descr' => [
'optional' => Translate::noop(
'cURL (might be required by some modules)'
),
'required' => Translate::noop(
'cURL (required if automatic version checks are used, also by some modules)'
),
]
],
'session_start' => [
'required' => $store === 'phpsession' ? 'required' : 'optional',
'descr' => [
'optional' => Translate::noop('Session extension (required if PHP sessions are used)'),
'required' => Translate::noop('Session extension'),
]
],
'pdo_drivers' => [
'required' => $store === 'sql' ? 'required' : 'optional',
'descr' => [
'optional' => Translate::noop('PDO Extension (required if a database backend is used)'),
'required' => Translate::noop('PDO extension'),
]
],
'ldap_bind' => [
'required' => Module::isModuleEnabled('ldap') ? 'required' : 'optional',
'descr' => [
'optional' => Translate::noop('LDAP extension (required if an LDAP backend is used)'),
'required' => Translate::noop('LDAP extension'),
]
],
];
foreach ($functions as $function => $description) {
$matrix[] = [
'required' => $description['required'],
'descr' => $description['descr'][$description['required']],
'enabled' => function_exists($function),
];
}
// check object-oriented external libraries and extensions
$libs = [
[
'classes' => ['\Predis\Client'],
'required' => $store === 'redis' ? 'required' : 'optional',
'descr' => [
'optional' => Translate::noop('predis/predis (required if the redis data store is used)'),
'required' => Translate::noop('predis/predis library'),
]
],
[
'classes' => ['\Memcache', '\Memcached'],
'required' => $store === 'memcache' ? 'required' : 'optional',
'descr' => [
'optional' => Translate::noop(
'Memcache or Memcached extension (required if the memcache backend is used)'
),
'required' => Translate::noop('Memcache or Memcached extension'),
]
]
];
foreach ($libs as $lib) {
$enabled = false;
foreach ($lib['classes'] as $class) {
/** @psalm-suppress InvalidOperand - See https://github.com/vimeo/psalm/issues/1340 */
$enabled |= class_exists($class);
}
$matrix[] = [
'required' => $lib['required'],
'descr' => $lib['descr'][$lib['required']],
'enabled' => $enabled,
];
}
// perform some basic configuration checks
$matrix[] = [
'required' => 'optional',
'descr' => Translate::noop('The <code>technicalcontact_email</code> configuration option should be set'),
'enabled' => $this->config->getOptionalString('technicalcontact_email', 'na@example.org') !== 'na@example.org',
];
$matrix[] = [
'required' => 'required',
'descr' => Translate::noop('The auth.adminpassword configuration option must be set'),
'enabled' => $this->config->getOptionalString('auth.adminpassword', '123') !== '123',
];
$cryptoUtils = new Utils\Crypto();
// perform some sanity checks on the configured certificates
if ($this->config->getOptionalBoolean('enable.saml20-idp', false) !== false) {
$handler = MetaDataStorageHandler::getMetadataHandler();
try {
$metadata = $handler->getMetaDataCurrent('saml20-idp-hosted');
} catch (Exception $e) {
$matrix[] = [
'required' => 'required',
'descr' => Translate::noop('Hosted IdP metadata present'),
'enabled' => false
];
}
if (isset($metadata)) {
$metadata_config = Configuration::loadfromArray($metadata);
$private = $cryptoUtils->loadPrivateKey($metadata_config, false);
$public = $cryptoUtils->loadPublicKey($metadata_config, false);
$matrix[] = [
'required' => 'required',
'descr' => Translate::noop('Matching key-pair for signing assertions'),
'enabled' => $this->matchingKeyPair($public['PEM'], $private['PEM'], $private['password']),
];
$private = $cryptoUtils->loadPrivateKey($metadata_config, false, 'new_');
if ($private !== null) {
$public = $cryptoUtils->loadPublicKey($metadata_config, false, 'new_');
$matrix[] = [
'required' => 'required',
'descr' => Translate::noop('Matching key-pair for signing assertions (rollover key)'),
'enabled' => $this->matchingKeyPair($public['PEM'], $private['PEM'], $private['password']),
];
}
}
}
if ($this->config->getOptionalBoolean('metadata.sign.enable', false) !== false) {
$private = $cryptoUtils->loadPrivateKey($this->config, false, 'metadata.sign.');
$public = $cryptoUtils->loadPublicKey($this->config, false, 'metadata.sign.');
$matrix[] = [
'required' => 'required',
'descr' => Translate::noop('Matching key-pair for signing metadata'),
'enabled' => $this->matchingKeyPair($public['PEM'], $private['PEM'], $private['password']),
];
}
return $matrix;
}
/**
* Compile a list of warnings about the current deployment.
*
* The returned array can contain either strings that can be translated directly, or arrays. If an element is an
* array, the first value in that array is a string that can be translated, and the second value will be a hashed
* array that contains the substitutions that must be applied to the translation, with its corresponding value. This
* can be used in twig like this, assuming an element called "e":
*
* {{ e[0]|trans(e[1])|raw }}
*
* @return array
*/
protected function getWarnings(): array
{
$warnings = [];
// make sure we're using HTTPS
if (!$this->httpUtils->isHTTPS()) {
$warnings[] = Translate::noop(
'<strong>You are not using HTTPS</strong> to protect communications with your users. HTTP works fine ' .
'for testing purposes, but in a production environment you should use HTTPS. <a ' .
'href="https://simplesamlphp.org/docs/stable/simplesamlphp-maintenance">Read more about the ' .
'maintenance of SimpleSAMLphp</a>.'
);
}
// make sure we have a secret salt set
if ($this->config->getString('secretsalt') === 'defaultsecretsalt') {
$warnings[] = Translate::noop(
'<strong>The configuration uses the default secret salt</strong>. Make sure to modify the <code>' .
'secretsalt</code> option in the SimpleSAMLphp configuration in production environments. <a ' .
'href="https://simplesamlphp.org/docs/stable/simplesamlphp-install">Read more about the ' .
'maintenance of SimpleSAMLphp</a>.'
);
}
/*
* Check for updates. Store the remote result in the session so we don't need to fetch it on every access to
* this page.
*/
if ($this->config->getOptionalBoolean('admin.checkforupdates', true) && $this->config->getVersion() !== 'master') {
if (!function_exists('curl_init')) {
$warnings[] = Translate::noop(
'The cURL PHP extension is missing. Cannot check for SimpleSAMLphp updates.'
);
} else {
$latest = $this->session->getData(self::LATEST_VERSION_STATE_KEY, "version");
if (!$latest) {
$ch = curl_init(self::RELEASES_API);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_USERAGENT, 'SimpleSAMLphp');
curl_setopt($ch, CURLOPT_TIMEOUT, 2);
curl_setopt($ch, CURLOPT_PROXY, $this->config->getOptionalString('proxy', null));
curl_setopt($ch, CURLOPT_PROXYUSERPWD, $this->config->getOptionalValue('proxy.auth', null));
$response = curl_exec($ch);
if (curl_getinfo($ch, CURLINFO_RESPONSE_CODE) === 200) {
/** @psalm-var string $response */
$latest = json_decode($response, true);
$this->session->setData(self::LATEST_VERSION_STATE_KEY, 'version', $latest);
}
curl_close($ch);
}
if ($latest && version_compare($this->config->getVersion(), ltrim($latest['tag_name'], 'v'), 'lt')) {
$warnings[] = [
Translate::noop(
'You are running an outdated version of SimpleSAMLphp. Please update to <a href="' .
'%latest%">the latest version</a> as soon as possible.'
),
[
'%latest%' => $latest['html_url']
]
];
}
}
}
return $warnings;
}
/**
* Test whether public & private key are a matching pair
*
* @param string $publicKey
* @param string $privateKey
* @param string|null $password
* @return bool
*/
private function matchingKeyPair(
string $publicKey,
string $privateKey,
?string $password = null
): bool {
return openssl_x509_check_private_key($publicKey, [$privateKey, $password]);
}
}