Fix the findings of the third security audit
- Privileged accounts: the UPN rule also applies when bind_oid is off or the account is not bound; privileges are checked on every site of a multisite user, include code/HTML capabilities (unfiltered_html, plugins, themes, users) and the remembered roles of deactivated accounts. - send_auth_cookies protection also works on WordPress 6.0/6.1. - Run lock via INSERT IGNORE (atomic), refreshed during long runs; a shutdown handler reports fatal errors and frees the lock. - Deprovisioning only for accounts linked in the current tenant (tenant recorded per account; legacy links not found are left alone). - Safety stop based on the accounts linked before the run; new safety stop for removals of administrative roles. - Disable is idempotent; row-action nonces are bound to the state. - Profile photos are re-encoded to 240 px (drops EXIF and appended data), size-limited while downloading, removed on deactivation; index.php guard in the photo folder. - Privacy exporter and eraser for the copied data. - One-time migration hardens accounts deactivated by 1.0 and cleans a stored certificate bundle; the .cer download is always re-exported. - Password fields hidden in button-only mode even when the connection is broken; settings written non-autoloaded; robust user ID queries. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b35f6a867b
commit
9b893e42bc
9 changed files with 582 additions and 104 deletions
|
|
@ -567,7 +567,8 @@ class M365_Login_Admin {
|
||||||
}
|
}
|
||||||
check_admin_referer( self::POST_CERT );
|
check_admin_referer( self::POST_CERT );
|
||||||
|
|
||||||
$pem = $this->settings->certificate_pem();
|
// Only ever the re-exported public certificate, whatever an older version stored.
|
||||||
|
$pem = M365_Login_Certificate::clean_pem( $this->settings->certificate_pem() );
|
||||||
if ( '' === $pem ) {
|
if ( '' === $pem ) {
|
||||||
wp_die( esc_html__( 'No certificate is stored.', 'm365-login' ), 404 );
|
wp_die( esc_html__( 'No certificate is stored.', 'm365-login' ), 404 );
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ class M365_Login_Auth {
|
||||||
const STATE_TTL = 600; // 10 minutes.
|
const STATE_TTL = 600; // 10 minutes.
|
||||||
const META_OID = '_m365_login_oid';
|
const META_OID = '_m365_login_oid';
|
||||||
const META_LAST_LOGIN = '_m365_login_last_login';
|
const META_LAST_LOGIN = '_m365_login_last_login';
|
||||||
|
const META_TID = '_m365_login_tid'; // Tenant the object ID belongs to.
|
||||||
const JWKS_CACHE_TTL = 12 * HOUR_IN_SECONDS;
|
const JWKS_CACHE_TTL = 12 * HOUR_IN_SECONDS;
|
||||||
const HTTP_TIMEOUT = 15;
|
const HTTP_TIMEOUT = 15;
|
||||||
|
|
||||||
|
|
@ -223,11 +224,13 @@ class M365_Login_Auth {
|
||||||
* @param bool $send Whether to send the cookies.
|
* @param bool $send Whether to send the cookies.
|
||||||
* @param int $expire Expiry (unused).
|
* @param int $expire Expiry (unused).
|
||||||
* @param int $expiration Expiration (unused).
|
* @param int $expiration Expiration (unused).
|
||||||
* @param int $user_id User ID (0 when cookies are cleared).
|
* @param int|null $user_id User ID (0 when cookies are cleared; not passed before WordPress 6.2).
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public function block_api_auth_cookies( $send, $expire = 0, $expiration = 0, $user_id = 0 ) {
|
public function block_api_auth_cookies( $send, $expire = 0, $expiration = 0, $user_id = null ) {
|
||||||
if ( ! $send || ! $user_id || ! $this->settings->button_only() || $this->fallback_active() ) {
|
// Before WordPress 6.2 the filter gets no user ID: block in API contexts anyway (also blocks
|
||||||
|
// clearing cookies there, which is harmless).
|
||||||
|
if ( ! $send || 0 === $user_id || ! $this->settings->button_only() || $this->fallback_active() ) {
|
||||||
return $send;
|
return $send;
|
||||||
}
|
}
|
||||||
if ( ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
|
if ( ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
|
||||||
|
|
@ -537,9 +540,11 @@ class M365_Login_Auth {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Privileged accounts that are not bound yet: only the user principal name of a member
|
// Privileged accounts are only safe through a verified binding (bind_oid on and the stored
|
||||||
// account may claim them (its domain is verified in the tenant, the e-mail attribute is not).
|
// object ID matches – checked above). Otherwise only the user principal name of a member
|
||||||
if ( '' === $stored && M365_Login_Sync::is_privileged( $user ) && ! $this->may_claim_privileged( $claims, $user ) ) {
|
// account may claim them: its domain is verified in the tenant, the e-mail attribute is not.
|
||||||
|
$bound = $this->settings->get( 'bind_oid' ) && '' !== $stored;
|
||||||
|
if ( ! $bound && M365_Login_Sync::is_privileged( $user ) && ! $this->may_claim_privileged( $claims, $user ) ) {
|
||||||
$this->log( sprintf( 'Refused first sign-in of privileged user #%d without a matching user principal name.', $user->ID ) );
|
$this->log( sprintf( 'Refused first sign-in of privileged user #%d without a matching user principal name.', $user->ID ) );
|
||||||
$this->fail( 'privileged_unlinked' );
|
$this->fail( 'privileged_unlinked' );
|
||||||
}
|
}
|
||||||
|
|
@ -561,6 +566,9 @@ class M365_Login_Auth {
|
||||||
$this->fail( 'oid_mismatch' );
|
$this->fail( 'oid_mismatch' );
|
||||||
}
|
}
|
||||||
update_user_meta( $user->ID, self::META_OID, $oid );
|
update_user_meta( $user->ID, self::META_OID, $oid );
|
||||||
|
if ( isset( $claims['tid'] ) && M365_Login_Settings::is_guid( (string) $claims['tid'] ) ) {
|
||||||
|
update_user_meta( $user->ID, self::META_TID, strtolower( (string) $claims['tid'] ) );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -167,14 +167,7 @@ class M365_Login_Button {
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
private function should_render() {
|
private function should_render() {
|
||||||
if ( ! $this->settings->is_configured() ) {
|
if ( ! $this->settings->is_configured() || ! $this->is_login_action() ) {
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only routing check.
|
|
||||||
$action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : 'login';
|
|
||||||
$interim = ! empty( $_REQUEST['interim-login'] );
|
|
||||||
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
|
||||||
if ( $interim || ! in_array( $action, array( '', 'login' ), true ) ) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
|
@ -185,13 +178,31 @@ class M365_Login_Button {
|
||||||
return (bool) apply_filters( 'm365_login_show_button', true );
|
return (bool) apply_filters( 'm365_login_show_button', true );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the current wp-login.php request shows the sign-in form (not interim login or another action).
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private function is_login_action() {
|
||||||
|
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only routing check.
|
||||||
|
$action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : 'login';
|
||||||
|
$interim = ! empty( $_REQUEST['interim-login'] );
|
||||||
|
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
||||||
|
return ! $interim && in_array( $action, array( '', 'login' ), true );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the password form is hidden for this request.
|
* Whether the password form is hidden for this request.
|
||||||
*
|
*
|
||||||
|
* Also while the connection is broken: password sign-in is refused anyway, the fields would only mislead.
|
||||||
|
*
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
private function form_hidden() {
|
private function form_hidden() {
|
||||||
return $this->should_render() && $this->settings->button_only() && ! M365_Login::instance()->auth->fallback_active();
|
if ( ! $this->settings->button_only() || M365_Login::instance()->auth->fallback_active() || ! $this->is_login_action() ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return $this->should_render() || ! $this->settings->is_configured();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,24 @@ final class M365_Login_Certificate {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exactly one certificate re-exported from a PEM text, or '' if none can be read.
|
||||||
|
*
|
||||||
|
* @param string $pem PEM text (may contain other blocks).
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public static function clean_pem( $pem ) {
|
||||||
|
if ( ! preg_match( '/-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----/s', (string) $pem, $m ) ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$cert = openssl_x509_read( $m[0] );
|
||||||
|
$clean = '';
|
||||||
|
if ( false === $cert || ! openssl_x509_export( $cert, $clean ) ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return self::normalise_pem( $clean );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalises line endings and trims a PEM block.
|
* Normalises line endings and trims a PEM block.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -108,9 +108,10 @@ class M365_Login_Graph {
|
||||||
* @param array|null $json JSON body for POST requests.
|
* @param array|null $json JSON body for POST requests.
|
||||||
* @param array $headers Extra headers.
|
* @param array $headers Extra headers.
|
||||||
* @param bool $retry Retry on 429/503/504.
|
* @param bool $retry Retry on 429/503/504.
|
||||||
|
* @param int $max_bytes Maximum response size (0 = unlimited).
|
||||||
* @return array|WP_Error Response array from wp_remote_request().
|
* @return array|WP_Error Response array from wp_remote_request().
|
||||||
*/
|
*/
|
||||||
private function raw_request( $method, $path, $json = null, $headers = array(), $retry = true ) {
|
private function raw_request( $method, $path, $json = null, $headers = array(), $retry = true, $max_bytes = 0 ) {
|
||||||
$url = 0 === strpos( $path, self::GRAPH_BASE . '/' ) ? $path : self::GRAPH_BASE . $path;
|
$url = 0 === strpos( $path, self::GRAPH_BASE . '/' ) ? $path : self::GRAPH_BASE . $path;
|
||||||
if ( 0 !== strpos( $url, self::GRAPH_BASE . '/' ) ) {
|
if ( 0 !== strpos( $url, self::GRAPH_BASE . '/' ) ) {
|
||||||
return new WP_Error( 'graph_bad_url', 'Refusing to call a non-Graph URL.' );
|
return new WP_Error( 'graph_bad_url', 'Refusing to call a non-Graph URL.' );
|
||||||
|
|
@ -137,6 +138,9 @@ class M365_Login_Graph {
|
||||||
$args['headers']['Content-Type'] = 'application/json';
|
$args['headers']['Content-Type'] = 'application/json';
|
||||||
$args['body'] = wp_json_encode( $json );
|
$args['body'] = wp_json_encode( $json );
|
||||||
}
|
}
|
||||||
|
if ( $max_bytes > 0 ) {
|
||||||
|
$args['limit_response_size'] = $max_bytes; // Stop downloading oversized bodies early.
|
||||||
|
}
|
||||||
|
|
||||||
$response = wp_remote_request( $url, $args );
|
$response = wp_remote_request( $url, $args );
|
||||||
if ( is_wp_error( $response ) ) {
|
if ( is_wp_error( $response ) ) {
|
||||||
|
|
@ -368,7 +372,7 @@ class M365_Login_Graph {
|
||||||
}
|
}
|
||||||
$base = '/users/' . rawurlencode( strtolower( $oid ) );
|
$base = '/users/' . rawurlencode( strtolower( $oid ) );
|
||||||
foreach ( array( $base . '/photos/240x240/$value', $base . '/photo/$value' ) as $path ) {
|
foreach ( array( $base . '/photos/240x240/$value', $base . '/photo/$value' ) as $path ) {
|
||||||
$response = $this->raw_request( 'GET', $path, null, array( 'Accept' => 'image/*' ) );
|
$response = $this->raw_request( 'GET', $path, null, array( 'Accept' => 'image/*' ), true, 2 * MB_IN_BYTES + 1 );
|
||||||
if ( is_wp_error( $response ) ) {
|
if ( is_wp_error( $response ) ) {
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,22 @@ class M365_Login_Settings {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the stored certificate by a clean re-export (drops key bundles or chains saved by older versions).
|
||||||
|
*/
|
||||||
|
public function normalise_stored_certificate() {
|
||||||
|
$pem = $this->certificate_pem();
|
||||||
|
if ( '' === $pem ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$clean = M365_Login_Certificate::clean_pem( $pem );
|
||||||
|
if ( '' !== $clean && $clean !== $pem ) {
|
||||||
|
$all = $this->all();
|
||||||
|
$all['cert_certificate'] = $clean;
|
||||||
|
$this->write( $all );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes the stored certificate and key.
|
* Removes the stored certificate and key.
|
||||||
*/
|
*/
|
||||||
|
|
@ -252,7 +268,7 @@ class M365_Login_Settings {
|
||||||
*/
|
*/
|
||||||
private function write( $all ) {
|
private function write( $all ) {
|
||||||
$this->raw_write = true;
|
$this->raw_write = true;
|
||||||
update_option( M365_LOGIN_OPTION, $all );
|
update_option( M365_LOGIN_OPTION, $all, false ); // Holds encrypted secrets: never autoloaded.
|
||||||
$this->raw_write = false;
|
$this->raw_write = false;
|
||||||
$this->cache = null;
|
$this->cache = null;
|
||||||
}
|
}
|
||||||
|
|
@ -655,7 +671,7 @@ class M365_Login_Settings {
|
||||||
if ( '' === $map_role || ! get_role( $map_role ) ) {
|
if ( '' === $map_role || ! get_role( $map_role ) ) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$name = isset( $row['name'] ) ? sanitize_text_field( wp_unslash( (string) $row['name'] ) ) : '';
|
$name = isset( $row['name'] ) && is_scalar( $row['name'] ) ? sanitize_text_field( wp_unslash( (string) $row['name'] ) ) : '';
|
||||||
$map[ $id ] = array(
|
$map[ $id ] = array(
|
||||||
'name' => '' === $name ? $id : mb_substr( $name, 0, 120 ),
|
'name' => '' === $name ? $id : mb_substr( $name, 0, 120 ),
|
||||||
'role' => $map_role,
|
'role' => $map_role,
|
||||||
|
|
@ -671,6 +687,9 @@ class M365_Login_Settings {
|
||||||
if ( ! empty( $input['sync_attributes'] ) && is_array( $input['sync_attributes'] ) ) {
|
if ( ! empty( $input['sync_attributes'] ) && is_array( $input['sync_attributes'] ) ) {
|
||||||
$known = array_keys( M365_Login_Sync::attributes() );
|
$known = array_keys( M365_Login_Sync::attributes() );
|
||||||
foreach ( $input['sync_attributes'] as $attribute ) {
|
foreach ( $input['sync_attributes'] as $attribute ) {
|
||||||
|
if ( ! is_scalar( $attribute ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$attribute = sanitize_text_field( wp_unslash( (string) $attribute ) );
|
$attribute = sanitize_text_field( wp_unslash( (string) $attribute ) );
|
||||||
if ( in_array( $attribute, $known, true ) ) {
|
if ( in_array( $attribute, $known, true ) ) {
|
||||||
$attributes[] = $attribute;
|
$attributes[] = $attribute;
|
||||||
|
|
@ -722,7 +741,7 @@ class M365_Login_Settings {
|
||||||
if ( ! self::is_guid( $id ) ) {
|
if ( ! self::is_guid( $id ) ) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$name = sanitize_text_field( wp_unslash( (string) $name ) );
|
$name = is_scalar( $name ) ? sanitize_text_field( wp_unslash( (string) $name ) ) : '';
|
||||||
$groups[ $id ] = '' === $name ? $id : mb_substr( $name, 0, 120 );
|
$groups[ $id ] = '' === $name ? $id : mb_substr( $name, 0, 120 );
|
||||||
if ( count( $groups ) >= 100 ) {
|
if ( count( $groups ) >= 100 ) {
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ class M365_Login_Sync {
|
||||||
const CRON_HOOK = '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 = 'm365_login_sync_lock'; // Option (atomic via add_option), value "token|time".
|
||||||
const LOCK_TTL = 2 * HOUR_IN_SECONDS;
|
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 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 REPORT_OPTION = 'm365_login_sync_report';
|
||||||
const META_SYNCED = '_m365_login_synced'; // Account was created by the sync.
|
const META_SYNCED = '_m365_login_synced'; // Account was created by the sync.
|
||||||
|
|
@ -64,6 +63,20 @@ class M365_Login_Sync {
|
||||||
*/
|
*/
|
||||||
private $duplicates = array();
|
private $duplicates = array();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role changes that remove administrative rights, applied after the safety check.
|
||||||
|
*
|
||||||
|
* @var array[]
|
||||||
|
*/
|
||||||
|
private $demotions = array();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token of the run lock held by this process ('' when none).
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
private $lock_token = '';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor.
|
* Constructor.
|
||||||
*
|
*
|
||||||
|
|
@ -85,6 +98,8 @@ class M365_Login_Sync {
|
||||||
|
|
||||||
add_filter( 'pre_get_avatar_data', array( $this, 'avatar_data' ), 10, 2 );
|
add_filter( 'pre_get_avatar_data', array( $this, 'avatar_data' ), 10, 2 );
|
||||||
add_action( 'delete_user', array( $this, 'delete_photo' ) );
|
add_action( 'delete_user', array( $this, 'delete_photo' ) );
|
||||||
|
add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'register_exporter' ) );
|
||||||
|
add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'register_eraser' ) );
|
||||||
|
|
||||||
if ( is_admin() ) {
|
if ( is_admin() ) {
|
||||||
add_filter( 'manage_users_columns', array( $this, 'users_column' ) );
|
add_filter( 'manage_users_columns', array( $this, 'users_column' ) );
|
||||||
|
|
@ -319,6 +334,11 @@ class M365_Login_Sync {
|
||||||
$this->log( 'error', __( 'Another sync is still running. Please try again in a few minutes.', 'm365-login' ) );
|
$this->log( 'error', __( 'Another sync is still running. Please try again in a few minutes.', 'm365-login' ) );
|
||||||
return $this->finish( 'locked', false );
|
return $this->finish( 'locked', false );
|
||||||
}
|
}
|
||||||
|
$this->lock_token = $lock;
|
||||||
|
$this->demotions = array();
|
||||||
|
|
||||||
|
// A fatal error (memory, time limit) skips "finally": record the failure and free the lock anyway.
|
||||||
|
register_shutdown_function( array( $this, 'shutdown' ) );
|
||||||
|
|
||||||
if ( function_exists( 'set_time_limit' ) ) {
|
if ( function_exists( 'set_time_limit' ) ) {
|
||||||
set_time_limit( 0 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- long-running directory sync.
|
set_time_limit( 0 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- long-running directory sync.
|
||||||
|
|
@ -335,6 +355,7 @@ class M365_Login_Sync {
|
||||||
} finally {
|
} finally {
|
||||||
remove_filter( 'send_password_change_email', '__return_false', 99 );
|
remove_filter( 'send_password_change_email', '__return_false', 99 );
|
||||||
$this->release_lock( $lock );
|
$this->release_lock( $lock );
|
||||||
|
$this->lock_token = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->finish( $status, true );
|
return $this->finish( $status, true );
|
||||||
|
|
@ -346,17 +367,69 @@ class M365_Login_Sync {
|
||||||
* @return string Lock token, or '' when another run holds the lock.
|
* @return string Lock token, or '' when another run holds the lock.
|
||||||
*/
|
*/
|
||||||
private function acquire_lock() {
|
private function acquire_lock() {
|
||||||
|
global $wpdb;
|
||||||
$token = wp_generate_password( 20, false );
|
$token = wp_generate_password( 20, false );
|
||||||
if ( add_option( self::LOCK, $token . '|' . time(), '', 'no' ) ) {
|
$value = $token . '|' . time();
|
||||||
|
|
||||||
|
// A single INSERT is atomic; add_option() would check first and then insert.
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- lock row, must bypass the cache.
|
||||||
|
$inserted = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", self::LOCK, $value ) );
|
||||||
|
if ( 1 === (int) $inserted ) {
|
||||||
|
$this->flush_lock_cache();
|
||||||
return $token;
|
return $token;
|
||||||
}
|
}
|
||||||
$held = explode( '|', (string) get_option( self::LOCK, '' ) );
|
|
||||||
if ( isset( $held[1] ) && time() - (int) $held[1] < self::LOCK_TTL ) {
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- lock row, must bypass the cache.
|
||||||
|
$held = (string) $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", self::LOCK ) );
|
||||||
|
$parts = explode( '|', $held );
|
||||||
|
if ( isset( $parts[1] ) && time() - (int) $parts[1] < self::LOCK_TTL ) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
// Stale lock of a crashed run.
|
// Stale lock of a crashed run: take it over only if nobody else did in the meantime.
|
||||||
delete_option( self::LOCK );
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- lock row, must bypass the cache.
|
||||||
return add_option( self::LOCK, $token . '|' . time(), '', 'no' ) ? $token : '';
|
$updated = $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s", $value, self::LOCK, $held ) );
|
||||||
|
$this->flush_lock_cache();
|
||||||
|
return 1 === (int) $updated ? $token : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renews the timestamp of the run lock (long runs must not look stale).
|
||||||
|
*/
|
||||||
|
private function refresh_lock() {
|
||||||
|
global $wpdb;
|
||||||
|
if ( '' === $this->lock_token ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- lock row, must bypass the cache.
|
||||||
|
$wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value LIKE %s", $this->lock_token . '|' . time(), self::LOCK, $wpdb->esc_like( $this->lock_token . '|' ) . '%' ) );
|
||||||
|
$this->flush_lock_cache();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shutdown handler: a run that did not finish (fatal error) is reported as failed and its lock released.
|
||||||
|
*/
|
||||||
|
public function shutdown() {
|
||||||
|
if ( '' === $this->lock_token ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$error = error_get_last();
|
||||||
|
$this->log( 'error', __( 'The sync stopped unexpectedly (PHP error, memory or time limit). Accounts after this point were not processed and nothing was deactivated or deleted. For large directories use "wp m365-login sync".', 'm365-login' ) );
|
||||||
|
if ( $error && ! empty( $error['message'] ) ) {
|
||||||
|
$this->log( 'error', wp_strip_all_tags( (string) $error['message'] ) );
|
||||||
|
}
|
||||||
|
$this->report['status'] = 'failed';
|
||||||
|
$this->report['finished'] = time();
|
||||||
|
update_option( self::REPORT_OPTION, $this->report, false );
|
||||||
|
$this->release_lock( $this->lock_token );
|
||||||
|
$this->lock_token = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops cached copies of the lock row.
|
||||||
|
*/
|
||||||
|
private function flush_lock_cache() {
|
||||||
|
wp_cache_delete( self::LOCK, 'options' );
|
||||||
|
wp_cache_delete( 'notoptions', 'options' );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -365,10 +438,10 @@ class M365_Login_Sync {
|
||||||
* @param string $token Lock token.
|
* @param string $token Lock token.
|
||||||
*/
|
*/
|
||||||
private function release_lock( $token ) {
|
private function release_lock( $token ) {
|
||||||
$held = explode( '|', (string) get_option( self::LOCK, '' ) );
|
global $wpdb;
|
||||||
if ( $held[0] === $token ) {
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- lock row, must bypass the cache.
|
||||||
delete_option( self::LOCK );
|
$wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value LIKE %s", self::LOCK, $wpdb->esc_like( $token . '|' ) . '%' ) );
|
||||||
}
|
$this->flush_lock_cache();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -437,10 +510,12 @@ class M365_Login_Sync {
|
||||||
return 'aborted';
|
return 'aborted';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$linked_before = count( $linked ); // Basis of the safety stop: accounts created in this run must not dilute it.
|
||||||
$seen = array();
|
$seen = array();
|
||||||
$pending = array(); // Deactivations/deletions, applied after the safety check.
|
$pending = array(); // Deactivations/deletions, applied after the safety check.
|
||||||
$photo_of = array(); // oid => WP_User whose photo is kept in sync.
|
$photo_of = array(); // oid => user ID whose photo is kept in sync.
|
||||||
$photo_on = array_key_exists( 'photo', $this->selected_attributes() );
|
$photo_on = array_key_exists( 'photo', $this->selected_attributes() );
|
||||||
|
$done = 0;
|
||||||
|
|
||||||
foreach ( $people as $person ) {
|
foreach ( $people as $person ) {
|
||||||
$oid = strtolower( (string) $person['id'] );
|
$oid = strtolower( (string) $person['id'] );
|
||||||
|
|
@ -450,8 +525,21 @@ class M365_Login_Sync {
|
||||||
if ( is_array( $result ) ) {
|
if ( is_array( $result ) ) {
|
||||||
$pending = array_merge( $pending, $result );
|
$pending = array_merge( $pending, $result );
|
||||||
} elseif ( $result instanceof WP_User ) {
|
} elseif ( $result instanceof WP_User ) {
|
||||||
$photo_of[ $oid ] = $result;
|
$photo_of[ $oid ] = $result->ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Large directories: keep memory flat and the run lock fresh.
|
||||||
|
if ( 0 === ++$done % 250 ) {
|
||||||
|
if ( function_exists( 'wp_cache_flush_runtime' ) ) {
|
||||||
|
wp_cache_flush_runtime();
|
||||||
|
}
|
||||||
|
$this->refresh_lock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role removals that would take administrative rights away are applied only after a safety check.
|
||||||
|
if ( ! $this->apply_demotions() ) {
|
||||||
|
return 'aborted';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Profile photos: new, changed and removed photos, or cleanup when the photo sync was switched off.
|
// Profile photos: new, changed and removed photos, or cleanup when the photo sync was switched off.
|
||||||
|
|
@ -461,12 +549,25 @@ class M365_Login_Sync {
|
||||||
$this->remove_all_photos();
|
$this->remove_all_photos();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Linked accounts that were not part of the directory listing.
|
// 3. Linked accounts that were not part of the directory listing – only those that belong to
|
||||||
|
// this tenant: an object ID from another tenant is "not found" here, not deleted.
|
||||||
|
$tenant = strtolower( $this->settings->tenant() );
|
||||||
|
$foreign = 0;
|
||||||
foreach ( $linked as $oid => $user_id ) {
|
foreach ( $linked as $oid => $user_id ) {
|
||||||
if ( isset( $seen[ $oid ] ) ) {
|
if ( isset( $seen[ $oid ] ) ) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$action = $this->classify_missing( $oid, $user_id );
|
$user_tenant = strtolower( (string) get_user_meta( $user_id, M365_Login_Auth::META_TID, true ) );
|
||||||
|
if ( '' !== $user_tenant && $user_tenant !== $tenant ) {
|
||||||
|
++$foreign;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Linked before tenants were recorded: a 404 cannot tell "deleted" from "other tenant".
|
||||||
|
$action = $this->classify_missing( $oid, $user_id, '' === $user_tenant );
|
||||||
|
if ( 'unknown' === $action ) {
|
||||||
|
++$foreign;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if ( is_wp_error( $action ) ) {
|
if ( is_wp_error( $action ) ) {
|
||||||
$this->log( 'error', $this->graph_error_text( $action ) );
|
$this->log( 'error', $this->graph_error_text( $action ) );
|
||||||
return 'failed';
|
return 'failed';
|
||||||
|
|
@ -476,18 +577,14 @@ class M365_Login_Sync {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ( $foreign ) {
|
||||||
|
/* translators: %d: number of accounts */
|
||||||
|
$this->log( 'warning', sprintf( _n( '%d linked account belongs to another (or an unknown) tenant and was not deactivated or deleted. Unlink it by hand if it is no longer needed.', '%d linked accounts belong to another (or an unknown) tenant and were not deactivated or deleted. Unlink them by hand if they are no longer needed.', $foreign, 'm365-login' ), $foreign ) );
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Safety net: never deactivate or delete a large part of the linked accounts in one go.
|
// 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' ) ) );
|
$pending = array_values( array_filter( $pending, array( $this, 'is_effective_action' ) ) );
|
||||||
$tenant = strtolower( $this->settings->tenant() );
|
$limit = (int) apply_filters( 'm365_login_sync_deprovision_limit', max( 5, (int) ceil( $linked_before * 0.2 ) ), $linked_before );
|
||||||
$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 ) {
|
if ( count( $pending ) > $limit ) {
|
||||||
$this->log(
|
$this->log(
|
||||||
'error',
|
'error',
|
||||||
|
|
@ -576,21 +673,22 @@ class M365_Login_Sync {
|
||||||
array(
|
array(
|
||||||
'meta_key' => M365_Login_Auth::META_OID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
|
'meta_key' => M365_Login_Auth::META_OID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
|
||||||
'meta_compare' => 'EXISTS',
|
'meta_compare' => 'EXISTS',
|
||||||
'fields' => array( 'ID' ),
|
'fields' => 'ID',
|
||||||
'number' => -1,
|
'number' => -1,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
$out = array();
|
$out = array();
|
||||||
$this->duplicates = array();
|
$this->duplicates = array();
|
||||||
foreach ( $users as $row ) {
|
foreach ( $users as $user_id ) {
|
||||||
$oid = strtolower( (string) get_user_meta( (int) $row->ID, M365_Login_Auth::META_OID, true ) );
|
$user_id = (int) $user_id;
|
||||||
|
$oid = strtolower( (string) get_user_meta( $user_id, M365_Login_Auth::META_OID, true ) );
|
||||||
if ( ! M365_Login_Settings::is_guid( $oid ) ) {
|
if ( ! M365_Login_Settings::is_guid( $oid ) ) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if ( isset( $out[ $oid ] ) ) {
|
if ( isset( $out[ $oid ] ) ) {
|
||||||
$this->duplicates[ $oid ][] = (int) $row->ID;
|
$this->duplicates[ $oid ][] = $user_id;
|
||||||
} else {
|
} else {
|
||||||
$out[ $oid ] = (int) $row->ID;
|
$out[ $oid ] = $user_id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return $out;
|
return $out;
|
||||||
|
|
@ -661,6 +759,7 @@ class M365_Login_Sync {
|
||||||
$this->count( 'linked' );
|
$this->count( 'linked' );
|
||||||
if ( ! $this->dry ) {
|
if ( ! $this->dry ) {
|
||||||
update_user_meta( $user->ID, M365_Login_Auth::META_OID, $oid );
|
update_user_meta( $user->ID, M365_Login_Auth::META_OID, $oid );
|
||||||
|
update_user_meta( $user->ID, M365_Login_Auth::META_TID, strtolower( $this->settings->tenant() ) );
|
||||||
}
|
}
|
||||||
$linked[ $oid ] = $user->ID;
|
$linked[ $oid ] = $user->ID;
|
||||||
}
|
}
|
||||||
|
|
@ -709,6 +808,7 @@ class M365_Login_Sync {
|
||||||
}
|
}
|
||||||
if ( ! $this->dry ) {
|
if ( ! $this->dry ) {
|
||||||
update_user_meta( $user->ID, self::META_LAST_SYNC, time() );
|
update_user_meta( $user->ID, self::META_LAST_SYNC, time() );
|
||||||
|
update_user_meta( $user->ID, M365_Login_Auth::META_TID, strtolower( $this->settings->tenant() ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
|
|
@ -760,6 +860,7 @@ class M365_Login_Sync {
|
||||||
}
|
}
|
||||||
|
|
||||||
update_user_meta( $user_id, M365_Login_Auth::META_OID, $oid );
|
update_user_meta( $user_id, M365_Login_Auth::META_OID, $oid );
|
||||||
|
update_user_meta( $user_id, M365_Login_Auth::META_TID, strtolower( $this->settings->tenant() ) );
|
||||||
update_user_meta( $user_id, self::META_SYNCED, time() );
|
update_user_meta( $user_id, self::META_SYNCED, time() );
|
||||||
update_user_meta( $user_id, self::META_LAST_SYNC, time() );
|
update_user_meta( $user_id, self::META_LAST_SYNC, time() );
|
||||||
$linked[ $oid ] = (int) $user_id;
|
$linked[ $oid ] = (int) $user_id;
|
||||||
|
|
@ -978,14 +1079,99 @@ class M365_Login_Sync {
|
||||||
if ( $same ) {
|
if ( $same ) {
|
||||||
return array();
|
return array();
|
||||||
}
|
}
|
||||||
if ( ! $this->dry ) {
|
if ( self::roles_privileged( $current ) && ! self::roles_privileged( $roles ) ) {
|
||||||
|
// Losing administrative rights: collected and applied after the safety check in apply_demotions().
|
||||||
|
$this->demotions[] = array(
|
||||||
|
'user' => $user,
|
||||||
|
'roles' => $roles,
|
||||||
|
);
|
||||||
|
} elseif ( ! $this->dry ) {
|
||||||
|
self::set_roles( $user, $roles );
|
||||||
|
}
|
||||||
|
/* translators: %s: role names */
|
||||||
|
return array( sprintf( __( 'roles: %s', 'm365-login' ), $this->role_names( $roles ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the roles of a user (first = primary role).
|
||||||
|
*
|
||||||
|
* @param WP_User $user User.
|
||||||
|
* @param string[] $roles Roles.
|
||||||
|
*/
|
||||||
|
private static function set_roles( $user, $roles ) {
|
||||||
$user->set_role( $roles[0] );
|
$user->set_role( $roles[0] );
|
||||||
foreach ( array_slice( $roles, 1 ) as $role ) {
|
foreach ( array_slice( $roles, 1 ) as $role ) {
|
||||||
$user->add_role( $role );
|
$user->add_role( $role );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* translators: %s: role names */
|
|
||||||
return array( sprintf( __( 'roles: %s', 'm365-login' ), $this->role_names( $roles ) ) );
|
/**
|
||||||
|
* Whether any of the roles grants a privileged capability.
|
||||||
|
*
|
||||||
|
* @param string[] $roles Role slugs.
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private static function roles_privileged( $roles ) {
|
||||||
|
foreach ( (array) $roles as $slug ) {
|
||||||
|
$role = is_string( $slug ) ? get_role( $slug ) : null;
|
||||||
|
if ( $role && array_intersect( self::privileged_caps(), array_keys( array_filter( $role->capabilities ) ) ) ) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies collected removals of administrative roles unless they would take away too many
|
||||||
|
* administrators at once (e.g. a mapped group was emptied by mistake).
|
||||||
|
*
|
||||||
|
* @return bool False when the run was stopped.
|
||||||
|
*/
|
||||||
|
private function apply_demotions() {
|
||||||
|
if ( empty( $this->demotions ) ) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$privileged_roles = array();
|
||||||
|
foreach ( wp_roles()->roles as $slug => $definition ) {
|
||||||
|
if ( self::roles_privileged( array( $slug ) ) ) {
|
||||||
|
$privileged_roles[] = $slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$admins = count(
|
||||||
|
get_users(
|
||||||
|
array(
|
||||||
|
'role__in' => $privileged_roles,
|
||||||
|
'fields' => 'ID',
|
||||||
|
'number' => -1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of accounts that may lose administrative rights in one sync run.
|
||||||
|
*
|
||||||
|
* @param int $limit Limit (default: 20 % of the privileged accounts, at least 1; never all of them).
|
||||||
|
* @param int $admins Number of accounts with privileged roles on this site.
|
||||||
|
*/
|
||||||
|
$limit = (int) apply_filters( 'm365_login_sync_demotion_limit', max( 1, (int) floor( $admins * 0.2 ) ), $admins );
|
||||||
|
if ( count( $this->demotions ) > $limit || count( $this->demotions ) >= $admins ) {
|
||||||
|
$this->log(
|
||||||
|
'error',
|
||||||
|
sprintf(
|
||||||
|
/* translators: 1: number of accounts, 2: limit */
|
||||||
|
__( 'Safety stop: %1$d accounts would lose administrative rights, more than the limit of %2$d per run (or all of them). Nothing was demoted, deactivated or deleted. Check the group → role mapping, then run the sync again (filter m365_login_sync_demotion_limit).', 'm365-login' ),
|
||||||
|
count( $this->demotions ),
|
||||||
|
$limit
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ( ! $this->dry ) {
|
||||||
|
foreach ( $this->demotions as $demotion ) {
|
||||||
|
self::set_roles( $demotion['user'], $demotion['roles'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1008,9 +1194,10 @@ class M365_Login_Sync {
|
||||||
*
|
*
|
||||||
* @param string $oid Object ID.
|
* @param string $oid Object ID.
|
||||||
* @param int $user_id User ID.
|
* @param int $user_id User ID.
|
||||||
* @return array[]|null|WP_Error Pending actions, null for none.
|
* @param bool $legacy Linked without a recorded tenant.
|
||||||
|
* @return array[]|null|string|WP_Error Pending actions, null for none, 'unknown' when a legacy link is not found.
|
||||||
*/
|
*/
|
||||||
private function classify_missing( $oid, $user_id ) {
|
private function classify_missing( $oid, $user_id, $legacy = false ) {
|
||||||
$user = get_userdata( $user_id );
|
$user = get_userdata( $user_id );
|
||||||
if ( ! $user ) {
|
if ( ! $user ) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -1020,10 +1207,16 @@ class M365_Login_Sync {
|
||||||
$person = $this->graph->get_user( $oid, array( 'id', 'accountEnabled', 'userType' ) );
|
$person = $this->graph->get_user( $oid, array( 'id', 'accountEnabled', 'userType' ) );
|
||||||
if ( is_wp_error( $person ) ) {
|
if ( is_wp_error( $person ) ) {
|
||||||
if ( M365_Login_Graph::is_not_found( $person ) ) {
|
if ( M365_Login_Graph::is_not_found( $person ) ) {
|
||||||
|
if ( $legacy ) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
return $this->action( (string) $this->settings->get( 'sync_deleted_action' ), $user, 'deleted', __( 'deleted in Microsoft 365', 'm365-login' ), $oid );
|
return $this->action( (string) $this->settings->get( 'sync_deleted_action' ), $user, 'deleted', __( 'deleted in Microsoft 365', 'm365-login' ), $oid );
|
||||||
}
|
}
|
||||||
return $person;
|
return $person;
|
||||||
}
|
}
|
||||||
|
if ( $legacy && ! $this->dry ) {
|
||||||
|
update_user_meta( $user->ID, M365_Login_Auth::META_TID, strtolower( $this->settings->tenant() ) );
|
||||||
|
}
|
||||||
if ( isset( $person['accountEnabled'] ) && false === $person['accountEnabled'] ) {
|
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 );
|
return $this->action( (string) $this->settings->get( 'sync_disabled_action' ), $user, 'disabled', __( 'disabled in Microsoft 365', 'm365-login' ), $oid );
|
||||||
}
|
}
|
||||||
|
|
@ -1094,6 +1287,15 @@ class M365_Login_Sync {
|
||||||
return ! $guest && '' !== $upn && false === strpos( $upn, '#ext#' ) && strtolower( $user->user_email ) === $upn && $upn === $email;
|
return ! $guest && '' !== $upn && false === strpos( $upn, '#ext#' ) && strtolower( $user->user_email ) === $upn && $upn === $email;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capabilities that make an account privileged (administrative or able to run code/HTML).
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private static function privileged_caps() {
|
||||||
|
return array( 'manage_options', 'promote_users', 'edit_users', 'create_users', 'delete_users', 'unfiltered_html', 'activate_plugins', 'install_plugins', 'edit_plugins', 'edit_themes', 'switch_themes', 'update_core' );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accounts with administrative capabilities (they get extra protection against linking by e-mail).
|
* Accounts with administrative capabilities (they get extra protection against linking by e-mail).
|
||||||
*
|
*
|
||||||
|
|
@ -1101,10 +1303,26 @@ class M365_Login_Sync {
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public static function is_privileged( $user ) {
|
public static function is_privileged( $user ) {
|
||||||
$privileged = is_super_admin( $user->ID )
|
$privileged = is_super_admin( $user->ID );
|
||||||
|| user_can( $user, 'manage_options' )
|
|
||||||
|| user_can( $user, 'promote_users' )
|
// A deactivated account has no role, but gets its roles back on reactivation.
|
||||||
|| user_can( $user, 'edit_users' );
|
$disabled = self::disabled_info( $user->ID );
|
||||||
|
if ( ! $privileged && $disabled && ! empty( $disabled['roles'] ) ) {
|
||||||
|
$privileged = self::roles_privileged( (array) $disabled['roles'] );
|
||||||
|
}
|
||||||
|
if ( ! $privileged ) {
|
||||||
|
// On multisite the rights on every site of the user count, not only on the current one.
|
||||||
|
$sites = is_multisite() ? array_keys( get_blogs_of_user( $user->ID ) ) : array( 0 );
|
||||||
|
foreach ( $sites as $site_id ) {
|
||||||
|
$check = $site_id ? new WP_User( $user->ID, '', $site_id ) : $user;
|
||||||
|
foreach ( self::privileged_caps() as $cap ) {
|
||||||
|
if ( $check->has_cap( $cap ) ) {
|
||||||
|
$privileged = true;
|
||||||
|
break 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filters whether an account counts as privileged (linked only via a matching user principal name).
|
* Filters whether an account counts as privileged (linked only via a matching user principal name).
|
||||||
|
|
@ -1291,9 +1509,16 @@ class M365_Login_Sync {
|
||||||
* Photo versions are compared on every run (20 users per Graph batch request);
|
* Photo versions are compared on every run (20 users per Graph batch request);
|
||||||
* only changed photos are downloaded.
|
* only changed photos are downloaded.
|
||||||
*
|
*
|
||||||
* @param WP_User[] $users oid => user.
|
* @param int[] $user_ids oid => user ID.
|
||||||
*/
|
*/
|
||||||
private function sync_photos( $users ) {
|
private function sync_photos( $user_ids ) {
|
||||||
|
$users = array();
|
||||||
|
foreach ( $user_ids as $oid => $user_id ) {
|
||||||
|
$user = get_userdata( $user_id );
|
||||||
|
if ( $user ) {
|
||||||
|
$users[ $oid ] = $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Minimum number of seconds between two photo checks of the same user (0 = every run).
|
* Minimum number of seconds between two photo checks of the same user (0 = every run).
|
||||||
*
|
*
|
||||||
|
|
@ -1431,18 +1656,18 @@ class M365_Login_Sync {
|
||||||
array(
|
array(
|
||||||
'meta_key' => self::META_PHOTO, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
|
'meta_key' => self::META_PHOTO, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
|
||||||
'meta_compare' => 'EXISTS',
|
'meta_compare' => 'EXISTS',
|
||||||
'fields' => array( 'ID' ),
|
'fields' => 'ID',
|
||||||
'number' => -1,
|
'number' => -1,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
$removed = 0;
|
$removed = 0;
|
||||||
foreach ( $users as $row ) {
|
foreach ( $users as $user_id ) {
|
||||||
$stored = $this->stored_photo( (int) $row->ID );
|
$stored = $this->stored_photo( (int) $user_id );
|
||||||
if ( ! empty( $stored['file'] ) ) {
|
if ( ! empty( $stored['file'] ) ) {
|
||||||
++$removed;
|
++$removed;
|
||||||
}
|
}
|
||||||
if ( ! $this->dry ) {
|
if ( ! $this->dry ) {
|
||||||
$this->delete_photo( (int) $row->ID );
|
$this->delete_photo( (int) $user_id );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ( $removed ) {
|
if ( $removed ) {
|
||||||
|
|
@ -1474,36 +1699,55 @@ class M365_Login_Sync {
|
||||||
*/
|
*/
|
||||||
private function store_photo( $user_id, $oid, $etag, $bytes ) {
|
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.
|
$size = @getimagesizefromstring( $bytes ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- invalid data is expected to fail quietly.
|
||||||
$exts = array(
|
if ( ! is_array( $size ) || empty( $size['mime'] ) || ! in_array( $size['mime'], array( 'image/jpeg', 'image/png', 'image/gif' ), true ) ) {
|
||||||
'image/jpeg' => 'jpg',
|
return '';
|
||||||
'image/png' => 'png',
|
}
|
||||||
'image/gif' => 'gif',
|
// No decompression bombs: Microsoft 365 photos are at most 648×648 (originals up to a few thousand pixels).
|
||||||
);
|
if ( empty( $size[0] ) || empty( $size[1] ) || $size[0] > 4096 || $size[1] > 4096 ) {
|
||||||
if ( ! is_array( $size ) || empty( $size['mime'] ) || ! isset( $exts[ $size['mime'] ] ) ) {
|
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
$name = 'm365-' . substr( wp_hash( $oid . '|avatar' ), 0, 16 ) . '-' . substr( md5( $etag ), 0, 8 ) . '.' . $exts[ $size['mime'] ];
|
$uploads = wp_upload_dir();
|
||||||
$subdir = static function ( $dirs ) {
|
$dir = trailingslashit( $uploads['basedir'] ) . self::PHOTO_DIR;
|
||||||
$dirs['subdir'] = '/' . self::PHOTO_DIR;
|
if ( ! wp_mkdir_p( $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 '';
|
return '';
|
||||||
}
|
}
|
||||||
$uploads = wp_get_upload_dir();
|
if ( ! file_exists( $dir . '/index.php' ) ) {
|
||||||
return ltrim( str_replace( wp_normalize_path( $uploads['basedir'] ), '', wp_normalize_path( $upload['file'] ) ), '/' );
|
file_put_contents( $dir . '/index.php', "<?php\n// Silence is golden.\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- tiny guard file in our own folder.
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! function_exists( 'wp_tempnam' ) ) {
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/file.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-encode through the image editor: drops EXIF (location data), anything appended to the image
|
||||||
|
// data and oversized dimensions; the stored file is always a 240×240 JPEG (or PNG).
|
||||||
|
$tmp = wp_tempnam( 'm365-photo' );
|
||||||
|
if ( ! $tmp || false === file_put_contents( $tmp, $bytes ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- temporary file for the image editor.
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$editor = wp_get_image_editor( $tmp );
|
||||||
|
if ( is_wp_error( $editor ) ) {
|
||||||
|
wp_delete_file( $tmp );
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$editor->resize( 240, 240, true );
|
||||||
|
$editor->set_quality( 85 );
|
||||||
|
|
||||||
|
// JPEG where the server can write it, PNG otherwise (some GD builds lack JPEG).
|
||||||
|
$ext = 'jpg';
|
||||||
|
$out = 'image/jpeg';
|
||||||
|
if ( ! wp_image_editor_supports( array( 'mime_type' => 'image/jpeg' ) ) ) {
|
||||||
|
$ext = 'png';
|
||||||
|
$out = 'image/png';
|
||||||
|
}
|
||||||
|
$name = 'm365-' . substr( wp_hash( $oid . '|avatar' ), 0, 16 ) . '-' . substr( md5( $etag ), 0, 8 ) . '.' . $ext;
|
||||||
|
$saved = $editor->save( $dir . '/' . $name, $out );
|
||||||
|
wp_delete_file( $tmp );
|
||||||
|
if ( is_wp_error( $saved ) || empty( $saved['path'] ) ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return self::PHOTO_DIR . '/' . $name;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1533,6 +1777,15 @@ class M365_Login_Sync {
|
||||||
* @param int $user_id User ID.
|
* @param int $user_id User ID.
|
||||||
*/
|
*/
|
||||||
public function delete_photo( $user_id ) {
|
public function delete_photo( $user_id ) {
|
||||||
|
self::remove_stored_photo( $user_id );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a user's stored photo file and record.
|
||||||
|
*
|
||||||
|
* @param int $user_id User ID.
|
||||||
|
*/
|
||||||
|
public static function remove_stored_photo( $user_id ) {
|
||||||
$stored = get_user_meta( $user_id, self::META_PHOTO, true );
|
$stored = get_user_meta( $user_id, self::META_PHOTO, true );
|
||||||
if ( is_array( $stored ) && ! empty( $stored['file'] ) && self::is_photo_file( $stored['file'] ) ) {
|
if ( is_array( $stored ) && ! empty( $stored['file'] ) && self::is_photo_file( $stored['file'] ) ) {
|
||||||
wp_delete_file( self::photo_path( $stored['file'] ) );
|
wp_delete_file( self::photo_path( $stored['file'] ) );
|
||||||
|
|
@ -1579,6 +1832,126 @@ class M365_Login_Sync {
|
||||||
return $args;
|
return $args;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Privacy tools (Tools → Export / Erase Personal Data) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers the exporter.
|
||||||
|
*
|
||||||
|
* @param array $exporters Exporters.
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function register_exporter( $exporters ) {
|
||||||
|
$exporters['m365-login'] = array(
|
||||||
|
'exporter_friendly_name' => __( 'Microsoft 365 (M365 Login)', 'm365-login' ),
|
||||||
|
'callback' => array( $this, 'export_personal_data' ),
|
||||||
|
);
|
||||||
|
return $exporters;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers the eraser.
|
||||||
|
*
|
||||||
|
* @param array $erasers Erasers.
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function register_eraser( $erasers ) {
|
||||||
|
$erasers['m365-login'] = array(
|
||||||
|
'eraser_friendly_name' => __( 'Microsoft 365 (M365 Login)', 'm365-login' ),
|
||||||
|
'callback' => array( $this, 'erase_personal_data' ),
|
||||||
|
);
|
||||||
|
return $erasers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports the data the plugin stores about a user.
|
||||||
|
*
|
||||||
|
* @param string $email E-mail address.
|
||||||
|
* @param int $page Page.
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function export_personal_data( $email, $page = 1 ) {
|
||||||
|
$user = get_user_by( 'email', $email );
|
||||||
|
$data = array();
|
||||||
|
if ( $user ) {
|
||||||
|
$fields = array(
|
||||||
|
__( 'Microsoft object ID', 'm365-login' ) => (string) get_user_meta( $user->ID, M365_Login_Auth::META_OID, true ),
|
||||||
|
__( 'Microsoft tenant ID', 'm365-login' ) => (string) get_user_meta( $user->ID, M365_Login_Auth::META_TID, true ),
|
||||||
|
);
|
||||||
|
foreach ( self::attributes() as $attribute ) {
|
||||||
|
$target = (string) $attribute['target'];
|
||||||
|
if ( 0 === strpos( $target, 'm365_' ) ) {
|
||||||
|
$fields[ (string) $attribute['label'] ] = (string) get_user_meta( $user->ID, $target, true );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$stored = get_user_meta( $user->ID, self::META_PHOTO, true );
|
||||||
|
if ( is_array( $stored ) && ! empty( $stored['file'] ) && self::is_photo_file( $stored['file'] ) ) {
|
||||||
|
$uploads = wp_get_upload_dir();
|
||||||
|
$fields[ __( 'Profile photo', 'm365-login' ) ] = trailingslashit( $uploads['baseurl'] ) . $stored['file'];
|
||||||
|
}
|
||||||
|
$last = (int) get_user_meta( $user->ID, self::META_LAST_SYNC, true );
|
||||||
|
if ( $last ) {
|
||||||
|
$fields[ __( 'Last sync', 'm365-login' ) ] = wp_date( 'c', $last );
|
||||||
|
}
|
||||||
|
$items = array();
|
||||||
|
foreach ( $fields as $name => $value ) {
|
||||||
|
if ( '' !== $value ) {
|
||||||
|
$items[] = array(
|
||||||
|
'name' => $name,
|
||||||
|
'value' => $value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( $items ) {
|
||||||
|
$data[] = array(
|
||||||
|
'group_id' => 'm365-login',
|
||||||
|
'group_label' => __( 'Microsoft 365', 'm365-login' ),
|
||||||
|
'item_id' => 'm365-login-' . $user->ID,
|
||||||
|
'data' => $items,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return array(
|
||||||
|
'data' => $data,
|
||||||
|
'done' => true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erases copied profile data and the photo. The object ID link and a deactivation are kept:
|
||||||
|
* they protect the account (removing them would unlock or unbind it).
|
||||||
|
*
|
||||||
|
* @param string $email E-mail address.
|
||||||
|
* @param int $page Page.
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function erase_personal_data( $email, $page = 1 ) {
|
||||||
|
$user = get_user_by( 'email', $email );
|
||||||
|
$removed = false;
|
||||||
|
$kept = false;
|
||||||
|
if ( $user ) {
|
||||||
|
foreach ( self::attributes() as $attribute ) {
|
||||||
|
$target = (string) $attribute['target'];
|
||||||
|
if ( 0 === strpos( $target, 'm365_' ) && '' !== (string) get_user_meta( $user->ID, $target, true ) ) {
|
||||||
|
delete_user_meta( $user->ID, $target );
|
||||||
|
$removed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( get_user_meta( $user->ID, self::META_PHOTO, true ) ) {
|
||||||
|
self::remove_stored_photo( $user->ID );
|
||||||
|
$removed = true;
|
||||||
|
}
|
||||||
|
$kept = '' !== (string) get_user_meta( $user->ID, M365_Login_Auth::META_OID, true ) || (bool) self::disabled_info( $user->ID );
|
||||||
|
}
|
||||||
|
return array(
|
||||||
|
'items_removed' => $removed,
|
||||||
|
'items_retained' => $kept,
|
||||||
|
'messages' => $kept ? array( __( 'The link to the Microsoft account and a possible deactivation were kept because they secure the account. The next user sync copies selected profile fields again unless the person is excluded from the sync.', 'm365-login' ) ) : array(),
|
||||||
|
'done' => true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Deactivated accounts */
|
/* Deactivated accounts */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
@ -1611,6 +1984,9 @@ class M365_Login_Sync {
|
||||||
* @param string $reason Machine reason.
|
* @param string $reason Machine reason.
|
||||||
*/
|
*/
|
||||||
public static function disable( $user_id, $by, $reason = '' ) {
|
public static function disable( $user_id, $by, $reason = '' ) {
|
||||||
|
if ( self::disabled_info( $user_id ) ) {
|
||||||
|
return; // Already deactivated: keep the remembered roles and the origin.
|
||||||
|
}
|
||||||
$user = get_userdata( $user_id );
|
$user = get_userdata( $user_id );
|
||||||
$roles = $user ? array_values( $user->roles ) : array();
|
$roles = $user ? array_values( $user->roles ) : array();
|
||||||
|
|
||||||
|
|
@ -1635,6 +2011,7 @@ class M365_Login_Sync {
|
||||||
if ( $user ) {
|
if ( $user ) {
|
||||||
$user->set_role( '' );
|
$user->set_role( '' );
|
||||||
}
|
}
|
||||||
|
self::remove_stored_photo( $user_id ); // No public photo of a deactivated account.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fires after an account was deactivated.
|
* Fires after an account was deactivated.
|
||||||
|
|
@ -1646,6 +2023,29 @@ class M365_Login_Sync {
|
||||||
do_action( 'm365_login_user_disabled', $user_id, $by, $reason );
|
do_action( 'm365_login_user_disabled', $user_id, $by, $reason );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time hardening of accounts deactivated before 1.1.0 (no role/password/app-password lock yet).
|
||||||
|
*/
|
||||||
|
public static function harden_legacy_disabled() {
|
||||||
|
$users = get_users(
|
||||||
|
array(
|
||||||
|
'meta_key' => self::META_DISABLED, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
|
||||||
|
'meta_compare' => 'EXISTS',
|
||||||
|
'fields' => 'ID',
|
||||||
|
'number' => -1,
|
||||||
|
'blog_id' => 0,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
foreach ( $users as $user_id ) {
|
||||||
|
$info = self::disabled_info( (int) $user_id );
|
||||||
|
if ( ! $info || array_key_exists( 'roles', $info ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
delete_user_meta( (int) $user_id, self::META_DISABLED );
|
||||||
|
self::disable( (int) $user_id, $info['by'], $info['reason'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reactivates an account.
|
* Reactivates an account.
|
||||||
*
|
*
|
||||||
|
|
@ -1759,7 +2159,7 @@ class M365_Login_Sync {
|
||||||
),
|
),
|
||||||
admin_url( 'admin-post.php' )
|
admin_url( 'admin-post.php' )
|
||||||
),
|
),
|
||||||
self::POST_STATE . '_' . $user->ID
|
self::POST_STATE . '_' . $user->ID . '_' . ( $disabled ? 'enable' : 'disable' )
|
||||||
);
|
);
|
||||||
$actions['m365_login_state'] = '<a href="' . esc_url( $url ) . '">' . ( $disabled ? esc_html__( 'Reactivate', 'm365-login' ) : esc_html__( 'Deactivate', 'm365-login' ) ) . '</a>';
|
$actions['m365_login_state'] = '<a href="' . esc_url( $url ) . '">' . ( $disabled ? esc_html__( 'Reactivate', 'm365-login' ) : esc_html__( 'Deactivate', 'm365-login' ) ) . '</a>';
|
||||||
return $actions;
|
return $actions;
|
||||||
|
|
@ -1770,11 +2170,11 @@ class M365_Login_Sync {
|
||||||
*/
|
*/
|
||||||
public function handle_user_state() {
|
public function handle_user_state() {
|
||||||
$user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0;
|
$user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0;
|
||||||
check_admin_referer( self::POST_STATE . '_' . $user_id );
|
$state = isset( $_GET['state'] ) && 'disable' === $_GET['state'] ? 'disable' : 'enable';
|
||||||
|
check_admin_referer( self::POST_STATE . '_' . $user_id . '_' . $state );
|
||||||
if ( ! $user_id || ! current_user_can( 'edit_user', $user_id ) || get_current_user_id() === $user_id ) {
|
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 );
|
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 ) {
|
if ( 'disable' === $state ) {
|
||||||
self::disable( $user_id, 'manual' );
|
self::disable( $user_id, 'manual' );
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,23 @@ final class M365_Login {
|
||||||
}
|
}
|
||||||
|
|
||||||
add_filter( 'plugin_action_links_' . plugin_basename( M365_LOGIN_FILE ), array( $this, 'action_links' ) );
|
add_filter( 'plugin_action_links_' . plugin_basename( M365_LOGIN_FILE ), array( $this, 'action_links' ) );
|
||||||
|
add_action( 'init', array( $this, 'maybe_upgrade' ), 1 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time data migrations after an update.
|
||||||
|
*/
|
||||||
|
public function maybe_upgrade() {
|
||||||
|
$stored = (string) get_option( 'm365_login_version', '1.0.0' );
|
||||||
|
if ( version_compare( $stored, M365_LOGIN_VERSION, '>=' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
update_option( 'm365_login_version', M365_LOGIN_VERSION );
|
||||||
|
|
||||||
|
if ( version_compare( $stored, '1.1.0', '<' ) ) {
|
||||||
|
M365_Login_Sync::harden_legacy_disabled();
|
||||||
|
$this->settings->normalise_stored_certificate();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ function m365_login_uninstall_site() {
|
||||||
|
|
||||||
delete_option( 'm365_login_settings' );
|
delete_option( 'm365_login_settings' );
|
||||||
delete_option( 'm365_login_sync_lock' );
|
delete_option( 'm365_login_sync_lock' );
|
||||||
delete_option( 'm365_login_sync_tenant' );
|
delete_option( 'm365_login_version' );
|
||||||
delete_option( 'm365_login_sync_report' );
|
delete_option( 'm365_login_sync_report' );
|
||||||
wp_clear_scheduled_hook( 'm365_login_sync' );
|
wp_clear_scheduled_hook( 'm365_login_sync' );
|
||||||
|
|
||||||
|
|
@ -64,6 +64,6 @@ if ( is_multisite() ) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// User meta is global. Imported accounts stay; copied profile fields (m365_*) are kept as ordinary user data.
|
// User meta is global. Imported accounts stay; copied profile fields (m365_*) are kept as ordinary user data.
|
||||||
foreach ( array( '_m365_login_oid', '_m365_login_last_login', '_m365_login_synced', '_m365_login_disabled', '_m365_login_last_sync', '_m365_login_photo' ) as $m365_login_meta_key ) {
|
foreach ( array( '_m365_login_oid', '_m365_login_last_login', '_m365_login_synced', '_m365_login_disabled', '_m365_login_last_sync', '_m365_login_photo', '_m365_login_tid' ) as $m365_login_meta_key ) {
|
||||||
delete_metadata( 'user', 0, $m365_login_meta_key, '', true );
|
delete_metadata( 'user', 0, $m365_login_meta_key, '', true );
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue