- Privileged accounts: the UPN rule also applies when bind_oid is off or the account is not bound; privileges are checked on every site of a multisite user, include code/HTML capabilities (unfiltered_html, plugins, themes, users) and the remembered roles of deactivated accounts. - send_auth_cookies protection also works on WordPress 6.0/6.1. - Run lock via INSERT IGNORE (atomic), refreshed during long runs; a shutdown handler reports fatal errors and frees the lock. - Deprovisioning only for accounts linked in the current tenant (tenant recorded per account; legacy links not found are left alone). - Safety stop based on the accounts linked before the run; new safety stop for removals of administrative roles. - Disable is idempotent; row-action nonces are bound to the state. - Profile photos are re-encoded to 240 px (drops EXIF and appended data), size-limited while downloading, removed on deactivation; index.php guard in the photo folder. - Privacy exporter and eraser for the copied data. - One-time migration hardens accounts deactivated by 1.0 and cleans a stored certificate bundle; the .cer download is always re-exported. - Password fields hidden in button-only mode even when the connection is broken; settings written non-autoloaded; robust user ID queries. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2258 lines
76 KiB
PHP
2258 lines
76 KiB
PHP
<?php
|
||
/**
|
||
* User sync: imports Microsoft 365 / Entra ID users, assigns roles, copies profile
|
||
* attributes and photos, and deactivates or deletes accounts that were disabled or
|
||
* removed in Microsoft 365.
|
||
*
|
||
* @package M365_Login
|
||
*/
|
||
|
||
defined( 'ABSPATH' ) || exit;
|
||
|
||
/**
|
||
* Directory sync and account deactivation.
|
||
*/
|
||
class M365_Login_Sync {
|
||
|
||
const CRON_HOOK = 'm365_login_sync';
|
||
const LOCK = 'm365_login_sync_lock'; // Option (atomic via add_option), value "token|time".
|
||
const LOCK_TTL = 2 * HOUR_IN_SECONDS;
|
||
const PHOTO_FILE = '#^m365-login-avatars/m365-[a-f0-9]{16}-[a-f0-9]{8}\.(?:jpg|png|gif)$#';
|
||
const REPORT_OPTION = 'm365_login_sync_report';
|
||
const META_SYNCED = '_m365_login_synced'; // Account was created by the sync.
|
||
const META_DISABLED = '_m365_login_disabled'; // Time, origin (sync or manual) and reason.
|
||
const META_LAST_SYNC = '_m365_login_last_sync';
|
||
const META_PHOTO = '_m365_login_photo'; // Stored file, photo version and last check.
|
||
const PHOTO_DIR = 'm365-login-avatars';
|
||
const PHOTO_MAX = 2 * MB_IN_BYTES;
|
||
const LOG_LIMIT = 500;
|
||
const POST_STATE = 'm365_login_user_state';
|
||
|
||
/**
|
||
* Settings.
|
||
*
|
||
* @var M365_Login_Settings
|
||
*/
|
||
private $settings;
|
||
|
||
/**
|
||
* Graph client.
|
||
*
|
||
* @var M365_Login_Graph
|
||
*/
|
||
private $graph;
|
||
|
||
/**
|
||
* Report of the run in progress.
|
||
*
|
||
* @var array
|
||
*/
|
||
private $report = array();
|
||
|
||
/**
|
||
* Whether the run in progress only simulates changes.
|
||
*
|
||
* @var bool
|
||
*/
|
||
private $dry = false;
|
||
|
||
/**
|
||
* Further accounts bound to the same object ID as the one in linked_users() (oid => 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( '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.
|
||
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: match an existing account by e-mail address.
|
||
if ( ! $user ) {
|
||
$by_mail = get_user_by( 'email', $email );
|
||
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 – it is only linked when the Microsoft user principal name equals its e-mail address (member account, no guest). 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 = isset( $person['userPrincipalName'] ) ? strtolower( (string) $person['userPrincipalName'] ) : '';
|
||
$guest = isset( $person['userType'] ) && 'Guest' === $person['userType'];
|
||
return ! $guest && '' !== $upn && false === strpos( $upn, '#ext#' ) && strtolower( $user->user_email ) === $upn && $upn === $email;
|
||
}
|
||
|
||
/**
|
||
* 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', "<?php\n// Silence is golden.\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- tiny guard file in our own folder.
|
||
}
|
||
|
||
if ( ! function_exists( 'wp_tempnam' ) ) {
|
||
require_once ABSPATH . 'wp-admin/includes/file.php';
|
||
}
|
||
|
||
// Re-encode through the image editor: drops EXIF (location data), anything appended to the image
|
||
// data and oversized dimensions; the stored file is always a 240×240 JPEG (or PNG).
|
||
$tmp = wp_tempnam( 'm365-photo' );
|
||
if ( ! $tmp || false === file_put_contents( $tmp, $bytes ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- temporary file for the image editor.
|
||
return '';
|
||
}
|
||
$editor = wp_get_image_editor( $tmp );
|
||
if ( is_wp_error( $editor ) ) {
|
||
wp_delete_file( $tmp );
|
||
return '';
|
||
}
|
||
$editor->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[] = '<span class="m365-user-state m365-user-state--disabled" style="color:#b32d2e;font-weight:600">' . esc_html__( 'Deactivated', 'm365-login' ) . '</span>';
|
||
}
|
||
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( '<br>', $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'] = '<a href="' . esc_url( $url ) . '">' . ( $disabled ? esc_html__( 'Reactivate', 'm365-login' ) : esc_html__( 'Deactivate', 'm365-login' ) ) . '</a>';
|
||
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: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( '<div class="notice notice-success is-dismissible"><p>%s</p></div>', 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 );
|
||
if ( '' === $oid && ! self::disabled_info( $user->ID ) ) {
|
||
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;
|
||
}
|
||
}
|
||
?>
|
||
<h2><?php esc_html_e( 'Microsoft 365', 'm365-login' ); ?></h2>
|
||
<table class="form-table" role="presentation">
|
||
<?php foreach ( $rows as $label => $value ) : ?>
|
||
<tr>
|
||
<th scope="row"><?php echo esc_html( $label ); ?></th>
|
||
<td><?php echo esc_html( $value ); ?></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</table>
|
||
<p class="description"><?php esc_html_e( 'These values are managed by the Microsoft 365 user sync and overwritten on the next run.', 'm365-login' ); ?></p>
|
||
<?php
|
||
}
|
||
}
|