Fix the findings of a full second security audit
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

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>
This commit is contained in:
Friederich Loheide 2026-09-23 17:10:30 +00:00
parent 791f43a80b
commit 850f0dcd54
18 changed files with 1908 additions and 1270 deletions

View file

@ -12,15 +12,18 @@ defined( 'ABSPATH' ) || exit;
*/
class M365_Login_Admin {
const PAGE = 'm365-login';
const GROUP = 'm365_login';
const AJAX_TEST = 'm365_login_test_connection';
const AJAX_GROUPS = 'm365_login_search_groups';
const AJAX_CERT = 'm365_login_certificate';
const AJAX_SYNC = 'm365_login_sync_run';
const POST_CERT = 'm365_login_download_cert';
const MENU_ICON = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMCAyMCI+PHBhdGggZmlsbD0iYmxhY2siIGQ9Ik0yIDJoNy41djcuNUgyek0xMC41IDJIMTh2Ny41aC03LjV6TTIgMTAuNWg3LjVWMThIMnpNMTAuNSAxMC41SDE4VjE4aC03LjV6Ii8+PC9zdmc+';
const NONCE_TEST = 'm365_login_test';
const PAGE = 'm365-login';
const GROUP = 'm365_login';
const AJAX_TEST = 'm365_login_test_connection';
const AJAX_GROUPS = 'm365_login_search_groups';
const AJAX_CERT = 'm365_login_certificate';
const AJAX_SYNC = 'm365_login_sync_run';
const POST_CERT = 'm365_login_download_cert';
const MENU_ICON = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMCAyMCI+PHBhdGggZmlsbD0iYmxhY2siIGQ9Ik0yIDJoNy41djcuNUgyek0xMC41IDJIMTh2Ny41aC03LjV6TTIgMTAuNWg3LjVWMThIMnpNMTAuNSAxMC41SDE4VjE4aC03LjV6Ii8+PC9zdmc+';
const NONCE_TEST = 'm365_login_test';
const NONCE_GROUPS = 'm365_login_groups';
const NONCE_CERT = 'm365_login_cert';
const NONCE_SYNC = 'm365_login_sync';
/**
* Settings.
@ -82,6 +85,19 @@ class M365_Login_Admin {
add_action( 'update_option_' . M365_LOGIN_OPTION, array( $this->graph, 'flush_token' ) );
add_action( 'admin_notices', array( $this, 'setup_notice' ) );
add_filter( 'submenu_file', array( $this, 'highlight_submenu' ) );
add_filter( 'option_page_capability_' . self::GROUP, array( __CLASS__, 'capability' ) );
}
/**
* Capability needed for the settings, the connection test, certificates and the user sync.
*
* On multisite only super admins: the settings decide which Microsoft identities may sign in
* as which (network-wide) WordPress users, so a site administrator must not control them.
*
* @return string
*/
public static function capability() {
return is_multisite() ? 'manage_network_options' : 'manage_options';
}
/**
@ -91,7 +107,7 @@ class M365_Login_Admin {
$this->hook = add_menu_page(
__( 'M365 Login', 'm365-login' ),
__( 'M365 Login', 'm365-login' ),
'manage_options',
self::capability(),
self::PAGE,
array( $this, 'render' ),
self::MENU_ICON,
@ -103,7 +119,7 @@ class M365_Login_Admin {
self::PAGE,
$label . ' ' . __( 'M365 Login', 'm365-login' ),
$label,
'manage_options',
self::capability(),
'connection' === $tab ? self::PAGE : 'admin.php?page=' . self::PAGE . '&tab=' . $tab
);
}
@ -182,7 +198,19 @@ class M365_Login_Admin {
* Nudges administrators to finish the setup.
*/
public function setup_notice() {
if ( $this->settings->is_configured() || ! current_user_can( 'manage_options' ) ) {
if ( ! current_user_can( self::capability() ) ) {
return;
}
if ( ! $this->settings->is_configured() && $this->settings->button_only() ) {
printf(
'<div class="notice notice-error"><p>%s <a href="%s">%s</a></p></div>',
esc_html__( 'M365 Login: button-only mode is on, but the connection to Microsoft is broken (missing or undecryptable secret, or expired certificate). Nobody can sign in except through the fallback link.', 'm365-login' ),
esc_url( self::url() ),
esc_html__( 'Open the settings', 'm365-login' )
);
return;
}
if ( $this->settings->is_configured() ) {
return;
}
$screen = get_current_screen();
@ -220,7 +248,12 @@ class M365_Login_Admin {
'm365LoginAdmin',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( self::NONCE_TEST ),
'nonces' => array(
'test' => wp_create_nonce( self::NONCE_TEST ),
'groups' => wp_create_nonce( self::NONCE_GROUPS ),
'cert' => wp_create_nonce( self::NONCE_CERT ),
'sync' => wp_create_nonce( self::NONCE_SYNC ),
),
'action' => self::AJAX_TEST,
'groupAction' => self::AJAX_GROUPS,
'certAction' => self::AJAX_CERT,
@ -257,7 +290,7 @@ class M365_Login_Admin {
*/
public function ajax_test_connection() {
check_ajax_referer( self::NONCE_TEST, 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
if ( ! current_user_can( self::capability() ) ) {
wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'm365-login' ) ), 403 );
}
@ -295,8 +328,8 @@ class M365_Login_Admin {
* AJAX: search Entra groups through Microsoft Graph.
*/
public function ajax_search_groups() {
check_ajax_referer( self::NONCE_TEST, 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
check_ajax_referer( self::NONCE_GROUPS, 'nonce' );
if ( ! current_user_can( self::capability() ) ) {
wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'm365-login' ) ), 403 );
}
if ( ! $this->settings->is_configured() ) {
@ -321,8 +354,8 @@ class M365_Login_Admin {
* AJAX: generate a new self-signed certificate and store it (key encrypted).
*/
public function ajax_certificate() {
check_ajax_referer( self::NONCE_TEST, 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
check_ajax_referer( self::NONCE_CERT, 'nonce' );
if ( ! current_user_can( self::capability() ) ) {
wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'm365-login' ) ), 403 );
}
$op = isset( $_POST['op'] ) ? sanitize_key( wp_unslash( $_POST['op'] ) ) : '';
@ -354,8 +387,8 @@ class M365_Login_Admin {
* AJAX: run the user sync (dry run or live) with the saved settings.
*/
public function ajax_sync() {
check_ajax_referer( self::NONCE_TEST, 'nonce' );
if ( ! current_user_can( 'manage_options' ) || ! current_user_can( 'create_users' ) ) {
check_ajax_referer( self::NONCE_SYNC, 'nonce' );
if ( ! current_user_can( self::capability() ) || ! current_user_can( 'create_users' ) ) {
wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'm365-login' ) ), 403 );
}
$op = isset( $_POST['op'] ) ? sanitize_key( wp_unslash( $_POST['op'] ) ) : '';
@ -486,7 +519,7 @@ class M365_Login_Admin {
<?php if ( 'roles' === $mode ) : ?>
<span class="m365-group-chip__arrow" aria-hidden="true">&rarr;</span>
<select name="<?php echo esc_attr( $base . '[' . $gid . '][role]' ); ?>" aria-label="<?php esc_attr_e( 'WordPress role', 'm365-login' ); ?>">
<?php wp_dropdown_roles( $value['role'] ); ?>
<?php $this->role_options( $value['role'] ); ?>
</select>
<input type="hidden" name="<?php echo esc_attr( $base . '[' . $gid . '][name]' ); ?>" value="<?php echo esc_attr( $gname ); ?>" />
<?php else : ?>
@ -498,7 +531,7 @@ class M365_Login_Admin {
</ul>
<?php if ( 'roles' === $mode ) : ?>
<select class="m365-picker__role-template" hidden disabled>
<?php wp_dropdown_roles( 'editor' ); ?>
<?php $this->role_options( 'editor' ); ?>
</select>
<?php endif; ?>
</div>
@ -506,11 +539,30 @@ class M365_Login_Admin {
<?php
}
/**
* Prints <option> elements for all editable roles (escaped, unlike wp_dropdown_roles()).
*
* @param string $selected Selected role slug.
*/
private function role_options( $selected ) {
if ( ! function_exists( 'get_editable_roles' ) ) {
require_once ABSPATH . 'wp-admin/includes/user.php';
}
foreach ( array_reverse( get_editable_roles() ) as $role => $details ) {
printf(
'<option value="%s"%s>%s</option>',
esc_attr( $role ),
selected( $selected, $role, false ),
esc_html( translate_user_role( $details['name'] ) )
);
}
}
/**
* Sends the public certificate as a .cer download (never the private key).
*/
public function download_certificate() {
if ( ! current_user_can( 'manage_options' ) ) {
if ( ! current_user_can( self::capability() ) ) {
wp_die( esc_html__( 'You are not allowed to do this.', 'm365-login' ), 403 );
}
check_admin_referer( self::POST_CERT );
@ -599,13 +651,14 @@ class M365_Login_Admin {
<div class="m365-field">
<label for="m365-sync-role"><?php esc_html_e( 'Default role', 'm365-login' ); ?></label>
<select id="m365-sync-role" name="<?php echo $field( 'sync_default_role' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>">
<?php wp_dropdown_roles( $s['sync_default_role'] ); ?>
<?php $this->role_options( $s['sync_default_role'] ); ?>
</select>
<p class="description"><?php esc_html_e( 'Every imported user gets this role. The sync manages the roles of imported accounts manual role changes are overwritten on the next run.', 'm365-login' ); ?></p>
</div>
<h3 class="m365-card__subtitle"><?php esc_html_e( 'Additional roles from Microsoft 365 groups', 'm365-login' ); ?></h3>
<p class="description"><?php esc_html_e( 'Members of a group (nested memberships count) get the role next to it. If a person leaves the group, the role is removed again on the next sync.', 'm365-login' ); ?></p>
<p class="m365-warning"><?php esc_html_e( 'Whoever can change a group\'s members controls the mapped role. For roles with administrative rights use security groups (ideally role-assignable ones) never public Microsoft 365 groups or Teams, which members can join themselves.', 'm365-login' ); ?></p>
<?php $this->group_picker( 'sync_role_map', $this->settings->sync_role_map(), 'roles', __( 'No group mapping everybody gets the default role.', 'm365-login' ) ); ?>
<div class="m365-field">
@ -703,7 +756,7 @@ class M365_Login_Admin {
* Renders the settings screen.
*/
public function render() {
if ( ! current_user_can( 'manage_options' ) ) {
if ( ! current_user_can( self::capability() ) ) {
wp_die( esc_html__( 'You are not allowed to access this page.', 'm365-login' ) );
}
@ -806,6 +859,9 @@ class M365_Login_Admin {
</label>
<?php endif; ?>
<p class="description"><?php esc_html_e( 'Stored encrypted (AES-256-GCM, key derived from your WordPress salts) and never displayed again. Client secrets expire note the expiry date in Entra ID.', 'm365-login' ); ?></p>
<?php if ( ! defined( 'AUTH_KEY' ) || ! defined( 'SECURE_AUTH_KEY' ) ) : ?>
<p class="m365-warning"><?php esc_html_e( 'AUTH_KEY and SECURE_AUTH_KEY are not defined in wp-config.php, so WordPress keeps its salts in the database right next to the encrypted secret. Add the salts to wp-config.php to make the encryption effective.', 'm365-login' ); ?></p>
<?php endif; ?>
</div>
<details class="m365-guide" <?php echo $has_secret ? '' : 'open'; ?>>
@ -1085,6 +1141,9 @@ class M365_Login_Admin {
<h2 class="m365-card__title"><?php esc_html_e( 'Excluded Entra groups (optional)', 'm365-login' ); ?></h2>
<p class="m365-card__intro"><?php esc_html_e( 'Members of these groups can never sign in with Microsoft even if they are in an allowed group. Nested memberships count.', 'm365-login' ); ?></p>
<?php if ( $this->settings->is_multi_tenant() ) : ?>
<p class="m365-warning m365-warning--strong"><?php esc_html_e( 'Group rules need a pinned tenant ID (GUID) on the Connection tab. In multi-tenant mode the group check cannot ask Microsoft Graph, so every sign-in is refused while groups are selected here or above.', 'm365-login' ); ?></p>
<?php endif; ?>
<?php $this->group_picker( 'denied_groups', $this->settings->denied_groups(), 'chips', __( 'No groups excluded.', 'm365-login' ) ); ?>
<div class="m365-field">
<p class="description"><?php esc_html_e( 'The plugin asks Microsoft Graph on every sign-in (application permission "User.Read.All" or "Directory.Read.All"), because a "groups" claim may be filtered and cannot prove that someone is not a member. If the check fails, the sign-in is refused. Password sign-in is not affected combine with button-only mode if needed.', 'm365-login' ); ?></p>
@ -1093,7 +1152,7 @@ class M365_Login_Admin {
<div class="m365-card">
<h2 class="m365-card__title"><?php esc_html_e( 'Button-only mode', 'm365-login' ); ?></h2>
<p class="m365-card__intro"><?php esc_html_e( 'Hides the username/password fields (on wp-login.php and in wp_login_form() forms) and refuses every interactive password sign-in on the site, including custom login forms. Application passwords, REST, XML-RPC and WP-CLI are not affected.', 'm365-login' ); ?></p>
<p class="m365-card__intro"><?php esc_html_e( 'Hides the username/password fields (on wp-login.php and in wp_login_form() forms) and refuses every sign-in with a normal password on the site custom login forms, XML-RPC and login endpoints of other plugins included. Application passwords (REST, XML-RPC) and WP-CLI keep working; API requests never receive a login cookie.', 'm365-login' ); ?></p>
<label class="m365-check m365-check--block">
<input type="checkbox" name="<?php echo $field( 'button_only' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['button_only'] ); ?> id="m365-button-only" />

View file

@ -55,6 +55,8 @@ class M365_Login_Auth {
add_action( 'init', array( $this, 'maybe_accept_fallback_key' ), 6 );
// Runs after core's username/password handlers (priority 20), which would otherwise overwrite an early WP_Error.
add_filter( 'authenticate', array( $this, 'block_password_login' ), 99, 3 );
// No session cookies from API contexts (XML-RPC, REST) while password sign-in is disabled.
add_filter( 'send_auth_cookies', array( $this, 'block_api_auth_cookies' ), 99, 4 );
// Custom login page: send people back there after logging out.
add_filter( 'logout_redirect', array( $this, 'logout_redirect' ), 10, 3 );
@ -74,16 +76,25 @@ class M365_Login_Auth {
return true; // Nothing is hidden, the form is always available.
}
$cookie = isset( $_COOKIE[ self::FALLBACK_COOKIE ] ) ? sanitize_text_field( wp_unslash( $_COOKIE[ self::FALLBACK_COOKIE ] ) ) : '';
return '' !== $cookie && hash_equals( $this->fallback_cookie_value(), $cookie );
$parts = explode( '|', $cookie );
if ( 2 !== count( $parts ) || ! ctype_digit( $parts[0] ) ) {
return false;
}
$issued = (int) $parts[0];
if ( $issued > time() + 60 || time() - $issued > self::FALLBACK_TTL ) {
return false; // Expired on the server side, whatever the browser keeps.
}
return hash_equals( $this->fallback_cookie_value( $issued ), $cookie );
}
/**
* Expected fallback cookie value (HMAC of the key, so the key itself never sits in the cookie).
* Fallback cookie value for an issue time: "time|HMAC" (the key itself never sits in the cookie).
*
* @param int $issued Issue timestamp.
* @return string
*/
private function fallback_cookie_value() {
return hash_hmac( 'sha256', 'fallback|' . $this->settings->fallback_key(), wp_salt( 'auth' ) );
private function fallback_cookie_value( $issued ) {
return $issued . '|' . hash_hmac( 'sha256', 'fallback|' . $issued . '|' . $this->settings->fallback_key(), wp_salt( 'auth' ) );
}
/**
@ -99,20 +110,21 @@ class M365_Login_Auth {
return;
}
// Slow down brute force attempts on the key.
$ip_key = 'm365_login_fb_' . md5( $this->client_ip() );
$attempts = (int) get_transient( $ip_key );
if ( $attempts >= 10 ) {
$this->fail( 'fallback_locked' );
}
// The correct key always works (a shared office IP must not lock the administrator out);
// wrong keys are slowed down per IP. The key has ~139 bits, the limit only reduces log noise.
$ip_key = 'm365_login_fb_' . md5( $this->client_ip() );
if ( ! hash_equals( $this->settings->fallback_key(), $given ) ) {
$attempts = (int) get_transient( $ip_key );
if ( $attempts >= 10 ) {
$this->fail( 'fallback_locked' );
}
set_transient( $ip_key, $attempts + 1, 15 * MINUTE_IN_SECONDS );
$this->fail( 'fallback_invalid' );
}
delete_transient( $ip_key );
$this->send_cookie( self::FALLBACK_COOKIE, $this->fallback_cookie_value(), time() + self::FALLBACK_TTL );
$issued = time();
$this->send_cookie( self::FALLBACK_COOKIE, $this->fallback_cookie_value( $issued ), $issued + self::FALLBACK_TTL );
nocache_headers();
wp_safe_redirect( add_query_arg( 'm365_fallback', 'on', $this->settings->login_page_url() ) );
exit;
@ -149,16 +161,12 @@ class M365_Login_Auth {
if ( '' === (string) $username && '' === (string) $password ) {
return $user; // Initial form render or cookie auth, no password attempt.
}
// Interactive password logins only: XML-RPC, REST (application passwords), WP-CLI and cron keep working.
if ( ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST )
|| ( defined( 'REST_REQUEST' ) && REST_REQUEST )
|| ( defined( 'WP_CLI' ) && WP_CLI )
|| wp_doing_cron() ) {
// Exempt by credential, not by request context: application passwords (XML-RPC, REST) and
// WP-CLI keep working. A normal password is refused everywhere also in forms of other
// plugins that happen to run inside xmlrpc.php or a REST request.
if ( ( defined( 'WP_CLI' ) && WP_CLI ) || ( $user instanceof WP_User && did_action( 'application_password_did_authenticate' ) ) ) {
return $user;
}
if ( ! $user instanceof WP_User ) {
return $user; // Already failed for another reason; keep core's message.
}
/**
* Allows exempting a password sign-in from button-only mode (e.g. a trusted membership plugin).
@ -166,13 +174,36 @@ class M365_Login_Auth {
* @param bool $block Whether to block. Default true.
* @param WP_User $user Authenticated user.
*/
if ( ! apply_filters( 'm365_login_block_password_login', true, $user ) ) {
if ( $user instanceof WP_User && ! apply_filters( 'm365_login_block_password_login', true, $user ) ) {
return $user;
}
// Same answer for right and wrong passwords: the form must not become a password oracle.
return new WP_Error( 'm365_login_button_only', __( 'Password sign-in is disabled on this site. Please use the Microsoft button.', 'm365-login' ) );
}
/**
* While button-only mode is active, API requests (XML-RPC, REST) never receive session cookies.
*
* Core can set cookies there, e.g. when an application password is used to change the account
* password via REST, or when another plugin's login handler runs inside xmlrpc.php.
*
* @param bool $send Whether to send the cookies.
* @param int $expire Expiry (unused).
* @param int $expiration Expiration (unused).
* @param int $user_id User ID (0 when cookies are cleared).
* @return bool
*/
public function block_api_auth_cookies( $send, $expire = 0, $expiration = 0, $user_id = 0 ) {
if ( ! $send || ! $user_id || ! $this->settings->button_only() || $this->fallback_active() ) {
return $send;
}
if ( ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
return false;
}
return $send;
}
/**
* Best-effort client IP for rate limiting.
*
@ -190,7 +221,10 @@ class M365_Login_Auth {
*/
$header = apply_filters( 'm365_login_client_ip_header', defined( 'M365_LOGIN_CLIENT_IP_HEADER' ) ? M365_LOGIN_CLIENT_IP_HEADER : '' );
if ( '' !== $header && ! empty( $_SERVER[ $header ] ) ) {
$candidate = trim( explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ) )[0] );
// Proxies append to the list: the right-most entry was written by the trusted proxy,
// entries further left are supplied by the client.
$hops = explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ) );
$candidate = trim( (string) end( $hops ) );
if ( filter_var( $candidate, FILTER_VALIDATE_IP ) ) {
$ip = $candidate;
}
@ -254,9 +288,10 @@ class M365_Login_Auth {
* @return string
*/
public function start_url( $redirect_to = '' ) {
$args = array( 'action' => self::ACTION_START );
$args = array( 'action' => self::ACTION_START );
$redirect_to = '' !== (string) $redirect_to ? wp_validate_redirect( (string) $redirect_to, '' ) : '';
if ( '' !== $redirect_to ) {
$args['redirect_to'] = $redirect_to;
$args['redirect_to'] = rawurlencode( $redirect_to ); // add_query_arg() does not encode values.
}
return add_query_arg( $args, wp_login_url() );
}
@ -276,7 +311,7 @@ class M365_Login_Auth {
// Cap the number of pending login attempts one client can create (state records are stored server-side).
$throttle_key = 'm365_login_start_' . md5( $this->client_ip() );
$starts = (int) get_transient( $throttle_key );
if ( $starts >= 30 ) {
if ( $starts >= 300 ) {
$this->fail( 'too_many_attempts' );
}
set_transient( $throttle_key, $starts + 1, self::STATE_TTL );
@ -421,6 +456,11 @@ class M365_Login_Auth {
$this->fail( 'invalid_token' );
}
// In multi-tenant mode a guest or federated identity could present any user name.
if ( $this->settings->is_multi_tenant() && $this->is_external_identity( $claims ) ) {
$this->fail( 'external_identity' );
}
$email = $this->email_from_claims( $claims );
if ( '' === $email ) {
$this->fail( 'no_email' );
@ -454,18 +494,41 @@ class M365_Login_Auth {
}
// Bind the account to the immutable Microsoft object ID after first login.
$stored = strtolower( (string) get_user_meta( $user->ID, self::META_OID, true ) );
if ( $this->settings->get( 'bind_oid' ) ) {
if ( '' === $oid || ! M365_Login_Settings::is_guid( $oid ) ) {
$this->fail( 'invalid_token' );
}
$stored = (string) get_user_meta( $user->ID, self::META_OID, true );
if ( '' !== $stored && ! hash_equals( $stored, $oid ) ) {
$this->log( sprintf( 'Object ID mismatch for user #%d.', $user->ID ) );
$this->fail( 'oid_mismatch' );
}
if ( '' === $stored ) {
update_user_meta( $user->ID, self::META_OID, $oid );
}
// Privileged accounts that are not bound yet: only the user principal name of a member
// account may claim them (its domain is verified in the tenant, the e-mail attribute is not).
if ( '' === $stored && 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->fail( 'privileged_unlinked' );
}
if ( $this->settings->get( 'bind_oid' ) && '' === $stored ) {
// One Microsoft identity, one WordPress account.
$taken = get_users(
array(
'meta_key' => self::META_OID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => $oid, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
'exclude' => array( $user->ID ),
'fields' => 'ID',
'number' => 1,
'blog_id' => 0,
)
);
if ( ! empty( $taken ) ) {
$this->log( sprintf( 'Object ID is already bound to another account (user #%d).', $user->ID ) );
$this->fail( 'oid_mismatch' );
}
update_user_meta( $user->ID, self::META_OID, $oid );
}
/**
@ -642,6 +705,35 @@ class M365_Login_Auth {
return $body;
}
/**
* Whether the token comes from a guest or another external identity provider.
*
* Entra only adds the "idp" claim when the identity provider differs from the issuer.
*
* @param array $claims Verified claims.
* @return bool
*/
private function is_external_identity( $claims ) {
$idp = isset( $claims['idp'] ) && is_string( $claims['idp'] ) ? $claims['idp'] : '';
$iss = isset( $claims['iss'] ) && is_string( $claims['iss'] ) ? $claims['iss'] : '';
return '' !== $idp && $idp !== $iss;
}
/**
* Whether a token may claim a privileged account that is not bound yet.
*
* @param array $claims Verified claims.
* @param WP_User $user Matched account.
* @return bool
*/
private function may_claim_privileged( $claims, $user ) {
$upn = ! empty( $claims['preferred_username'] ) && is_string( $claims['preferred_username'] ) ? strtolower( trim( $claims['preferred_username'] ) ) : '';
return '' !== $upn
&& ! $this->settings->is_multi_tenant()
&& ! $this->is_external_identity( $claims )
&& hash_equals( strtolower( $user->user_email ), $upn );
}
/**
* Applies the Entra group rules: members of an excluded group are refused,
* everybody else needs membership in one of the allowed groups (if any are set).
@ -699,7 +791,7 @@ class M365_Login_Auth {
return 'invalid_token';
}
$matches = $this->graph->check_member_groups( $oid, $denied );
$matches = $this->graph->check_member_groups( $oid, $denied, false );
if ( is_wp_error( $matches ) ) {
$this->log( 'Excluded-group check via Microsoft Graph failed: ' . $matches->get_error_message() );
return 'group_check_failed';
@ -741,7 +833,7 @@ class M365_Login_Auth {
return 'invalid_token';
}
$matches = $this->graph->check_member_groups( $oid, $allowed );
$matches = $this->graph->check_member_groups( $oid, $allowed, false );
if ( is_wp_error( $matches ) ) {
$this->log( 'Group check via Microsoft Graph failed: ' . $matches->get_error_message() );
return 'group_check_failed';
@ -899,6 +991,13 @@ class M365_Login_Auth {
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$out = array();
if ( $this->settings->button_only() && ! $this->settings->is_configured() && ! $this->fallback_active() ) {
$out[] = array(
'type' => 'error',
'code' => 'unavailable',
'text' => __( 'Microsoft sign-in is temporarily unavailable. Please contact an administrator.', 'm365-login' ),
);
}
if ( $fallback_on && $this->settings->button_only() && $this->fallback_active() ) {
$out[] = array(
'type' => 'message',
@ -924,24 +1023,26 @@ class M365_Login_Auth {
*/
private function error_messages() {
return array(
'not_configured' => __( 'Microsoft login is not configured yet.', 'm365-login' ),
'invalid_state' => __( 'The login request expired or was invalid. Please try again.', 'm365-login' ),
'access_denied' => __( 'Microsoft sign-in was cancelled.', 'm365-login' ),
'provider_error' => __( 'Microsoft returned an error. Please try again.', 'm365-login' ),
'token_exchange' => __( 'Could not complete the sign-in with Microsoft. Please try again or contact an administrator.', 'm365-login' ),
'invalid_token' => __( 'The Microsoft sign-in could not be verified.', 'm365-login' ),
'no_email' => __( 'Your Microsoft account did not provide an e-mail address.', 'm365-login' ),
'domain_not_allowed' => __( 'Your e-mail domain is not allowed to sign in here.', 'm365-login' ),
'no_user' => __( 'No WordPress account exists for your Microsoft e-mail address.', 'm365-login' ),
'oid_mismatch' => __( 'This WordPress account is linked to a different Microsoft account. Please contact an administrator.', 'm365-login' ),
'not_allowed' => __( 'You are not allowed to sign in with this account.', 'm365-login' ),
'not_in_group' => __( 'Your Microsoft account is not a member of a group that is allowed to sign in here.', 'm365-login' ),
'group_check_failed' => __( 'Your group membership could not be verified. Please contact an administrator.', 'm365-login' ),
'in_denied_group' => __( 'Your Microsoft account is a member of a group that is not allowed to sign in here.', 'm365-login' ),
'fallback_invalid' => __( 'The fallback key is not valid.', 'm365-login' ),
'fallback_locked' => __( 'Too many attempts. Please wait 15 minutes.', 'm365-login' ),
'too_many_attempts' => __( 'Too many sign-in attempts from your connection. Please wait a few minutes and try again.', 'm365-login' ),
'account_disabled' => __( 'This account has been deactivated.', 'm365-login' ),
'not_configured' => __( 'Microsoft login is not configured yet.', 'm365-login' ),
'invalid_state' => __( 'The login request expired or was invalid. Please try again.', 'm365-login' ),
'access_denied' => __( 'Microsoft sign-in was cancelled.', 'm365-login' ),
'provider_error' => __( 'Microsoft returned an error. Please try again.', 'm365-login' ),
'token_exchange' => __( 'Could not complete the sign-in with Microsoft. Please try again or contact an administrator.', 'm365-login' ),
'invalid_token' => __( 'The Microsoft sign-in could not be verified.', 'm365-login' ),
'no_email' => __( 'Your Microsoft account did not provide an e-mail address.', 'm365-login' ),
'domain_not_allowed' => __( 'Your e-mail domain is not allowed to sign in here.', 'm365-login' ),
'no_user' => __( 'No WordPress account exists for your Microsoft e-mail address.', 'm365-login' ),
'oid_mismatch' => __( 'This WordPress account is linked to a different Microsoft account. Please contact an administrator.', 'm365-login' ),
'not_allowed' => __( 'You are not allowed to sign in with this account.', 'm365-login' ),
'not_in_group' => __( 'Your Microsoft account is not a member of a group that is allowed to sign in here.', 'm365-login' ),
'group_check_failed' => __( 'Your group membership could not be verified. Please contact an administrator.', 'm365-login' ),
'in_denied_group' => __( 'Your Microsoft account is a member of a group that is not allowed to sign in here.', 'm365-login' ),
'fallback_invalid' => __( 'The fallback key is not valid.', 'm365-login' ),
'fallback_locked' => __( 'Too many attempts. Please wait 15 minutes.', 'm365-login' ),
'too_many_attempts' => __( 'Too many sign-in attempts from your connection. Please wait a few minutes and try again.', 'm365-login' ),
'account_disabled' => __( 'This account has been deactivated.', 'm365-login' ),
'privileged_unlinked' => __( 'For security reasons this administrator account can only be linked to a Microsoft account whose user principal name equals the WordPress e-mail address. Please contact an administrator.', 'm365-login' ),
'external_identity' => __( 'Guest and external accounts cannot sign in here.', 'm365-login' ),
);
}

View file

@ -97,6 +97,9 @@ final class M365_Login_Certificate {
return new WP_Error( 'key_bits', __( 'The RSA key must have at least 2048 bits.', 'm365-login' ) );
}
if ( false !== strpos( $cert_pem, 'PRIVATE KEY' ) ) {
return new WP_Error( 'cert_has_key', __( 'The certificate field contains a private key. Paste only the certificate (-----BEGIN CERTIFICATE-----) there.', 'm365-login' ) );
}
$cert = openssl_x509_read( $cert_pem );
if ( false === $cert ) {
return new WP_Error( 'cert_invalid', self::openssl_error( __( 'The certificate could not be read. Paste it in PEM format (-----BEGIN CERTIFICATE-----).', 'm365-login' ) ) );
@ -110,9 +113,15 @@ final class M365_Login_Certificate {
return new WP_Error( 'cert_expired', __( 'The certificate has already expired.', 'm365-login' ) );
}
// Store exactly one clean certificate (drops chains, bundles and surrounding text).
$clean = '';
if ( ! openssl_x509_export( $cert, $clean ) || '' === $clean ) {
return new WP_Error( 'cert_invalid', self::openssl_error( __( 'The certificate could not be read. Paste it in PEM format (-----BEGIN CERTIFICATE-----).', 'm365-login' ) ) );
}
return array(
'private_key' => $key_pem,
'certificate' => $cert_pem,
'certificate' => self::normalise_pem( $clean ),
);
}

View file

@ -11,7 +11,9 @@ defined( 'ABSPATH' ) || exit;
* AES-256-GCM helper keyed from the WordPress salts.
*
* The key is derived from AUTH_KEY / SECURE_AUTH_KEY (via wp_salt()), so the
* stored client secret is useless without access to wp-config.php.
* stored client secret is useless without access to wp-config.php provided the
* salts are defined there. Without them wp_salt() keeps generated salts in the
* database next to the ciphertext; the settings screen warns about that.
*/
final class M365_Login_Crypto {

View file

@ -107,9 +107,10 @@ class M365_Login_Graph {
* @param string $path Path relative to the v1.0 base (with query string) or an absolute Graph URL (paging links).
* @param array|null $json JSON body for POST requests.
* @param array $headers Extra headers.
* @param bool $retry Retry on 429/503/504.
* @return array|WP_Error Response array from wp_remote_request().
*/
private function raw_request( $method, $path, $json = null, $headers = array() ) {
private function raw_request( $method, $path, $json = null, $headers = array(), $retry = true ) {
$url = 0 === strpos( $path, self::GRAPH_BASE . '/' ) ? $path : self::GRAPH_BASE . $path;
if ( 0 !== strpos( $url, self::GRAPH_BASE . '/' ) ) {
return new WP_Error( 'graph_bad_url', 'Refusing to call a non-Graph URL.' );
@ -146,7 +147,7 @@ class M365_Login_Graph {
if ( 401 === $code ) {
$this->flush_token();
}
if ( $attempt < 4 && in_array( $code, array( 429, 503, 504 ), true ) ) {
if ( $retry && $attempt < 4 && in_array( $code, array( 429, 503, 504 ), true ) ) {
$wait = (int) wp_remote_retrieve_header( $response, 'retry-after' );
sleep( max( 1, min( 10, $wait > 0 ? $wait : $attempt * 2 ) ) );
continue;
@ -162,10 +163,11 @@ class M365_Login_Graph {
* @param string $path Path relative to the v1.0 base (with query string).
* @param array|null $json JSON body for POST requests.
* @param array $headers Extra headers.
* @param bool $retry Retry on 429/503/504.
* @return array|WP_Error Decoded JSON. Errors carry array( 'status' => HTTP code ) as data.
*/
private function request( $method, $path, $json = null, $headers = array() ) {
$response = $this->raw_request( $method, $path, $json, $headers );
private function request( $method, $path, $json = null, $headers = array(), $retry = true ) {
$response = $this->raw_request( $method, $path, $json, $headers, $retry );
if ( is_wp_error( $response ) ) {
return $response;
}
@ -390,10 +392,10 @@ class M365_Login_Graph {
*/
public function search_groups( $query ) {
$query = trim( (string) $query );
$select = '$select=id,displayName,description,securityEnabled,mailEnabled&$top=25&$orderby=displayName';
$select = '$select=id,displayName,description,securityEnabled,mailEnabled,groupTypes,visibility&$top=25&$orderby=displayName';
if ( '' !== $query && M365_Login_Settings::is_guid( $query ) ) {
$path = '/groups/' . rawurlencode( strtolower( $query ) ) . '?$select=id,displayName,description,securityEnabled,mailEnabled';
$path = '/groups/' . rawurlencode( strtolower( $query ) ) . '?$select=id,displayName,description,securityEnabled,mailEnabled,groupTypes,visibility';
$item = $this->request( 'GET', $path );
if ( is_wp_error( $item ) ) {
return $item;
@ -437,6 +439,10 @@ class M365_Login_Graph {
} elseif ( ! empty( $item['mailEnabled'] ) ) {
$type = __( 'Microsoft 365 group', 'm365-login' );
}
$unified = isset( $item['groupTypes'] ) && is_array( $item['groupTypes'] ) && in_array( 'Unified', $item['groupTypes'], true );
if ( $unified && isset( $item['visibility'] ) && 'Public' === $item['visibility'] ) {
$type = __( 'Public Microsoft 365 group anyone in the organisation can join', 'm365-login' );
}
return array(
'id' => strtolower( (string) $item['id'] ),
'name' => isset( $item['displayName'] ) ? (string) $item['displayName'] : (string) $item['id'],
@ -450,9 +456,10 @@ class M365_Login_Graph {
*
* @param string $user_oid User object ID.
* @param string[] $group_ids Group object IDs (any count; chunked by 20).
* @param bool $retry Retry on throttling (off in the interactive sign-in).
* @return string[]|WP_Error Matching group IDs.
*/
public function check_member_groups( $user_oid, $group_ids ) {
public function check_member_groups( $user_oid, $group_ids, $retry = true ) {
if ( ! M365_Login_Settings::is_guid( $user_oid ) ) {
return new WP_Error( 'graph_bad_oid', 'Invalid user object ID.' );
}
@ -462,7 +469,9 @@ class M365_Login_Graph {
$result = $this->request(
'POST',
'/users/' . rawurlencode( strtolower( $user_oid ) ) . '/checkMemberGroups',
array( 'groupIds' => $chunk )
array( 'groupIds' => $chunk ),
array(),
$retry
);
if ( is_wp_error( $result ) ) {
return $result;

View file

@ -402,7 +402,10 @@ class M365_Login_Settings {
if ( defined( 'M365_LOGIN_DISABLE_BUTTON_ONLY' ) && M365_LOGIN_DISABLE_BUTTON_ONLY ) {
return false;
}
return $this->is_configured() && (bool) $this->get( 'button_only' ) && '' !== $this->fallback_key();
// Deliberately not tied to is_configured(): an expired certificate or rotated salts must not
// silently re-enable password sign-in. The fallback link and the constant stay available.
return '' !== (string) $this->get( 'tenant_id' ) && '' !== (string) $this->get( 'client_id' )
&& (bool) $this->get( 'button_only' ) && '' !== $this->fallback_key();
}
/**
@ -497,7 +500,7 @@ class M365_Login_Settings {
$out['client_id'] = strtolower( $client_id );
// Client secret: only replaced when a new value was entered.
$secret_input = isset( $input['client_secret'] ) ? (string) wp_unslash( $input['client_secret'] ) : '';
$secret_input = self::scalar( $input, 'client_secret' );
$secret_input = trim( $secret_input );
if ( ! empty( $input['client_secret_clear'] ) ) {
$out['client_secret'] = '';
@ -520,8 +523,8 @@ class M365_Login_Settings {
// Certificate: keep the stored pair unless a new one is pasted or removal is requested.
$out['cert_private_key'] = $current['cert_private_key'];
$out['cert_certificate'] = $current['cert_certificate'];
$pasted_key = isset( $input['cert_key_pem'] ) ? trim( (string) wp_unslash( $input['cert_key_pem'] ) ) : '';
$pasted_cert = isset( $input['cert_cert_pem'] ) ? trim( (string) wp_unslash( $input['cert_cert_pem'] ) ) : '';
$pasted_key = trim( self::scalar( $input, 'cert_key_pem' ) );
$pasted_cert = trim( self::scalar( $input, 'cert_cert_pem' ) );
if ( ! empty( $input['cert_remove'] ) ) {
$out['cert_private_key'] = '';
$out['cert_certificate'] = '';
@ -573,7 +576,7 @@ class M365_Login_Settings {
$out['fallback_key'] = $key;
// Custom login page (must be on this site).
$custom = isset( $input['custom_login_url'] ) ? esc_url_raw( trim( wp_unslash( $input['custom_login_url'] ) ) ) : '';
$custom = esc_url_raw( trim( self::scalar( $input, 'custom_login_url' ) ) );
if ( '' !== $custom ) {
if ( 0 === strpos( $custom, '/' ) ) {
$custom = home_url( $custom );
@ -590,13 +593,13 @@ class M365_Login_Settings {
$text = isset( $input['button_text'] ) ? sanitize_text_field( wp_unslash( $input['button_text'] ) ) : '';
$out['button_text'] = '' === trim( $text ) ? $defaults['button_text'] : mb_substr( $text, 0, 80 );
$icon = isset( $input['button_icon'] ) ? esc_url_raw( trim( wp_unslash( $input['button_icon'] ) ) ) : '';
$icon = esc_url_raw( trim( self::scalar( $input, 'button_icon' ) ) );
$out['button_icon'] = self::is_safe_image_url( $icon ) ? $icon : '';
$out['button_show_icon'] = empty( $input['button_show_icon'] ) ? 0 : 1;
foreach ( array( 'button_bg', 'button_bg_hover', 'button_color', 'button_border' ) as $color_key ) {
$color = isset( $input[ $color_key ] ) ? sanitize_hex_color( trim( wp_unslash( $input[ $color_key ] ) ) ) : '';
$color = sanitize_hex_color( trim( self::scalar( $input, $color_key ) ) );
$out[ $color_key ] = $color ? $color : $defaults[ $color_key ];
}
@ -692,6 +695,17 @@ class M365_Login_Settings {
return $out;
}
/**
* Unslashed string value of a posted field ('' for missing or non-scalar values such as arrays).
*
* @param array $input Raw input.
* @param string $key Field.
* @return string
*/
private static function scalar( $input, $key ) {
return isset( $input[ $key ] ) && is_scalar( $input[ $key ] ) ? (string) wp_unslash( $input[ $key ] ) : '';
}
/**
* Sanitises a GUID => name list posted by a group picker.
*

View file

@ -15,8 +15,10 @@ defined( 'ABSPATH' ) || exit;
class M365_Login_Sync {
const CRON_HOOK = 'm365_login_sync';
const LOCK = 'm365_login_sync_lock';
const LOCK_TTL = 30 * MINUTE_IN_SECONDS;
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.
@ -55,6 +57,13 @@ class M365_Login_Sync {
*/
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.
*
@ -305,11 +314,11 @@ class M365_Login_Sync {
'log' => array(),
);
if ( get_transient( self::LOCK ) ) {
$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 );
}
set_transient( self::LOCK, time(), self::LOCK_TTL );
if ( function_exists( 'set_time_limit' ) ) {
set_time_limit( 0 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- long-running directory sync.
@ -317,21 +326,51 @@ class M365_Login_Sync {
wp_raise_memory_limit( 'admin' );
require_once ABSPATH . 'wp-admin/includes/user.php';
// No "your e-mail/password changed" mails for changes made by the sync.
add_filter( 'send_email_change_email', '__return_false', 99 );
// 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_email_change_email', '__return_false', 99 );
remove_filter( 'send_password_change_email', '__return_false', 99 );
delete_transient( self::LOCK );
$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.
*
@ -392,7 +431,12 @@ class M365_Login_Sync {
}
// 2. Create, link and update accounts.
$linked = $this->linked_users();
$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.
@ -404,7 +448,7 @@ class M365_Login_Sync {
$result = $this->sync_person( $person, $linked, $memberships );
if ( is_array( $result ) ) {
$pending[] = $result;
$pending = array_merge( $pending, $result );
} elseif ( $result instanceof WP_User ) {
$photo_of[ $oid ] = $result;
}
@ -428,13 +472,22 @@ class M365_Login_Sync {
return 'failed';
}
if ( null !== $action ) {
$pending[] = $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' ) ) );
$limit = (int) apply_filters( 'm365_login_sync_deprovision_limit', max( 5, (int) ceil( count( $linked ) * 0.2 ) ), count( $linked ) );
$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',
@ -519,7 +572,7 @@ class M365_Login_Sync {
* @return array oid => user ID.
*/
private function linked_users() {
$users = get_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',
@ -527,10 +580,16 @@ class M365_Login_Sync {
'number' => -1,
)
);
$out = array();
$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 ) ) {
if ( ! M365_Login_Settings::is_guid( $oid ) ) {
continue;
}
if ( isset( $out[ $oid ] ) ) {
$this->duplicates[ $oid ][] = (int) $row->ID;
} else {
$out[ $oid ] = (int) $row->ID;
}
}
@ -556,6 +615,12 @@ class M365_Login_Sync {
$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).
}
@ -586,6 +651,11 @@ class M365_Login_Sync {
$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' );
@ -601,7 +671,7 @@ class M365_Login_Sync {
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' ) );
return $this->action( (string) $this->settings->get( 'sync_disabled_action' ), $user, 'disabled', __( 'disabled in Microsoft 365', 'm365-login' ), $oid );
}
if ( ! $user ) {
@ -626,7 +696,7 @@ class M365_Login_Sync {
}
$changes = $this->update_profile( $user, $person, $email );
if ( $this->manages_roles( $user ) ) {
if ( $this->manages_roles( $user ) && ! self::disabled_info( $user->ID ) ) {
$changes = array_merge( $changes, $this->update_roles( $user, $this->desired_roles( $oid, $memberships ) ) );
}
@ -741,7 +811,10 @@ class M365_Login_Sync {
if ( strtolower( $user->user_email ) !== $email ) {
$owner = get_user_by( 'email', $email );
if ( $owner && $owner->ID !== $user->ID ) {
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 {
@ -935,7 +1008,7 @@ class M365_Login_Sync {
*
* @param string $oid Object ID.
* @param int $user_id User ID.
* @return array|null|WP_Error Pending action, null for none.
* @return array[]|null|WP_Error Pending actions, null for none.
*/
private function classify_missing( $oid, $user_id ) {
$user = get_userdata( $user_id );
@ -947,15 +1020,15 @@ class M365_Login_Sync {
$person = $this->graph->get_user( $oid, array( 'id', 'accountEnabled', 'userType' ) );
if ( is_wp_error( $person ) ) {
if ( M365_Login_Graph::is_not_found( $person ) ) {
return $this->action( (string) $this->settings->get( 'sync_deleted_action' ), $user, 'deleted', __( 'deleted in Microsoft 365', 'm365-login' ) );
return $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' ) );
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' ) );
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;
}
@ -967,23 +1040,79 @@ class M365_Login_Sync {
* @param WP_User $user User.
* @param string $reason Machine reason.
* @param string $label Human reason.
* @return array|null
* @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 ) {
private function action( $what, $user, $reason, $label, $oid = '' ) {
if ( ! in_array( $what, array( 'disable', 'delete' ), true ) ) {
return null;
}
if ( $this->is_protected( $user ) ) {
/* translators: 1: e-mail address, 2: reason */
$this->skip( sprintf( __( '%1$s: %2$s, but the account is protected (administrator or your own account) and was not changed.', 'm365-login' ), $user->user_email, $label ) );
return null;
$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;
}
}
}
return array(
'what' => $what,
'user' => $user,
'reason' => $reason,
'label' => $label,
);
$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 );
}
/**
@ -1215,7 +1344,7 @@ class M365_Login_Sync {
continue;
}
if ( ! empty( $stored['file'] ) && isset( $stored['etag'] ) && $stored['etag'] === $version && file_exists( self::photo_path( $stored['file'] ) ) ) {
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 );
@ -1256,7 +1385,7 @@ class M365_Login_Sync {
continue;
}
if ( ! empty( $stored['file'] ) && $stored['file'] !== $file ) {
if ( ! empty( $stored['file'] ) && $stored['file'] !== $file && self::is_photo_file( $stored['file'] ) ) {
wp_delete_file( self::photo_path( $stored['file'] ) );
}
update_user_meta(
@ -1388,6 +1517,16 @@ class M365_Login_Sync {
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).
*
@ -1395,7 +1534,7 @@ class M365_Login_Sync {
*/
public function delete_photo( $user_id ) {
$stored = get_user_meta( $user_id, self::META_PHOTO, true );
if ( is_array( $stored ) && ! empty( $stored['file'] ) && 0 === strpos( $stored['file'], self::PHOTO_DIR . '/' ) ) {
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 );
@ -1431,7 +1570,7 @@ class M365_Login_Sync {
}
$stored = get_user_meta( $user_id, self::META_PHOTO, true );
if ( ! is_array( $stored ) || empty( $stored['file'] ) ) {
if ( ! is_array( $stored ) || empty( $stored['file'] ) || ! self::is_photo_file( $stored['file'] ) ) {
return $args;
}
$uploads = wp_get_upload_dir();
@ -1472,6 +1611,9 @@ class M365_Login_Sync {
* @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,
@ -1479,9 +1621,20 @@ class M365_Login_Sync {
'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.
@ -1499,8 +1652,19 @@ class M365_Login_Sync {
* @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.
*