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
|
|
@ -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'],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue