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/SwitchAuth.php b/lib/Auth/Process/SwitchAuth.php index 58dd11d37c0eb4f5abe08ad4465b765662e7cdfb..4c9e40956a40443aeca1f7f2c3c89f6815c1bb99 100644 --- a/lib/Auth/Process/SwitchAuth.php +++ b/lib/Auth/Process/SwitchAuth.php @@ -60,6 +60,8 @@ class SwitchAuth extends \SimpleSAML\Auth\ProcessingFilter private $sfa_len_attr; + private $entityID; + /** * @override * @@ -92,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 @@ -112,10 +115,10 @@ class SwitchAuth extends \SimpleSAML\Auth\ProcessingFilter */ public function process(&$state) { - $mfaEnforced = Utils::isMFAEnforced($state); - $this->getConfig($this->config); + $mfaEnforced = Utils::isMFAEnforced($state, $this->entityID); + $usersCapabilities = $this->getMFAForUid($state); self::info('user capabilities: ' . json_encode($usersCapabilities)); diff --git a/lib/AuthSwitcher.php b/lib/AuthSwitcher.php index 39389a28b5414de8624209aa62c2e1fcf7c4dd28..232dea79f6892fce7b66270bf86d6710077b7733 100644 --- a/lib/AuthSwitcher.php +++ b/lib/AuthSwitcher.php @@ -63,4 +63,14 @@ class AuthSwitcher * 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 6eac2b113e7975f1b265242e2d9375ea0a60f1c9..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,11 +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; } - public static isMFAEnforced($state) { - return !empty($state['Attributes'][AuthSwitcher::MFA_ENFORCED]); + 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; } }