Add Microsoft 365 user sync with roles, profile fields and deprovisioning
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
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
New "User sync" tab that imports Microsoft 365 / Entra ID users as WordPress accounts and keeps them up to date: - Scope: whole tenant or the (nested) members of selected groups, guests optional, e-mail domain allow-list respected. Existing accounts are linked by e-mail address. - Roles: selectable default role plus a group -> role mapping (in addition to or instead of the default role, first match wins). Roles of pre-existing accounts are only managed on request. - Profile: selectable Graph attributes (names, job title, department, phones, address, language, ...) and the profile photo as avatar. - Deprovisioning: accounts disabled or deleted in Microsoft 365 (or removed from the sync groups) are deactivated or deleted; accounts deactivated by the sync are reactivated automatically. Deactivated accounts lose every sign-in path and all sessions. - Safeguards: dry run, safety stop above 20 % (min. 5) deprovisioning, abort on any Graph error, "deleted" only on a 404 for the object ID, protected pre-existing administrators and own account, content reassignment required for deletion, run lock. - Runs manually, via WP-Cron or `wp m365-login sync [--dry-run]`. - Users screen column with deactivate/reactivate row actions and a read-only Microsoft 365 section on the profile screen. The Graph client gains paging, retry on throttling and user, group member and photo endpoints. The group picker is now reusable. Version 1.1.0, German translations (du/Sie), docs and audit addendum. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1708bae91a
commit
4edf20bc45
20 changed files with 5532 additions and 982 deletions
|
|
@ -12,11 +12,12 @@ defined( 'ABSPATH' ) || exit;
|
|||
*/
|
||||
class M365_Login_Admin {
|
||||
|
||||
const PAGE = 'm365-login';
|
||||
const GROUP = 'm365_login';
|
||||
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';
|
||||
|
|
@ -42,6 +43,13 @@ class M365_Login_Admin {
|
|||
*/
|
||||
private $graph;
|
||||
|
||||
/**
|
||||
* User sync.
|
||||
*
|
||||
* @var M365_Login_Sync
|
||||
*/
|
||||
private $sync;
|
||||
|
||||
/**
|
||||
* Screen hook suffix.
|
||||
*
|
||||
|
|
@ -55,11 +63,13 @@ class M365_Login_Admin {
|
|||
* @param M365_Login_Settings $settings Settings.
|
||||
* @param M365_Login_Auth $auth Auth.
|
||||
* @param M365_Login_Graph $graph Graph client.
|
||||
* @param M365_Login_Sync $sync User sync.
|
||||
*/
|
||||
public function __construct( M365_Login_Settings $settings, M365_Login_Auth $auth, M365_Login_Graph $graph ) {
|
||||
public function __construct( M365_Login_Settings $settings, M365_Login_Auth $auth, M365_Login_Graph $graph, M365_Login_Sync $sync ) {
|
||||
$this->settings = $settings;
|
||||
$this->auth = $auth;
|
||||
$this->graph = $graph;
|
||||
$this->sync = $sync;
|
||||
|
||||
add_action( 'admin_menu', array( $this, 'menu' ) );
|
||||
add_action( 'admin_init', array( $this, 'register' ) );
|
||||
|
|
@ -67,6 +77,7 @@ class M365_Login_Admin {
|
|||
add_action( 'wp_ajax_' . self::AJAX_TEST, array( $this, 'ajax_test_connection' ) );
|
||||
add_action( 'wp_ajax_' . self::AJAX_GROUPS, array( $this, 'ajax_search_groups' ) );
|
||||
add_action( 'wp_ajax_' . self::AJAX_CERT, array( $this, 'ajax_certificate' ) );
|
||||
add_action( 'wp_ajax_' . self::AJAX_SYNC, array( $this, 'ajax_sync' ) );
|
||||
add_action( 'admin_post_' . self::POST_CERT, array( $this, 'download_certificate' ) );
|
||||
add_action( 'update_option_' . M365_LOGIN_OPTION, array( $this->graph, 'flush_token' ) );
|
||||
add_action( 'admin_notices', array( $this, 'setup_notice' ) );
|
||||
|
|
@ -108,6 +119,7 @@ class M365_Login_Admin {
|
|||
'connection' => __( 'Connection', 'm365-login' ),
|
||||
'button' => __( 'Button', 'm365-login' ),
|
||||
'security' => __( 'Security', 'm365-login' ),
|
||||
'sync' => __( 'User sync', 'm365-login' ),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -212,23 +224,29 @@ class M365_Login_Admin {
|
|||
'action' => self::AJAX_TEST,
|
||||
'groupAction' => self::AJAX_GROUPS,
|
||||
'certAction' => self::AJAX_CERT,
|
||||
'syncAction' => self::AJAX_SYNC,
|
||||
'defaultLogo' => M365_Login_Button::microsoft_logo(),
|
||||
'i18n' => array(
|
||||
'chooseIcon' => __( 'Choose button icon', 'm365-login' ),
|
||||
'useIcon' => __( 'Use this icon', 'm365-login' ),
|
||||
'copied' => __( 'Copied!', 'm365-login' ),
|
||||
'copy' => __( 'Copy', 'm365-login' ),
|
||||
'testing' => __( 'Testing…', 'm365-login' ),
|
||||
'testFailed' => __( 'The tenant could not be reached. Check the tenant ID and the server’s outgoing connections.', 'm365-login' ),
|
||||
'noGroups' => __( 'No groups found.', 'm365-login' ),
|
||||
'searching' => __( 'Searching…', 'm365-login' ),
|
||||
'add' => __( 'Add', 'm365-login' ),
|
||||
'remove' => __( 'Remove', 'm365-login' ),
|
||||
'saveFirst' => __( 'Save the connection settings first, then search for groups.', 'm365-login' ),
|
||||
'confirmKey' => __( 'Generate a new fallback key on save? The old link stops working.', 'm365-login' ),
|
||||
'generating' => __( 'Generating a 3072-bit key pair, this takes a moment…', 'm365-login' ),
|
||||
'confirmCert' => __( 'Replace the stored certificate? Sign-in stops working until the new certificate is uploaded to Entra ID.', 'm365-login' ),
|
||||
'chooseIcon' => __( 'Choose button icon', 'm365-login' ),
|
||||
'useIcon' => __( 'Use this icon', 'm365-login' ),
|
||||
'copied' => __( 'Copied!', 'm365-login' ),
|
||||
'copy' => __( 'Copy', 'm365-login' ),
|
||||
'testing' => __( 'Testing…', 'm365-login' ),
|
||||
'testFailed' => __( 'The tenant could not be reached. Check the tenant ID and the server’s outgoing connections.', 'm365-login' ),
|
||||
'noGroups' => __( 'No groups found.', 'm365-login' ),
|
||||
'searching' => __( 'Searching…', 'm365-login' ),
|
||||
'add' => __( 'Add', 'm365-login' ),
|
||||
'remove' => __( 'Remove', 'm365-login' ),
|
||||
'saveFirst' => __( 'Save the connection settings first, then search for groups.', 'm365-login' ),
|
||||
'confirmKey' => __( 'Generate a new fallback key on save? The old link stops working.', 'm365-login' ),
|
||||
'generating' => __( 'Generating a 3072-bit key pair, this takes a moment…', 'm365-login' ),
|
||||
'confirmCert' => __( 'Replace the stored certificate? Sign-in stops working until the new certificate is uploaded to Entra ID.', 'm365-login' ),
|
||||
'confirmCertRemove' => __( 'Remove the stored certificate when saving? Sign-in with the certificate method stops working.', 'm365-login' ),
|
||||
'syncRunning' => __( 'Sync is running, this can take a while for large directories…', 'm365-login' ),
|
||||
'confirmSync' => __( 'Run the sync now with the saved settings? Accounts are created, updated and possibly deactivated or deleted. Tip: run a dry run first.', 'm365-login' ),
|
||||
'syncFailed' => __( 'The request failed or timed out. Reload the page in a few minutes to see the report; for very large directories use "wp m365-login sync" (WP-CLI).', 'm365-login' ),
|
||||
'unsaved' => __( 'You have unsaved changes. The sync uses the saved settings – save first.', 'm365-login' ),
|
||||
'moveUp' => __( 'Move up', 'm365-login' ),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
|
@ -332,6 +350,162 @@ 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' ) ) {
|
||||
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'] ) ) : '';
|
||||
$report = $this->sync->run( 'live' !== $op, 'manual' );
|
||||
wp_send_json_success( array( 'html' => $this->report_markup( $report ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Markup of a sync report.
|
||||
*
|
||||
* @param array|null $report Report.
|
||||
* @return string
|
||||
*/
|
||||
private function report_markup( $report ) {
|
||||
if ( ! is_array( $report ) ) {
|
||||
return '<p class="m365-sync-report__empty">' . esc_html__( 'The sync has not run yet.', 'm365-login' ) . '</p>';
|
||||
}
|
||||
|
||||
$statuses = array(
|
||||
'ok' => __( 'Finished', 'm365-login' ),
|
||||
'failed' => __( 'Failed', 'm365-login' ),
|
||||
'aborted' => __( 'Stopped by the safety limit', 'm365-login' ),
|
||||
'locked' => __( 'Not started', 'm365-login' ),
|
||||
);
|
||||
$triggers = array(
|
||||
'manual' => __( 'started manually', 'm365-login' ),
|
||||
'cron' => __( 'scheduled', 'm365-login' ),
|
||||
'cli' => __( 'WP-CLI', 'm365-login' ),
|
||||
);
|
||||
$labels = array(
|
||||
'created' => $report['dry'] ? __( 'would be created', 'm365-login' ) : __( 'created', 'm365-login' ),
|
||||
'updated' => $report['dry'] ? __( 'would be updated', 'm365-login' ) : __( 'updated', 'm365-login' ),
|
||||
'linked' => $report['dry'] ? __( 'would be linked', 'm365-login' ) : __( 'linked', 'm365-login' ),
|
||||
'unchanged' => __( 'unchanged', 'm365-login' ),
|
||||
'disabled' => $report['dry'] ? __( 'would be deactivated', 'm365-login' ) : __( 'deactivated', 'm365-login' ),
|
||||
'enabled' => $report['dry'] ? __( 'would be reactivated', 'm365-login' ) : __( 'reactivated', 'm365-login' ),
|
||||
'deleted' => $report['dry'] ? __( 'would be deleted', 'm365-login' ) : __( 'deleted', 'm365-login' ),
|
||||
'photos' => __( 'photos', 'm365-login' ),
|
||||
'skipped' => __( 'skipped', 'm365-login' ),
|
||||
'errors' => __( 'errors', 'm365-login' ),
|
||||
);
|
||||
|
||||
$status = isset( $statuses[ $report['status'] ] ) ? $statuses[ $report['status'] ] : $report['status'];
|
||||
$class = 'ok' === $report['status'] ? 'is-ok' : 'is-bad';
|
||||
$when = wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), (int) $report['started'] );
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
<div class="m365-sync-report">
|
||||
<p class="m365-sync-report__head">
|
||||
<span class="m365-sync-report__status <?php echo esc_attr( $class ); ?>"><?php echo esc_html( $status ); ?></span>
|
||||
<?php if ( $report['dry'] ) : ?>
|
||||
<span class="m365-sync-report__dry"><?php esc_html_e( 'Dry run – nothing was changed', 'm365-login' ); ?></span>
|
||||
<?php endif; ?>
|
||||
<span class="m365-sync-report__meta">
|
||||
<?php
|
||||
/* translators: 1: date and time, 2: how the run was started, 3: duration in seconds */
|
||||
echo esc_html( sprintf( __( '%1$s, %2$s, %3$d s', 'm365-login' ), $when, isset( $triggers[ $report['trigger'] ] ) ? $triggers[ $report['trigger'] ] : $report['trigger'], max( 0, (int) $report['finished'] - (int) $report['started'] ) ) );
|
||||
?>
|
||||
</span>
|
||||
</p>
|
||||
<ul class="m365-sync-report__counts">
|
||||
<?php foreach ( $labels as $key => $label ) : ?>
|
||||
<?php
|
||||
$count = isset( $report['counts'][ $key ] ) ? (int) $report['counts'][ $key ] : 0;
|
||||
if ( 0 === $count && ! in_array( $key, array( 'created', 'updated', 'disabled' ), true ) ) {
|
||||
continue;
|
||||
}
|
||||
?>
|
||||
<li class="m365-sync-report__count m365-sync-report__count--<?php echo esc_attr( $key ); ?>"><strong><?php echo esc_html( number_format_i18n( $count ) ); ?></strong> <?php echo esc_html( $label ); ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php if ( ! empty( $report['log'] ) ) : ?>
|
||||
<details class="m365-sync-report__details" <?php echo 'ok' !== $report['status'] || $report['dry'] ? 'open' : ''; ?>>
|
||||
<?php /* translators: %d: number of log entries */ ?>
|
||||
<summary><?php echo esc_html( sprintf( _n( 'Log (%d entry)', 'Log (%d entries)', count( $report['log'] ), 'm365-login' ), count( $report['log'] ) ) ); ?></summary>
|
||||
<ul class="m365-sync-log">
|
||||
<?php foreach ( $report['log'] as $entry ) : ?>
|
||||
<li class="m365-sync-log__item is-<?php echo esc_attr( $entry['level'] ); ?>"><?php echo esc_html( $entry['message'] ); ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</details>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an Entra group picker.
|
||||
*
|
||||
* @param string $key Option key the picker writes to.
|
||||
* @param array $selected Selected groups: id => name (chips) or id => array( name, role ) (roles).
|
||||
* @param string $mode 'chips' or 'roles'.
|
||||
* @param string $empty_text Text shown when nothing is selected.
|
||||
*/
|
||||
private function group_picker( $key, $selected, $mode, $empty_text ) {
|
||||
$configured = $this->settings->is_configured();
|
||||
$base = M365_LOGIN_OPTION . '[' . $key . ']';
|
||||
$id = 'm365-picker-' . str_replace( '_', '-', $key );
|
||||
?>
|
||||
<div class="m365-picker" data-field="<?php echo esc_attr( $key ); ?>" data-mode="<?php echo esc_attr( $mode ); ?>">
|
||||
<div class="m365-field">
|
||||
<label for="<?php echo esc_attr( $id ); ?>"><?php esc_html_e( 'Search groups', 'm365-login' ); ?></label>
|
||||
<div class="m365-field__row">
|
||||
<input type="search" id="<?php echo esc_attr( $id ); ?>" class="regular-text m365-picker__query" placeholder="<?php esc_attr_e( 'Type a group name or paste an object ID…', 'm365-login' ); ?>" autocomplete="off" <?php disabled( ! $configured ); ?> />
|
||||
<button type="button" class="button m365-picker__search" <?php disabled( ! $configured ); ?>><?php esc_html_e( 'Search', 'm365-login' ); ?></button>
|
||||
</div>
|
||||
<?php if ( ! $configured ) : ?>
|
||||
<p class="description"><?php esc_html_e( 'Save the connection settings first, then search for groups.', 'm365-login' ); ?></p>
|
||||
<?php else : ?>
|
||||
<p class="description"><?php esc_html_e( 'Needs the application permission "GroupMember.Read.All" with admin consent. Without it you can still paste group object IDs.', 'm365-login' ); ?></p>
|
||||
<?php endif; ?>
|
||||
<div class="m365-group-results m365-picker__results" hidden></div>
|
||||
</div>
|
||||
|
||||
<div class="m365-field">
|
||||
<span class="m365-field__label"><?php esc_html_e( 'Selected groups', 'm365-login' ); ?></span>
|
||||
<ul class="m365-group-list m365-picker__list<?php echo 'roles' === $mode ? ' m365-group-list--roles' : ''; ?>" data-empty="<?php echo esc_attr( $empty_text ); ?>">
|
||||
<?php foreach ( $selected as $gid => $value ) : ?>
|
||||
<?php $gname = 'roles' === $mode ? $value['name'] : $value; ?>
|
||||
<li class="m365-group-chip<?php echo 'roles' === $mode ? ' m365-group-chip--role' : ''; ?>" data-id="<?php echo esc_attr( $gid ); ?>">
|
||||
<?php if ( 'roles' === $mode ) : ?>
|
||||
<button type="button" class="m365-group-chip__up" aria-label="<?php esc_attr_e( 'Move up', 'm365-login' ); ?>">↑</button>
|
||||
<?php endif; ?>
|
||||
<span class="m365-group-chip__name"><?php echo esc_html( $gname ); ?></span>
|
||||
<code class="m365-group-chip__id"><?php echo esc_html( $gid ); ?></code>
|
||||
<?php if ( 'roles' === $mode ) : ?>
|
||||
<span class="m365-group-chip__arrow" aria-hidden="true">→</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'] ); ?>
|
||||
</select>
|
||||
<input type="hidden" name="<?php echo esc_attr( $base . '[' . $gid . '][name]' ); ?>" value="<?php echo esc_attr( $gname ); ?>" />
|
||||
<?php else : ?>
|
||||
<input type="hidden" name="<?php echo esc_attr( $base . '[' . $gid . ']' ); ?>" value="<?php echo esc_attr( $gname ); ?>" />
|
||||
<?php endif; ?>
|
||||
<button type="button" class="m365-group-chip__remove" aria-label="<?php esc_attr_e( 'Remove', 'm365-login' ); ?>">×</button>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php if ( 'roles' === $mode ) : ?>
|
||||
<select class="m365-picker__role-template" hidden disabled>
|
||||
<?php wp_dropdown_roles( 'editor' ); ?>
|
||||
</select>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the public certificate as a .cer download (never the private key).
|
||||
*/
|
||||
|
|
@ -356,6 +530,175 @@ class M365_Login_Admin {
|
|||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the "User sync" tab.
|
||||
*
|
||||
* @param array $s Settings.
|
||||
*/
|
||||
private function render_sync_panel( $s ) {
|
||||
$field = function ( $key ) {
|
||||
return esc_attr( M365_LOGIN_OPTION . '[' . $key . ']' );
|
||||
};
|
||||
$configured = $this->settings->is_configured();
|
||||
$next = wp_next_scheduled( M365_Login_Sync::CRON_HOOK );
|
||||
$actions = array(
|
||||
'none' => __( 'Do nothing', 'm365-login' ),
|
||||
'disable' => __( 'Deactivate the WordPress account', 'm365-login' ),
|
||||
'delete' => __( 'Delete the WordPress account', 'm365-login' ),
|
||||
);
|
||||
$selects = array(
|
||||
'sync_disabled_action' => __( 'Account disabled in Microsoft 365 (sign-in blocked)', 'm365-login' ),
|
||||
'sync_deleted_action' => __( 'Account deleted in Microsoft 365', 'm365-login' ),
|
||||
'sync_scope_action' => __( 'No longer a member of the sync groups', 'm365-login' ),
|
||||
);
|
||||
?>
|
||||
<section class="m365-admin__panel" data-panel="sync">
|
||||
<div class="m365-card">
|
||||
<h2 class="m365-card__title"><?php esc_html_e( 'Import users from Microsoft 365', 'm365-login' ); ?></h2>
|
||||
<p class="m365-card__intro"><?php esc_html_e( 'Creates a WordPress account for every Microsoft 365 user in scope, links existing accounts by e-mail address, keeps roles and profile fields up to date and deactivates or deletes accounts that were disabled or removed in Microsoft 365. New accounts get a random password and no e-mail; people sign in with the Microsoft button.', 'm365-login' ); ?></p>
|
||||
|
||||
<label class="m365-check m365-check--block">
|
||||
<input type="checkbox" name="<?php echo $field( 'sync_enabled' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['sync_enabled'] ); ?> />
|
||||
<span>
|
||||
<strong><?php esc_html_e( 'Run the sync automatically', 'm365-login' ); ?></strong>
|
||||
<em><?php esc_html_e( 'Uses WP-Cron, which runs when the site receives visits. For exact timing, trigger wp-cron.php from a real cron job or run "wp m365-login sync".', 'm365-login' ); ?></em>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="m365-grid">
|
||||
<div class="m365-field">
|
||||
<label for="m365-sync-interval"><?php esc_html_e( 'Interval', 'm365-login' ); ?></label>
|
||||
<select id="m365-sync-interval" name="<?php echo $field( 'sync_interval' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>">
|
||||
<option value="hourly" <?php selected( $s['sync_interval'], 'hourly' ); ?>><?php esc_html_e( 'Hourly', 'm365-login' ); ?></option>
|
||||
<option value="twicedaily" <?php selected( $s['sync_interval'], 'twicedaily' ); ?>><?php esc_html_e( 'Twice daily', 'm365-login' ); ?></option>
|
||||
<option value="daily" <?php selected( $s['sync_interval'], 'daily' ); ?>><?php esc_html_e( 'Daily', 'm365-login' ); ?></option>
|
||||
</select>
|
||||
<?php if ( $s['sync_enabled'] && $next ) : ?>
|
||||
<?php /* translators: %s: date and time */ ?>
|
||||
<p class="description"><?php echo esc_html( sprintf( __( 'Next run: %s', 'm365-login' ), wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $next ) ) ); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="m365-check m365-check--block">
|
||||
<input type="checkbox" name="<?php echo $field( 'sync_guests' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['sync_guests'] ); ?> />
|
||||
<span>
|
||||
<strong><?php esc_html_e( 'Also import guest users (B2B)', 'm365-login' ); ?></strong>
|
||||
<em><?php esc_html_e( 'Guests are external people invited into your tenant. Off by default.', 'm365-login' ); ?></em>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<h3 class="m365-card__subtitle"><?php esc_html_e( 'Which users? (optional)', 'm365-login' ); ?></h3>
|
||||
<p class="description"><?php esc_html_e( 'Limit the import to members of these groups (nested memberships count). Without groups, every user of the tenant is imported. The e-mail domain allow-list on the Security tab applies as well.', 'm365-login' ); ?></p>
|
||||
<?php $this->group_picker( 'sync_scope_groups', $this->settings->sync_scope_groups(), 'chips', __( 'No groups selected – all users of the tenant are imported.', 'm365-login' ) ); ?>
|
||||
</div>
|
||||
|
||||
<div class="m365-card">
|
||||
<h2 class="m365-card__title"><?php esc_html_e( 'Roles', 'm365-login' ); ?></h2>
|
||||
|
||||
<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'] ); ?>
|
||||
</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>
|
||||
<?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">
|
||||
<span class="m365-field__label"><?php esc_html_e( 'How are mapped roles applied?', 'm365-login' ); ?></span>
|
||||
<label class="m365-check">
|
||||
<input type="radio" name="<?php echo $field( 'sync_role_mode' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="add" <?php checked( $s['sync_role_mode'], 'add' ); ?> />
|
||||
<?php esc_html_e( 'In addition to the default role (a user can have several roles)', 'm365-login' ); ?>
|
||||
</label>
|
||||
<label class="m365-check">
|
||||
<input type="radio" name="<?php echo $field( 'sync_role_mode' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="replace" <?php checked( $s['sync_role_mode'], 'replace' ); ?> />
|
||||
<?php esc_html_e( 'Instead of the default role – the first matching group in the list wins (use ↑ to reorder)', 'm365-login' ); ?>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="m365-check m365-check--block">
|
||||
<input type="checkbox" name="<?php echo $field( 'sync_manage_existing' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['sync_manage_existing'] ); ?> />
|
||||
<span>
|
||||
<strong><?php esc_html_e( 'Also manage the roles of accounts that existed before the sync', 'm365-login' ); ?></strong>
|
||||
<em><?php esc_html_e( 'Off: existing accounts are only linked and get their profile fields updated; their roles stay as they are. Administrators that existed before the sync and your own account are never changed.', 'm365-login' ); ?></em>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="m365-card">
|
||||
<h2 class="m365-card__title"><?php esc_html_e( 'Profile fields', 'm365-login' ); ?></h2>
|
||||
<p class="m365-card__intro"><?php esc_html_e( 'Selected Microsoft 365 attributes are copied into the WordPress profile on every sync (Microsoft 365 wins). Name fields go into the standard profile fields, everything else into user meta keys starting with "m365_" – usable by themes and other plugins – and is shown on the profile screen.', 'm365-login' ); ?></p>
|
||||
<div class="m365-attributes">
|
||||
<?php foreach ( M365_Login_Sync::attributes() as $key => $attribute ) : ?>
|
||||
<label class="m365-check">
|
||||
<input type="checkbox" name="<?php echo $field( 'sync_attributes' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>[]" value="<?php echo esc_attr( $key ); ?>" <?php checked( in_array( $key, (array) $s['sync_attributes'], true ) ); ?> />
|
||||
<span><?php echo esc_html( $attribute['label'] ); ?> <code><?php echo esc_html( $key ); ?></code></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<p class="description"><?php esc_html_e( 'Profile photos are stored in wp-content/uploads/m365-login-avatars/ and replace the Gravatar. They are checked about once a day per user.', 'm365-login' ); ?></p>
|
||||
</div>
|
||||
|
||||
<div class="m365-card">
|
||||
<h2 class="m365-card__title"><?php esc_html_e( 'Disabled and deleted Microsoft 365 accounts', 'm365-login' ); ?></h2>
|
||||
<p class="m365-card__intro"><?php esc_html_e( 'Applies to WordPress accounts linked to a Microsoft account (imported, or signed in with Microsoft at least once). Deactivated accounts cannot sign in at all – not with Microsoft, a password or an application password – and are signed out immediately. When the person is active in Microsoft 365 again, the sync reactivates the account.', 'm365-login' ); ?></p>
|
||||
|
||||
<?php foreach ( $selects as $key => $label ) : ?>
|
||||
<div class="m365-field">
|
||||
<label for="m365-<?php echo esc_attr( $key ); ?>"><?php echo esc_html( $label ); ?></label>
|
||||
<select id="m365-<?php echo esc_attr( $key ); ?>" name="<?php echo $field( $key ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>">
|
||||
<?php foreach ( $actions as $value => $text ) : ?>
|
||||
<option value="<?php echo esc_attr( $value ); ?>" <?php selected( $s[ $key ], $value ); ?>><?php echo esc_html( $text ); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php if ( 'sync_scope_action' === $key ) : ?>
|
||||
<p class="description"><?php esc_html_e( 'Only relevant when the import is limited to groups.', 'm365-login' ); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<div class="m365-field">
|
||||
<label for="m365-sync-reassign"><?php esc_html_e( 'Posts of deleted accounts go to', 'm365-login' ); ?></label>
|
||||
<?php
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- core function, escapes its output.
|
||||
echo wp_dropdown_users(
|
||||
array(
|
||||
'name' => M365_LOGIN_OPTION . '[sync_reassign]',
|
||||
'id' => 'm365-sync-reassign',
|
||||
'selected' => (int) $s['sync_reassign'],
|
||||
'show_option_none' => __( '— Select a user —', 'm365-login' ),
|
||||
'option_none_value' => 0,
|
||||
'capability' => array( 'edit_posts' ),
|
||||
'echo' => false,
|
||||
)
|
||||
);
|
||||
?>
|
||||
<p class="description"><?php esc_html_e( 'Required for "Delete". Without a user, accounts are deactivated instead, so no content is ever lost.', 'm365-login' ); ?></p>
|
||||
</div>
|
||||
|
||||
<p class="m365-warning"><?php esc_html_e( 'Safety stop: if a run would deactivate or delete more than 20 % of the linked accounts (at least 5), nothing is deactivated or deleted and the run is reported as stopped. A failed Microsoft Graph request also stops the run before anything is deactivated.', 'm365-login' ); ?></p>
|
||||
</div>
|
||||
|
||||
<div class="m365-card m365-card--accent">
|
||||
<h2 class="m365-card__title"><?php esc_html_e( 'Run the sync', 'm365-login' ); ?></h2>
|
||||
<p><?php esc_html_e( 'The run uses the saved settings. Start with a dry run: it reads Microsoft 365 and lists what would change, without changing anything.', 'm365-login' ); ?></p>
|
||||
<div class="m365-field__row">
|
||||
<button type="button" class="button button-primary m365-sync-run" data-op="dry" <?php disabled( ! $configured ); ?>><?php esc_html_e( 'Dry run', 'm365-login' ); ?></button>
|
||||
<button type="button" class="button m365-sync-run" data-op="live" <?php disabled( ! $configured ); ?>><?php esc_html_e( 'Sync now', 'm365-login' ); ?></button>
|
||||
</div>
|
||||
<p class="description"><?php esc_html_e( 'Required application permissions (Microsoft Graph, admin consent): User.Read.All, and GroupMember.Read.All when groups are used.', 'm365-login' ); ?></p>
|
||||
<div id="m365-sync-report" class="m365-sync-report-wrap">
|
||||
<?php echo $this->report_markup( M365_Login_Sync::last_report() ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in report_markup(). ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the settings screen.
|
||||
*/
|
||||
|
|
@ -695,7 +1038,7 @@ class M365_Login_Admin {
|
|||
<section class="m365-admin__panel" data-panel="security">
|
||||
<div class="m365-card">
|
||||
<h2 class="m365-card__title"><?php esc_html_e( 'User matching & hardening', 'm365-login' ); ?></h2>
|
||||
<p class="m365-card__intro"><?php esc_html_e( 'Users are never created automatically. A Microsoft sign-in only succeeds when a WordPress user with the same e-mail address already exists.', 'm365-login' ); ?></p>
|
||||
<p class="m365-card__intro"><?php esc_html_e( 'Sign-in never creates users. A Microsoft sign-in only succeeds when a WordPress user with the same e-mail address already exists – created by hand or imported by the user sync.', 'm365-login' ); ?></p>
|
||||
|
||||
<label class="m365-check m365-check--block">
|
||||
<input type="checkbox" name="<?php echo $field( 'bind_oid' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['bind_oid'] ); ?> />
|
||||
|
|
@ -732,32 +1075,8 @@ class M365_Login_Admin {
|
|||
<h2 class="m365-card__title"><?php esc_html_e( 'Allowed Entra groups (optional)', 'm365-login' ); ?></h2>
|
||||
<p class="m365-card__intro"><?php esc_html_e( 'Only members of at least one of these groups may sign in. Leave empty to allow every matched user. Nested memberships count.', 'm365-login' ); ?></p>
|
||||
|
||||
<?php $this->group_picker( 'allowed_groups', $this->settings->allowed_groups(), 'chips', __( 'No groups selected – every matched user may sign in.', 'm365-login' ) ); ?>
|
||||
<div class="m365-field">
|
||||
<label for="m365-group-search"><?php esc_html_e( 'Search groups', 'm365-login' ); ?></label>
|
||||
<div class="m365-field__row">
|
||||
<input type="search" id="m365-group-search" class="regular-text" placeholder="<?php esc_attr_e( 'Type a group name or paste an object ID…', 'm365-login' ); ?>" autocomplete="off" <?php disabled( ! $configured ); ?> />
|
||||
<button type="button" class="button" id="m365-group-search-btn" <?php disabled( ! $configured ); ?>><?php esc_html_e( 'Search', 'm365-login' ); ?></button>
|
||||
</div>
|
||||
<?php if ( ! $configured ) : ?>
|
||||
<p class="description"><?php esc_html_e( 'Save the connection settings first, then search for groups.', 'm365-login' ); ?></p>
|
||||
<?php else : ?>
|
||||
<p class="description"><?php esc_html_e( 'Needs the application permission "GroupMember.Read.All" with admin consent. Without it you can still paste group object IDs.', 'm365-login' ); ?></p>
|
||||
<?php endif; ?>
|
||||
<div id="m365-group-results" class="m365-group-results" hidden></div>
|
||||
</div>
|
||||
|
||||
<div class="m365-field">
|
||||
<span class="m365-field__label"><?php esc_html_e( 'Selected groups', 'm365-login' ); ?></span>
|
||||
<ul id="m365-group-list" class="m365-group-list" data-empty="<?php esc_attr_e( 'No groups selected – every matched user may sign in.', 'm365-login' ); ?>">
|
||||
<?php foreach ( $this->settings->allowed_groups() as $gid => $gname ) : ?>
|
||||
<li class="m365-group-chip" data-id="<?php echo esc_attr( $gid ); ?>">
|
||||
<span class="m365-group-chip__name"><?php echo esc_html( $gname ); ?></span>
|
||||
<code class="m365-group-chip__id"><?php echo esc_html( $gid ); ?></code>
|
||||
<input type="hidden" name="<?php echo esc_attr( $option . '[allowed_groups][' . $gid . ']' ); ?>" value="<?php echo esc_attr( $gname ); ?>" />
|
||||
<button type="button" class="m365-group-chip__remove" aria-label="<?php esc_attr_e( 'Remove', 'm365-login' ); ?>">×</button>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<p class="description"><?php esc_html_e( 'Membership is read from the "groups" claim of the ID token when present; otherwise the plugin asks Microsoft Graph (application permission "User.Read.All" or "Directory.Read.All"). If neither works, the sign-in is refused.', 'm365-login' ); ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -807,11 +1126,13 @@ class M365_Login_Admin {
|
|||
<li><?php esc_html_e( 'OpenID Connect authorization code flow with PKCE (S256) – no tokens ever pass through the browser.', 'm365-login' ); ?></li>
|
||||
<li><?php esc_html_e( 'Single-use state and nonce values bound to the browser via an HttpOnly cookie (CSRF and replay protection).', 'm365-login' ); ?></li>
|
||||
<li><?php esc_html_e( 'ID token signature verified against Microsoft’s published signing keys; issuer, audience, tenant, expiry and nonce are checked.', 'm365-login' ); ?></li>
|
||||
<li><?php esc_html_e( 'Client secret encrypted at rest; no accounts are created, no passwords are changed.', 'm365-login' ); ?></li>
|
||||
<li><?php esc_html_e( 'Client secret encrypted at rest; sign-in never creates accounts or changes passwords.', 'm365-login' ); ?></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php $this->render_sync_panel( $s ); ?>
|
||||
|
||||
<div class="m365-admin__actions">
|
||||
<?php submit_button( __( 'Save changes', 'm365-login' ), 'primary large', 'submit', false ); ?>
|
||||
</div>
|
||||
|
|
@ -846,7 +1167,7 @@ class M365_Login_Admin {
|
|||
<li><?php esc_html_e( 'Optional: restrict who may use the app under Enterprise applications → your app → Properties → "Assignment required" = Yes, then assign users/groups.', 'm365-login' ); ?></li>
|
||||
</ol>
|
||||
<p class="description"><?php esc_html_e( 'Required API permission: openid, profile, email (delegated) – granted by default.', 'm365-login' ); ?></p>
|
||||
<p class="description"><?php esc_html_e( 'Optional, for group restrictions: application permissions GroupMember.Read.All and User.Read.All (Microsoft Graph) with admin consent.', 'm365-login' ); ?></p>
|
||||
<p class="description"><?php esc_html_e( 'Optional, for group restrictions and the user sync: application permissions GroupMember.Read.All and User.Read.All (Microsoft Graph) with admin consent.', 'm365-login' ); ?></p>
|
||||
</div>
|
||||
|
||||
<div class="m365-card m365-card--muted">
|
||||
|
|
|
|||
|
|
@ -441,6 +441,10 @@ class M365_Login_Auth {
|
|||
$this->fail( 'no_user' );
|
||||
}
|
||||
|
||||
if ( M365_Login_Sync::disabled_info( $user->ID ) ) {
|
||||
$this->fail( 'account_disabled' );
|
||||
}
|
||||
|
||||
$oid = isset( $claims['oid'] ) && is_string( $claims['oid'] ) ? strtolower( $claims['oid'] ) : '';
|
||||
|
||||
// Entra group restriction.
|
||||
|
|
@ -857,6 +861,7 @@ class M365_Login_Auth {
|
|||
'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' ),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Obtains app-only tokens via client credentials and queries groups.
|
||||
* Obtains app-only tokens via client credentials and queries users and groups.
|
||||
*/
|
||||
class M365_Login_Graph {
|
||||
|
||||
|
|
@ -98,6 +98,63 @@ class M365_Login_Graph {
|
|||
return (string) $body['access_token'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs an authenticated Graph request and returns the raw HTTP response.
|
||||
*
|
||||
* Retries a few times when Microsoft throttles (HTTP 429) or is briefly unavailable (503/504).
|
||||
*
|
||||
* @param string $method HTTP method.
|
||||
* @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.
|
||||
* @return array|WP_Error Response array from wp_remote_request().
|
||||
*/
|
||||
private function raw_request( $method, $path, $json = null, $headers = array() ) {
|
||||
$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.' );
|
||||
}
|
||||
|
||||
for ( $attempt = 1; ; $attempt++ ) {
|
||||
$token = $this->app_token();
|
||||
if ( is_wp_error( $token ) ) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
$args = array(
|
||||
'method' => $method,
|
||||
'timeout' => self::HTTP_TIMEOUT,
|
||||
'headers' => array_merge(
|
||||
array(
|
||||
'Authorization' => 'Bearer ' . $token,
|
||||
'Accept' => 'application/json',
|
||||
),
|
||||
$headers
|
||||
),
|
||||
);
|
||||
if ( null !== $json ) {
|
||||
$args['headers']['Content-Type'] = 'application/json';
|
||||
$args['body'] = wp_json_encode( $json );
|
||||
}
|
||||
|
||||
$response = wp_remote_request( $url, $args );
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$code = (int) wp_remote_retrieve_response_code( $response );
|
||||
if ( 401 === $code ) {
|
||||
$this->flush_token();
|
||||
}
|
||||
if ( $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;
|
||||
}
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs an authenticated Graph request.
|
||||
*
|
||||
|
|
@ -105,31 +162,10 @@ 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.
|
||||
* @return array|WP_Error Decoded JSON.
|
||||
* @return array|WP_Error Decoded JSON. Errors carry array( 'status' => HTTP code ) as data.
|
||||
*/
|
||||
private function request( $method, $path, $json = null, $headers = array() ) {
|
||||
$token = $this->app_token();
|
||||
if ( is_wp_error( $token ) ) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
$args = array(
|
||||
'method' => $method,
|
||||
'timeout' => self::HTTP_TIMEOUT,
|
||||
'headers' => array_merge(
|
||||
array(
|
||||
'Authorization' => 'Bearer ' . $token,
|
||||
'Accept' => 'application/json',
|
||||
),
|
||||
$headers
|
||||
),
|
||||
);
|
||||
if ( null !== $json ) {
|
||||
$args['headers']['Content-Type'] = 'application/json';
|
||||
$args['body'] = wp_json_encode( $json );
|
||||
}
|
||||
|
||||
$response = wp_remote_request( self::GRAPH_BASE . $path, $args );
|
||||
$response = $this->raw_request( $method, $path, $json, $headers );
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
|
@ -137,18 +173,156 @@ class M365_Login_Graph {
|
|||
$code = (int) wp_remote_retrieve_response_code( $response );
|
||||
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
|
||||
if ( 401 === $code ) {
|
||||
$this->flush_token();
|
||||
}
|
||||
if ( $code < 200 || $code >= 300 || ! is_array( $body ) ) {
|
||||
$graph_code = isset( $body['error']['code'] ) ? (string) $body['error']['code'] : 'HTTP ' . $code;
|
||||
$message = isset( $body['error']['message'] ) ? (string) $body['error']['message'] : '';
|
||||
return new WP_Error( 'graph_' . sanitize_key( $graph_code ), $graph_code . ( $message ? ': ' . $message : '' ) );
|
||||
return $this->error_from( $code, $body );
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a WP_Error from a failed Graph response.
|
||||
*
|
||||
* @param int $code HTTP status.
|
||||
* @param array|null $body Decoded body.
|
||||
* @return WP_Error
|
||||
*/
|
||||
private function error_from( $code, $body ) {
|
||||
$graph_code = isset( $body['error']['code'] ) ? (string) $body['error']['code'] : 'HTTP ' . $code;
|
||||
$message = isset( $body['error']['message'] ) ? (string) $body['error']['message'] : '';
|
||||
return new WP_Error( 'graph_' . sanitize_key( $graph_code ), $graph_code . ( $message ? ': ' . $message : '' ), array( 'status' => (int) $code ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a Graph error means "object does not exist".
|
||||
*
|
||||
* @param WP_Error $error Error.
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_not_found( $error ) {
|
||||
$data = $error->get_error_data();
|
||||
return is_array( $data ) && isset( $data['status'] ) && 404 === (int) $data['status'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows @odata.nextLink until every page of a collection is read.
|
||||
*
|
||||
* @param string $path First page (relative path).
|
||||
* @param array $headers Extra headers.
|
||||
* @return array[]|WP_Error All items.
|
||||
*/
|
||||
private function collect( $path, $headers = array() ) {
|
||||
$items = array();
|
||||
$next = $path;
|
||||
$pages = 0;
|
||||
while ( '' !== $next ) {
|
||||
if ( ++$pages > 1000 ) {
|
||||
return new WP_Error( 'graph_paging', 'Too many result pages.' );
|
||||
}
|
||||
$result = $this->request( 'GET', $next, null, $headers );
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
if ( isset( $result['value'] ) && is_array( $result['value'] ) ) {
|
||||
foreach ( $result['value'] as $item ) {
|
||||
if ( is_array( $item ) && ! empty( $item['id'] ) ) {
|
||||
$items[] = $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
$next = isset( $result['@odata.nextLink'] ) && is_string( $result['@odata.nextLink'] ) ? $result['@odata.nextLink'] : '';
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists every user of the tenant.
|
||||
*
|
||||
* @param string[] $select Properties to read.
|
||||
* @return array[]|WP_Error
|
||||
*/
|
||||
public function list_users( $select ) {
|
||||
return $this->collect( '/users?$select=' . rawurlencode( implode( ',', $select ) ) . '&$top=999' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the users that are (directly or through nested groups) members of a group.
|
||||
*
|
||||
* @param string $group_id Group object ID.
|
||||
* @param string[] $select Properties to read.
|
||||
* @return array[]|WP_Error
|
||||
*/
|
||||
public function list_group_users( $group_id, $select ) {
|
||||
if ( ! M365_Login_Settings::is_guid( $group_id ) ) {
|
||||
return new WP_Error( 'graph_bad_group', 'Invalid group object ID.' );
|
||||
}
|
||||
return $this->collect(
|
||||
'/groups/' . rawurlencode( strtolower( $group_id ) ) . '/transitiveMembers/microsoft.graph.user?$select=' . rawurlencode( implode( ',', $select ) ) . '&$top=999&$count=true',
|
||||
array( 'ConsistencyLevel' => 'eventual' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a single user.
|
||||
*
|
||||
* @param string $oid User object ID.
|
||||
* @param string[] $select Properties to read.
|
||||
* @return array|WP_Error WP_Error with status 404 when the user does not exist (anymore).
|
||||
*/
|
||||
public function get_user( $oid, $select ) {
|
||||
if ( ! M365_Login_Settings::is_guid( $oid ) ) {
|
||||
return new WP_Error( 'graph_bad_oid', 'Invalid user object ID.' );
|
||||
}
|
||||
return $this->request( 'GET', '/users/' . rawurlencode( strtolower( $oid ) ) . '?$select=' . rawurlencode( implode( ',', $select ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata of a user's profile photo (prefers the 240×240 rendition).
|
||||
*
|
||||
* @param string $oid User object ID.
|
||||
* @return array|null|WP_Error array( 'path' => photo path, 'etag' => string ), null when the user has no photo.
|
||||
*/
|
||||
public function photo_info( $oid ) {
|
||||
if ( ! M365_Login_Settings::is_guid( $oid ) ) {
|
||||
return new WP_Error( 'graph_bad_oid', 'Invalid user object ID.' );
|
||||
}
|
||||
$base = '/users/' . rawurlencode( strtolower( $oid ) );
|
||||
foreach ( array( $base . '/photos/240x240', $base . '/photo' ) as $path ) {
|
||||
$meta = $this->request( 'GET', $path );
|
||||
if ( is_wp_error( $meta ) ) {
|
||||
if ( self::is_not_found( $meta ) ) {
|
||||
continue;
|
||||
}
|
||||
return $meta;
|
||||
}
|
||||
$etag = isset( $meta['@odata.mediaEtag'] ) ? (string) $meta['@odata.mediaEtag'] : '';
|
||||
return array(
|
||||
'path' => $path,
|
||||
'etag' => '' !== $etag ? $etag : md5( (string) wp_json_encode( $meta ) ),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads photo bytes.
|
||||
*
|
||||
* @param string $path Photo path returned by photo_info().
|
||||
* @return string|WP_Error Binary image data.
|
||||
*/
|
||||
public function photo_bytes( $path ) {
|
||||
$response = $this->raw_request( 'GET', $path . '/$value', null, array( 'Accept' => 'image/*' ) );
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
$code = (int) wp_remote_retrieve_response_code( $response );
|
||||
$body = wp_remote_retrieve_body( $response );
|
||||
if ( 200 !== $code ) {
|
||||
return $this->error_from( $code, json_decode( $body, true ) );
|
||||
}
|
||||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches groups by display name.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -34,36 +34,50 @@ class M365_Login_Settings {
|
|||
public function defaults() {
|
||||
return array(
|
||||
// Connection.
|
||||
'tenant_id' => '',
|
||||
'client_id' => '',
|
||||
'client_secret' => '', // Stored encrypted.
|
||||
'auth_method' => 'secret', // 'secret' or 'certificate'.
|
||||
'cert_private_key' => '', // PEM, stored encrypted.
|
||||
'cert_certificate' => '', // PEM (public).
|
||||
'prompt' => 'select_account',
|
||||
'tenant_id' => '',
|
||||
'client_id' => '',
|
||||
'client_secret' => '', // Stored encrypted.
|
||||
'auth_method' => 'secret', // 'secret' or 'certificate'.
|
||||
'cert_private_key' => '', // PEM, stored encrypted.
|
||||
'cert_certificate' => '', // PEM (public).
|
||||
'prompt' => 'select_account',
|
||||
// Security / matching.
|
||||
'upn_fallback' => 1,
|
||||
'bind_oid' => 1,
|
||||
'allowed_domains' => '',
|
||||
'allowed_groups' => array(), // id => display name.
|
||||
'remember_me' => 0,
|
||||
'upn_fallback' => 1,
|
||||
'bind_oid' => 1,
|
||||
'allowed_domains' => '',
|
||||
'allowed_groups' => array(), // id => display name.
|
||||
'remember_me' => 0,
|
||||
// Button-only mode.
|
||||
'button_only' => 0,
|
||||
'fallback_key' => '',
|
||||
'button_only' => 0,
|
||||
'fallback_key' => '',
|
||||
// Button appearance.
|
||||
'button_text' => __( 'Sign in with Microsoft', 'm365-login' ),
|
||||
'button_icon' => '', // Empty = bundled Microsoft logo.
|
||||
'button_show_icon' => 1,
|
||||
'button_bg' => '#2f2f2f',
|
||||
'button_bg_hover' => '#1a1a1a',
|
||||
'button_color' => '#ffffff',
|
||||
'button_border' => '#2f2f2f',
|
||||
'button_radius' => 4,
|
||||
'button_position' => 'below',
|
||||
'divider_text' => __( 'or', 'm365-login' ),
|
||||
'button_text' => __( 'Sign in with Microsoft', 'm365-login' ),
|
||||
'button_icon' => '', // Empty = bundled Microsoft logo.
|
||||
'button_show_icon' => 1,
|
||||
'button_bg' => '#2f2f2f',
|
||||
'button_bg_hover' => '#1a1a1a',
|
||||
'button_color' => '#ffffff',
|
||||
'button_border' => '#2f2f2f',
|
||||
'button_radius' => 4,
|
||||
'button_position' => 'below',
|
||||
'divider_text' => __( 'or', 'm365-login' ),
|
||||
// Custom login pages.
|
||||
'custom_login_url' => '',
|
||||
'inject_form' => 1, // Add the button to wp_login_form() output.
|
||||
'custom_login_url' => '',
|
||||
'inject_form' => 1, // Add the button to wp_login_form() output.
|
||||
// User sync.
|
||||
'sync_enabled' => 0, // Scheduled sync via WP-Cron.
|
||||
'sync_interval' => 'daily',
|
||||
'sync_guests' => 0,
|
||||
'sync_scope_groups' => array(), // id => display name; empty = whole tenant.
|
||||
'sync_default_role' => 'subscriber',
|
||||
'sync_role_map' => array(), // id => array( 'name' => .., 'role' => .. ), in priority order.
|
||||
'sync_role_mode' => 'add', // 'add' (extra roles) or 'replace' (first match replaces the default role).
|
||||
'sync_manage_existing' => 0, // Also manage roles of accounts that existed before the sync.
|
||||
'sync_attributes' => array( 'displayName', 'givenName', 'surname' ),
|
||||
'sync_disabled_action' => 'disable', // Account disabled in Microsoft 365: none|disable|delete.
|
||||
'sync_deleted_action' => 'disable', // Account deleted in Microsoft 365: none|disable|delete.
|
||||
'sync_scope_action' => 'none', // Removed from the sync groups: none|disable|delete.
|
||||
'sync_reassign' => 0, // User ID that receives content of deleted users.
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +94,13 @@ class M365_Login_Settings {
|
|||
return $this->cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the cached settings (after the option was written).
|
||||
*/
|
||||
public function flush() {
|
||||
$this->cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a single setting.
|
||||
*
|
||||
|
|
@ -309,7 +330,47 @@ class M365_Login_Settings {
|
|||
* @return array
|
||||
*/
|
||||
public function allowed_groups() {
|
||||
$raw = $this->get( 'allowed_groups', array() );
|
||||
return self::guid_map( $this->get( 'allowed_groups', array() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups that limit the user sync (lowercase GUID => name); empty = whole tenant.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function sync_scope_groups() {
|
||||
return self::guid_map( $this->get( 'sync_scope_groups', array() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Group → role mapping in priority order.
|
||||
*
|
||||
* @return array lowercase GUID => array( 'name' => string, 'role' => string ).
|
||||
*/
|
||||
public function sync_role_map() {
|
||||
$raw = $this->get( 'sync_role_map', array() );
|
||||
$out = array();
|
||||
if ( is_array( $raw ) ) {
|
||||
foreach ( $raw as $id => $row ) {
|
||||
$id = strtolower( (string) $id );
|
||||
if ( self::is_guid( $id ) && is_array( $row ) && ! empty( $row['role'] ) ) {
|
||||
$out[ $id ] = array(
|
||||
'name' => isset( $row['name'] ) ? (string) $row['name'] : $id,
|
||||
'role' => (string) $row['role'],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps GUID keys (lowercased) of an id => name array.
|
||||
*
|
||||
* @param mixed $raw Stored value.
|
||||
* @return array
|
||||
*/
|
||||
private static function guid_map( $raw ) {
|
||||
$out = array();
|
||||
if ( is_array( $raw ) ) {
|
||||
foreach ( $raw as $id => $name ) {
|
||||
|
|
@ -449,8 +510,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 = 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'] ) ) : '';
|
||||
if ( ! empty( $input['cert_remove'] ) ) {
|
||||
$out['cert_private_key'] = '';
|
||||
$out['cert_certificate'] = '';
|
||||
|
|
@ -485,26 +546,12 @@ class M365_Login_Settings {
|
|||
$out['bind_oid'] = empty( $input['bind_oid'] ) ? 0 : 1;
|
||||
$out['remember_me'] = empty( $input['remember_me'] ) ? 0 : 1;
|
||||
|
||||
$domains = isset( $input['allowed_domains'] ) ? sanitize_textarea_field( wp_unslash( $input['allowed_domains'] ) ) : '';
|
||||
$domains = preg_replace( '/[^a-z0-9.\-@,;\s]/i', '', $domains );
|
||||
$domains = isset( $input['allowed_domains'] ) ? sanitize_textarea_field( wp_unslash( $input['allowed_domains'] ) ) : '';
|
||||
$domains = preg_replace( '/[^a-z0-9.\-@,;\s]/i', '', $domains );
|
||||
$out['allowed_domains'] = trim( (string) $domains );
|
||||
|
||||
// Allowed groups: GUID => name.
|
||||
$groups = array();
|
||||
if ( ! empty( $input['allowed_groups'] ) && is_array( $input['allowed_groups'] ) ) {
|
||||
foreach ( $input['allowed_groups'] as $id => $name ) {
|
||||
$id = strtolower( trim( sanitize_text_field( wp_unslash( (string) $id ) ) ) );
|
||||
if ( ! self::is_guid( $id ) ) {
|
||||
continue;
|
||||
}
|
||||
$name = sanitize_text_field( wp_unslash( (string) $name ) );
|
||||
$groups[ $id ] = '' === $name ? $id : mb_substr( $name, 0, 120 );
|
||||
if ( count( $groups ) >= 100 ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$out['allowed_groups'] = $groups;
|
||||
$out['allowed_groups'] = self::sanitize_group_list( isset( $input['allowed_groups'] ) ? $input['allowed_groups'] : array() );
|
||||
|
||||
// Button-only mode + fallback key.
|
||||
$out['button_only'] = empty( $input['button_only'] ) ? 0 : 1;
|
||||
|
|
@ -551,11 +598,114 @@ class M365_Login_Settings {
|
|||
$divider = isset( $input['divider_text'] ) ? sanitize_text_field( wp_unslash( $input['divider_text'] ) ) : '';
|
||||
$out['divider_text'] = mb_substr( $divider, 0, 40 );
|
||||
|
||||
$out = $this->sanitize_sync( $input, $out );
|
||||
|
||||
$this->cache = null;
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitises the user sync settings.
|
||||
*
|
||||
* @param array $input Raw input.
|
||||
* @param array $out Settings sanitised so far.
|
||||
* @return array
|
||||
*/
|
||||
private function sanitize_sync( $input, $out ) {
|
||||
$defaults = $this->defaults();
|
||||
|
||||
$out['sync_enabled'] = empty( $input['sync_enabled'] ) ? 0 : 1;
|
||||
$out['sync_guests'] = empty( $input['sync_guests'] ) ? 0 : 1;
|
||||
$out['sync_manage_existing'] = empty( $input['sync_manage_existing'] ) ? 0 : 1;
|
||||
|
||||
$interval = isset( $input['sync_interval'] ) ? sanitize_key( $input['sync_interval'] ) : '';
|
||||
$out['sync_interval'] = in_array( $interval, array( 'hourly', 'twicedaily', 'daily' ), true ) ? $interval : $defaults['sync_interval'];
|
||||
|
||||
$mode = isset( $input['sync_role_mode'] ) ? sanitize_key( $input['sync_role_mode'] ) : '';
|
||||
$out['sync_role_mode'] = in_array( $mode, array( 'add', 'replace' ), true ) ? $mode : $defaults['sync_role_mode'];
|
||||
|
||||
$role = isset( $input['sync_default_role'] ) ? sanitize_key( $input['sync_default_role'] ) : '';
|
||||
$out['sync_default_role'] = '' !== $role && get_role( $role ) ? $role : $defaults['sync_default_role'];
|
||||
|
||||
$out['sync_scope_groups'] = self::sanitize_group_list( isset( $input['sync_scope_groups'] ) ? $input['sync_scope_groups'] : array() );
|
||||
|
||||
$map = array();
|
||||
if ( ! empty( $input['sync_role_map'] ) && is_array( $input['sync_role_map'] ) ) {
|
||||
foreach ( $input['sync_role_map'] as $id => $row ) {
|
||||
$id = strtolower( trim( sanitize_text_field( wp_unslash( (string) $id ) ) ) );
|
||||
if ( ! self::is_guid( $id ) || ! is_array( $row ) ) {
|
||||
continue;
|
||||
}
|
||||
$map_role = isset( $row['role'] ) ? sanitize_key( $row['role'] ) : '';
|
||||
if ( '' === $map_role || ! get_role( $map_role ) ) {
|
||||
continue;
|
||||
}
|
||||
$name = isset( $row['name'] ) ? sanitize_text_field( wp_unslash( (string) $row['name'] ) ) : '';
|
||||
$map[ $id ] = array(
|
||||
'name' => '' === $name ? $id : mb_substr( $name, 0, 120 ),
|
||||
'role' => $map_role,
|
||||
);
|
||||
if ( count( $map ) >= 100 ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$out['sync_role_map'] = $map;
|
||||
|
||||
$attributes = array();
|
||||
if ( ! empty( $input['sync_attributes'] ) && is_array( $input['sync_attributes'] ) ) {
|
||||
$known = array_keys( M365_Login_Sync::attributes() );
|
||||
foreach ( $input['sync_attributes'] as $attribute ) {
|
||||
$attribute = sanitize_text_field( wp_unslash( (string) $attribute ) );
|
||||
if ( in_array( $attribute, $known, true ) ) {
|
||||
$attributes[] = $attribute;
|
||||
}
|
||||
}
|
||||
}
|
||||
$out['sync_attributes'] = array_values( array_unique( $attributes ) );
|
||||
|
||||
foreach ( array( 'sync_disabled_action', 'sync_deleted_action', 'sync_scope_action' ) as $key ) {
|
||||
$action = isset( $input[ $key ] ) ? sanitize_key( $input[ $key ] ) : '';
|
||||
$out[ $key ] = in_array( $action, array( 'none', 'disable', 'delete' ), true ) ? $action : $defaults[ $key ];
|
||||
}
|
||||
|
||||
$reassign = isset( $input['sync_reassign'] ) ? absint( $input['sync_reassign'] ) : 0;
|
||||
$out['sync_reassign'] = $reassign && get_userdata( $reassign ) ? $reassign : 0;
|
||||
|
||||
$deletes = in_array( 'delete', array( $out['sync_disabled_action'], $out['sync_deleted_action'], $out['sync_scope_action'] ), true );
|
||||
if ( $deletes && ! $out['sync_reassign'] ) {
|
||||
add_settings_error( M365_LOGIN_OPTION, 'sync_reassign', __( 'User sync: "Delete" needs a user who receives the posts of deleted accounts. Until one is selected, accounts are deactivated instead.', 'm365-login' ), 'warning' );
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitises a GUID => name list posted by a group picker.
|
||||
*
|
||||
* @param mixed $raw Raw input.
|
||||
* @return array
|
||||
*/
|
||||
private static function sanitize_group_list( $raw ) {
|
||||
$groups = array();
|
||||
if ( empty( $raw ) || ! is_array( $raw ) ) {
|
||||
return $groups;
|
||||
}
|
||||
foreach ( $raw as $id => $name ) {
|
||||
$id = strtolower( trim( sanitize_text_field( wp_unslash( (string) $id ) ) ) );
|
||||
if ( ! self::is_guid( $id ) ) {
|
||||
continue;
|
||||
}
|
||||
$name = sanitize_text_field( wp_unslash( (string) $name ) );
|
||||
$groups[ $id ] = '' === $name ? $id : mb_substr( $name, 0, 120 );
|
||||
if ( count( $groups ) >= 100 ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a GUID.
|
||||
*
|
||||
|
|
|
|||
1578
includes/class-m365-login-sync.php
Normal file
1578
includes/class-m365-login-sync.php
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -40,6 +40,13 @@ final class M365_Login {
|
|||
*/
|
||||
public $graph;
|
||||
|
||||
/**
|
||||
* User sync component.
|
||||
*
|
||||
* @var M365_Login_Sync
|
||||
*/
|
||||
public $sync;
|
||||
|
||||
/**
|
||||
* Login button component.
|
||||
*
|
||||
|
|
@ -75,10 +82,11 @@ final class M365_Login {
|
|||
$this->settings = new M365_Login_Settings();
|
||||
$this->graph = new M365_Login_Graph( $this->settings );
|
||||
$this->auth = new M365_Login_Auth( $this->settings, $this->graph );
|
||||
$this->sync = new M365_Login_Sync( $this->settings, $this->graph );
|
||||
$this->button = new M365_Login_Button( $this->settings );
|
||||
|
||||
if ( is_admin() ) {
|
||||
$this->admin = new M365_Login_Admin( $this->settings, $this->auth, $this->graph );
|
||||
$this->admin = new M365_Login_Admin( $this->settings, $this->auth, $this->graph, $this->sync );
|
||||
}
|
||||
|
||||
add_filter( 'plugin_action_links_' . plugin_basename( M365_LOGIN_FILE ), array( $this, 'action_links' ) );
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue