user IDs). * * @var array */ private $duplicates = array(); /** * Role changes that remove administrative rights, applied after the safety check. * * @var array[] */ private $demotions = array(); /** * Token of the run lock held by this process ('' when none). * * @var string */ private $lock_token = ''; /** * Constructor. * * @param M365_Login_Settings $settings Settings. * @param M365_Login_Graph $graph Graph client. */ public function __construct( M365_Login_Settings $settings, M365_Login_Graph $graph ) { $this->settings = $settings; $this->graph = $graph; add_action( self::CRON_HOOK, array( $this, 'run_scheduled' ) ); add_action( 'init', array( $this, 'ensure_schedule' ) ); add_action( 'update_option_' . M365_LOGIN_OPTION, array( $this, 'reschedule' ) ); add_action( 'add_option_' . M365_LOGIN_OPTION, array( $this, 'reschedule' ) ); // Deactivated accounts: no password, application password, cookie or Microsoft sign-in. add_filter( 'authenticate', array( $this, 'block_disabled_login' ), 100, 1 ); add_filter( 'determine_current_user', array( $this, 'drop_disabled_session' ), 100 ); add_filter( 'pre_get_avatar_data', array( $this, 'avatar_data' ), 10, 2 ); add_action( 'delete_user', array( $this, 'delete_photo' ) ); add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'register_exporter' ) ); add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'register_eraser' ) ); if ( is_admin() ) { add_filter( 'manage_users_columns', array( $this, 'users_column' ) ); add_filter( 'manage_users_custom_column', array( $this, 'users_column_value' ), 10, 3 ); add_filter( 'user_row_actions', array( $this, 'user_row_actions' ), 10, 2 ); add_action( 'admin_post_' . self::POST_STATE, array( $this, 'handle_user_state' ) ); add_action( 'show_user_profile', array( $this, 'profile_section' ) ); add_action( 'edit_user_profile', array( $this, 'profile_section' ) ); add_action( 'personal_options_update', array( $this, 'save_profile' ) ); add_action( 'edit_user_profile_update', array( $this, 'save_profile' ) ); add_action( 'admin_notices', array( $this, 'user_state_notice' ) ); } if ( defined( 'WP_CLI' ) && WP_CLI ) { WP_CLI::add_command( 'm365-login sync', array( $this, 'cli' ) ); } } /* ------------------------------------------------------------------ */ /* Attributes */ /* ------------------------------------------------------------------ */ /** * Microsoft Graph user properties that can be copied into WordPress profiles. * * The target is either a WordPress user field (display_name, first_name, last_name, * locale), a user meta key or "avatar" for the profile photo. * * @return array Graph property => array( 'label' => string, 'target' => string ). */ public static function attributes() { $attributes = array( 'displayName' => array( 'label' => __( 'Display name', 'm365-login' ), 'target' => 'display_name', ), 'givenName' => array( 'label' => __( 'First name', 'm365-login' ), 'target' => 'first_name', ), 'surname' => array( 'label' => __( 'Last name', 'm365-login' ), 'target' => 'last_name', ), 'photo' => array( 'label' => __( 'Profile photo (used as avatar)', 'm365-login' ), 'target' => 'avatar', ), 'jobTitle' => array( 'label' => __( 'Job title', 'm365-login' ), 'target' => 'm365_job_title', ), 'department' => array( 'label' => __( 'Department', 'm365-login' ), 'target' => 'm365_department', ), 'companyName' => array( 'label' => __( 'Company', 'm365-login' ), 'target' => 'm365_company_name', ), 'officeLocation' => array( 'label' => __( 'Office', 'm365-login' ), 'target' => 'm365_office_location', ), 'employeeId' => array( 'label' => __( 'Employee ID', 'm365-login' ), 'target' => 'm365_employee_id', ), 'businessPhones' => array( 'label' => __( 'Business phone', 'm365-login' ), 'target' => 'm365_business_phone', ), 'mobilePhone' => array( 'label' => __( 'Mobile phone', 'm365-login' ), 'target' => 'm365_mobile_phone', ), 'streetAddress' => array( 'label' => __( 'Street address', 'm365-login' ), 'target' => 'm365_street_address', ), 'postalCode' => array( 'label' => __( 'Postal code', 'm365-login' ), 'target' => 'm365_postal_code', ), 'city' => array( 'label' => __( 'City', 'm365-login' ), 'target' => 'm365_city', ), 'state' => array( 'label' => __( 'State / province', 'm365-login' ), 'target' => 'm365_state', ), 'country' => array( 'label' => __( 'Country', 'm365-login' ), 'target' => 'm365_country', ), 'preferredLanguage' => array( 'label' => __( 'Language (sets the admin language if installed)', 'm365-login' ), 'target' => 'locale', ), ); /** * Filters the Graph properties offered for the profile sync. * * Add entries as 'graphProperty' => array( 'label' => .., 'target' => 'meta_key' ). * * @param array $attributes Attributes. */ return (array) apply_filters( 'm365_login_sync_attributes', $attributes ); } /** * Selected attributes that exist in the registry. * * @return array Graph property => target. */ private function selected_attributes() { $all = self::attributes(); $out = array(); foreach ( (array) $this->settings->get( 'sync_attributes', array() ) as $key ) { if ( isset( $all[ $key ]['target'] ) ) { $out[ $key ] = (string) $all[ $key ]['target']; } } return $out; } /* ------------------------------------------------------------------ */ /* Scheduling */ /* ------------------------------------------------------------------ */ /** * Schedules the cron event if the sync is enabled but no event is queued. */ public function ensure_schedule() { if ( $this->settings->get( 'sync_enabled' ) && ! wp_next_scheduled( self::CRON_HOOK ) ) { $this->reschedule(); } } /** * (Re)creates or removes the cron event after the settings changed. */ public function reschedule() { $this->settings->flush(); wp_clear_scheduled_hook( self::CRON_HOOK ); if ( $this->settings->get( 'sync_enabled' ) ) { wp_schedule_event( time() + 5 * MINUTE_IN_SECONDS, (string) $this->settings->get( 'sync_interval', 'daily' ), self::CRON_HOOK ); } } /** * Removes the cron event (plugin deactivation). */ public static function unschedule() { wp_clear_scheduled_hook( self::CRON_HOOK ); } /** * Cron callback. */ public function run_scheduled() { if ( $this->settings->get( 'sync_enabled' ) ) { $this->run( false, 'cron' ); } } /** * WP-CLI: synchronise users from Microsoft 365. * * ## OPTIONS * * [--dry-run] * : Only report what would change. * * ## EXAMPLES * * wp m365-login sync --dry-run * * @param array $args Positional arguments. * @param array $assoc_args Flags. */ public function cli( $args, $assoc_args ) { $report = $this->run( ! empty( $assoc_args['dry-run'] ), 'cli' ); foreach ( $report['log'] as $entry ) { WP_CLI::log( sprintf( '[%s] %s', $entry['level'], $entry['message'] ) ); } foreach ( $report['counts'] as $key => $count ) { WP_CLI::log( sprintf( '%s: %d', $key, $count ) ); } if ( 'ok' === $report['status'] ) { WP_CLI::success( $report['dry'] ? 'Dry run finished.' : 'Sync finished.' ); } else { WP_CLI::error( 'Sync failed or was aborted.' ); } } /* ------------------------------------------------------------------ */ /* Run */ /* ------------------------------------------------------------------ */ /** * Last stored report or null. * * @return array|null */ public static function last_report() { $report = get_option( self::REPORT_OPTION, null ); return is_array( $report ) ? $report : null; } /** * Runs a full sync. * * @param bool $dry Only simulate. * @param string $trigger 'manual', 'cron' or 'cli'. * @return array Report. */ public function run( $dry = false, $trigger = 'manual' ) { $this->dry = (bool) $dry; $this->report = array( 'started' => time(), 'finished' => 0, 'dry' => $this->dry, 'trigger' => $trigger, 'status' => 'ok', 'counts' => array_fill_keys( array( 'created', 'updated', 'linked', 'unchanged', 'disabled', 'enabled', 'deleted', 'photos', 'skipped', 'errors' ), 0 ), 'log' => array(), ); $lock = $this->acquire_lock(); if ( '' === $lock ) { $this->log( 'error', __( 'Another sync is still running. Please try again in a few minutes.', 'm365-login' ) ); return $this->finish( 'locked', false ); } $this->lock_token = $lock; $this->demotions = array(); // A fatal error (memory, time limit) skips "finally": record the failure and free the lock anyway. register_shutdown_function( array( $this, 'shutdown' ) ); if ( function_exists( 'set_time_limit' ) ) { set_time_limit( 0 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- long-running directory sync. } wp_raise_memory_limit( 'admin' ); require_once ABSPATH . 'wp-admin/includes/user.php'; // No "your password changed" mails for the random passwords of deactivated accounts. // E-mail change notifications stay on: the previous address is told about the change. add_filter( 'send_password_change_email', '__return_false', 99 ); try { $status = $this->sync(); } finally { remove_filter( 'send_password_change_email', '__return_false', 99 ); $this->release_lock( $lock ); $this->lock_token = ''; } return $this->finish( $status, true ); } /** * Takes the run lock atomically (add_option fails when the row exists). * * @return string Lock token, or '' when another run holds the lock. */ private function acquire_lock() { global $wpdb; $token = wp_generate_password( 20, false ); $value = $token . '|' . time(); // A single INSERT is atomic; add_option() would check first and then insert. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- lock row, must bypass the cache. $inserted = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", self::LOCK, $value ) ); if ( 1 === (int) $inserted ) { $this->flush_lock_cache(); return $token; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- lock row, must bypass the cache. $held = (string) $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", self::LOCK ) ); $parts = explode( '|', $held ); if ( isset( $parts[1] ) && time() - (int) $parts[1] < self::LOCK_TTL ) { return ''; } // Stale lock of a crashed run: take it over only if nobody else did in the meantime. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- lock row, must bypass the cache. $updated = $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s", $value, self::LOCK, $held ) ); $this->flush_lock_cache(); return 1 === (int) $updated ? $token : ''; } /** * Renews the timestamp of the run lock (long runs must not look stale). */ private function refresh_lock() { global $wpdb; if ( '' === $this->lock_token ) { return; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- lock row, must bypass the cache. $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value LIKE %s", $this->lock_token . '|' . time(), self::LOCK, $wpdb->esc_like( $this->lock_token . '|' ) . '%' ) ); $this->flush_lock_cache(); } /** * Shutdown handler: a run that did not finish (fatal error) is reported as failed and its lock released. */ public function shutdown() { if ( '' === $this->lock_token ) { return; } $error = error_get_last(); $this->log( 'error', __( 'The sync stopped unexpectedly (PHP error, memory or time limit). Accounts after this point were not processed and nothing was deactivated or deleted. For large directories use "wp m365-login sync".', 'm365-login' ) ); if ( $error && ! empty( $error['message'] ) ) { $this->log( 'error', wp_strip_all_tags( (string) $error['message'] ) ); } $this->report['status'] = 'failed'; $this->report['finished'] = time(); update_option( self::REPORT_OPTION, $this->report, false ); $this->release_lock( $this->lock_token ); $this->lock_token = ''; } /** * Drops cached copies of the lock row. */ private function flush_lock_cache() { wp_cache_delete( self::LOCK, 'options' ); wp_cache_delete( 'notoptions', 'options' ); } /** * Releases the run lock if this run still holds it. * * @param string $token Lock token. */ private function release_lock( $token ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- lock row, must bypass the cache. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value LIKE %s", self::LOCK, $wpdb->esc_like( $token . '|' ) . '%' ) ); $this->flush_lock_cache(); } /** * Stores and returns the report. * * @param string $status 'ok', 'failed', 'aborted' or 'locked'. * @param bool $store Whether to persist it. * @return array */ private function finish( $status, $store ) { $this->report['status'] = $status; $this->report['finished'] = time(); if ( $store ) { update_option( self::REPORT_OPTION, $this->report, false ); } /** * Fires after a user sync run. * * @param array $report Report (counts, log, status, dry). */ do_action( 'm365_login_sync_finished', $this->report ); return $this->report; } /** * The actual sync. * * @return string Status. */ private function sync() { if ( ! $this->settings->is_configured() ) { $this->log( 'error', __( 'The connection to Microsoft Entra ID is not configured yet.', 'm365-login' ) ); return 'failed'; } if ( $this->settings->is_multi_tenant() ) { $this->log( 'error', __( 'The user sync needs a pinned tenant ID (GUID) on the Connection tab.', 'm365-login' ) ); return 'failed'; } if ( ! get_role( (string) $this->settings->get( 'sync_default_role' ) ) ) { $this->log( 'error', __( 'The default role does not exist. Please check the sync settings.', 'm365-login' ) ); return 'failed'; } // 1. Read the directory. Any error aborts the run before anything is changed. $select = $this->graph_select(); $people = $this->fetch_people( $select ); if ( is_wp_error( $people ) ) { $this->log( 'error', $this->graph_error_text( $people ) ); return 'failed'; } /* translators: %d: number of users */ $this->log( 'info', sprintf( _n( '%d user read from Microsoft 365.', '%d users read from Microsoft 365.', count( $people ), 'm365-login' ), count( $people ) ) ); $memberships = $this->fetch_role_groups(); if ( is_wp_error( $memberships ) ) { $this->log( 'error', $this->graph_error_text( $memberships ) ); return 'failed'; } // 2. Create, link and update accounts. $linked = $this->linked_users(); if ( empty( $people ) && ! empty( $linked ) ) { $this->log( 'error', __( 'Microsoft 365 returned no users at all while accounts are linked. Nothing was changed. Check the tenant and the sync groups.', 'm365-login' ) ); return 'aborted'; } $linked_before = count( $linked ); // Basis of the safety stop: accounts created in this run must not dilute it. $seen = array(); $pending = array(); // Deactivations/deletions, applied after the safety check. $photo_of = array(); // oid => user ID whose photo is kept in sync. $photo_on = array_key_exists( 'photo', $this->selected_attributes() ); $done = 0; foreach ( $people as $person ) { $oid = strtolower( (string) $person['id'] ); $seen[ $oid ] = true; $result = $this->sync_person( $person, $linked, $memberships ); if ( is_array( $result ) ) { $pending = array_merge( $pending, $result ); } elseif ( $result instanceof WP_User ) { $photo_of[ $oid ] = $result->ID; } // Large directories: keep memory flat and the run lock fresh. ++$done; if ( 0 === $done % 250 ) { if ( function_exists( 'wp_cache_flush_runtime' ) ) { wp_cache_flush_runtime(); } $this->refresh_lock(); } } // Role removals that would take administrative rights away are applied only after a safety check. if ( ! $this->apply_demotions() ) { return 'aborted'; } // Profile photos: new, changed and removed photos, or cleanup when the photo sync was switched off. if ( $photo_on ) { $this->sync_photos( $photo_of ); } else { $this->remove_all_photos(); } // 3. Linked accounts that were not part of the directory listing – only those that belong to // this tenant: an object ID from another tenant is "not found" here, not deleted. $tenant = strtolower( $this->settings->tenant() ); $foreign = 0; foreach ( $linked as $oid => $user_id ) { if ( isset( $seen[ $oid ] ) ) { continue; } $user_tenant = strtolower( (string) get_user_meta( $user_id, M365_Login_Auth::META_TID, true ) ); if ( '' !== $user_tenant && $user_tenant !== $tenant ) { ++$foreign; continue; } // Linked before tenants were recorded: a 404 cannot tell "deleted" from "other tenant". $action = $this->classify_missing( $oid, $user_id, '' === $user_tenant ); if ( 'unknown' === $action ) { ++$foreign; continue; } if ( is_wp_error( $action ) ) { $this->log( 'error', $this->graph_error_text( $action ) ); return 'failed'; } if ( null !== $action ) { $pending = array_merge( $pending, $action ); } } if ( $foreign ) { /* translators: %d: number of accounts */ $this->log( 'warning', sprintf( _n( '%d linked account belongs to another (or an unknown) tenant and was not deactivated or deleted. Unlink it by hand if it is no longer needed.', '%d linked accounts belong to another (or an unknown) tenant and were not deactivated or deleted. Unlink them by hand if they are no longer needed.', $foreign, 'm365-login' ), $foreign ) ); } // 4. Safety net: never deactivate or delete a large part of the linked accounts in one go. $pending = array_values( array_filter( $pending, array( $this, 'is_effective_action' ) ) ); $limit = (int) apply_filters( 'm365_login_sync_deprovision_limit', max( 5, (int) ceil( $linked_before * 0.2 ) ), $linked_before ); if ( count( $pending ) > $limit ) { $this->log( 'error', sprintf( /* translators: 1: number of accounts, 2: limit */ __( 'Safety stop: %1$d accounts would be deactivated or deleted, more than the limit of %2$d per run. No account was deactivated or deleted. Check the sync groups and the tenant, then run the sync again (the limit can be changed with the m365_login_sync_deprovision_limit filter).', 'm365-login' ), count( $pending ), $limit ) ); return 'aborted'; } foreach ( $pending as $action ) { $this->apply_action( $action ); } return 'ok'; } /** * Graph properties to read for every user. * * @return string[] */ private function graph_select() { $select = array( 'id', 'accountEnabled', 'mail', 'userPrincipalName', 'userType', 'displayName' ); foreach ( array_keys( $this->selected_attributes() ) as $key ) { if ( 'photo' !== $key && preg_match( '/^[A-Za-z]+$/', $key ) ) { $select[] = $key; } } return array_values( array_unique( $select ) ); } /** * Users in scope: the whole tenant or the (nested) members of the sync groups. * * @param string[] $select Properties. * @return array[]|WP_Error */ private function fetch_people( $select ) { $groups = $this->settings->sync_scope_groups(); if ( empty( $groups ) ) { return $this->graph->list_users( $select ); } $people = array(); foreach ( $groups as $group_id => $name ) { $members = $this->graph->list_group_users( $group_id, $select ); if ( is_wp_error( $members ) ) { return $members; } foreach ( $members as $member ) { $people[ strtolower( (string) $member['id'] ) ] = $member; } } return array_values( $people ); } /** * Members (object IDs) of every group used in the role mapping. * * @return array|WP_Error group ID => array( oid => true ). */ private function fetch_role_groups() { $out = array(); foreach ( array_keys( $this->settings->sync_role_map() ) as $group_id ) { $members = $this->graph->list_group_users( $group_id, array( 'id' ) ); if ( is_wp_error( $members ) ) { return $members; } $out[ $group_id ] = array(); foreach ( $members as $member ) { $out[ $group_id ][ strtolower( (string) $member['id'] ) ] = true; } } return $out; } /** * WordPress users linked to a Microsoft object ID (this site only). * * @return array oid => user ID. */ private function linked_users() { $users = get_users( array( 'meta_key' => M365_Login_Auth::META_OID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key 'meta_compare' => 'EXISTS', 'fields' => 'ID', 'number' => -1, ) ); $out = array(); $this->duplicates = array(); foreach ( $users as $user_id ) { $user_id = (int) $user_id; $oid = strtolower( (string) get_user_meta( $user_id, M365_Login_Auth::META_OID, true ) ); if ( ! M365_Login_Settings::is_guid( $oid ) ) { continue; } if ( isset( $out[ $oid ] ) ) { $this->duplicates[ $oid ][] = $user_id; } else { $out[ $oid ] = $user_id; } } return $out; } /** * Creates, links or updates the account of one directory user. * * @param array $person Graph user. * @param array $linked oid => user ID (updated when an account is linked or created). * @param array $memberships Role group memberships. * @return WP_User|array|null The synced account, a pending deprovision action, or null when skipped. */ private function sync_person( $person, &$linked, $memberships ) { $oid = strtolower( (string) $person['id'] ); $upn = isset( $person['userPrincipalName'] ) ? (string) $person['userPrincipalName'] : $oid; $enabled = ! isset( $person['accountEnabled'] ) || false !== $person['accountEnabled']; if ( ! M365_Login_Settings::is_guid( $oid ) ) { return null; } $user = isset( $linked[ $oid ] ) ? get_userdata( $linked[ $oid ] ) : false; // A linked person disabled in Microsoft 365 is handled before anything else, so a // removed or changed e-mail address during offboarding cannot keep the account alive. if ( $user && ! $enabled ) { return $this->action( (string) $this->settings->get( 'sync_disabled_action' ), $user, 'disabled', __( 'disabled in Microsoft 365', 'm365-login' ), $oid ); } if ( ! $user && isset( $person['userType'] ) && 'Guest' === $person['userType'] && ! $this->settings->get( 'sync_guests' ) ) { return null; // Guests are not imported (they may still be linked through a sign-in). } $email = $this->email_of( $person ); if ( '' === $email ) { if ( ! $user ) { /* translators: %s: user principal name */ $this->skip( sprintf( __( '%s: no usable e-mail address, skipped.', 'm365-login' ), $upn ) ); } return $user ? $user : null; } if ( ! $this->domain_allowed( $email ) ) { if ( ! $user ) { /* translators: %s: e-mail address */ $this->skip( sprintf( __( '%s: e-mail domain is not on the allow-list, skipped.', 'm365-login' ), $email ) ); } return null; } // Not linked yet: the account an administrator assigned this user principal name to, // otherwise an existing account with the same e-mail address. if ( ! $user ) { $by_mail = $this->assigned_user( $person ); if ( ! $by_mail ) { $by_mail = get_user_by( 'email', $email ); } // The WordPress account may use the user principal name instead of the mail address. $upn = self::member_upn( $person ); if ( ! $by_mail && '' !== $upn && $upn !== $email && is_email( $upn ) && $this->domain_allowed( $upn ) ) { $by_mail = get_user_by( 'email', $upn ); } if ( $by_mail instanceof WP_User ) { $stored = strtolower( (string) get_user_meta( $by_mail->ID, M365_Login_Auth::META_OID, true ) ); if ( '' !== $stored && $stored !== $oid ) { /* translators: %s: e-mail address */ $this->skip( sprintf( __( '%s: the WordPress account with this e-mail address is linked to a different Microsoft account, skipped.', 'm365-login' ), $email ) ); return null; } if ( ! $this->may_link( $by_mail, $person, $email ) ) { /* translators: %s: e-mail address */ $this->skip( sprintf( __( '%s: privileged WordPress account – linked only when the Microsoft user principal name equals its e-mail address or the Microsoft account assigned in its profile, or when the person links it from the profile. Skipped.', 'm365-login' ), $email ) ); return null; } $user = $by_mail; $this->log( 'info', sprintf( /* translators: %s: e-mail address */ __( '%s: existing account linked.', 'm365-login' ), $email ) ); $this->count( 'linked' ); if ( ! $this->dry ) { update_user_meta( $user->ID, M365_Login_Auth::META_OID, $oid ); update_user_meta( $user->ID, M365_Login_Auth::META_TID, strtolower( $this->settings->tenant() ) ); } $linked[ $oid ] = $user->ID; } } // Disabled in Microsoft 365. if ( ! $enabled ) { if ( ! $user ) { return null; // Nothing to create for disabled people. } return $this->action( (string) $this->settings->get( 'sync_disabled_action' ), $user, 'disabled', __( 'disabled in Microsoft 365', 'm365-login' ), $oid ); } if ( ! $user ) { return $this->create_user( $person, $oid, $email, $linked, $memberships ); } if ( is_multisite() && ! is_user_member_of_blog( $user->ID ) ) { if ( ! $this->dry ) { add_user_to_blog( get_current_blog_id(), $user->ID, (string) $this->settings->get( 'sync_default_role' ) ); } $this->log( 'info', sprintf( /* translators: %s: e-mail address */ __( '%s: added to this site.', 'm365-login' ), $email ) ); } // Accounts deactivated by the sync come back when the person is active again. $disabled = self::disabled_info( $user->ID ); if ( $disabled && 'sync' === $disabled['by'] ) { if ( ! $this->dry ) { self::enable( $user->ID ); } $this->log( 'info', sprintf( /* translators: %s: e-mail address */ __( '%s: reactivated (active in Microsoft 365 again).', 'm365-login' ), $email ) ); $this->count( 'enabled' ); } $changes = $this->update_profile( $user, $person, $email ); if ( $this->manages_roles( $user ) && ! self::disabled_info( $user->ID ) ) { $changes = array_merge( $changes, $this->update_roles( $user, $this->desired_roles( $oid, $memberships ) ) ); } if ( $changes ) { /* translators: 1: e-mail address, 2: list of changed fields */ $this->log( 'info', sprintf( __( '%1$s: updated (%2$s).', 'm365-login' ), $email, implode( ', ', $changes ) ) ); $this->count( 'updated' ); } else { $this->count( 'unchanged' ); } if ( ! $this->dry ) { update_user_meta( $user->ID, self::META_LAST_SYNC, time() ); update_user_meta( $user->ID, M365_Login_Auth::META_TID, strtolower( $this->settings->tenant() ) ); } return $user; } /** * Creates a new WordPress account for a directory user. * * @param array $person Graph user. * @param string $oid Object ID. * @param string $email E-mail address. * @param array $linked oid => user ID. * @param array $memberships Role group memberships. * @return WP_User|null */ private function create_user( $person, $oid, $email, &$linked, $memberships ) { $roles = $this->desired_roles( $oid, $memberships ); /* translators: 1: e-mail address, 2: role names */ $this->log( 'info', sprintf( __( '%1$s: account created (%2$s).', 'm365-login' ), $email, $this->role_names( $roles ) ) ); $this->count( 'created' ); if ( $this->dry ) { return null; } $data = array( 'user_login' => $this->unique_login( $email ), 'user_email' => $email, 'user_pass' => wp_generate_password( 40, true, true ), 'role' => $roles[0], 'display_name' => ! empty( $person['displayName'] ) ? sanitize_text_field( (string) $person['displayName'] ) : $email, ); /** * Filters the data used to create a WordPress account for a Microsoft 365 user. * * @param array $data Arguments for wp_insert_user(). * @param array $person Graph user object. */ $data = apply_filters( 'm365_login_sync_new_user_data', $data, $person ); $user_id = wp_insert_user( $data ); if ( is_wp_error( $user_id ) ) { $this->count( 'created', -1 ); $this->count( 'errors' ); /* translators: 1: e-mail address, 2: error message */ $this->log( 'error', sprintf( __( '%1$s: account could not be created: %2$s', 'm365-login' ), $email, $user_id->get_error_message() ) ); return null; } update_user_meta( $user_id, M365_Login_Auth::META_OID, $oid ); update_user_meta( $user_id, M365_Login_Auth::META_TID, strtolower( $this->settings->tenant() ) ); update_user_meta( $user_id, self::META_SYNCED, time() ); update_user_meta( $user_id, self::META_LAST_SYNC, time() ); $linked[ $oid ] = (int) $user_id; $user = get_userdata( $user_id ); $this->update_profile( $user, $person, $email ); $this->update_roles( $user, $roles ); /** * Fires after the sync created a WordPress account. * * @param WP_User $user New user. * @param array $person Graph user object. */ do_action( 'm365_login_sync_user_created', $user, $person ); return $user; } /** * Unique user_login derived from the e-mail address. * * @param string $email E-mail address. * @return string */ private function unique_login( $email ) { $base = sanitize_user( strtok( $email, '@' ), true ); $base = '' === $base ? 'm365user' : mb_substr( $base, 0, 50 ); $login = $base; $suffix = 2; while ( username_exists( $login ) ) { $login = $base . $suffix; ++$suffix; } return $login; } /** * Copies e-mail address and selected attributes into the profile. * * @param WP_User $user User. * @param array $person Graph user. * @param string $email E-mail from the directory. * @return string[] Changed fields (for the log). */ private function update_profile( $user, $person, $email ) { $changes = array(); $fields = array(); if ( strtolower( $user->user_email ) !== $email ) { $owner = get_user_by( 'email', $email ); if ( self::is_privileged( $user ) ) { /* translators: 1: current e-mail address, 2: e-mail address in Microsoft 365 */ $this->log( 'warning', sprintf( __( '%1$s: the e-mail address in Microsoft 365 changed to %2$s. It is not changed automatically for privileged accounts – update it by hand if intended.', 'm365-login' ), $user->user_email, $email ) ); } elseif ( $owner && $owner->ID !== $user->ID ) { /* translators: %s: e-mail address */ $this->log( 'warning', sprintf( __( '%s: e-mail address is used by another WordPress account and was not changed.', 'm365-login' ), $email ) ); } else { $fields['user_email'] = $email; $changes[] = __( 'e-mail', 'm365-login' ); } } $labels = self::attributes(); foreach ( $this->selected_attributes() as $key => $target ) { if ( 'avatar' === $target ) { continue; } $value = $this->attribute_value( $person, $key, $target ); if ( null === $value ) { continue; } if ( in_array( $target, array( 'display_name', 'first_name', 'last_name', 'locale' ), true ) ) { if ( '' === $value && 'display_name' === $target ) { continue; } if ( (string) $user->$target !== $value ) { $fields[ $target ] = $value; $changes[] = $labels[ $key ]['label']; } continue; } $meta_key = sanitize_key( $target ); if ( (string) get_user_meta( $user->ID, $meta_key, true ) !== $value ) { if ( ! $this->dry ) { if ( '' === $value ) { delete_user_meta( $user->ID, $meta_key ); } else { update_user_meta( $user->ID, $meta_key, $value ); } } $changes[] = $labels[ $key ]['label']; } } // Fields that are no longer selected are removed from the profile (only the plugin's own m365_* keys). $selected = $this->selected_attributes(); foreach ( $labels as $key => $attribute ) { $target = isset( $attribute['target'] ) ? (string) $attribute['target'] : ''; if ( isset( $selected[ $key ] ) || 0 !== strpos( $target, 'm365_' ) ) { continue; } if ( '' !== (string) get_user_meta( $user->ID, $target, true ) ) { if ( ! $this->dry ) { delete_user_meta( $user->ID, $target ); } /* translators: %s: profile field */ $changes[] = sprintf( __( '%s removed', 'm365-login' ), $attribute['label'] ); } } if ( $fields && ! $this->dry ) { $fields['ID'] = $user->ID; $result = wp_update_user( $fields ); if ( is_wp_error( $result ) ) { $this->count( 'errors' ); /* translators: 1: e-mail address, 2: error message */ $this->log( 'error', sprintf( __( '%1$s: profile could not be updated: %2$s', 'm365-login' ), $email, $result->get_error_message() ) ); return array(); } clean_user_cache( $user->ID ); } return $changes; } /** * Normalised value of one attribute, or null to leave the field alone. * * @param array $person Graph user. * @param string $key Graph property. * @param string $target Target field. * @return string|null */ private function attribute_value( $person, $key, $target ) { if ( ! array_key_exists( $key, $person ) ) { return null; } $raw = $person[ $key ]; if ( is_array( $raw ) ) { $raw = isset( $raw[0] ) && is_scalar( $raw[0] ) ? $raw[0] : ''; } $value = null === $raw ? '' : sanitize_text_field( (string) $raw ); if ( 'locale' === $target ) { $locale = str_replace( '-', '_', $value ); if ( '' === $locale ) { return null; } if ( 'en_US' !== $locale && ! in_array( $locale, get_available_languages(), true ) ) { return null; // Language pack not installed: keep the site default. } return $locale; } return mb_substr( $value, 0, 250 ); } /** * Roles a person should have, in order (the first one is the primary role). * * @param string $oid Object ID. * @param array $memberships Role group memberships. * @return string[] */ private function desired_roles( $oid, $memberships ) { $default = (string) $this->settings->get( 'sync_default_role' ); $mapped = array(); foreach ( $this->settings->sync_role_map() as $group_id => $row ) { if ( isset( $memberships[ $group_id ][ $oid ] ) && get_role( $row['role'] ) ) { $mapped[] = $row['role']; } } $mapped = array_values( array_unique( $mapped ) ); if ( 'replace' === $this->settings->get( 'sync_role_mode' ) ) { $roles = $mapped ? array( $mapped[0] ) : array( $default ); } else { $roles = array_values( array_unique( array_merge( array( $default ), $mapped ) ) ); } /** * Filters the WordPress roles the sync assigns to a Microsoft 365 user. * * @param string[] $roles Role slugs, the first is the primary role. * @param string $oid Microsoft object ID. */ $roles = array_values( array_filter( (array) apply_filters( 'm365_login_sync_roles', $roles, $oid ), 'get_role' ) ); return $roles ? $roles : array( $default ); } /** * Whether the sync may change the roles of this account. * * @param WP_User $user User. * @return bool */ private function manages_roles( $user ) { if ( $this->is_protected( $user ) ) { return false; } return (bool) get_user_meta( $user->ID, self::META_SYNCED, true ) || (bool) $this->settings->get( 'sync_manage_existing' ); } /** * Applies the desired roles. * * @param WP_User $user User. * @param string[] $roles Desired roles. * @return string[] Changes for the log. */ private function update_roles( $user, $roles ) { $current = array_values( $user->roles ); $same = count( $current ) === count( $roles ) && ! array_diff( $current, $roles ) && reset( $current ) === $roles[0]; if ( $same ) { return array(); } if ( self::roles_privileged( $current ) && ! self::roles_privileged( $roles ) ) { // Losing administrative rights: collected and applied after the safety check in apply_demotions(). $this->demotions[] = array( 'user' => $user, 'roles' => $roles, ); } elseif ( ! $this->dry ) { self::set_roles( $user, $roles ); } /* translators: %s: role names */ return array( sprintf( __( 'roles: %s', 'm365-login' ), $this->role_names( $roles ) ) ); } /** * Replaces the roles of a user (first = primary role). * * @param WP_User $user User. * @param string[] $roles Roles. */ private static function set_roles( $user, $roles ) { $user->set_role( $roles[0] ); foreach ( array_slice( $roles, 1 ) as $role ) { $user->add_role( $role ); } } /** * Whether any of the roles grants a privileged capability. * * @param string[] $roles Role slugs. * @return bool */ private static function roles_privileged( $roles ) { foreach ( (array) $roles as $slug ) { $role = is_string( $slug ) ? get_role( $slug ) : null; if ( $role && array_intersect( self::privileged_caps(), array_keys( array_filter( $role->capabilities ) ) ) ) { return true; } } return false; } /** * Applies collected removals of administrative roles unless they would take away too many * administrators at once (e.g. a mapped group was emptied by mistake). * * @return bool False when the run was stopped. */ private function apply_demotions() { if ( empty( $this->demotions ) ) { return true; } $privileged_roles = array(); foreach ( wp_roles()->roles as $slug => $definition ) { if ( self::roles_privileged( array( $slug ) ) ) { $privileged_roles[] = $slug; } } $admins = count( get_users( array( 'role__in' => $privileged_roles, 'fields' => 'ID', 'number' => -1, ) ) ); /** * Maximum number of accounts that may lose administrative rights in one sync run. * * @param int $limit Limit (default: 20 % of the privileged accounts, at least 1; never all of them). * @param int $admins Number of accounts with privileged roles on this site. */ $limit = (int) apply_filters( 'm365_login_sync_demotion_limit', max( 1, (int) floor( $admins * 0.2 ) ), $admins ); if ( count( $this->demotions ) > $limit || count( $this->demotions ) >= $admins ) { $this->log( 'error', sprintf( /* translators: 1: number of accounts, 2: limit */ __( 'Safety stop: %1$d accounts would lose administrative rights, more than the limit of %2$d per run (or all of them). Nothing was demoted, deactivated or deleted. Check the group → role mapping, then run the sync again (filter m365_login_sync_demotion_limit).', 'm365-login' ), count( $this->demotions ), $limit ) ); return false; } if ( ! $this->dry ) { foreach ( $this->demotions as $demotion ) { self::set_roles( $demotion['user'], $demotion['roles'] ); } } return true; } /** * Human readable role list. * * @param string[] $roles Role slugs. * @return string */ private function role_names( $roles ) { $names = wp_roles()->get_names(); $out = array(); foreach ( $roles as $role ) { $out[] = isset( $names[ $role ] ) ? translate_user_role( $names[ $role ] ) : $role; } return implode( ', ', $out ); } /** * Decides what happens to a linked account that was not in the directory listing. * * @param string $oid Object ID. * @param int $user_id User ID. * @param bool $legacy Linked without a recorded tenant. * @return array[]|null|string|WP_Error Pending actions, null for none, 'unknown' when a legacy link is not found. */ private function classify_missing( $oid, $user_id, $legacy = false ) { $user = get_userdata( $user_id ); if ( ! $user ) { return null; } // Double-check with Graph: only a 404 proves that the account was deleted. $person = $this->graph->get_user( $oid, array( 'id', 'accountEnabled', 'userType' ) ); if ( is_wp_error( $person ) ) { if ( M365_Login_Graph::is_not_found( $person ) ) { if ( $legacy ) { return 'unknown'; } return $this->action( (string) $this->settings->get( 'sync_deleted_action' ), $user, 'deleted', __( 'deleted in Microsoft 365', 'm365-login' ), $oid ); } return $person; } if ( $legacy && ! $this->dry ) { update_user_meta( $user->ID, M365_Login_Auth::META_TID, strtolower( $this->settings->tenant() ) ); } if ( isset( $person['accountEnabled'] ) && false === $person['accountEnabled'] ) { return $this->action( (string) $this->settings->get( 'sync_disabled_action' ), $user, 'disabled', __( 'disabled in Microsoft 365', 'm365-login' ), $oid ); } if ( $this->settings->sync_scope_groups() && ( ! isset( $person['userType'] ) || 'Guest' !== $person['userType'] || $this->settings->get( 'sync_guests' ) ) ) { return $this->action( (string) $this->settings->get( 'sync_scope_action' ), $user, 'scope', __( 'no longer a member of the sync groups', 'm365-login' ), $oid ); } return null; } /** * Builds a pending deprovision action. * * @param string $what 'none', 'disable' or 'delete'. * @param WP_User $user User. * @param string $reason Machine reason. * @param string $label Human reason. * @param string $oid Object ID (further accounts bound to it get the same action). * @return array[]|null Pending actions. */ private function action( $what, $user, $reason, $label, $oid = '' ) { if ( ! in_array( $what, array( 'disable', 'delete' ), true ) ) { return null; } $users = array( $user ); if ( '' !== $oid && ! empty( $this->duplicates[ $oid ] ) ) { foreach ( $this->duplicates[ $oid ] as $user_id ) { $other = get_userdata( $user_id ); if ( $other && $other->ID !== $user->ID ) { $users[] = $other; } } } $out = array(); foreach ( $users as $target ) { if ( $this->is_protected( $target ) ) { /* translators: 1: e-mail address, 2: reason */ $this->skip( sprintf( __( '%1$s: %2$s, but the account is protected (administrator or your own account) and was not changed.', 'm365-login' ), $target->user_email, $label ) ); continue; } $out[] = array( 'what' => $what, 'user' => $target, 'reason' => $reason, 'label' => $label, ); } return $out ? $out : null; } /** * Whether an existing account may be linked to a directory user by e-mail address. * * Privileged accounts are only linked through the user principal name of a member * account: its domain must be verified in the tenant, whereas the "mail" attribute can * be set to any address by a user or Exchange administrator of the tenant. * * @param WP_User $user Existing account. * @param array $person Graph user. * @param string $email Address the account was found by. * @return bool */ private function may_link( $user, $person, $email ) { if ( ! self::is_privileged( $user ) ) { return true; } $upn = self::member_upn( $person ); if ( '' === $upn ) { return false; } $assigned = strtolower( (string) get_user_meta( $user->ID, M365_Login_Auth::META_UPN, true ) ); return ( '' !== $assigned && $assigned === $upn ) || strtolower( $user->user_email ) === $upn; } /** * User principal name of a member (not a guest), lowercase, or ''. * * @param array $person Graph user. * @return string */ private static function member_upn( $person ) { $upn = isset( $person['userPrincipalName'] ) ? strtolower( trim( (string) $person['userPrincipalName'] ) ) : ''; $guest = isset( $person['userType'] ) && 'Guest' === $person['userType']; return $guest || false !== strpos( $upn, '#ext#' ) ? '' : $upn; } /** * Account an administrator assigned this person's user principal name to. * * @param array $person Graph user. * @return WP_User|null */ private function assigned_user( $person ) { $upn = self::member_upn( $person ); if ( '' === $upn ) { return null; } $ids = get_users( array( 'meta_key' => M365_Login_Auth::META_UPN, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key 'meta_value' => $upn, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value 'fields' => 'ID', 'number' => 2, 'blog_id' => 0, ) ); if ( 1 !== count( $ids ) ) { return null; } $user = get_userdata( (int) $ids[0] ); return $user ? $user : null; } /** * Capabilities that make an account privileged (administrative or able to run code/HTML). * * @return string[] */ private static function privileged_caps() { return array( 'manage_options', 'promote_users', 'edit_users', 'create_users', 'delete_users', 'unfiltered_html', 'activate_plugins', 'install_plugins', 'edit_plugins', 'edit_themes', 'switch_themes', 'update_core' ); } /** * Accounts with administrative capabilities (they get extra protection against linking by e-mail). * * @param WP_User $user User. * @return bool */ public static function is_privileged( $user ) { $privileged = is_super_admin( $user->ID ); // A deactivated account has no role, but gets its roles back on reactivation. $disabled = self::disabled_info( $user->ID ); if ( ! $privileged && $disabled && ! empty( $disabled['roles'] ) ) { $privileged = self::roles_privileged( (array) $disabled['roles'] ); } if ( ! $privileged ) { // On multisite the rights on every site of the user count, not only on the current one. $sites = is_multisite() ? array_keys( get_blogs_of_user( $user->ID ) ) : array( 0 ); foreach ( $sites as $site_id ) { $check = $site_id ? new WP_User( $user->ID, '', $site_id ) : $user; foreach ( self::privileged_caps() as $cap ) { if ( $check->has_cap( $cap ) ) { $privileged = true; break 2; } } } } /** * Filters whether an account counts as privileged (linked only via a matching user principal name). * * @param bool $privileged Whether the account is privileged. * @param WP_User $user User. */ return (bool) apply_filters( 'm365_login_is_privileged_user', $privileged, $user ); } /** * Filters out actions that would not change anything (already deactivated). * * @param array $action Pending action. * @return bool */ private function is_effective_action( $action ) { return 'delete' === $action['what'] || ! self::disabled_info( $action['user']->ID ); } /** * Deactivates or deletes an account. * * @param array $action Pending action. */ private function apply_action( $action ) { $user = $action['user']; $reassign = (int) $this->settings->get( 'sync_reassign' ); $what = $action['what']; if ( 'delete' === $what && ( ! $reassign || $reassign === $user->ID || ! get_userdata( $reassign ) ) ) { /* translators: %s: e-mail address */ $this->log( 'warning', sprintf( __( '%s: no valid user to receive the content is selected, so the account is deactivated instead of deleted.', 'm365-login' ), $user->user_email ) ); $what = 'disable'; if ( self::disabled_info( $user->ID ) ) { return; } } if ( 'delete' === $what ) { /* translators: 1: e-mail address, 2: reason */ $this->log( 'info', sprintf( __( '%1$s: account deleted (%2$s).', 'm365-login' ), $user->user_email, $action['label'] ) ); $this->count( 'deleted' ); if ( ! $this->dry ) { wp_delete_user( $user->ID, $reassign ); // The delete_user hook removes the photo. } return; } /* translators: 1: e-mail address, 2: reason */ $this->log( 'info', sprintf( __( '%1$s: account deactivated (%2$s).', 'm365-login' ), $user->user_email, $action['label'] ) ); $this->count( 'disabled' ); if ( ! $this->dry ) { self::disable( $user->ID, 'sync', $action['reason'] ); } } /** * Accounts the sync never deactivates, deletes or re-roles. * * Administrators that existed before the sync are protected; accounts the sync * created (and may have promoted through a group mapping) are fully managed. * * @param WP_User $user User. * @return bool */ private function is_protected( $user ) { $protected = get_current_user_id() === $user->ID || ( is_multisite() && is_super_admin( $user->ID ) ) || ( ! get_user_meta( $user->ID, self::META_SYNCED, true ) && user_can( $user, 'manage_options' ) ); /** * Filters whether the sync must leave an account alone (no role changes, deactivation or deletion). * * @param bool $protected Whether the account is protected. * @param WP_User $user User. */ return (bool) apply_filters( 'm365_login_sync_protect_user', $protected, $user ); } /** * E-mail address of a directory user (mail, else a usable UPN). * * @param array $person Graph user. * @return string Lowercase address or ''. */ private function email_of( $person ) { $candidates = array(); if ( ! empty( $person['mail'] ) ) { $candidates[] = (string) $person['mail']; } if ( ! empty( $person['userPrincipalName'] ) && false === stripos( (string) $person['userPrincipalName'], '#ext#' ) ) { $candidates[] = (string) $person['userPrincipalName']; } foreach ( $candidates as $candidate ) { $candidate = strtolower( trim( $candidate ) ); if ( is_email( $candidate ) ) { /** * Filters the e-mail address the sync uses for a Microsoft 365 user. * * @param string $email Address. * @param array $person Graph user object. */ return strtolower( (string) apply_filters( 'm365_login_sync_email', $candidate, $person ) ); } } return ''; } /** * Domain allow-list from the Security tab. * * @param string $email E-mail. * @return bool */ private function domain_allowed( $email ) { $allowed = $this->settings->allowed_domains(); return empty( $allowed ) || in_array( strtolower( substr( strrchr( $email, '@' ), 1 ) ), $allowed, true ); } /** * Friendlier text for common Graph permission errors. * * @param WP_Error $error Error. * @return string */ private function graph_error_text( $error ) { $message = $error->get_error_message(); if ( false !== stripos( $message, 'Authorization_RequestDenied' ) || false !== stripos( $message, 'Insufficient privileges' ) ) { return __( 'Microsoft Graph refused the request. Grant the application permissions "User.Read.All" and "GroupMember.Read.All" with admin consent in Entra ID.', 'm365-login' ); } /* translators: %s: error message */ return sprintf( __( 'Microsoft Graph error: %s', 'm365-login' ), $message ); } /** * Adds a log line. * * @param string $level 'info', 'warning' or 'error'. * @param string $message Message. */ private function log( $level, $message ) { if ( count( $this->report['log'] ) < self::LOG_LIMIT ) { $this->report['log'][] = array( 'level' => $level, 'message' => $message, ); } elseif ( count( $this->report['log'] ) === self::LOG_LIMIT ) { $this->report['log'][] = array( 'level' => 'warning', 'message' => __( 'Log truncated.', 'm365-login' ), ); } } /** * Logs a skipped person. * * @param string $message Message. */ private function skip( $message ) { $this->log( 'warning', $message ); $this->count( 'skipped' ); } /** * Increments a counter. * * @param string $key Counter. * @param int $delta Amount. */ private function count( $key, $delta = 1 ) { $this->report['counts'][ $key ] += $delta; } /* ------------------------------------------------------------------ */ /* Profile photos */ /* ------------------------------------------------------------------ */ /** * Brings the stored photos in line with Microsoft 365: downloads new and changed * photos and deletes photos that were removed in Microsoft 365. * * Photo versions are compared on every run (20 users per Graph batch request); * only changed photos are downloaded. * * @param int[] $user_ids oid => user ID. */ private function sync_photos( $user_ids ) { $users = array(); foreach ( $user_ids as $oid => $user_id ) { $user = get_userdata( $user_id ); if ( $user ) { $users[ $oid ] = $user; } } /** * Minimum number of seconds between two photo checks of the same user (0 = every run). * * @param int $interval Interval. */ $interval = (int) apply_filters( 'm365_login_sync_photo_interval', 0 ); /** * Maximum number of photo downloads per sync run (the rest follows in later runs). * * @param int $limit Limit. */ $limit = (int) apply_filters( 'm365_login_sync_photo_limit', 500 ); $check = array(); foreach ( $users as $oid => $user ) { $stored = $this->stored_photo( $user->ID ); if ( $interval > 0 && ! empty( $stored['checked'] ) && time() - (int) $stored['checked'] < $interval ) { continue; } $check[ $oid ] = $user; } if ( empty( $check ) ) { return; } $versions = $this->graph->photo_versions( array_keys( $check ) ); $downloads = 0; $deferred = 0; foreach ( $check as $oid => $user ) { $version = array_key_exists( $oid, $versions ) ? $versions[ $oid ] : new WP_Error( 'graph_photo', 'No answer.' ); $stored = $this->stored_photo( $user->ID ); // Never delete anything because of an error – only a clear "no photo" removes it. if ( is_wp_error( $version ) ) { /* translators: 1: e-mail address, 2: error message */ $this->log( 'warning', sprintf( __( '%1$s: profile photo could not be read: %2$s', 'm365-login' ), $user->user_email, $version->get_error_message() ) ); continue; } if ( null === $version ) { if ( ! empty( $stored['file'] ) ) { $this->remove_photo( $user ); } elseif ( ! $this->dry ) { update_user_meta( $user->ID, self::META_PHOTO, array( 'checked' => time() ) ); } continue; } if ( ! empty( $stored['file'] ) && isset( $stored['etag'] ) && $stored['etag'] === $version && self::is_photo_file( $stored['file'] ) && file_exists( self::photo_path( $stored['file'] ) ) ) { if ( ! $this->dry ) { $stored['checked'] = time(); update_user_meta( $user->ID, self::META_PHOTO, $stored ); } continue; } // New or changed photo. if ( $this->dry ) { /* translators: %s: e-mail address */ $this->log( 'info', sprintf( __( '%s: profile photo updated.', 'm365-login' ), $user->user_email ) ); $this->count( 'photos' ); continue; } if ( $downloads >= $limit ) { ++$deferred; continue; } ++$downloads; $bytes = $this->graph->photo_bytes( $oid ); if ( null === $bytes ) { if ( ! empty( $stored['file'] ) ) { $this->remove_photo( $user ); } continue; } if ( is_wp_error( $bytes ) || '' === $bytes || strlen( $bytes ) > self::PHOTO_MAX ) { /* translators: %s: e-mail address */ $this->log( 'warning', sprintf( __( '%s: profile photo could not be downloaded.', 'm365-login' ), $user->user_email ) ); continue; } $file = $this->store_photo( $user->ID, $oid, $version, $bytes ); if ( '' === $file ) { /* translators: %s: e-mail address */ $this->log( 'warning', sprintf( __( '%s: profile photo is not a valid image or could not be saved.', 'm365-login' ), $user->user_email ) ); continue; } if ( ! empty( $stored['file'] ) && $stored['file'] !== $file && self::is_photo_file( $stored['file'] ) ) { wp_delete_file( self::photo_path( $stored['file'] ) ); } update_user_meta( $user->ID, self::META_PHOTO, array( 'file' => $file, 'etag' => $version, 'checked' => time(), ) ); /* translators: %s: e-mail address */ $this->log( 'info', sprintf( __( '%s: profile photo updated.', 'm365-login' ), $user->user_email ) ); $this->count( 'photos' ); } if ( $deferred ) { /* translators: %d: number of photos */ $this->log( 'info', sprintf( _n( '%d changed profile photo will be downloaded in the next run (download limit per run reached).', '%d changed profile photos will be downloaded in the next run (download limit per run reached).', $deferred, 'm365-login' ), $deferred ) ); } } /** * Removes the stored photo of a user whose photo was deleted in Microsoft 365. * * @param WP_User $user User. */ private function remove_photo( $user ) { if ( ! $this->dry ) { $this->delete_photo( $user->ID ); update_user_meta( $user->ID, self::META_PHOTO, array( 'checked' => time() ) ); } /* translators: %s: e-mail address */ $this->log( 'info', sprintf( __( '%s: profile photo removed.', 'm365-login' ), $user->user_email ) ); $this->count( 'photos' ); } /** * Deletes every stored photo (the photo sync was switched off). */ private function remove_all_photos() { $users = get_users( array( 'meta_key' => self::META_PHOTO, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key 'meta_compare' => 'EXISTS', 'fields' => 'ID', 'number' => -1, ) ); $removed = 0; foreach ( $users as $user_id ) { $stored = $this->stored_photo( (int) $user_id ); if ( ! empty( $stored['file'] ) ) { ++$removed; } if ( ! $this->dry ) { $this->delete_photo( (int) $user_id ); } } if ( $removed ) { /* translators: %d: number of photos */ $this->log( 'info', sprintf( _n( 'Profile photo sync is off: %d stored photo removed.', 'Profile photo sync is off: %d stored photos removed.', $removed, 'm365-login' ), $removed ) ); $this->count( 'photos', $removed ); } } /** * Stored photo record of a user. * * @param int $user_id User ID. * @return array */ private function stored_photo( $user_id ) { $stored = get_user_meta( $user_id, self::META_PHOTO, true ); return is_array( $stored ) ? $stored : array(); } /** * Writes the image into uploads/m365-login-avatars/. * * @param int $user_id User ID. * @param string $oid Object ID. * @param string $etag Photo version. * @param string $bytes Image data. * @return string File path relative to the uploads base directory, or ''. */ private function store_photo( $user_id, $oid, $etag, $bytes ) { $size = @getimagesizefromstring( $bytes ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- invalid data is expected to fail quietly. if ( ! is_array( $size ) || empty( $size['mime'] ) || ! in_array( $size['mime'], array( 'image/jpeg', 'image/png', 'image/gif' ), true ) ) { return ''; } // No decompression bombs: Microsoft 365 photos are at most 648×648 (originals up to a few thousand pixels). if ( empty( $size[0] ) || empty( $size[1] ) || $size[0] > 4096 || $size[1] > 4096 ) { return ''; } $uploads = wp_upload_dir(); $dir = trailingslashit( $uploads['basedir'] ) . self::PHOTO_DIR; if ( ! wp_mkdir_p( $dir ) ) { return ''; } if ( ! file_exists( $dir . '/index.php' ) ) { file_put_contents( $dir . '/index.php', "resize( 240, 240, true ); $editor->set_quality( 85 ); // JPEG where the server can write it, PNG otherwise (some GD builds lack JPEG). $ext = 'jpg'; $out = 'image/jpeg'; if ( ! wp_image_editor_supports( array( 'mime_type' => 'image/jpeg' ) ) ) { $ext = 'png'; $out = 'image/png'; } $name = 'm365-' . substr( wp_hash( $oid . '|avatar' ), 0, 16 ) . '-' . substr( md5( $etag ), 0, 8 ) . '.' . $ext; $saved = $editor->save( $dir . '/' . $name, $out ); wp_delete_file( $tmp ); if ( is_wp_error( $saved ) || empty( $saved['path'] ) ) { return ''; } return self::PHOTO_DIR . '/' . $name; } /** * Absolute path of a stored photo. * * @param string $file Relative path. * @return string */ private static function photo_path( $file ) { $uploads = wp_get_upload_dir(); return trailingslashit( $uploads['basedir'] ) . ltrim( $file, '/' ); } /** * Whether a stored photo path is one the plugin wrote (no traversal, fixed folder and pattern). * * @param mixed $file Relative path from user meta. * @return bool */ private static function is_photo_file( $file ) { return is_string( $file ) && (bool) preg_match( self::PHOTO_FILE, $file ); } /** * Deletes a user's stored photo (also hooked to user deletion). * * @param int $user_id User ID. */ public function delete_photo( $user_id ) { self::remove_stored_photo( $user_id ); } /** * Deletes a user's stored photo file and record. * * @param int $user_id User ID. */ public static function remove_stored_photo( $user_id ) { $stored = get_user_meta( $user_id, self::META_PHOTO, true ); if ( is_array( $stored ) && ! empty( $stored['file'] ) && self::is_photo_file( $stored['file'] ) ) { wp_delete_file( self::photo_path( $stored['file'] ) ); } delete_user_meta( $user_id, self::META_PHOTO ); } /** * Uses the synced Microsoft 365 photo as avatar. * * @param array $args Avatar data. * @param mixed $id_or_email User ID, e-mail, WP_User, WP_Post or WP_Comment. * @return array */ public function avatar_data( $args, $id_or_email ) { if ( ! in_array( 'photo', (array) $this->settings->get( 'sync_attributes', array() ), true ) ) { return $args; } $user_id = 0; if ( is_numeric( $id_or_email ) ) { $user_id = (int) $id_or_email; } elseif ( $id_or_email instanceof WP_User ) { $user_id = $id_or_email->ID; } elseif ( $id_or_email instanceof WP_Post ) { $user_id = (int) $id_or_email->post_author; } elseif ( $id_or_email instanceof WP_Comment ) { $user_id = (int) $id_or_email->user_id; } elseif ( is_string( $id_or_email ) && is_email( $id_or_email ) ) { $user = get_user_by( 'email', $id_or_email ); $user_id = $user ? $user->ID : 0; } if ( ! $user_id ) { return $args; } $stored = get_user_meta( $user_id, self::META_PHOTO, true ); if ( ! is_array( $stored ) || empty( $stored['file'] ) || ! self::is_photo_file( $stored['file'] ) ) { return $args; } $uploads = wp_get_upload_dir(); $args['url'] = trailingslashit( $uploads['baseurl'] ) . ltrim( $stored['file'], '/' ); $args['found_avatar'] = true; return $args; } /* ------------------------------------------------------------------ */ /* Privacy tools (Tools → Export / Erase Personal Data) */ /* ------------------------------------------------------------------ */ /** * Registers the exporter. * * @param array $exporters Exporters. * @return array */ public function register_exporter( $exporters ) { $exporters['m365-login'] = array( 'exporter_friendly_name' => __( 'Microsoft 365 (M365 Login)', 'm365-login' ), 'callback' => array( $this, 'export_personal_data' ), ); return $exporters; } /** * Registers the eraser. * * @param array $erasers Erasers. * @return array */ public function register_eraser( $erasers ) { $erasers['m365-login'] = array( 'eraser_friendly_name' => __( 'Microsoft 365 (M365 Login)', 'm365-login' ), 'callback' => array( $this, 'erase_personal_data' ), ); return $erasers; } /** * Exports the data the plugin stores about a user. * * @param string $email E-mail address. * @param int $page Page. * @return array */ public function export_personal_data( $email, $page = 1 ) { $user = get_user_by( 'email', $email ); $data = array(); if ( $user ) { $fields = array( __( 'Microsoft object ID', 'm365-login' ) => (string) get_user_meta( $user->ID, M365_Login_Auth::META_OID, true ), __( 'Microsoft tenant ID', 'm365-login' ) => (string) get_user_meta( $user->ID, M365_Login_Auth::META_TID, true ), ); foreach ( self::attributes() as $attribute ) { $target = (string) $attribute['target']; if ( 0 === strpos( $target, 'm365_' ) ) { $fields[ (string) $attribute['label'] ] = (string) get_user_meta( $user->ID, $target, true ); } } $stored = get_user_meta( $user->ID, self::META_PHOTO, true ); if ( is_array( $stored ) && ! empty( $stored['file'] ) && self::is_photo_file( $stored['file'] ) ) { $uploads = wp_get_upload_dir(); $fields[ __( 'Profile photo', 'm365-login' ) ] = trailingslashit( $uploads['baseurl'] ) . $stored['file']; } $last = (int) get_user_meta( $user->ID, self::META_LAST_SYNC, true ); if ( $last ) { $fields[ __( 'Last sync', 'm365-login' ) ] = wp_date( 'c', $last ); } $items = array(); foreach ( $fields as $name => $value ) { if ( '' !== $value ) { $items[] = array( 'name' => $name, 'value' => $value, ); } } if ( $items ) { $data[] = array( 'group_id' => 'm365-login', 'group_label' => __( 'Microsoft 365', 'm365-login' ), 'item_id' => 'm365-login-' . $user->ID, 'data' => $items, ); } } return array( 'data' => $data, 'done' => true, ); } /** * Erases copied profile data and the photo. The object ID link and a deactivation are kept: * they protect the account (removing them would unlock or unbind it). * * @param string $email E-mail address. * @param int $page Page. * @return array */ public function erase_personal_data( $email, $page = 1 ) { $user = get_user_by( 'email', $email ); $removed = false; $kept = false; if ( $user ) { foreach ( self::attributes() as $attribute ) { $target = (string) $attribute['target']; if ( 0 === strpos( $target, 'm365_' ) && '' !== (string) get_user_meta( $user->ID, $target, true ) ) { delete_user_meta( $user->ID, $target ); $removed = true; } } if ( get_user_meta( $user->ID, self::META_PHOTO, true ) ) { self::remove_stored_photo( $user->ID ); $removed = true; } $kept = '' !== (string) get_user_meta( $user->ID, M365_Login_Auth::META_OID, true ) || (bool) self::disabled_info( $user->ID ); } return array( 'items_removed' => $removed, 'items_retained' => $kept, 'messages' => $kept ? array( __( 'The link to the Microsoft account and a possible deactivation were kept because they secure the account. The next user sync copies selected profile fields again unless the person is excluded from the sync.', 'm365-login' ) ) : array(), 'done' => true, ); } /* ------------------------------------------------------------------ */ /* Deactivated accounts */ /* ------------------------------------------------------------------ */ /** * Deactivation details or null when the account is active. * * @param int $user_id User ID. * @return array|null */ public static function disabled_info( $user_id ) { $info = get_user_meta( (int) $user_id, self::META_DISABLED, true ); if ( ! is_array( $info ) || empty( $info['time'] ) ) { return null; } return wp_parse_args( $info, array( 'by' => 'manual', 'reason' => '', ) ); } /** * Deactivates an account and ends all of its sessions. * * @param int $user_id User ID. * @param string $by 'sync' or 'manual'. * @param string $reason Machine reason. */ public static function disable( $user_id, $by, $reason = '' ) { if ( self::disabled_info( $user_id ) ) { return; // Already deactivated: keep the remembered roles and the origin. } $user = get_userdata( $user_id ); $roles = $user ? array_values( $user->roles ) : array(); update_user_meta( $user_id, self::META_DISABLED, array( 'time' => time(), 'by' => $by, 'reason' => $reason, 'roles' => $roles, ) ); // Lock the account for good, also without this plugin: no sessions, no role on this // site, a random password nobody knows and no application passwords. WP_Session_Tokens::get_instance( $user_id )->destroy_all(); if ( class_exists( 'WP_Application_Passwords' ) ) { WP_Application_Passwords::delete_all_application_passwords( $user_id ); } wp_set_password( wp_generate_password( 64, true, true ), $user_id ); if ( $user ) { $user->set_role( '' ); } self::remove_stored_photo( $user_id ); // No public photo of a deactivated account. /** * Fires after an account was deactivated. * * @param int $user_id User ID. * @param string $by 'sync' or 'manual'. * @param string $reason 'disabled', 'deleted', 'scope' or ''. */ do_action( 'm365_login_user_disabled', $user_id, $by, $reason ); } /** * One-time hardening of accounts deactivated before 1.1.0 (no role/password/app-password lock yet). */ public static function harden_legacy_disabled() { $users = get_users( array( 'meta_key' => self::META_DISABLED, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key 'meta_compare' => 'EXISTS', 'fields' => 'ID', 'number' => -1, 'blog_id' => 0, ) ); foreach ( $users as $user_id ) { $info = self::disabled_info( (int) $user_id ); if ( ! $info || array_key_exists( 'roles', $info ) ) { continue; } delete_user_meta( (int) $user_id, self::META_DISABLED ); self::disable( (int) $user_id, $info['by'], $info['reason'] ); } } /** * Reactivates an account. * * @param int $user_id User ID. */ public static function enable( $user_id ) { $info = self::disabled_info( $user_id ); delete_user_meta( $user_id, self::META_DISABLED ); // Give back the roles taken away on deactivation (the sync may adjust them afterwards). $user = get_userdata( $user_id ); if ( $user && empty( $user->roles ) && $info && ! empty( $info['roles'] ) && is_array( $info['roles'] ) ) { foreach ( $info['roles'] as $role ) { if ( is_string( $role ) && get_role( $role ) ) { $user->add_role( $role ); } } } /** * Fires after an account was reactivated. * * @param int $user_id User ID. */ do_action( 'm365_login_user_enabled', $user_id ); } /** * Refuses every sign-in (password, application password, Microsoft) of deactivated accounts. * * @param null|WP_User|WP_Error $user Result so far. * @return null|WP_User|WP_Error */ public function block_disabled_login( $user ) { if ( $user instanceof WP_User && self::disabled_info( $user->ID ) ) { return new WP_Error( 'm365_login_disabled', __( 'This account has been deactivated.', 'm365-login' ) ); } return $user; } /** * Treats existing sessions of deactivated accounts as logged out. * * @param int|false $user_id Detected user. * @return int|false */ public function drop_disabled_session( $user_id ) { if ( $user_id && self::disabled_info( (int) $user_id ) ) { return false; } return $user_id; } /* ------------------------------------------------------------------ */ /* Users screen and profile */ /* ------------------------------------------------------------------ */ /** * Adds the "Microsoft 365" column to the users list. * * @param string[] $columns Columns. * @return string[] */ public function users_column( $columns ) { $columns['m365_login'] = __( 'Microsoft 365', 'm365-login' ); return $columns; } /** * Renders the "Microsoft 365" column. * * @param string $output Output so far. * @param string $column Column. * @param int $user_id User ID. * @return string */ public function users_column_value( $output, $column, $user_id ) { if ( 'm365_login' !== $column ) { return $output; } $parts = array(); if ( self::disabled_info( $user_id ) ) { $parts[] = '' . esc_html__( 'Deactivated', 'm365-login' ) . ''; } if ( get_user_meta( $user_id, self::META_SYNCED, true ) ) { $parts[] = esc_html__( 'Imported', 'm365-login' ); } elseif ( get_user_meta( $user_id, M365_Login_Auth::META_OID, true ) ) { $parts[] = esc_html__( 'Linked', 'm365-login' ); } return $parts ? implode( '
', $parts ) : '—'; } /** * "Deactivate" / "Reactivate" row actions. * * @param string[] $actions Actions. * @param WP_User $user User. * @return string[] */ public function user_row_actions( $actions, $user ) { if ( ! current_user_can( 'edit_user', $user->ID ) || get_current_user_id() === $user->ID ) { return $actions; } $disabled = (bool) self::disabled_info( $user->ID ); $url = wp_nonce_url( add_query_arg( array( 'action' => self::POST_STATE, 'user_id' => $user->ID, 'state' => $disabled ? 'enable' : 'disable', ), admin_url( 'admin-post.php' ) ), self::POST_STATE . '_' . $user->ID . '_' . ( $disabled ? 'enable' : 'disable' ) ); $actions['m365_login_state'] = '' . ( $disabled ? esc_html__( 'Reactivate', 'm365-login' ) : esc_html__( 'Deactivate', 'm365-login' ) ) . ''; return $actions; } /** * Handles the row actions. */ public function handle_user_state() { $user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0; $state = isset( $_GET['state'] ) && 'disable' === $_GET['state'] ? 'disable' : 'enable'; check_admin_referer( self::POST_STATE . '_' . $user_id . '_' . $state ); if ( ! $user_id || ! current_user_can( 'edit_user', $user_id ) || get_current_user_id() === $user_id ) { wp_die( esc_html__( 'You are not allowed to do this.', 'm365-login' ), 403 ); } if ( 'disable' === $state ) { self::disable( $user_id, 'manual' ); } else { self::enable( $user_id ); } wp_safe_redirect( add_query_arg( 'm365_user_state', 'disable' === $state ? 'disabled' : 'enabled', admin_url( 'users.php' ) ) ); exit; } /** * Confirmation after a row action. */ public function user_state_notice() { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- display only. if ( isset( $_GET['m365_linked'] ) ) { printf( '

%s

', esc_html__( 'Your Microsoft account is now linked. From now on you can sign in with the Microsoft button.', 'm365-login' ) ); } $link_error = isset( $_GET['m365_link_error'] ) ? sanitize_key( wp_unslash( $_GET['m365_link_error'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended if ( '' !== $link_error ) { printf( '

%s

', esc_html( M365_Login::instance()->auth->error_message( $link_error ) ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only. $state = isset( $_GET['m365_user_state'] ) ? sanitize_key( wp_unslash( $_GET['m365_user_state'] ) ) : ''; if ( '' === $state ) { return; } $text = 'disabled' === $state ? __( 'The account has been deactivated and signed out everywhere.', 'm365-login' ) : __( 'The account has been reactivated.', 'm365-login' ); printf( '

%s

', esc_html( $text ) ); } /** * Read-only "Microsoft 365" section on the profile screen. * * @param WP_User $user User being edited. */ public function profile_section( $user ) { $oid = (string) get_user_meta( $user->ID, M365_Login_Auth::META_OID, true ); $own = get_current_user_id() === $user->ID; $is_admin = current_user_can( M365_Login_Admin::capability() ) && current_user_can( 'edit_user', $user->ID ); $can_link = $own && '' === $oid && $this->settings->is_configured() && ! self::disabled_info( $user->ID ); if ( '' === $oid && ! self::disabled_info( $user->ID ) && ! $can_link && ! $is_admin ) { return; } $rows = array(); $disabled = self::disabled_info( $user->ID ); if ( $disabled ) { $reasons = array( 'disabled' => __( 'disabled in Microsoft 365', 'm365-login' ), 'deleted' => __( 'deleted in Microsoft 365', 'm365-login' ), 'scope' => __( 'no longer a member of the sync groups', 'm365-login' ), ); $text = sprintf( /* translators: 1: date, 2: reason */ __( 'Deactivated since %1$s (%2$s)', 'm365-login' ), wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), (int) $disabled['time'] ), isset( $reasons[ $disabled['reason'] ] ) ? $reasons[ $disabled['reason'] ] : __( 'manually', 'm365-login' ) ); $rows[ __( 'Status', 'm365-login' ) ] = $text; } if ( '' !== $oid ) { $rows[ __( 'Object ID', 'm365-login' ) ] = $oid; } $last = (int) get_user_meta( $user->ID, self::META_LAST_SYNC, true ); if ( $last ) { $rows[ __( 'Last sync', 'm365-login' ) ] = wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $last ); } foreach ( self::attributes() as $attribute ) { $target = (string) $attribute['target']; if ( 0 !== strpos( $target, 'm365_' ) ) { continue; } $value = (string) get_user_meta( $user->ID, $target, true ); if ( '' !== $value ) { $rows[ (string) $attribute['label'] ] = $value; } } $assigned = (string) get_user_meta( $user->ID, M365_Login_Auth::META_UPN, true ); ?>

$value ) : ?>