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
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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue