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">

View file

@ -15,6 +15,8 @@ class M365_Login_Auth {
const ACTION_START = 'm365_login';
const CALLBACK_PATH = 'm365-login/callback';
const STATE_COOKIE = 'm365_login_state';
const FALLBACK_COOKIE = 'm365_login_fallback';
const FALLBACK_TTL = 30 * MINUTE_IN_SECONDS;
const STATE_TTL = 600; // 10 minutes.
const META_OID = '_m365_login_oid';
const META_LAST_LOGIN = '_m365_login_last_login';
@ -28,17 +30,123 @@ class M365_Login_Auth {
*/
private $settings;
/**
* Graph client.
*
* @var M365_Login_Graph
*/
private $graph;
/**
* Constructor.
*
* @param M365_Login_Settings $settings Settings.
* @param M365_Login_Graph $graph Graph client.
*/
public function __construct( M365_Login_Settings $settings ) {
public function __construct( M365_Login_Settings $settings, M365_Login_Graph $graph ) {
$this->settings = $settings;
$this->graph = $graph;
add_action( 'login_form_' . self::ACTION_START, array( $this, 'handle_start' ) );
add_action( 'init', array( $this, 'maybe_handle_callback' ), 5 );
add_filter( 'wp_login_errors', array( $this, 'login_errors' ), 10, 1 );
// Button-only mode.
add_action( 'login_init', array( $this, 'maybe_accept_fallback_key' ) );
// 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 );
}
/* ------------------------------------------------------------------ */
/* Button-only mode */
/* ------------------------------------------------------------------ */
/**
* Whether the current browser presented the fallback key (cookie set for 30 minutes).
*
* @return bool
*/
public function fallback_active() {
if ( ! $this->settings->button_only() ) {
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 );
}
/**
* Expected fallback cookie value (HMAC of the key, so the key itself never sits in the cookie).
*
* @return string
*/
private function fallback_cookie_value() {
return hash_hmac( 'sha256', 'fallback|' . $this->settings->fallback_key(), wp_salt( 'auth' ) );
}
/**
* wp-login.php?m365_fallback=KEY sets the fallback cookie and reloads without the key in the URL.
*/
public function maybe_accept_fallback_key() {
if ( ! $this->settings->button_only() ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the key itself is the secret.
$given = isset( $_GET['m365_fallback'] ) ? sanitize_text_field( wp_unslash( $_GET['m365_fallback'] ) ) : '';
if ( '' === $given ) {
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' );
}
if ( ! hash_equals( $this->settings->fallback_key(), $given ) ) {
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 );
nocache_headers();
wp_safe_redirect( add_query_arg( 'm365_fallback', 'on', wp_login_url() ) );
exit;
}
/**
* Refuses username/password sign-in on wp-login.php while button-only mode is active.
*
* @param null|WP_User|WP_Error $user Result so far.
* @param string $username Username.
* @param string $password Password.
* @return null|WP_User|WP_Error
*/
public function block_password_login( $user, $username, $password ) {
if ( ! $this->settings->button_only() || $this->fallback_active() ) {
return $user;
}
if ( '' === (string) $username && '' === (string) $password ) {
return $user; // Initial form render or cookie auth, no password attempt.
}
// Only the interactive login form is affected: XML-RPC, REST and application passwords keep working.
if ( ! isset( $GLOBALS['pagenow'] ) || 'wp-login.php' !== $GLOBALS['pagenow'] ) {
return $user;
}
if ( ! $user instanceof WP_User ) {
return $user; // Already failed for another reason; keep core's message.
}
return new WP_Error( 'm365_login_button_only', __( 'Password sign-in is disabled on this site. Please use the Microsoft button.', 'm365-login' ) );
}
/**
* Best-effort client IP for rate limiting.
*
* @return string
*/
private function client_ip() {
return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '0.0.0.0';
}
/* ------------------------------------------------------------------ */
@ -276,8 +384,15 @@ class M365_Login_Auth {
$this->fail( 'no_user' );
}
// Bind the account to the immutable Microsoft object ID after first login.
$oid = isset( $claims['oid'] ) && is_string( $claims['oid'] ) ? strtolower( $claims['oid'] ) : '';
// Entra group restriction.
$group_check = $this->check_groups( $claims, $oid );
if ( true !== $group_check ) {
$this->fail( $group_check );
}
// Bind the account to the immutable Microsoft object ID after first login.
if ( $this->settings->get( 'bind_oid' ) ) {
if ( '' === $oid || ! M365_Login_Settings::is_guid( $oid ) ) {
$this->fail( 'invalid_token' );
@ -459,6 +574,49 @@ class M365_Login_Auth {
return $body;
}
/**
* Verifies membership in one of the allowed Entra groups.
*
* Uses the "groups" claim when the token carries one (and is not in overage),
* otherwise asks Microsoft Graph (transitive check, needs application permissions).
*
* @param array $claims Verified claims.
* @param string $oid User object ID.
* @return true|string True, or an error code for fail().
*/
private function check_groups( $claims, $oid ) {
$allowed = array_keys( $this->settings->allowed_groups() );
if ( empty( $allowed ) ) {
return true;
}
$overage = ! empty( $claims['_claim_names'] ) || ! empty( $claims['hasgroups'] );
if ( ! $overage && isset( $claims['groups'] ) && is_array( $claims['groups'] ) ) {
$token_groups = array_map( 'strtolower', array_filter( $claims['groups'], 'is_string' ) );
if ( array_intersect( $allowed, $token_groups ) ) {
return true;
}
// The claim is authoritative when present: no need to ask Graph.
$this->log( 'User is not a member of an allowed group (token claim).' );
return 'not_in_group';
}
if ( '' === $oid || ! M365_Login_Settings::is_guid( $oid ) ) {
return 'invalid_token';
}
$matches = $this->graph->check_member_groups( $oid, $allowed );
if ( is_wp_error( $matches ) ) {
$this->log( 'Group check via Microsoft Graph failed: ' . $matches->get_error_message() );
return 'group_check_failed';
}
if ( empty( $matches ) ) {
$this->log( 'User is not a member of an allowed group (Graph).' );
return 'not_in_group';
}
return true;
}
/**
* Extracts the e-mail address used for matching.
*
@ -520,28 +678,29 @@ class M365_Login_Auth {
* @param string $token Cookie value.
*/
private function set_state_cookie( $token ) {
$this->send_cookie( $token, time() + self::STATE_TTL );
$this->send_cookie( self::STATE_COOKIE, $token, time() + self::STATE_TTL );
}
/**
* Removes the state cookie.
*/
private function clear_state_cookie() {
$this->send_cookie( '', time() - YEAR_IN_SECONDS );
$this->send_cookie( self::STATE_COOKIE, '', time() - YEAR_IN_SECONDS );
}
/**
* Cookie helper: HttpOnly, SameSite=Lax (needed for the top-level redirect back), Secure on HTTPS.
*
* @param string $name Cookie name.
* @param string $value Value.
* @param int $expires Expiry timestamp.
*/
private function send_cookie( $value, $expires ) {
private function send_cookie( $name, $value, $expires ) {
$path = wp_parse_url( home_url( '/' ), PHP_URL_PATH );
$path = is_string( $path ) && '' !== $path ? $path : '/';
setcookie(
self::STATE_COOKIE,
$name,
$value,
array(
'expires' => $expires,
@ -583,8 +742,18 @@ class M365_Login_Auth {
* @return WP_Error
*/
public function login_errors( $errors ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display of a whitelisted error code.
$code = isset( $_GET['m365_error'] ) ? sanitize_key( wp_unslash( $_GET['m365_error'] ) ) : '';
if ( ! $errors instanceof WP_Error ) {
$errors = new WP_Error();
}
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only display of whitelisted flags.
$code = isset( $_GET['m365_error'] ) ? sanitize_key( wp_unslash( $_GET['m365_error'] ) ) : '';
$fallback_on = isset( $_GET['m365_fallback'] ) && 'on' === $_GET['m365_fallback'];
// phpcs:enable WordPress.Security.NonceVerification.Recommended
if ( $fallback_on && $this->settings->button_only() && $this->fallback_active() ) {
$errors->add( 'm365_login_fallback_on', __( 'Password sign-in is temporarily enabled for this browser (30 minutes).', 'm365-login' ), 'message' );
}
if ( '' === $code ) {
return $errors;
}
@ -601,11 +770,12 @@ class M365_Login_Auth {
'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' ),
'fallback_invalid' => __( 'The fallback key is not valid.', 'm365-login' ),
'fallback_locked' => __( 'Too many attempts. Please wait 15 minutes.', 'm365-login' ),
);
if ( ! $errors instanceof WP_Error ) {
$errors = new WP_Error();
}
$errors->add(
'm365_login_' . $code,
isset( $messages[ $code ] ) ? $messages[ $code ] : $messages['provider_error'],

View file

@ -28,6 +28,7 @@ class M365_Login_Button {
$this->settings = $settings;
add_action( 'login_enqueue_scripts', array( $this, 'enqueue' ) );
add_filter( 'login_body_class', array( $this, 'body_class' ) );
add_filter( 'login_message', array( $this, 'render_above' ), 20 );
add_action( 'login_footer', array( $this, 'render_below' ) );
add_shortcode( 'm365_login_button', array( $this, 'shortcode' ) );
@ -57,6 +58,28 @@ class M365_Login_Button {
return (bool) apply_filters( 'm365_login_show_button', true );
}
/**
* Whether the password form is hidden for this request.
*
* @return bool
*/
private function form_hidden() {
return $this->should_render() && $this->settings->button_only() && ! M365_Login::instance()->auth->fallback_active();
}
/**
* Adds a body class while the password form is hidden.
*
* @param string[] $classes Body classes.
* @return string[]
*/
public function body_class( $classes ) {
if ( $this->form_hidden() ) {
$classes[] = 'm365-button-only';
}
return $classes;
}
/**
* Enqueues login styles and the small positioning script.
*/
@ -140,7 +163,7 @@ class M365_Login_Button {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- passed through to the flow, validated there.
$redirect_to = isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : '';
$divider = (string) $this->settings->get( 'divider_text' );
$divider = $this->form_hidden() ? '' : (string) $this->settings->get( 'divider_text' );
$divider = '' === trim( $divider ) ? '' : '<div class="m365-login__divider" aria-hidden="true"><span>' . esc_html( $divider ) . '</span></div>';
$html = '<div class="m365-login m365-login--' . esc_attr( $position ) . '" id="m365-login-block">';

View file

@ -0,0 +1,240 @@
<?php
/**
* Minimal Microsoft Graph client (application permissions).
*
* @package M365_Login
*/
defined( 'ABSPATH' ) || exit;
/**
* Obtains app-only tokens via client credentials and queries groups.
*/
class M365_Login_Graph {
const GRAPH_BASE = 'https://graph.microsoft.com/v1.0';
const TOKEN_TTL = 50 * MINUTE_IN_SECONDS; // Graph tokens last ~60 minutes.
const HTTP_TIMEOUT = 15;
/**
* Settings.
*
* @var M365_Login_Settings
*/
private $settings;
/**
* Constructor.
*
* @param M365_Login_Settings $settings Settings.
*/
public function __construct( M365_Login_Settings $settings ) {
$this->settings = $settings;
}
/**
* Transient key for the cached app token.
*
* @return string
*/
private function token_cache_key() {
return 'm365_login_apptoken_' . md5( $this->settings->tenant() . '|' . $this->settings->get( 'client_id' ) );
}
/**
* Drops the cached app token (e.g. after the client secret changed).
*/
public function flush_token() {
delete_transient( $this->token_cache_key() );
}
/**
* Returns an app-only access token for Microsoft Graph.
*
* @return string|WP_Error
*/
public function app_token() {
$cached = get_transient( $this->token_cache_key() );
if ( is_string( $cached ) && '' !== $cached ) {
return $cached;
}
if ( ! $this->settings->is_configured() ) {
return new WP_Error( 'graph_not_configured', __( 'Microsoft login is not configured yet.', 'm365-login' ) );
}
$response = wp_remote_post(
'https://login.microsoftonline.com/' . rawurlencode( $this->settings->tenant() ) . '/oauth2/v2.0/token',
array(
'timeout' => self::HTTP_TIMEOUT,
'headers' => array( 'Accept' => 'application/json' ),
'body' => array(
'client_id' => $this->settings->get( 'client_id' ),
'client_secret' => $this->settings->client_secret(),
'grant_type' => 'client_credentials',
'scope' => 'https://graph.microsoft.com/.default',
),
)
);
if ( is_wp_error( $response ) ) {
return $response;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) || empty( $body['access_token'] ) ) {
$detail = is_array( $body ) && ! empty( $body['error_description'] ) ? (string) $body['error_description'] : 'HTTP ' . wp_remote_retrieve_response_code( $response );
return new WP_Error( 'graph_token', $detail );
}
set_transient( $this->token_cache_key(), (string) $body['access_token'], self::TOKEN_TTL );
return (string) $body['access_token'];
}
/**
* Performs an authenticated Graph request.
*
* @param string $method HTTP method.
* @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.
*/
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 );
if ( is_wp_error( $response ) ) {
return $response;
}
$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 $body;
}
/**
* Searches groups by display name.
*
* @param string $query Search text (may be empty for the first page).
* @return array|WP_Error List of ['id' => .., 'name' => .., 'description' => ..].
*/
public function search_groups( $query ) {
$query = trim( (string) $query );
$select = '$select=id,displayName,description,securityEnabled,mailEnabled&$top=25&$orderby=displayName';
if ( '' !== $query && M365_Login_Settings::is_guid( $query ) ) {
$path = '/groups/' . rawurlencode( strtolower( $query ) ) . '?$select=id,displayName,description,securityEnabled,mailEnabled';
$item = $this->request( 'GET', $path );
if ( is_wp_error( $item ) ) {
return $item;
}
return array( $this->format_group( $item ) );
}
$path = '/groups?' . $select;
if ( '' !== $query ) {
// $search needs the ConsistencyLevel header; the value must be wrapped in double quotes.
$search = '"displayName:' . str_replace( '"', '', $query ) . '"';
$path = '/groups?' . $select . '&$search=' . rawurlencode( $search ) . '&$count=true';
}
$result = $this->request( 'GET', $path, null, array( 'ConsistencyLevel' => 'eventual' ) );
if ( is_wp_error( $result ) ) {
return $result;
}
$groups = array();
if ( ! empty( $result['value'] ) && is_array( $result['value'] ) ) {
foreach ( $result['value'] as $item ) {
if ( is_array( $item ) && ! empty( $item['id'] ) ) {
$groups[] = $this->format_group( $item );
}
}
}
return $groups;
}
/**
* Normalises a Graph group object.
*
* @param array $item Graph group.
* @return array
*/
private function format_group( $item ) {
$type = __( 'Group', 'm365-login' );
if ( ! empty( $item['securityEnabled'] ) && empty( $item['mailEnabled'] ) ) {
$type = __( 'Security group', 'm365-login' );
} elseif ( ! empty( $item['mailEnabled'] ) ) {
$type = __( 'Microsoft 365 group', 'm365-login' );
}
return array(
'id' => strtolower( (string) $item['id'] ),
'name' => isset( $item['displayName'] ) ? (string) $item['displayName'] : (string) $item['id'],
'description' => isset( $item['description'] ) ? (string) $item['description'] : '',
'type' => $type,
);
}
/**
* Checks (transitively) which of the given groups the user belongs to.
*
* @param string $user_oid User object ID.
* @param string[] $group_ids Group object IDs (any count; chunked by 20).
* @return string[]|WP_Error Matching group IDs.
*/
public function check_member_groups( $user_oid, $group_ids ) {
if ( ! M365_Login_Settings::is_guid( $user_oid ) ) {
return new WP_Error( 'graph_bad_oid', 'Invalid user object ID.' );
}
$matches = array();
foreach ( array_chunk( array_values( $group_ids ), 20 ) as $chunk ) {
$result = $this->request(
'POST',
'/users/' . rawurlencode( strtolower( $user_oid ) ) . '/checkMemberGroups',
array( 'groupIds' => $chunk )
);
if ( is_wp_error( $result ) ) {
return $result;
}
if ( ! empty( $result['value'] ) && is_array( $result['value'] ) ) {
foreach ( $result['value'] as $id ) {
$matches[] = strtolower( (string) $id );
}
}
if ( ! empty( $matches ) ) {
break; // One match is enough.
}
}
return $matches;
}
}

View file

@ -35,7 +35,11 @@ class M365_Login_Settings {
'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 appearance.
'button_text' => __( 'Sign in with Microsoft', 'm365-login' ),
'button_icon' => '', // Empty = bundled Microsoft logo.
@ -157,6 +161,71 @@ class M365_Login_Settings {
return array_values( array_unique( $out ) );
}
/**
* Allowed Entra group IDs (lowercase GUIDs) mapped to display names.
*
* @return array
*/
public function allowed_groups() {
$raw = $this->get( 'allowed_groups', array() );
$out = array();
if ( is_array( $raw ) ) {
foreach ( $raw as $id => $name ) {
$id = strtolower( (string) $id );
if ( self::is_guid( $id ) ) {
$out[ $id ] = (string) $name;
}
}
}
return $out;
}
/**
* Whether the password form is hidden and password sign-in blocked.
*
* @return bool
*/
public function button_only() {
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();
}
/**
* Secret key that re-enables the password form.
*
* @return string
*/
public function fallback_key() {
$key = (string) $this->get( 'fallback_key', '' );
return preg_match( '/^[A-Za-z0-9]{16,64}$/', $key ) ? $key : '';
}
/**
* URL that shows the password form again when button-only mode is active.
*
* @return string
*/
public function fallback_url() {
$key = $this->fallback_key();
return '' === $key ? '' : add_query_arg( 'm365_fallback', $key, wp_login_url() );
}
/**
* Generates a new fallback key.
*
* @return string
*/
public static function generate_fallback_key() {
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
$key = '';
for ( $i = 0; $i < 24; $i++ ) {
$key .= $alphabet[ random_int( 0, strlen( $alphabet ) - 1 ) ];
}
return $key;
}
/**
* Sanitises settings coming from the admin form.
*
@ -215,6 +284,31 @@ class M365_Login_Settings {
$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;
// Button-only mode + fallback key.
$out['button_only'] = empty( $input['button_only'] ) ? 0 : 1;
$key = (string) $current['fallback_key'];
if ( ! empty( $input['fallback_regenerate'] ) || ! preg_match( '/^[A-Za-z0-9]{16,64}$/', $key ) ) {
$key = self::generate_fallback_key();
}
$out['fallback_key'] = $key;
// Button.
$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 );

View file

@ -33,6 +33,13 @@ final class M365_Login {
*/
public $auth;
/**
* Microsoft Graph client.
*
* @var M365_Login_Graph
*/
public $graph;
/**
* Login button component.
*
@ -66,11 +73,12 @@ final class M365_Login {
add_action( 'init', array( $this, 'load_textdomain' ) );
$this->settings = new M365_Login_Settings();
$this->auth = new M365_Login_Auth( $this->settings );
$this->graph = new M365_Login_Graph( $this->settings );
$this->auth = new M365_Login_Auth( $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->admin = new M365_Login_Admin( $this->settings, $this->auth, $this->graph );
}
add_filter( 'plugin_action_links_' . plugin_basename( M365_LOGIN_FILE ), array( $this, 'action_links' ) );