Some checks are pending
CI / PHP lint (7.4) (pull_request) Waiting to run
CI / PHP lint (8.0) (pull_request) Waiting to run
CI / PHP lint (8.1) (pull_request) Waiting to run
CI / PHP lint (8.2) (pull_request) Waiting to run
CI / PHP lint (8.3) (pull_request) Waiting to run
CI / PHP lint (8.4) (pull_request) Waiting to run
CI / WordPress Coding Standards (pull_request) Waiting to run
CI / WordPress.org Plugin Check (pull_request) Waiting to run
- Photo versions are compared on every sync run via Graph $batch (20 users per request); only changed photos are downloaded, the old file is deleted and the avatar URL changes. Photos deleted in Microsoft 365 are deleted in WordPress. Graph errors never delete a photo. Download limit per run (500) with deferral to the next run. - Switching the photo sync off removes all stored photos; deselected m365_* profile fields are removed from the profiles. - A user's photo is deleted together with the user (delete_user hook). - Dry run reports photo changes without downloading. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1694 lines
54 KiB
PHP
1694 lines
54 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';
|
||
const LOCK_TTL = 30 * MINUTE_IN_SECONDS;
|
||
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;
|
||
|
||
/**
|
||
* 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' ) );
|
||
|
||
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(),
|
||
);
|
||
|
||
if ( get_transient( self::LOCK ) ) {
|
||
$this->log( 'error', __( 'Another sync is still running. Please try again in a few minutes.', 'm365-login' ) );
|
||
return $this->finish( 'locked', false );
|
||
}
|
||
set_transient( self::LOCK, time(), self::LOCK_TTL );
|
||
|
||
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 e-mail/password changed" mails for changes made by the sync.
|
||
add_filter( 'send_email_change_email', '__return_false', 99 );
|
||
add_filter( 'send_password_change_email', '__return_false', 99 );
|
||
|
||
try {
|
||
$status = $this->sync();
|
||
} finally {
|
||
remove_filter( 'send_email_change_email', '__return_false', 99 );
|
||
remove_filter( 'send_password_change_email', '__return_false', 99 );
|
||
delete_transient( self::LOCK );
|
||
}
|
||
|
||
return $this->finish( $status, true );
|
||
}
|
||
|
||
/**
|
||
* 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();
|
||
$seen = array();
|
||
$pending = array(); // Deactivations/deletions, applied after the safety check.
|
||
$photo_of = array(); // oid => WP_User whose photo is kept in sync.
|
||
$photo_on = array_key_exists( 'photo', $this->selected_attributes() );
|
||
|
||
foreach ( $people as $person ) {
|
||
$oid = strtolower( (string) $person['id'] );
|
||
$seen[ $oid ] = true;
|
||
|
||
$result = $this->sync_person( $person, $linked, $memberships );
|
||
if ( is_array( $result ) ) {
|
||
$pending[] = $result;
|
||
} elseif ( $result instanceof WP_User ) {
|
||
$photo_of[ $oid ] = $result;
|
||
}
|
||
}
|
||
|
||
// 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.
|
||
foreach ( $linked as $oid => $user_id ) {
|
||
if ( isset( $seen[ $oid ] ) ) {
|
||
continue;
|
||
}
|
||
$action = $this->classify_missing( $oid, $user_id );
|
||
if ( is_wp_error( $action ) ) {
|
||
$this->log( 'error', $this->graph_error_text( $action ) );
|
||
return 'failed';
|
||
}
|
||
if ( null !== $action ) {
|
||
$pending[] = $action;
|
||
}
|
||
}
|
||
|
||
// 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( count( $linked ) * 0.2 ) ), count( $linked ) );
|
||
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' => array( 'ID' ),
|
||
'number' => -1,
|
||
)
|
||
);
|
||
$out = array();
|
||
foreach ( $users as $row ) {
|
||
$oid = strtolower( (string) get_user_meta( (int) $row->ID, M365_Login_Auth::META_OID, true ) );
|
||
if ( M365_Login_Settings::is_guid( $oid ) ) {
|
||
$out[ $oid ] = (int) $row->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;
|
||
|
||
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;
|
||
}
|
||
$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 );
|
||
}
|
||
$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' ) );
|
||
}
|
||
|
||
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 ) ) {
|
||
$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() );
|
||
}
|
||
|
||
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, 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 ( $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 ( ! $this->dry ) {
|
||
$user->set_role( $roles[0] );
|
||
foreach ( array_slice( $roles, 1 ) as $role ) {
|
||
$user->add_role( $role );
|
||
}
|
||
}
|
||
/* translators: %s: role names */
|
||
return array( sprintf( __( 'roles: %s', 'm365-login' ), $this->role_names( $roles ) ) );
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
* @return array|null|WP_Error Pending action, null for none.
|
||
*/
|
||
private function classify_missing( $oid, $user_id ) {
|
||
$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 ) ) {
|
||
return $this->action( (string) $this->settings->get( 'sync_deleted_action' ), $user, 'deleted', __( 'deleted in Microsoft 365', 'm365-login' ) );
|
||
}
|
||
return $person;
|
||
}
|
||
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' ) );
|
||
}
|
||
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' ) );
|
||
}
|
||
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.
|
||
* @return array|null
|
||
*/
|
||
private function action( $what, $user, $reason, $label ) {
|
||
if ( ! in_array( $what, array( 'disable', 'delete' ), true ) ) {
|
||
return null;
|
||
}
|
||
if ( $this->is_protected( $user ) ) {
|
||
/* 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' ), $user->user_email, $label ) );
|
||
return null;
|
||
}
|
||
return array(
|
||
'what' => $what,
|
||
'user' => $user,
|
||
'reason' => $reason,
|
||
'label' => $label,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 WP_User[] $users oid => user.
|
||
*/
|
||
private function sync_photos( $users ) {
|
||
/**
|
||
* 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 && 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 ) {
|
||
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' => array( 'ID' ),
|
||
'number' => -1,
|
||
)
|
||
);
|
||
$removed = 0;
|
||
foreach ( $users as $row ) {
|
||
$stored = $this->stored_photo( (int) $row->ID );
|
||
if ( ! empty( $stored['file'] ) ) {
|
||
++$removed;
|
||
}
|
||
if ( ! $this->dry ) {
|
||
$this->delete_photo( (int) $row->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.
|
||
$exts = array(
|
||
'image/jpeg' => 'jpg',
|
||
'image/png' => 'png',
|
||
'image/gif' => 'gif',
|
||
);
|
||
if ( ! is_array( $size ) || empty( $size['mime'] ) || ! isset( $exts[ $size['mime'] ] ) ) {
|
||
return '';
|
||
}
|
||
|
||
$name = 'm365-' . substr( wp_hash( $oid . '|avatar' ), 0, 16 ) . '-' . substr( md5( $etag ), 0, 8 ) . '.' . $exts[ $size['mime'] ];
|
||
$subdir = static function ( $dirs ) {
|
||
$dirs['subdir'] = '/' . self::PHOTO_DIR;
|
||
$dirs['path'] = $dirs['basedir'] . $dirs['subdir'];
|
||
$dirs['url'] = $dirs['baseurl'] . $dirs['subdir'];
|
||
return $dirs;
|
||
};
|
||
|
||
add_filter( 'upload_dir', $subdir );
|
||
$existing = wp_upload_dir();
|
||
if ( file_exists( trailingslashit( $existing['path'] ) . $name ) ) {
|
||
wp_delete_file( trailingslashit( $existing['path'] ) . $name );
|
||
}
|
||
$upload = wp_upload_bits( $name, null, $bytes );
|
||
remove_filter( 'upload_dir', $subdir );
|
||
|
||
if ( ! empty( $upload['error'] ) || empty( $upload['file'] ) ) {
|
||
return '';
|
||
}
|
||
$uploads = wp_get_upload_dir();
|
||
return ltrim( str_replace( wp_normalize_path( $uploads['basedir'] ), '', wp_normalize_path( $upload['file'] ) ), '/' );
|
||
}
|
||
|
||
/**
|
||
* 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, '/' );
|
||
}
|
||
|
||
/**
|
||
* Deletes a user's stored photo (also hooked to user deletion).
|
||
*
|
||
* @param int $user_id User ID.
|
||
*/
|
||
public function delete_photo( $user_id ) {
|
||
$stored = get_user_meta( $user_id, self::META_PHOTO, true );
|
||
if ( is_array( $stored ) && ! empty( $stored['file'] ) && 0 === strpos( $stored['file'], self::PHOTO_DIR . '/' ) ) {
|
||
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'] ) ) {
|
||
return $args;
|
||
}
|
||
$uploads = wp_get_upload_dir();
|
||
$args['url'] = trailingslashit( $uploads['baseurl'] ) . ltrim( $stored['file'], '/' );
|
||
$args['found_avatar'] = true;
|
||
return $args;
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* 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 = '' ) {
|
||
update_user_meta(
|
||
$user_id,
|
||
self::META_DISABLED,
|
||
array(
|
||
'time' => time(),
|
||
'by' => $by,
|
||
'reason' => $reason,
|
||
)
|
||
);
|
||
WP_Session_Tokens::get_instance( $user_id )->destroy_all();
|
||
|
||
/**
|
||
* 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 );
|
||
}
|
||
|
||
/**
|
||
* Reactivates an account.
|
||
*
|
||
* @param int $user_id User ID.
|
||
*/
|
||
public static function enable( $user_id ) {
|
||
delete_user_meta( $user_id, self::META_DISABLED );
|
||
|
||
/**
|
||
* 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
|
||
);
|
||
$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;
|
||
check_admin_referer( self::POST_STATE . '_' . $user_id );
|
||
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 );
|
||
}
|
||
$state = isset( $_GET['state'] ) ? sanitize_key( wp_unslash( $_GET['state'] ) ) : '';
|
||
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
|
||
}
|
||
}
|