wp-m365-login/includes/class-m365-login-graph.php
Friederich Loheide 9b893e42bc Fix the findings of the third security audit
- 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>
2026-09-24 03:52:13 +00:00

494 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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;
}
}