Add excluded Entra groups for the Microsoft sign-in
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 "Excluded Entra groups" card on the Security tab. Members of these groups (nested memberships count) can never sign in with Microsoft, even if they are in an allowed group. A hit in the ID token's groups claim refuses immediately. Otherwise the plugin always asks Microsoft Graph (checkMemberGroups), because a groups claim can be filtered in the app registration and cannot prove non-membership. Graph errors refuse the sign-in (fail closed). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5c9b19399a
commit
791f43a80b
12 changed files with 372 additions and 196 deletions
|
|
@ -1081,6 +1081,16 @@ class M365_Login_Admin {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m365-card">
|
||||
<h2 class="m365-card__title"><?php esc_html_e( 'Excluded Entra groups (optional)', 'm365-login' ); ?></h2>
|
||||
<p class="m365-card__intro"><?php esc_html_e( 'Members of these groups can never sign in with Microsoft – even if they are in an allowed group. Nested memberships count.', 'm365-login' ); ?></p>
|
||||
|
||||
<?php $this->group_picker( 'denied_groups', $this->settings->denied_groups(), 'chips', __( 'No groups excluded.', 'm365-login' ) ); ?>
|
||||
<div class="m365-field">
|
||||
<p class="description"><?php esc_html_e( 'The plugin asks Microsoft Graph on every sign-in (application permission "User.Read.All" or "Directory.Read.All"), because a "groups" claim may be filtered and cannot prove that someone is not a member. If the check fails, the sign-in is refused. Password sign-in is not affected – combine with button-only mode if needed.', 'm365-login' ); ?></p>
|
||||
</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( 'Hides the username/password fields (on wp-login.php and in wp_login_form() forms) and refuses every interactive password sign-in on the site, including custom login forms. Application passwords, REST, XML-RPC and WP-CLI are not affected.', 'm365-login' ); ?></p>
|
||||
|
|
|
|||
|
|
@ -642,6 +642,75 @@ class M365_Login_Auth {
|
|||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the Entra group rules: members of an excluded group are refused,
|
||||
* everybody else needs membership in one of the allowed groups (if any are set).
|
||||
*
|
||||
* @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 ) {
|
||||
$denied = $this->check_denied_groups( $claims, $oid );
|
||||
if ( true !== $denied ) {
|
||||
return $denied;
|
||||
}
|
||||
return $this->check_allowed_groups( $claims, $oid );
|
||||
}
|
||||
|
||||
/**
|
||||
* Group IDs from the "groups" claim, or null when the token has no complete list (claim missing or overage).
|
||||
*
|
||||
* @param array $claims Verified claims.
|
||||
* @return string[]|null
|
||||
*/
|
||||
private function token_groups( $claims ) {
|
||||
$overage = ! empty( $claims['_claim_names'] ) || ! empty( $claims['hasgroups'] );
|
||||
if ( $overage || ! isset( $claims['groups'] ) || ! is_array( $claims['groups'] ) ) {
|
||||
return null;
|
||||
}
|
||||
return array_map( 'strtolower', array_filter( $claims['groups'], 'is_string' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses members of an excluded group (fails closed).
|
||||
*
|
||||
* A "groups" claim can be filtered in the app registration (e.g. only groups assigned to the
|
||||
* application), so it can prove membership but never non-membership: without a match in the
|
||||
* token the plugin always asks Microsoft Graph.
|
||||
*
|
||||
* @param array $claims Verified claims.
|
||||
* @param string $oid User object ID.
|
||||
* @return true|string True, or an error code for fail().
|
||||
*/
|
||||
private function check_denied_groups( $claims, $oid ) {
|
||||
$denied = array_keys( $this->settings->denied_groups() );
|
||||
if ( empty( $denied ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$token_groups = $this->token_groups( $claims );
|
||||
if ( null !== $token_groups && array_intersect( $denied, $token_groups ) ) {
|
||||
$this->log( 'User is a member of an excluded group (token claim).' );
|
||||
return 'in_denied_group';
|
||||
}
|
||||
|
||||
if ( '' === $oid || ! M365_Login_Settings::is_guid( $oid ) ) {
|
||||
return 'invalid_token';
|
||||
}
|
||||
|
||||
$matches = $this->graph->check_member_groups( $oid, $denied );
|
||||
if ( is_wp_error( $matches ) ) {
|
||||
$this->log( 'Excluded-group check via Microsoft Graph failed: ' . $matches->get_error_message() );
|
||||
return 'group_check_failed';
|
||||
}
|
||||
if ( ! empty( $matches ) ) {
|
||||
$this->log( 'User is a member of an excluded group (Graph).' );
|
||||
return 'in_denied_group';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies membership in one of the allowed Entra groups.
|
||||
*
|
||||
|
|
@ -652,15 +721,14 @@ class M365_Login_Auth {
|
|||
* @param string $oid User object ID.
|
||||
* @return true|string True, or an error code for fail().
|
||||
*/
|
||||
private function check_groups( $claims, $oid ) {
|
||||
private function check_allowed_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' ) );
|
||||
$token_groups = $this->token_groups( $claims );
|
||||
if ( null !== $token_groups ) {
|
||||
if ( array_intersect( $allowed, $token_groups ) ) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -869,6 +937,7 @@ class M365_Login_Auth {
|
|||
'not_allowed' => __( 'You are not allowed to sign in with this account.', 'm365-login' ),
|
||||
'not_in_group' => __( 'Your Microsoft account is not a member of a group that is allowed to sign in here.', 'm365-login' ),
|
||||
'group_check_failed' => __( 'Your group membership could not be verified. Please contact an administrator.', 'm365-login' ),
|
||||
'in_denied_group' => __( 'Your Microsoft account is a member of a group that is not allowed to sign in here.', 'm365-login' ),
|
||||
'fallback_invalid' => __( 'The fallback key is not valid.', 'm365-login' ),
|
||||
'fallback_locked' => __( 'Too many attempts. Please wait 15 minutes.', 'm365-login' ),
|
||||
'too_many_attempts' => __( 'Too many sign-in attempts from your connection. Please wait a few minutes and try again.', 'm365-login' ),
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ class M365_Login_Settings {
|
|||
'bind_oid' => 1,
|
||||
'allowed_domains' => '',
|
||||
'allowed_groups' => array(), // id => display name.
|
||||
'denied_groups' => array(), // id => display name; members may never sign in.
|
||||
'remember_me' => 0,
|
||||
// Button-only mode.
|
||||
'button_only' => 0,
|
||||
|
|
@ -333,6 +334,15 @@ class M365_Login_Settings {
|
|||
return self::guid_map( $this->get( 'allowed_groups', array() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Excluded Entra group IDs (lowercase GUIDs) mapped to display names.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function denied_groups() {
|
||||
return self::guid_map( $this->get( 'denied_groups', array() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups that limit the user sync (lowercase GUID => name); empty = whole tenant.
|
||||
*
|
||||
|
|
@ -552,6 +562,7 @@ class M365_Login_Settings {
|
|||
|
||||
// Allowed groups: GUID => name.
|
||||
$out['allowed_groups'] = self::sanitize_group_list( isset( $input['allowed_groups'] ) ? $input['allowed_groups'] : array() );
|
||||
$out['denied_groups'] = self::sanitize_group_list( isset( $input['denied_groups'] ) ? $input['denied_groups'] : array() );
|
||||
|
||||
// Button-only mode + fallback key.
|
||||
$out['button_only'] = empty( $input['button_only'] ) ? 0 : 1;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue