wp-m365-login/includes/class-m365-login-sync.php
Friederich Loheide 850f0dcd54
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
Fix the findings of a full second security audit
Four-part audit (OIDC/JWT/crypto, user sync, admin UI, login bypasses)
with dynamic PoCs against a real WordPress install; every fix is covered
by a regression test. Report: docs/security-audit.md, section 6.

Critical/High
- Multisite: settings, AJAX actions and certificate download require
  manage_network_options (site admins could sign in as super admin).
- Privileged accounts are only linked (sync and first sign-in) via a
  matching UPN of a member account, never via the settable mail
  attribute; the sync never changes their e-mail address; e-mail change
  notifications stay on.
- Button-only mode exempts by credential (application passwords, WP-CLI)
  instead of request context, closing bypasses through xmlrpc.php and
  REST login handlers; API requests never receive login cookies.
- Multi-tenant mode refuses guest/external identities.

Medium/Low
- Same message for right and wrong passwords; button-only no longer
  switches off when the connection breaks; server-side fallback cookie
  expiry; correct fallback key beats IP lockouts; right-most proxy hop;
  higher start limit; one object ID per account.
- Deactivation sets a random password, revokes application passwords and
  removes the role (restored on reactivation); disabled people are
  deactivated even when their mail vanished; duplicate bindings handled.
- Sync: abort on empty directory answer, no deprovisioning right after a
  tenant change, atomic run lock, strict photo path validation.
- Certificates: key bundles refused, clean re-exported certificate.
- Array-safe sanitising, encoded redirect_to, per-action nonces, escaped
  role lists, no Graph sleeps during sign-in, warnings for public groups,
  multi-tenant group rules and missing salts, uninstall clears the token.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 17:10:30 +00:00

1858 lines
61 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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 TENANT_OPTION = 'm365_login_sync_tenant'; // Tenant of the last successful live run.
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();
/**
* 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(),
);
$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 );
}
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 );
}
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() {
$token = wp_generate_password( 20, false );
if ( add_option( self::LOCK, $token . '|' . time(), '', 'no' ) ) {
return $token;
}
$held = explode( '|', (string) get_option( self::LOCK, '' ) );
if ( isset( $held[1] ) && time() - (int) $held[1] < self::LOCK_TTL ) {
return '';
}
// Stale lock of a crashed run.
delete_option( self::LOCK );
return add_option( self::LOCK, $token . '|' . time(), '', 'no' ) ? $token : '';
}
/**
* Releases the run lock if this run still holds it.
*
* @param string $token Lock token.
*/
private function release_lock( $token ) {
$held = explode( '|', (string) get_option( self::LOCK, '' ) );
if ( $held[0] === $token ) {
delete_option( self::LOCK );
}
}
/**
* 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';
}
$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 = array_merge( $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 = array_merge( $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' ) ) );
$tenant = strtolower( $this->settings->tenant() );
$before = (string) get_option( self::TENANT_OPTION, '' );
if ( $pending && '' !== $before && $before !== $tenant ) {
$this->log( 'warning', __( 'The tenant ID changed since the last sync. Accounts linked in the old tenant are not found in the new one, so no account was deactivated or deleted in this run. Run the sync again to apply deactivations.', 'm365-login' ) );
$pending = array();
}
if ( ! $this->dry ) {
update_option( self::TENANT_OPTION, $tenant, false );
}
$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();
$this->duplicates = 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 ) ) {
continue;
}
if ( isset( $out[ $oid ] ) ) {
$this->duplicates[ $oid ][] = (int) $row->ID;
} else {
$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;
// 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 );
}
$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() );
}
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 ( 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 ( ! $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 actions, 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' ), $oid );
}
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' ), $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;
}
/**
* 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 )
|| user_can( $user, 'manage_options' )
|| user_can( $user, 'promote_users' )
|| user_can( $user, 'edit_users' );
/**
* 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 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 && 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' => 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, '/' );
}
/**
* 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 ) {
$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;
}
/* ------------------------------------------------------------------ */
/* 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 = '' ) {
$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( '' );
}
/**
* 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 ) {
$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
);
$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
}
}