- Privileged accounts: the UPN rule also applies when bind_oid is off or the account is not bound; privileges are checked on every site of a multisite user, include code/HTML capabilities (unfiltered_html, plugins, themes, users) and the remembered roles of deactivated accounts. - send_auth_cookies protection also works on WordPress 6.0/6.1. - Run lock via INSERT IGNORE (atomic), refreshed during long runs; a shutdown handler reports fatal errors and frees the lock. - Deprovisioning only for accounts linked in the current tenant (tenant recorded per account; legacy links not found are left alone). - Safety stop based on the accounts linked before the run; new safety stop for removals of administrative roles. - Disable is idempotent; row-action nonces are bound to the state. - Profile photos are re-encoded to 240 px (drops EXIF and appended data), size-limited while downloading, removed on deactivation; index.php guard in the photo folder. - Privacy exporter and eraser for the copied data. - One-time migration hardens accounts deactivated by 1.0 and cleans a stored certificate bundle; the .cer download is always re-exported. - Password fields hidden in button-only mode even when the connection is broken; settings written non-autoloaded; robust user ID queries. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
494 lines
16 KiB
PHP
494 lines
16 KiB
PHP
<?php
|
||
/**
|
||
* Minimal Microsoft Graph client (application permissions).
|
||
*
|
||
* @package M365_Login
|
||
*/
|
||
|
||
defined( 'ABSPATH' ) || exit;
|
||
|
||
/**
|
||
* Obtains app-only tokens via client credentials and queries users and 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' ) . '|' . $this->settings->auth_method() );
|
||
}
|
||
|
||
/**
|
||
* 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' ) );
|
||
}
|
||
|
||
$token_endpoint = 'https://login.microsoftonline.com/' . rawurlencode( $this->settings->tenant() ) . '/oauth2/v2.0/token';
|
||
$auth = $this->settings->client_auth_params( $token_endpoint );
|
||
if ( is_wp_error( $auth ) ) {
|
||
return $auth;
|
||
}
|
||
|
||
$response = wp_remote_post(
|
||
$token_endpoint,
|
||
array(
|
||
'timeout' => self::HTTP_TIMEOUT,
|
||
'headers' => array( 'Accept' => 'application/json' ),
|
||
'body' => array_merge(
|
||
array(
|
||
'client_id' => $this->settings->get( 'client_id' ),
|
||
'grant_type' => 'client_credentials',
|
||
'scope' => 'https://graph.microsoft.com/.default',
|
||
),
|
||
$auth
|
||
),
|
||
)
|
||
);
|
||
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 and returns the raw HTTP response.
|
||
*
|
||
* Retries a few times when Microsoft throttles (HTTP 429) or is briefly unavailable (503/504).
|
||
*
|
||
* @param string $method HTTP method.
|
||
* @param string $path Path relative to the v1.0 base (with query string) or an absolute Graph URL (paging links).
|
||
* @param array|null $json JSON body for POST requests.
|
||
* @param array $headers Extra headers.
|
||
* @param bool $retry Retry on 429/503/504.
|
||
* @param int $max_bytes Maximum response size (0 = unlimited).
|
||
* @return array|WP_Error Response array from wp_remote_request().
|
||
*/
|
||
private function raw_request( $method, $path, $json = null, $headers = array(), $retry = true, $max_bytes = 0 ) {
|
||
$url = 0 === strpos( $path, self::GRAPH_BASE . '/' ) ? $path : self::GRAPH_BASE . $path;
|
||
if ( 0 !== strpos( $url, self::GRAPH_BASE . '/' ) ) {
|
||
return new WP_Error( 'graph_bad_url', 'Refusing to call a non-Graph URL.' );
|
||
}
|
||
|
||
for ( $attempt = 1; ; $attempt++ ) {
|
||
$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 );
|
||
}
|
||
if ( $max_bytes > 0 ) {
|
||
$args['limit_response_size'] = $max_bytes; // Stop downloading oversized bodies early.
|
||
}
|
||
|
||
$response = wp_remote_request( $url, $args );
|
||
if ( is_wp_error( $response ) ) {
|
||
return $response;
|
||
}
|
||
|
||
$code = (int) wp_remote_retrieve_response_code( $response );
|
||
if ( 401 === $code ) {
|
||
$this->flush_token();
|
||
}
|
||
if ( $retry && $attempt < 4 && in_array( $code, array( 429, 503, 504 ), true ) ) {
|
||
$wait = (int) wp_remote_retrieve_header( $response, 'retry-after' );
|
||
sleep( max( 1, min( 10, $wait > 0 ? $wait : $attempt * 2 ) ) );
|
||
continue;
|
||
}
|
||
return $response;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
* @param bool $retry Retry on 429/503/504.
|
||
* @return array|WP_Error Decoded JSON. Errors carry array( 'status' => HTTP code ) as data.
|
||
*/
|
||
private function request( $method, $path, $json = null, $headers = array(), $retry = true ) {
|
||
$response = $this->raw_request( $method, $path, $json, $headers, $retry );
|
||
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 ( $code < 200 || $code >= 300 || ! is_array( $body ) ) {
|
||
return $this->error_from( $code, $body );
|
||
}
|
||
|
||
return $body;
|
||
}
|
||
|
||
/**
|
||
* Builds a WP_Error from a failed Graph response.
|
||
*
|
||
* @param int $code HTTP status.
|
||
* @param array|null $body Decoded body.
|
||
* @return WP_Error
|
||
*/
|
||
private function error_from( $code, $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 : '' ), array( 'status' => (int) $code ) );
|
||
}
|
||
|
||
/**
|
||
* Whether a Graph error means "object does not exist".
|
||
*
|
||
* @param WP_Error $error Error.
|
||
* @return bool
|
||
*/
|
||
public static function is_not_found( $error ) {
|
||
$data = $error->get_error_data();
|
||
return is_array( $data ) && isset( $data['status'] ) && 404 === (int) $data['status'];
|
||
}
|
||
|
||
/**
|
||
* Follows @odata.nextLink until every page of a collection is read.
|
||
*
|
||
* @param string $path First page (relative path).
|
||
* @param array $headers Extra headers.
|
||
* @return array[]|WP_Error All items.
|
||
*/
|
||
private function collect( $path, $headers = array() ) {
|
||
$items = array();
|
||
$next = $path;
|
||
$pages = 0;
|
||
while ( '' !== $next ) {
|
||
if ( ++$pages > 1000 ) {
|
||
return new WP_Error( 'graph_paging', 'Too many result pages.' );
|
||
}
|
||
$result = $this->request( 'GET', $next, null, $headers );
|
||
if ( is_wp_error( $result ) ) {
|
||
return $result;
|
||
}
|
||
if ( isset( $result['value'] ) && is_array( $result['value'] ) ) {
|
||
foreach ( $result['value'] as $item ) {
|
||
if ( is_array( $item ) && ! empty( $item['id'] ) ) {
|
||
$items[] = $item;
|
||
}
|
||
}
|
||
}
|
||
$next = isset( $result['@odata.nextLink'] ) && is_string( $result['@odata.nextLink'] ) ? $result['@odata.nextLink'] : '';
|
||
}
|
||
return $items;
|
||
}
|
||
|
||
/**
|
||
* Lists every user of the tenant.
|
||
*
|
||
* @param string[] $select Properties to read.
|
||
* @return array[]|WP_Error
|
||
*/
|
||
public function list_users( $select ) {
|
||
return $this->collect( '/users?$select=' . rawurlencode( implode( ',', $select ) ) . '&$top=999' );
|
||
}
|
||
|
||
/**
|
||
* Lists the users that are (directly or through nested groups) members of a group.
|
||
*
|
||
* @param string $group_id Group object ID.
|
||
* @param string[] $select Properties to read.
|
||
* @return array[]|WP_Error
|
||
*/
|
||
public function list_group_users( $group_id, $select ) {
|
||
if ( ! M365_Login_Settings::is_guid( $group_id ) ) {
|
||
return new WP_Error( 'graph_bad_group', 'Invalid group object ID.' );
|
||
}
|
||
return $this->collect(
|
||
'/groups/' . rawurlencode( strtolower( $group_id ) ) . '/transitiveMembers/microsoft.graph.user?$select=' . rawurlencode( implode( ',', $select ) ) . '&$top=999&$count=true',
|
||
array( 'ConsistencyLevel' => 'eventual' )
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Reads a single user.
|
||
*
|
||
* @param string $oid User object ID.
|
||
* @param string[] $select Properties to read.
|
||
* @return array|WP_Error WP_Error with status 404 when the user does not exist (anymore).
|
||
*/
|
||
public function get_user( $oid, $select ) {
|
||
if ( ! M365_Login_Settings::is_guid( $oid ) ) {
|
||
return new WP_Error( 'graph_bad_oid', 'Invalid user object ID.' );
|
||
}
|
||
return $this->request( 'GET', '/users/' . rawurlencode( strtolower( $oid ) ) . '?$select=' . rawurlencode( implode( ',', $select ) ) );
|
||
}
|
||
|
||
/**
|
||
* Runs up to 20 GET requests in one Graph JSON batch.
|
||
*
|
||
* @param string[] $paths Request key => path relative to the v1.0 base.
|
||
* @return array|WP_Error Request key => array( 'status' => int, 'body' => mixed ).
|
||
*/
|
||
public function batch_get( $paths ) {
|
||
$requests = array();
|
||
foreach ( array_values( $paths ) as $i => $path ) {
|
||
$requests[] = array(
|
||
'id' => (string) $i,
|
||
'method' => 'GET',
|
||
'url' => $path,
|
||
);
|
||
}
|
||
$keys = array_keys( $paths );
|
||
if ( empty( $requests ) ) {
|
||
return array();
|
||
}
|
||
if ( count( $requests ) > 20 ) {
|
||
return new WP_Error( 'graph_batch_size', 'A Graph batch holds at most 20 requests.' );
|
||
}
|
||
|
||
$result = $this->request( 'POST', '/$batch', array( 'requests' => $requests ) );
|
||
if ( is_wp_error( $result ) ) {
|
||
return $result;
|
||
}
|
||
|
||
$out = array();
|
||
foreach ( isset( $result['responses'] ) && is_array( $result['responses'] ) ? $result['responses'] : array() as $response ) {
|
||
$i = isset( $response['id'] ) ? (int) $response['id'] : -1;
|
||
if ( isset( $keys[ $i ] ) ) {
|
||
$out[ $keys[ $i ] ] = array(
|
||
'status' => isset( $response['status'] ) ? (int) $response['status'] : 0,
|
||
'body' => isset( $response['body'] ) ? $response['body'] : null,
|
||
);
|
||
}
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* Profile photo versions of several users (one batch request per 20 users).
|
||
*
|
||
* @param string[] $oids User object IDs.
|
||
* @return array oid => etag string, null (user has no photo) or WP_Error (could not be checked).
|
||
*/
|
||
public function photo_versions( $oids ) {
|
||
$out = array();
|
||
foreach ( array_chunk( array_values( array_filter( $oids, array( 'M365_Login_Settings', 'is_guid' ) ) ), 20 ) as $chunk ) {
|
||
$paths = array();
|
||
foreach ( $chunk as $oid ) {
|
||
$paths[ $oid ] = '/users/' . rawurlencode( strtolower( $oid ) ) . '/photo';
|
||
}
|
||
$responses = $this->batch_get( $paths );
|
||
foreach ( $chunk as $oid ) {
|
||
if ( is_wp_error( $responses ) ) {
|
||
$out[ $oid ] = $responses;
|
||
continue;
|
||
}
|
||
$response = isset( $responses[ $oid ] ) ? $responses[ $oid ] : array(
|
||
'status' => 0,
|
||
'body' => null,
|
||
);
|
||
if ( 404 === $response['status'] ) {
|
||
$out[ $oid ] = null;
|
||
} elseif ( 200 === $response['status'] && is_array( $response['body'] ) ) {
|
||
$etag = isset( $response['body']['@odata.mediaEtag'] ) ? (string) $response['body']['@odata.mediaEtag'] : '';
|
||
$out[ $oid ] = '' !== $etag ? $etag : md5( (string) wp_json_encode( $response['body'] ) );
|
||
} else {
|
||
$code = isset( $response['body']['error']['code'] ) ? (string) $response['body']['error']['code'] : 'HTTP ' . $response['status'];
|
||
$out[ $oid ] = new WP_Error( 'graph_photo', $code, array( 'status' => $response['status'] ) );
|
||
}
|
||
}
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* Downloads a user's photo (240×240 rendition, else the original).
|
||
*
|
||
* @param string $oid User object ID.
|
||
* @return string|null|WP_Error Binary image data, null when the user has no photo.
|
||
*/
|
||
public function photo_bytes( $oid ) {
|
||
if ( ! M365_Login_Settings::is_guid( $oid ) ) {
|
||
return new WP_Error( 'graph_bad_oid', 'Invalid user object ID.' );
|
||
}
|
||
$base = '/users/' . rawurlencode( strtolower( $oid ) );
|
||
foreach ( array( $base . '/photos/240x240/$value', $base . '/photo/$value' ) as $path ) {
|
||
$response = $this->raw_request( 'GET', $path, null, array( 'Accept' => 'image/*' ), true, 2 * MB_IN_BYTES + 1 );
|
||
if ( is_wp_error( $response ) ) {
|
||
return $response;
|
||
}
|
||
$code = (int) wp_remote_retrieve_response_code( $response );
|
||
$body = wp_remote_retrieve_body( $response );
|
||
if ( 200 === $code ) {
|
||
return $body;
|
||
}
|
||
if ( 404 !== $code ) {
|
||
return $this->error_from( $code, json_decode( $body, true ) );
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 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,groupTypes,visibility&$top=25&$orderby=displayName';
|
||
|
||
if ( '' !== $query && M365_Login_Settings::is_guid( $query ) ) {
|
||
$path = '/groups/' . rawurlencode( strtolower( $query ) ) . '?$select=id,displayName,description,securityEnabled,mailEnabled,groupTypes,visibility';
|
||
$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' );
|
||
}
|
||
$unified = isset( $item['groupTypes'] ) && is_array( $item['groupTypes'] ) && in_array( 'Unified', $item['groupTypes'], true );
|
||
if ( $unified && isset( $item['visibility'] ) && 'Public' === $item['visibility'] ) {
|
||
$type = __( 'Public Microsoft 365 group – anyone in the organisation can join', '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).
|
||
* @param bool $retry Retry on throttling (off in the interactive sign-in).
|
||
* @return string[]|WP_Error Matching group IDs.
|
||
*/
|
||
public function check_member_groups( $user_oid, $group_ids, $retry = true ) {
|
||
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 ),
|
||
array(),
|
||
$retry
|
||
);
|
||
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;
|
||
}
|
||
}
|