diff --git a/classes/auth.php b/classes/auth.php index 0663a4f4d..85620e846 100644 --- a/classes/auth.php +++ b/classes/auth.php @@ -671,8 +671,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(); @@ -689,26 +690,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. @@ -1343,4 +1333,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 9c5a3c2d7..bb11d98bf 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 161437a8c..31d64b206 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'],