diff --git a/admin/tool/oauth2/classes/form/user_field_mapping.php b/admin/tool/oauth2/classes/form/user_field_mapping.php index aa118e82a34..c6f5280aa89 100644 --- a/admin/tool/oauth2/classes/form/user_field_mapping.php +++ b/admin/tool/oauth2/classes/form/user_field_mapping.php @@ -60,7 +60,7 @@ class user_field_mapping extends persistent { // Internal. $choices = $userfieldmapping->get_internalfield_list(); - $mform->addElement('select', 'internalfield', get_string('userfieldinternalfield', 'tool_oauth2'), $choices); + $mform->addElement('selectgroups', 'internalfield', get_string('userfieldinternalfield', 'tool_oauth2'), $choices); $mform->addHelpButton('internalfield', 'userfieldinternalfield', 'tool_oauth2'); $mform->addElement('hidden', 'action', 'edit'); diff --git a/admin/tool/oauth2/tests/behat/user_profile_fields.feature b/admin/tool/oauth2/tests/behat/user_profile_fields.feature new file mode 100644 index 00000000000..bcb7d2da3c3 --- /dev/null +++ b/admin/tool/oauth2/tests/behat/user_profile_fields.feature @@ -0,0 +1,43 @@ +@tool @tool_oauth2 @javascript + +Feature: OAuth2 user profile fields functionality + In order to use them later for authentication or repository plugins + As an administrator + I need to be able to map data fields provided by an Oauth2 provider + to custom user profile fields defined by an administrator. + + Background: + Given the following "custom profile fields" exist: + | datatype | shortname | name | locked | + | text | unlocked_field | Unlocked field | 0 | + | text | locked_field | Locked field | 1 | + And I log in as "admin" + And I navigate to "Server > OAuth 2 services" in site administration + + Scenario: Verify custom user profile field mapping + Given I press "Microsoft" + And I should see "Create new service: Microsoft" + And I set the following fields to these values: + | Name | Testing service | + | Client ID | thisistheclientid | + | Client secret | supersecret | + When I press "Save changes" + Then I should see "Changes saved" + And I should see "Testing service" + And I click on "Configure user field mappings" "link" in the "Testing service" "table_row" + + # Create unlocked field + And I click on "Create new user field mapping for issuer \"Testing service\"" "button" + And I set the following fields to these values: + | External field name | External unlocked | + | Internal field name | Unlocked field | + And I click on "Save changes" "button" + And I should see "unlocked_field" + + # Create locked field + And I click on "Create new user field mapping for issuer \"Testing service\"" "button" + And I set the following fields to these values: + | External field name | External locked | + | Internal field name | Locked field | + And I click on "Save changes" "button" + And I should see "locked_field" diff --git a/auth/oauth2/classes/api.php b/auth/oauth2/classes/api.php index cc160d38e2d..50bc4f16756 100644 --- a/auth/oauth2/classes/api.php +++ b/auth/oauth2/classes/api.php @@ -258,15 +258,7 @@ class api { $user->password = ''; $user->confirmed = 1; // Set the user to confirmed. - // Map supplied issuer user info to Moodle user fields. - $userfieldmapping = new \core\oauth2\user_field_mapping(); - foreach ($userfieldmapping->get_internalfield_list() as $field) { - if (isset($userinfo[$field]) && $userinfo[$field]) { - $user->$field = $userinfo[$field]; - } - } - - $user->id = user_create_user($user, false, true); + $user = self::save_user($userinfo, $user); // The linked account is pre-confirmed. $record = new stdClass(); @@ -307,15 +299,7 @@ class api { $user->password = ''; $user->confirmed = 0; // The user is not yet confirmed. - // Map supplied issuer user info to Moodle user fields. - $userfieldmapping = new \core\oauth2\user_field_mapping(); - foreach ($userfieldmapping->get_internalfield_list() as $field) { - if (isset($userinfo[$field]) && $userinfo[$field]) { - $user->$field = $userinfo[$field]; - } - } - - $user->id = user_create_user($user, false, true); + $user = self::save_user($userinfo, $user); // The linked account is pre-confirmed. $record = new stdClass(); @@ -404,4 +388,36 @@ class api { public static function is_enabled() { return is_enabled_auth('oauth2'); } + + /** + * Create a new user & update the profile fields + * + * @param array $userinfo + * @param object $user + * @return object + */ + private static function save_user(array $userinfo, object $user): object { + // Map supplied issuer user info to Moodle user fields. + $userfieldmapping = new \core\oauth2\user_field_mapping(); + $userfieldlist = $userfieldmapping->get_internalfields(); + $hasprofilefield = false; + foreach ($userfieldlist as $field) { + if (isset($userinfo[$field]) && $userinfo[$field]) { + $user->$field = $userinfo[$field]; + + // Check whether the profile fields exist or not. + $hasprofilefield = $hasprofilefield || strpos($field, \core_user\fields::PROFILE_FIELD_PREFIX) === 0; + } + } + + // Create a new user. + $user->id = user_create_user($user, false, true); + + // If profile fields exist then save custom profile fields data. + if ($hasprofilefield) { + profile_save_data($user); + } + + return $user; + } } diff --git a/auth/oauth2/classes/auth.php b/auth/oauth2/classes/auth.php index 50ff36d3afa..fd2343f6bea 100644 --- a/auth/oauth2/classes/auth.php +++ b/auth/oauth2/classes/auth.php @@ -63,6 +63,7 @@ class auth extends \auth_plugin_base { public function __construct() { $this->authtype = 'oauth2'; $this->config = get_config('auth_oauth2'); + $this->customfields = $this->get_custom_user_profile_fields(); } /** @@ -309,23 +310,35 @@ class auth extends \auth_plugin_base { return $userdata; } + $allfields = array_merge($this->userfields, $this->customfields); + // Go through each field from the external data. foreach ($externaldata as $fieldname => $value) { - if (!in_array($fieldname, $this->userfields)) { + if (!in_array($fieldname, $allfields)) { // Skip if this field doesn't belong to the list of fields that can be synced with the OAuth2 issuer. continue; } - if (!property_exists($userdata, $fieldname)) { - // Just in case this field is on the list, but not part of the user data. This shouldn't happen though. + $userhasfield = property_exists($userdata, $fieldname); + // Find out if it is a profile field. + $isprofilefield = strpos($fieldname, 'profile_field_') === 0; + $profilefieldname = str_replace('profile_field_', '', $fieldname); + $userhasprofilefield = $isprofilefield && array_key_exists($profilefieldname, $userdata->profile); + + // Just in case this field is on the list, but not part of the user data. This shouldn't happen though. + if (!($userhasfield || $userhasprofilefield)) { continue; } // Get the old value. - $oldvalue = (string)$userdata->$fieldname; + $oldvalue = $isprofilefield ? (string) $userdata->profile[$profilefieldname] : (string) $userdata->$fieldname; // Get the lock configuration of the field. - $lockvalue = $this->config->{'field_lock_' . $fieldname}; + if (!empty($this->config->{'field_lock_' . $fieldname})) { + $lockvalue = $this->config->{'field_lock_' . $fieldname}; + } else { + $lockvalue = 'unlocked'; + } // We should update fields that meet the following criteria: // - Lock value set to 'unlocked'; or 'unlockedifempty', given the current value is empty. @@ -525,6 +538,9 @@ class auth extends \auth_plugin_base { exit(); } else { \auth_oauth2\api::link_login($userinfo, $issuer, $moodleuser->id, true); + // We dont have profile loaded on $moodleuser, so load it. + require_once($CFG->dirroot.'/user/profile/lib.php'); + profile_load_custom_fields($moodleuser); $userinfo = $this->update_user($userinfo, $moodleuser); // No redirect, we will complete this login. } diff --git a/auth/oauth2/settings.php b/auth/oauth2/settings.php index cecdae54e9e..cbf7397ea3e 100644 --- a/auth/oauth2/settings.php +++ b/auth/oauth2/settings.php @@ -31,5 +31,6 @@ if ($ADMIN->fulltree) { $authplugin = get_auth_plugin('oauth2'); display_auth_lock_options($settings, $authplugin->authtype, $authplugin->userfields, - get_string('auth_fieldlocks_help', 'auth'), false, false); + get_string('auth_fieldlocks_help', 'auth'), false, false, + $authplugin->customfields); } diff --git a/lib/classes/oauth2/user_field_mapping.php b/lib/classes/oauth2/user_field_mapping.php index edc24bd8fc1..cecbc9e84df 100644 --- a/lib/classes/oauth2/user_field_mapping.php +++ b/lib/classes/oauth2/user_field_mapping.php @@ -43,7 +43,7 @@ class user_field_mapping extends persistent { * @return array */ private static function get_user_fields() { - return array_merge(\core_user::AUTHSYNCFIELDS, ['picture', 'username']); + return array_merge(\core_user::AUTHSYNCFIELDS, ['picture', 'username'], get_profile_field_names()); } /** @@ -72,7 +72,28 @@ class user_field_mapping extends persistent { * @return array */ public function get_internalfield_list() { - return array_combine(self::get_user_fields(), self::get_user_fields()); + $userfields = array_merge(\core_user::AUTHSYNCFIELDS, ['picture', 'username']); + $internalfields = array_combine($userfields, $userfields); + return array_merge(['' => $internalfields], get_profile_field_list()); + } + + /** + * Return the list of internal fields with flat array + * + * Profile fields element has its array based on profile category. + * These elements need to be turned flat to make it easier to read. + * + * @return array + */ + public function get_internalfields() { + $userfieldlist = $this->get_internalfield_list(); + $userfields = []; + array_walk_recursive($userfieldlist, + function($value, $key) use (&$userfields) { + $userfields[] = $key; + } + ); + return $userfields; } /** @@ -87,4 +108,5 @@ class user_field_mapping extends persistent { } return true; } + } diff --git a/lib/tests/oauth2_test.php b/lib/tests/oauth2_test.php index 8b707375f9e..b54766439ff 100644 --- a/lib/tests/oauth2_test.php +++ b/lib/tests/oauth2_test.php @@ -21,6 +21,7 @@ use core\oauth2\api; use core\oauth2\endpoint; use core\oauth2\issuer; use core\oauth2\system_account; +use \core\oauth2\user_field_mapping; /** * Tests for oauth2 apis (\core\oauth2\*). @@ -442,4 +443,178 @@ class oauth2_test extends \advanced_testcase { $this->assertFalse($googleissuer->is_available_for_login()); } + + /** + * Data provider for test_get_internalfield_list and test_get_internalfields. + * + * @return array + */ + public function create_custom_profile_fields(): array { + return [ + 'data' => + [ + 'given' => [ + 'Hobbies' => [ + 'shortname' => 'hobbies', + 'name' => 'Hobbies', + ] + ], + 'expected' => [ + 'Hobbies' => [ + 'shortname' => 'hobbies', + 'name' => 'Hobbies', + ] + ] + ], + [ + 'given' => [ + 'Billing' => [ + 'shortname' => 'billingaddress', + 'name' => 'Billing Address', + ], + 'Payment' => [ + 'shortname' => 'creditcardnumber', + 'name' => 'Credit Card Number', + ] + ], + 'expected' => [ + 'Billing' => [ + 'shortname' => 'billingaddress', + 'name' => 'Billing Address', + ], + 'Payment' => [ + 'shortname' => 'creditcardnumber', + 'name' => 'Credit Card Number', + ] + ] + ] + ]; + } + + /** + * Test getting the list of internal fields. + * + * @dataProvider create_custom_profile_fields + * @covers ::get_internalfield_list + * @param array $given Categories and profile fields. + * @param array $expected Expected value. + */ + public function test_get_internalfield_list(array $given, array $expected): void { + $this->resetAfterTest(); + self::generate_custom_profile_fields($given); + + $userfieldmapping = new user_field_mapping(); + $internalfieldlist = $userfieldmapping->get_internalfield_list(); + + foreach ($expected as $category => $value) { + // Custom profile fields must exist. + $this->assertNotEmpty($internalfieldlist[$category]); + + // Category must have the custom profile fields with expected value. + $this->assertEquals( + $internalfieldlist[$category][\core_user\fields::PROFILE_FIELD_PREFIX . $value['shortname']], + $value['name'] + ); + } + } + + /** + * Test getting the list of internal fields with flat array. + * + * @dataProvider create_custom_profile_fields + * @covers ::get_internalfields + * @param array $given Categories and profile fields. + * @param array $expected Expected value. + */ + public function test_get_internalfields(array $given, array $expected): void { + $this->resetAfterTest(); + self::generate_custom_profile_fields($given); + + $userfieldmapping = new user_field_mapping(); + $internalfields = $userfieldmapping->get_internalfields(); + + // Custom profile fields must exist. + foreach ($expected as $category => $value) { + $this->assertContains( \core_user\fields::PROFILE_FIELD_PREFIX . $value['shortname'], $internalfields); + } + } + + /** + * Test getting the list of empty external/custom profile fields. + * + * @covers ::get_internalfields + */ + public function test_get_empty_internalfield_list(): void { + + // Get internal (profile) fields. + $userfieldmapping = new user_field_mapping(); + $internalfieldlist = $userfieldmapping->get_internalfields(); + + // Get user fields. + $userfields = array_merge(\core_user::AUTHSYNCFIELDS, ['picture', 'username']); + + // Internal fields and user fields must exact same. + $this->assertEquals($userfields, $internalfieldlist); + } + + /** + * Test getting Return the list of profile fields. + * + * @dataProvider create_custom_profile_fields + * @covers ::get_profile_field_list + * @param array $given Categories and profile fields. + * @param array $expected Expected value. + */ + public function test_get_profile_field_list(array $given, array $expected): void { + $this->resetAfterTest(); + self::generate_custom_profile_fields($given); + + $profilefieldlist = get_profile_field_list(); + + foreach ($expected as $category => $value) { + $this->assertEquals( + $profilefieldlist[$category][\core_user\fields::PROFILE_FIELD_PREFIX . $value['shortname']], + $value['name'] + ); + } + } + + /** + * Test getting the list of valid custom profile user fields. + * + * @dataProvider create_custom_profile_fields + * @covers ::get_profile_field_names + * @param array $given Categories and profile fields. + * @param array $expected Expected value. + */ + public function test_get_profile_field_names(array $given, array $expected): void { + $this->resetAfterTest(); + self::generate_custom_profile_fields($given); + + $profilefieldnames = get_profile_field_names(); + + // Custom profile fields must exist. + foreach ($expected as $category => $value) { + $this->assertContains( \core_user\fields::PROFILE_FIELD_PREFIX . $value['shortname'], $profilefieldnames); + } + } + + /** + * Generate data into DB for Testing getting user fields mapping. + * + * @param array $given Categories and profile fields. + */ + private function generate_custom_profile_fields(array $given): void { + // Create a profile category and the profile fields. + foreach ($given as $category => $value) { + $customprofilefieldcategory = ['name' => $category, 'sortorder' => 1]; + $category = $this->getDataGenerator()->create_custom_profile_field_category($customprofilefieldcategory); + $this->getDataGenerator()->create_custom_profile_field( + ['shortname' => $value['shortname'], + 'name' => $value['name'], + 'categoryid' => $category->id, + 'required' => 1, 'visible' => 1, 'locked' => 0, 'datatype' => 'text', 'defaultdata' => null]); + } + } + } diff --git a/user/profile/lib.php b/user/profile/lib.php index d27bdf5e9aa..b6a2cc21812 100644 --- a/user/profile/lib.php +++ b/user/profile/lib.php @@ -980,3 +980,38 @@ function profile_has_required_custom_fields_set($userid) { return true; } + +/** + * Return the list of valid custom profile user fields. + * + * @return array array of profile field names + */ +function get_profile_field_names(): array { + $profilefields = profile_get_user_fields_with_data(0); + $profilefieldnames = []; + foreach ($profilefields as $field) { + $profilefieldnames[] = $field->inputname; + } + return $profilefieldnames; +} + +/** + * Return the list of profile fields + * in a format they can be used for choices in a group select menu. + * + * @return array array of category name with its profile fields + */ +function get_profile_field_list(): array { + $customfields = profile_get_user_fields_with_data_by_category(0); + $data = []; + foreach ($customfields as $category) { + foreach ($category as $field) { + $categoryname = $field->get_category_name(); + if (!isset($data[$categoryname])) { + $data[$categoryname] = []; + } + $data[$categoryname][$field->inputname] = $field->field->name; + } + } + return $data; +} diff --git a/user/upgrade.txt b/user/upgrade.txt index 9dfbf0aba14..c4cd3633aa2 100644 --- a/user/upgrade.txt +++ b/user/upgrade.txt @@ -9,6 +9,15 @@ This files describes API changes for code that uses the user API. * New method `core_user::is_current_user`, useful for components implementing permission callbacks for their preferences * New `profile_get_user_field` method for returning profile field instance of given type * The `profile_field_base::is_visible` method now accepts an optional `$context` argument +* Added get_internalfield_list() and get_internalfields() in the user_field_mapping class. + The get_internalfield_list() returns data in an array by grouping profile fields based on field categories, + used for internal field name dropdown in the user field mapping of Oauth2 services + The get_internalfields() converts the result from get_internalfield_list() into flat array, + used to save/update the profile data when a user uses OAuth2 services. +* Added get_profile_field_names() and get_profile_field_list() in the profile_field_base class. + The get_profile_field_names() returns the list of valid custom profile user fields. + The get_profile_field_list() returns the profile fields + in a format that can be used for choices in a group select menu. === 4.1 ===