diff --git a/README.md b/README.md index ab1e2157a3629f4c44f5e137908e50af4d825dbd..d5826d863a3c4c0e5e10a7ebc7f3a58723ab1773 100755 --- a/README.md +++ b/README.md @@ -85,6 +85,9 @@ Add an instance of the auth proc filter with example configuration `authswitcher // 'my-custom-authn-context-for-mfa', //]), //'contexts_regex' => true, + //'entityID' => function($request){ + // return empty($request["saml:RequesterID"]) ? $request["SPMetadata"]["entityid"] : $request["saml:RequesterID"][0]; + //}, ], 'configs' => [ 'totp:Totp' => [ @@ -193,6 +196,18 @@ When the attribute is not empty, multi-factor authentication is always performed When used with proxy mode, MFA is not forced if it was already done at upstream IdP. +## Enforce MFA per user per service + +If some user should use MFA for some services, set `mfaEnforceSettings` user attribute to one of the following JSON-encoded object types: + +- `{"all":true}` to force MFA for all services (equivalent to mfaEnforced) +- `{"include_categories":["category1","category2"]}` to force MFA for all services from the listed categories +- `{"include_categories":["category1","category2"],"exclude_rps":["entityID1","entityID2"]}` to force MFA for all services from the listed categories except services with entity ID `entityID1` and `entityID2` + +For this to work, you must also fill the `rpCategory` user attribute with the appropriate category. If this attribute is empty, the service is assumed to belong to a category named `"other"`. + +By default, entity ID is read from the metadata of the current SP. You can override this by specifying the `entityID` config option to either a string (which is used as is) or a callable in the form `function getEntityID($state){return "str";}`. See example configs for more. + ## Add additional attributes when MFA is performed To add attributes only if MFA was performed, you can use a filter called `AddAdditionalAttributesAfterMfa`. diff --git a/config-templates/module_authswitcher.php b/config-templates/module_authswitcher.php index aeeb2db34d00d15a494dcc5fb9bf8bd26a609584..55cbe1c9e014d03d2040d959ac2099f41e6e34b1 100644 --- a/config-templates/module_authswitcher.php +++ b/config-templates/module_authswitcher.php @@ -19,4 +19,7 @@ $config = [ // 'my-custom-authn-context-for-mfa', //]), //'contexts_regex' => true, + //'entityID' => function($request){ + // return empty($request["saml:RequesterID"]) ? $request["SPMetadata"]["entityid"] : $request["saml:RequesterID"][0]; + //}, ]; diff --git a/lib/Auth/Process/GetMfaTokensPrivacyIDEA.php b/lib/Auth/Process/GetMfaTokensPrivacyIDEA.php index d3c2b556ad5a498e1b35368966e296aea48c7e0e..610e9f9b0b2544c51fb71be723458f9fa411819e 100644 --- a/lib/Auth/Process/GetMfaTokensPrivacyIDEA.php +++ b/lib/Auth/Process/GetMfaTokensPrivacyIDEA.php @@ -23,7 +23,7 @@ class GetMfaTokensPrivacyIDEA extends \SimpleSAML\Auth\ProcessingFilter private $timeout; - private $tokens_attr = 'mfaTokens'; + private $tokens_attr = AuthSwitcher::MFA_TOKENS; private $privacy_idea_username; diff --git a/lib/Auth/Process/SwitchAuth.php b/lib/Auth/Process/SwitchAuth.php index cbbe0ddd61bc7d8ace758637861b4d3dbe3b5797..4c9e40956a40443aeca1f7f2c3c89f6815c1bb99 100644 --- a/lib/Auth/Process/SwitchAuth.php +++ b/lib/Auth/Process/SwitchAuth.php @@ -20,8 +20,6 @@ class SwitchAuth extends \SimpleSAML\Auth\ProcessingFilter /* constants */ private const DEBUG_PREFIX = 'authswitcher:SwitchAuth: '; - private const MFA_TOKENS = 'mfaTokens'; - private $type_filter_array = [ 'TOTP' => 'privacyidea:PrivacyideaAuthProc', 'WebAuthn' => 'privacyidea:PrivacyideaAuthProc', @@ -56,17 +54,14 @@ class SwitchAuth extends \SimpleSAML\Auth\ProcessingFilter */ private $max_auth = 'https://id.muni.cz/profile/maxAuth'; - /** - * Whether MFA is enforced for the current user. - */ - private $mfa_enforced; - private $check_entropy = false; private $sfa_alphabet_attr; private $sfa_len_attr; + private $entityID; + /** * @override * @@ -99,6 +94,7 @@ class SwitchAuth extends \SimpleSAML\Auth\ProcessingFilter $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); list($this->password_contexts, $this->mfa_contexts, $password_contexts_patterns, $mfa_contexts_patterns) = ContextSettings::parse_config( $config @@ -119,10 +115,10 @@ class SwitchAuth extends \SimpleSAML\Auth\ProcessingFilter */ public function process(&$state) { - $this->mfa_enforced = !empty($state['Attributes']['mfaEnforced']); - $this->getConfig($this->config); + $mfaEnforced = Utils::isMFAEnforced($state, $this->entityID); + $usersCapabilities = $this->getMFAForUid($state); self::info('user capabilities: ' . json_encode($usersCapabilities)); @@ -142,16 +138,14 @@ class SwitchAuth extends \SimpleSAML\Auth\ProcessingFilter $state, $upstreamContext, !$this->check_entropy || $this->checkSfaEntropy($state['Attributes']), - $this->mfa_enforced + $mfaEnforced ); self::info('supported requested contexts: ' . json_encode($this->supported_requested_contexts)); $shouldPerformMFA = !$this->authnContextHelper->MFAin([ $upstreamContext, - ]) && ($this->mfa_enforced || $this->authnContextHelper->isMFAprefered( - $this->supported_requested_contexts - )); + ]) && ($mfaEnforced || $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.'); @@ -276,8 +270,8 @@ class SwitchAuth extends \SimpleSAML\Auth\ProcessingFilter private function getMFAForUid($state) { $result = []; - if (!empty($state['Attributes'][self::MFA_TOKENS])) { - foreach ($state['Attributes'][self::MFA_TOKENS] as $mfaToken) { + if (!empty($state['Attributes'][AuthSwitcher::MFA_TOKENS])) { + foreach ($state['Attributes'][AuthSwitcher::MFA_TOKENS] as $mfaToken) { if (is_string($mfaToken)) { $mfaToken = json_decode($mfaToken, true); } @@ -301,8 +295,8 @@ class SwitchAuth extends \SimpleSAML\Auth\ProcessingFilter private function getActiveMethod(&$state) { $result = []; - if (!empty($state['Attributes'][self::MFA_TOKENS])) { - foreach ($state['Attributes'][self::MFA_TOKENS] as $mfaToken) { + if (!empty($state['Attributes'][AuthSwitcher::MFA_TOKENS])) { + foreach ($state['Attributes'][AuthSwitcher::MFA_TOKENS] as $mfaToken) { if (is_string($mfaToken)) { $mfaToken = json_decode($mfaToken, true); } diff --git a/lib/AuthSwitcher.php b/lib/AuthSwitcher.php index 1bb80779b5c8f30c4e9dc5f7000c2386f89bb720..232dea79f6892fce7b66270bf86d6710077b7733 100644 --- a/lib/AuthSwitcher.php +++ b/lib/AuthSwitcher.php @@ -53,4 +53,24 @@ class AuthSwitcher public const PRIVACY_IDEA_FAIL = 'PrivacyIDEAFail'; public const SP_REQUESTED_CONTEXTS = 'AUTHSWITCHER_SP_REQUESTED_CONTEXTS'; + + /** + * user attribute which contains MFA tokens + */ + public const MFA_TOKENS = 'mfaTokens'; + + /** + * user attribute which contains boolean whether MFA should be enforced + */ + public const MFA_ENFORCED = 'mfaEnforced'; + + /** + * user attribute which contains settings object for enforcing MFA + */ + public const MFA_ENFORCE_SETTINGS = 'mfaEnforceSettings'; + + /** + * user attribute which contains RP category for the current service + */ + public const RP_CATEGORY = 'rpCategory'; } diff --git a/lib/Utils.php b/lib/Utils.php index a5231a9d9edca532aa44f7eef92d2ffcde29199d..40072aa2f25bca213065de46325d97fd020bbdb2 100644 --- a/lib/Utils.php +++ b/lib/Utils.php @@ -12,6 +12,8 @@ use SimpleSAML\Module; */ class Utils { + private const DEBUG_PREFIX = 'authswitcher:Utils: '; + /** * Execute an auth proc filter. * @@ -48,7 +50,60 @@ class Utils public static function checkVariableInStateAttributes($state, $variable) { if (!isset($state['Attributes'][$variable])) { - throw new Exception('authswitcher:SwitchMfaMethods: ' . $variable . ' missing in state attributes'); + throw new Exception(self::DEBUG_PREFIX . $variable . ' missing in state attributes'); + } + } + + public static function isMFAEnforced($state, $entityID = null) + { + if (!empty($state['Attributes'][AuthSwitcher::MFA_ENFORCE_SETTINGS])) { + $settings = $state['Attributes'][AuthSwitcher::MFA_ENFORCE_SETTINGS]; + if (is_string($settings)) { + $settings = json_decode($settings, true, 3, JSON_THROW_ON_ERROR); + } + + if (!empty($settings['all'])) { + Logger::info(self::DEBUG_PREFIX . 'MFA was forced for all services by settings'); + return true; + } + + $rpCategory = $state['Attributes'][AuthSwitcher::RP_CATEGORY][0] ?? 'other'; + + $rpIdentifier = self::getEntityID($entityID, $state); + + if (!empty($settings['include_categories']) && in_array( + $rpCategory, + $settings['include_categories'], + true + ) && !in_array($rpIdentifier, $settings['exclude_rps'] ?? [], true)) { + Logger::info(self::DEBUG_PREFIX . 'MFA was forced for this service by settings'); + return true; + } + + Logger::info(self::DEBUG_PREFIX . 'MFA was not forced by settings'); + return false; + } + if (!empty($state['Attributes'][AuthSwitcher::MFA_ENFORCED])) { + Logger::info(self::DEBUG_PREFIX . 'MFA was forced for all services by mfaEnforced'); + return true; + } + Logger::info(self::DEBUG_PREFIX . 'MFA was not forced'); + return false; + } + + 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; } }