<?php declare(strict_types=1); namespace SimpleSAML\Module\authswitcher\Auth\Process; use Detection\MobileDetect; use SimpleSAML\Auth\State; use SimpleSAML\Configuration; use SimpleSAML\Error\Exception; use SimpleSAML\Logger; use SimpleSAML\Module; use SimpleSAML\Module\authswitcher\AuthnContextHelper; use SimpleSAML\Module\authswitcher\AuthSwitcher; use SimpleSAML\Module\authswitcher\ContextSettings; use SimpleSAML\Module\authswitcher\ProxyHelper; use SimpleSAML\Module\authswitcher\Utils; use SimpleSAML\Utils\HTTP; class SwitchAuth extends \SimpleSAML\Auth\ProcessingFilter { /* constants */ private const DEBUG_PREFIX = 'authswitcher:SwitchAuth: '; private const SETUP_MFA_URL = 'authswitcher/setupMFA.php'; public const SETUP_MFA_TPL_URL = 'authswitcher:setup-mfa-tpl.php'; public const PARAM_MFA_REDIRECT_URL = 'mfa_redirect_url'; private $type_filter_array = [ 'TOTP' => 'privacyidea:PrivacyideaAuthProc', 'WebAuthn' => 'privacyidea:PrivacyideaAuthProc', ]; private $mobile_friendly_filters = ['privacyidea:PrivacyideaAuthProc', 'totp:Totp']; private $mfa_preferred_privacyidea_fail = false; /** * Associative array with keys of the form 'module:filter', values are config arrays to be passed to filters. */ private $configs = []; /** * Second constructor parameter. */ private $reserved; private $config; private $proxyMode = false; private $token_type_attr = 'type'; private $preferred_filter; private $max_user_capability_attr = 'maxUserCapability'; /** * Maximum Authentication assurance. */ private $max_auth = 'https://id.muni.cz/profile/maxAuth'; private $check_entropy = false; private $sfa_alphabet_attr = 'sfaAlphabet'; private $sfa_len_attr = 'sfaLen'; private $change_weak_password_urls; private $entityID; private $mfa_excluded_sps; private $setup_mfa_redirect_url; /** * @override * * @param mixed $config * @param mixed $reserved */ public function __construct($config, $reserved) { parent::__construct($config, $reserved); $this->config = $config; $this->reserved = $reserved; $config = isset($config['config']) ? Configuration::loadFromArray( $config['config'] ) : Configuration::getOptionalConfig('module_authswitcher.php'); $this->type_filter_array = $config->getArray('type_filter_array', $this->type_filter_array); $this->mobile_friendly_filters = $config->getArray('mobile_friendly_filters', $this->mobile_friendly_filters); $this->token_type_attr = $config->getString('token_type_attr', $this->token_type_attr); $this->preferred_filter = $config->getString('preferred_filter', $this->preferred_filter); $this->proxyMode = $config->getBoolean('proxy_mode', $this->proxyMode); $this->mfa_preferred_privacyidea_fail = $config->getBoolean( 'mfa_preferred_privacyidea_fail', $this->mfa_preferred_privacyidea_fail ); $this->max_user_capability_attr = $config->getString( 'max_user_capability_attr', $this->max_user_capability_attr ); $this->max_auth = $config->getString('max_auth', $this->max_auth); $this->sfa_alphabet_attr = $config->getString('sfa_alphabet_attr', $this->sfa_alphabet_attr); $this->sfa_len_attr = $config->getString('sfa_len_attr', $this->sfa_len_attr); $this->check_entropy = $config->getBoolean('check_entropy', $this->check_entropy); $this->entityID = $config->getValue('entityID', null); $this->mfa_excluded_sps = $config->getArray('mfa_excluded_sps', []); $this->setup_mfa_redirect_url = $config->getString('setup_mfa_redirect_url', ""); $this->change_weak_password_urls = $config->getArray('change_weak_password_urls', []); list($this->password_contexts, $this->mfa_contexts, $password_contexts_patterns, $mfa_contexts_patterns) = ContextSettings::parseConfig($config); $this->authnContextHelper = new AuthnContextHelper( $this->password_contexts, $this->mfa_contexts, $password_contexts_patterns, $mfa_contexts_patterns ); } /** * @override * * @param mixed $state */ public function process(&$state) { $this->getConfig($this->config); $rpIdentifier = self::getEntityID($this->entityID, $state); $mfaEnforced = Utils::isMFAEnforced($state, $rpIdentifier); $usersCapabilities = $this->getMFAForUid($state); $upstreamContext = $this->proxyMode ? ProxyHelper::fetchContextFromUpstreamIdp($state) : null; self::info('user capabilities: ' . json_encode($usersCapabilities)); self::setErrorHandling($state); if ($this->proxyMode) { self::info('upstream context: ' . $upstreamContext); ProxyHelper::recoverSPRequestedContexts($state); } $weak_password = false; $this->supported_requested_contexts = $this->authnContextHelper->getSupportedRequestedContexts( $usersCapabilities, $state, $upstreamContext, !$this->check_entropy || $this->checkSfaEntropy($state['Attributes']), $weak_password, $this->change_weak_password_urls ); self::info('supported requested contexts: ' . json_encode($this->supported_requested_contexts)); $mustPerformMFA = $this->authnContextHelper->MFAin([$upstreamContext]) ? false : ($weak_password || $mfaEnforced || empty($this->supported_requested_contexts)); $shouldPerformMFA = $this->authnContextHelper->MFAin([$upstreamContext]) ? false : ($mustPerformMFA || $this->authnContextHelper->isMFAprefered($this->supported_requested_contexts)); if ( $this->mfa_preferred_privacyidea_fail && !empty($state[AuthSwitcher::PRIVACY_IDEA_FAIL]) && $shouldPerformMFA ) { throw new Exception(self::DEBUG_PREFIX . 'MFA should be performed but connection to privacyidea failed.'); } if ( $mustPerformMFA && empty($state['Attributes'][AuthSwitcher::MFA_TOKENS]) && !empty($this->setup_mfa_redirect_url) && !in_array($rpIdentifier, $this->mfa_excluded_sps) ) { self::info('user must perform MFA but has no tokens, redirect to setup'); $url = Module::getModuleURL(self::SETUP_MFA_URL); $state[self::PARAM_MFA_REDIRECT_URL] = $this->setup_mfa_redirect_url; $stateId = State::saveState($state, 'authswitcher:authswitcher'); HTTP::redirectTrustedURL($url, ['stateId' => $stateId]); exit; } if (empty($this->supported_requested_contexts)) { Logger::info('authswitcher: no requested AuthnContext can be fulfilled'); AuthnContextHelper::noAuthnContextResponder($state); } // switch to MFA if enforced or preferred but not already done if we handle the proxy mode $performMFA = $this->authnContextHelper->MFAin($usersCapabilities) && $shouldPerformMFA; $maxUserCapability = ''; if ( in_array(AuthSwitcher::REFEDS_MFA, $usersCapabilities, true) || $this->authnContextHelper->MFAin([ $upstreamContext, ]) ) { $maxUserCapability = AuthSwitcher::REFEDS_MFA; } elseif (count($usersCapabilities) === 1) { $maxUserCapability = $usersCapabilities[0]; } $state['Attributes'][$this->max_user_capability_attr] = []; if ($performMFA) { $this->performMFA($state, $maxUserCapability); } else { // SFA or MFA was done at upstream IdP $this->setAuthnContext($state, $maxUserCapability, $upstreamContext); } } public function setAuthnContext(&$state, $maxUserCapability, $upstreamContext = null) { $state[AuthSwitcher::MFA_PERFORMED] = !empty($state[AuthSwitcher::MFA_BEING_PERFORMED]) || $this->authnContextHelper->MFAin([$upstreamContext]); if ( $maxUserCapability === AuthSwitcher::REFEDS_SFA || ($maxUserCapability === AuthSwitcher::REFEDS_MFA && $state[AuthSwitcher::MFA_PERFORMED]) ) { $state['Attributes'][$this->max_user_capability_attr][] = $this->max_auth; } $possibleReplies = $state[AuthSwitcher::MFA_PERFORMED] ? array_merge( $this->mfa_contexts, $this->password_contexts ) : $this->password_contexts; $possibleReplies = array_values(array_intersect($possibleReplies, $this->supported_requested_contexts)); if (empty($possibleReplies)) { AuthnContextHelper::noAuthnContextResponder($state); } else { $state['saml:AuthnContextClassRef'] = $possibleReplies[0]; } } private function checkSfaEntropy($attributes) { if ( !$this->sfa_len_attr || !$this->sfa_alphabet_attr || !in_array( $this->sfa_alphabet_attr, $attributes, true ) || !in_array($this->sfa_len_attr, $attributes, true) ) { return false; } if ($attributes[$this->sfa_alphabet_attr][0] >= 52 && $attributes[$this->sfa_len_attr][0] >= 12) { return true; } if ($attributes[$this->sfa_alphabet_attr][0] >= 72 && $attributes[$this->sfa_len_attr][0] >= 8) { return true; } return false; } /** * Handle NoAuthnContext errors by SAML responses. * * @param mixed $state */ private static function setErrorHandling(&$state) { $error_state = State::cloneState($state); unset($error_state[State::EXCEPTION_HANDLER_URL]); $error_state[State::EXCEPTION_HANDLER_FUNC] = ['\\SimpleSAML\\Module\\saml\\IdP\\SAML2', 'handleAuthError']; $state[AuthSwitcher::ERROR_STATE_ID] = State::saveState($error_state, Authswitcher::ERROR_STATE_STAGE); } /** * Log a warning. * * @param $message */ private function warning($message) { Logger::warning(self::DEBUG_PREFIX . $message); } /** * Log an info. * * @param $message */ private function info($message) { Logger::info(self::DEBUG_PREFIX . $message); } /** * Get configuration parameters from the config array. */ private function getConfig(array $config) { if (!is_array($config['configs'])) { throw new Exception(self::DEBUG_PREFIX . 'Configurations are missing.'); } $filterModules = array_keys($config['configs']); $invalidModules = Utils::areFilterModulesEnabled($filterModules); if ($invalidModules !== true) { $this->warning( 'Some modules (' . implode(',', $invalidModules) . ')' . ' in the configuration are missing or disabled.' ); } $this->configs = $config['configs']; } private function getMFAForUid($state) { $result = []; if (!empty($state['Attributes'][AuthSwitcher::MFA_TOKENS])) { foreach ($state['Attributes'][AuthSwitcher::MFA_TOKENS] as $mfaToken) { if (is_string($mfaToken)) { $mfaToken = json_decode($mfaToken, true); } foreach ($this->type_filter_array as $type => $method) { if ($mfaToken['revoked'] === false && $mfaToken[$this->token_type_attr] === $type) { $result[] = AuthSwitcher::REFEDS_MFA; break; } } if (!empty($result)) { break; } } } $result[] = AuthSwitcher::REFEDS_SFA; return $result; } private function getActiveMethod(&$state) { $result = []; if (!empty($state['Attributes'][AuthSwitcher::MFA_TOKENS])) { foreach ($state['Attributes'][AuthSwitcher::MFA_TOKENS] as $mfaToken) { if (is_string($mfaToken)) { $mfaToken = json_decode($mfaToken, true); } foreach ($this->type_filter_array as $type => $filter) { if ($mfaToken['revoked'] === false && $mfaToken[$this->token_type_attr] === $type) { $result[] = $filter; } } } } $result = array_values(array_unique($result)); $detect = new MobileDetect(); $mobile_pref = $detect->isMobile(); if ($result === []) { return null; } $state['Attributes']['MFA_FILTERS'] = $result; if ($this->preferred_filter !== null && in_array($this->preferred_filter, $result, true)) { return $this->preferred_filter; } if ($mobile_pref) { foreach ($result as $filter) { if (in_array($filter, $this->mobile_friendly_filters, true)) { return $filter; } } } return $result[0]; } /** * Perform the appropriate MFA. * * @param mixed $state * @param $maxUserCapability */ private function performMFA(&$state, $maxUserCapability) { $filter = $this->getActiveMethod($state); if (empty($filter)) { throw new Exception( self::DEBUG_PREFIX . 'Inconsistent data - no MFA methods for a user who should be able to do MFA.' ); } if (!isset($this->configs[$filter])) { throw new Exception(self::DEBUG_PREFIX . 'Configuration for ' . $filter . ' is missing.'); } if (!isset($state[AuthSwitcher::MFA_BEING_PERFORMED])) { $state[AuthSwitcher::MFA_BEING_PERFORMED] = true; } $this->setAuthnContext($state, $maxUserCapability); $state['Attributes']['Config'] = json_encode($this->configs); if ($this->reserved === null) { $this->reserved = ''; } $state['Attributes']['Reserved'] = $this->reserved; $state['Attributes']['MFA_FILTER_INDEX'] = array_search($filter, $state['Attributes']['MFA_FILTERS'], true); Utils::runAuthProcFilter($filter, $this->configs[$filter], $state, $this->reserved); } private static function getEntityID($entityID, $request) { if ($entityID === null) { return $request['SPMetadata']['entityid']; } if (is_callable($entityID)) { return call_user_func($entityID, $request); } if (!is_string($entityID)) { throw new Exception( self::DEBUG_PREFIX . 'Invalid configuration option entityID. It must be a string or a callable.' ); } return $entityID; } }