From 20241106c2678cc8acf9450867fc4f41ee08d8ea Mon Sep 17 00:00:00 2001 From: Kevin Pham Date: Mon, 4 Nov 2024 14:02:25 +1100 Subject: [PATCH] feat: add fallback idp/mdl attribute mapping Used when the primary mapping does not match against any particular user. This can be used in the case where attributes used for id management are transitioned from one field to another, and allows for a gradual non-disruptive rollover. --- classes/auth.php | 69 +++++++++++++++++++++++++++++------------- lang/en/auth_saml2.php | 4 +++ settings.php | 14 +++++++++ tests/auth_test.php | 37 +++++++++++++++++++++- 4 files changed, 102 insertions(+), 22 deletions(-) diff --git a/classes/auth.php b/classes/auth.php index b10686683..960684f0a 100644 --- a/classes/auth.php +++ b/classes/auth.php @@ -672,8 +672,9 @@ public function saml_login_complete($attributes) { } $attr = $this->config->idpattr; + $attrsecondary = $this->config->idpattrsecondary; if (empty($attributes[$attr])) { - // Missing mapping IdP attribute. Login failed. + // Missing mapping IdP attribute (both primary and secondary). Login failed. $event = \core\event\user_login_failed::create(['other' => ['username' => 'unknown', 'reason' => AUTH_LOGIN_NOUSER]]); $event->trigger(); @@ -690,26 +691,15 @@ public function saml_login_complete($attributes) { // Find Moodle user. $user = false; - foreach ($attributes[$attr] as $uid) { - $insensitive = false; - $accentsensitive = true; - if ($this->config->tolower == saml2_settings::OPTION_TOLOWER_LOWER_CASE) { - $this->log(__FUNCTION__ . " to lowercase for $uid"); - $uid = strtolower($uid); - } - if ($this->config->tolower == saml2_settings::OPTION_TOLOWER_CASE_INSENSITIVE) { - $this->log(__FUNCTION__ . " case insensitive compare for $uid"); - $insensitive = true; - } - if ($this->config->tolower == saml2_settings::OPTION_TOLOWER_CASE_AND_ACCENT_INSENSITIVE) { - $this->log(__FUNCTION__ . " case and accent insensitive compare for $uid"); - $insensitive = true; - $accentsensitive = false; - } - if ($user = user_extractor::get_user($this->config->mdlattr, $uid, $insensitive, $accentsensitive)) { - // We found a user. - break; - } + + // Primary IdP attribute mapping. + if (!empty($attributes[$attr])) { + [$user, $uid] = $this->find_user_by_attributes($attributes[$attr], $this->config->mdlattr); + } + + // Secondary IdP attribute mapping (if user not found yet). + if ($user === false && !empty($attributes[$attrsecondary])) { + [$user, $uid] = $this->find_user_by_attributes($attributes[$attrsecondary], $this->config->mdlattrsecondary); } // Moodle Workplace - Check IdP's tenant availability, for new user pre-allocate to tenant. @@ -1344,4 +1334,41 @@ private function execute_callback($function, $file = 'lib.php') { } } } + + /** + * Find and return a user matched using a list of provided attributes, against a Moodle field. + * + * Applies any case matching settings configured. + * + * @param array $idpattrs + * @param string $mdlattr + * @return array false if no user found, otherwise the user object, and the $uid of the iterated user + */ + private function find_user_by_attributes(array $idpattrs, string $mdlattr): array { + $user = false; + $uid = null; + foreach ($idpattrs as $uid) { + $insensitive = false; + $accentsensitive = true; + if ($this->config->tolower == saml2_settings::OPTION_TOLOWER_LOWER_CASE) { + $this->log(__FUNCTION__ . " to lowercase for $uid"); + $uid = strtolower($uid); + } + if ($this->config->tolower == saml2_settings::OPTION_TOLOWER_CASE_INSENSITIVE) { + $this->log(__FUNCTION__ . " case insensitive compare for $uid"); + $insensitive = true; + } + if ($this->config->tolower == saml2_settings::OPTION_TOLOWER_CASE_AND_ACCENT_INSENSITIVE) { + $this->log(__FUNCTION__ . " case and accent insensitive compare for $uid"); + $insensitive = true; + $accentsensitive = false; + } + if ($user = user_extractor::get_user($mdlattr, $uid, $insensitive, $accentsensitive)) { + // We found a user. + break; + } + } + return [$user, $uid]; + } + } diff --git a/lang/en/auth_saml2.php b/lang/en/auth_saml2.php index a345c27dd..a1fe9c356 100644 --- a/lang/en/auth_saml2.php +++ b/lang/en/auth_saml2.php @@ -95,6 +95,8 @@ $string['flagresponsetype_help'] = 'If access is blocked based on configured group restrictions, how should Moodle respond?'; $string['idpattr_help'] = 'Which IdP attribute should be matched against a Moodle user field?'; $string['idpattr'] = 'Mapping IdP'; +$string['idpattrsecondary_help'] = 'When the primary IdP attribute does not match a user, map this field to the secondary Moodle mapped field.'; +$string['idpattrsecondary'] = 'Secondary Mapping IdP'; $string['idpmetadata_badurl'] = 'Invalid metadata at {$a}'; $string['idpmetadata_help'] = 'To use multiple IdPs enter each public metadata url on a new line.
To override a name, place text before the http. eg. "Forced IdP Name http://ssp.local/simplesaml/saml2/idp/metadata.php"'; $string['idpmetadata'] = 'IdP metadata xml OR public xml URL'; @@ -115,6 +117,8 @@ $string['manageidpsheading'] = 'Manage available Identity Providers (IdPs)'; $string['mdlattr_help'] = 'Which Moodle user field should the IdP attribute be matched to?'; $string['mdlattr'] = 'Mapping Moodle'; +$string['mdlattrsecondary_help'] = 'Which Moodle user field should the IdP secondary attribute be matched to?'; +$string['mdlattrsecondary'] = 'Secondary Mapping Moodle'; $string['wantassertionssigned'] = 'Want assertions signed'; $string['wantassertionssigned_help'] = 'Whether assertions received by this SP must be signed'; $string['assertionsconsumerservices'] = 'Assertions consumer services'; diff --git a/settings.php b/settings.php index f26039875..26fe79df4 100644 --- a/settings.php +++ b/settings.php @@ -314,6 +314,20 @@ saml2_settings::OPTION_TOLOWER_EXACT, $toloweroptions)); + // IDP attribute (secondary). + $settings->add(new admin_setting_configtext( + 'auth_saml2/idpattrsecondary', + get_string('idpattrsecondary', 'auth_saml2'), + get_string('idpattrsecondary_help', 'auth_saml2'), + '', PARAM_TEXT)); + + // Moodle Field (secondary). + $settings->add(new admin_setting_configselect( + 'auth_saml2/mdlattrsecondary', + get_string('mdlattrsecondary', 'auth_saml2'), + get_string('mdlattrsecondary_help', 'auth_saml2'), + '', user_fields::get_supported_fields())); + // Requested Attributes. $settings->add(new admin_setting_configtextarea( 'auth_saml2/requestedattributes', diff --git a/tests/auth_test.php b/tests/auth_test.php index 522514123..c5f21d291 100644 --- a/tests/auth_test.php +++ b/tests/auth_test.php @@ -27,7 +27,7 @@ * @copyright 2021 Moodle Pty Ltd * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class auth_saml2_test extends \advanced_testcase { +class auth_test extends \advanced_testcase { /** * Set up */ @@ -435,6 +435,41 @@ public function test_saml_login_complete_missing_idpattr(): void { $this->assertEquals(AUTH_LOGIN_NOUSER, $event->get_data()['other']['reason']); } + public function test_saml_login_complete_secondary_mapping_used(): void { + global $USER; + + $attribs = [ + 'uid' => ['doesnotmatch'], + 'email' => ['anything@example.com'], + 'someidfield' => ['must-match-12345'], + ]; + + $user = $this->getDataGenerator()->create_user([ + 'auth' => 'saml2', + 'email' => 'notrelevant@example.com', + 'idnumber' => 'must-match-12345', + ]); + + // The primary was set up to fail. + set_config('idpattr', 'uid', 'auth_saml2'); + set_config('mdlattr', 'email', 'auth_saml2'); + // The secondary mapping should match and map to the generated user. + set_config('idpattrsecondary', 'someidfield', 'auth_saml2'); + set_config('mdlattrsecondary', 'idnumber', 'auth_saml2'); + + // Sanity check. + $this->assertFalse(isloggedin()); + $this->assertNotEquals($attribs['email'][0], $user->email); + + // Try to login, suppress output. + $auth = new \auth_saml2\auth(); + @$auth->saml_login_complete($attribs); + + // Check global object, make sure the created user is the one logged in, despite other non-matching attributes provided. + $this->assertEquals($user->id, $USER->id); + $this->assertEquals($user->username, $USER->username); + } + public function test_saml_login_complete_group_restriction(): void { $attribs = [ 'uid' => ['samlu1'],