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'],