Add Entra group restriction, button-only mode and detailed README

Groups: a Graph-backed picker on the Security tab (search by name or
paste object IDs) stores allowed group IDs. During sign-in membership is
read from the ID token's groups claim when present, otherwise verified
through Microsoft Graph checkMemberGroups (transitive). Verification
failures refuse the sign-in.

Button-only mode: hides the password form and the lost-password link
and rejects password sign-ins on wp-login.php via the authenticate
filter. A generated, rate-limited fallback key re-enables the form for
30 minutes per browser; M365_LOGIN_DISABLE_BUTTON_ONLY switches the
mode off from wp-config.php.

Also: new German-language README with sequence diagram, settings
reference, troubleshooting and hook examples; readme.txt external
services section now covers Microsoft Graph; translations updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJxAHYdMfKPoN4koRc4Ci2
This commit is contained in:
friloo 2026-09-22 14:30:53 +00:00
parent 1517e7e3bc
commit 1202283eda
No known key found for this signature in database
20 changed files with 2241 additions and 517 deletions

View file

@ -14,8 +14,9 @@ class M365_Login_Admin {
const PAGE = 'm365-login';
const GROUP = 'm365_login';
const AJAX_TEST = 'm365_login_test_connection';
const NONCE_TEST = 'm365_login_test';
const AJAX_TEST = 'm365_login_test_connection';
const AJAX_GROUPS = 'm365_login_search_groups';
const NONCE_TEST = 'm365_login_test';
/**
* Settings.
@ -31,6 +32,13 @@ class M365_Login_Admin {
*/
private $auth;
/**
* Graph client.
*
* @var M365_Login_Graph
*/
private $graph;
/**
* Screen hook suffix.
*
@ -43,15 +51,19 @@ class M365_Login_Admin {
*
* @param M365_Login_Settings $settings Settings.
* @param M365_Login_Auth $auth Auth.
* @param M365_Login_Graph $graph Graph client.
*/
public function __construct( M365_Login_Settings $settings, M365_Login_Auth $auth ) {
public function __construct( M365_Login_Settings $settings, M365_Login_Auth $auth, M365_Login_Graph $graph ) {
$this->settings = $settings;
$this->auth = $auth;
$this->graph = $graph;
add_action( 'admin_menu', array( $this, 'menu' ) );
add_action( 'admin_init', array( $this, 'register' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
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( 'update_option_' . M365_LOGIN_OPTION, array( $this->graph, 'flush_token' ) );
add_action( 'admin_notices', array( $this, 'setup_notice' ) );
}
@ -127,6 +139,7 @@ class M365_Login_Admin {
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( self::NONCE_TEST ),
'action' => self::AJAX_TEST,
'groupAction' => self::AJAX_GROUPS,
'defaultLogo' => M365_Login_Button::microsoft_logo(),
'i18n' => array(
'chooseIcon' => __( 'Choose button icon', 'm365-login' ),
@ -135,6 +148,12 @@ class M365_Login_Admin {
'copy' => __( 'Copy', 'm365-login' ),
'testing' => __( 'Testing…', 'm365-login' ),
'testFailed' => __( 'The tenant could not be reached. Check the tenant ID and the servers 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' ),
),
)
);
@ -179,6 +198,32 @@ 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' ) ) {
wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'm365-login' ) ), 403 );
}
if ( ! $this->settings->is_configured() ) {
wp_send_json_error( array( 'message' => __( 'Save the connection settings first, then search for groups.', 'm365-login' ) ) );
}
$query = isset( $_POST['query'] ) ? sanitize_text_field( wp_unslash( $_POST['query'] ) ) : '';
$groups = $this->graph->search_groups( mb_substr( $query, 0, 100 ) );
if ( is_wp_error( $groups ) ) {
$message = $groups->get_error_message();
if ( false !== stripos( $message, 'Authorization_RequestDenied' ) || false !== stripos( $message, 'Insufficient privileges' ) ) {
$message = __( 'Microsoft Graph refused the request. Grant the application permission "GroupMember.Read.All" (or "Directory.Read.All") with admin consent in Entra ID.', 'm365-login' );
}
wp_send_json_error( array( 'message' => $message ) );
}
wp_send_json_success( array( 'groups' => $groups ) );
}
/**
* Renders the settings screen.
*/
@ -402,6 +447,79 @@ class M365_Login_Admin {
</div>
</div>
<div class="m365-card">
<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>
<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' ); ?>">&times;</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>
<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( 'Hide the username/password form and the "Lost your password?" link, and refuse password sign-ins on the login page. Application passwords, REST and XML-RPC are not affected.', '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" />
<span>
<strong><?php esc_html_e( 'Show only the Microsoft button on the login page', 'm365-login' ); ?></strong>
<em><?php esc_html_e( 'Becomes active once the connection is configured. Make sure your own account can sign in via Microsoft before enabling this.', 'm365-login' ); ?></em>
</span>
</label>
<div class="m365-fallback">
<span class="m365-field__label"><?php esc_html_e( 'Fallback link (keep it secret)', 'm365-login' ); ?></span>
<p class="description"><?php esc_html_e( 'Opening this link shows the password form again in that browser for 30 minutes. Bookmark it somewhere safe it is your way back in if Microsoft sign-in ever breaks.', 'm365-login' ); ?></p>
<?php if ( '' !== $this->settings->fallback_url() ) : ?>
<div class="m365-copy">
<code id="m365-fallback-url"><?php echo esc_html( $this->settings->fallback_url() ); ?></code>
<button type="button" class="button button-small m365-copy__button" data-copy="m365-fallback-url"><?php esc_html_e( 'Copy', 'm365-login' ); ?></button>
</div>
<label class="m365-check m365-check--inline">
<input type="checkbox" name="<?php echo $field( 'fallback_regenerate' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" id="m365-fallback-regenerate" />
<?php esc_html_e( 'Generate a new key when saving', 'm365-login' ); ?>
</label>
<?php else : ?>
<p class="m365-inline-result"><?php esc_html_e( 'A key is generated automatically the first time you save these settings.', 'm365-login' ); ?></p>
<?php endif; ?>
<p class="description">
<?php
printf(
/* translators: %s: PHP constant */
esc_html__( 'Emergency switch: add %s to wp-config.php to disable button-only mode entirely.', 'm365-login' ),
'<code>define( \'M365_LOGIN_DISABLE_BUTTON_ONLY\', true );</code>'
);
?>
</p>
</div>
</div>
<div class="m365-card m365-card--muted">
<h2 class="m365-card__title"><?php esc_html_e( 'What the plugin does to keep sign-ins safe', 'm365-login' ); ?></h2>
<ul class="m365-list">
@ -444,6 +562,7 @@ class M365_Login_Admin {
<li><?php esc_html_e( 'Under Token configuration add the optional claim "email" for ID tokens (recommended), then save this page.', '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>
</div>
<div class="m365-card m365-card--muted">