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:
parent
1517e7e3bc
commit
1202283eda
20 changed files with 2241 additions and 517 deletions
|
|
@ -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 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' ),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
|
@ -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' ); ?>">×</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">
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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">';
|
||||
|
|
|
|||
240
includes/class-m365-login-graph.php
Normal file
240
includes/class-m365-login-graph.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 );
|
||||
|
|
|
|||
|
|
@ -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' ) );
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue