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

@ -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'],