wp-m365-login/includes/class-m365-login-settings.php
Friederich Loheide 4edf20bc45
Some checks are pending
CI / PHP lint (7.4) (pull_request) Waiting to run
CI / PHP lint (8.0) (pull_request) Waiting to run
CI / PHP lint (8.1) (pull_request) Waiting to run
CI / PHP lint (8.2) (pull_request) Waiting to run
CI / PHP lint (8.3) (pull_request) Waiting to run
CI / PHP lint (8.4) (pull_request) Waiting to run
CI / WordPress Coding Standards (pull_request) Waiting to run
CI / WordPress.org Plugin Check (pull_request) Waiting to run
Add Microsoft 365 user sync with roles, profile fields and deprovisioning
New "User sync" tab that imports Microsoft 365 / Entra ID users as
WordPress accounts and keeps them up to date:

- Scope: whole tenant or the (nested) members of selected groups,
  guests optional, e-mail domain allow-list respected. Existing accounts
  are linked by e-mail address.
- Roles: selectable default role plus a group -> role mapping (in
  addition to or instead of the default role, first match wins).
  Roles of pre-existing accounts are only managed on request.
- Profile: selectable Graph attributes (names, job title, department,
  phones, address, language, ...) and the profile photo as avatar.
- Deprovisioning: accounts disabled or deleted in Microsoft 365 (or
  removed from the sync groups) are deactivated or deleted; accounts
  deactivated by the sync are reactivated automatically. Deactivated
  accounts lose every sign-in path and all sessions.
- Safeguards: dry run, safety stop above 20 % (min. 5) deprovisioning,
  abort on any Graph error, "deleted" only on a 404 for the object ID,
  protected pre-existing administrators and own account, content
  reassignment required for deletion, run lock.
- Runs manually, via WP-Cron or `wp m365-login sync [--dry-run]`.
- Users screen column with deactivate/reactivate row actions and a
  read-only Microsoft 365 section on the profile screen.

The Graph client gains paging, retry on throttling and user, group
member and photo endpoints. The group picker is now reusable.
Version 1.1.0, German translations (du/Sie), docs and audit addendum.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 16:34:30 +00:00

746 lines
24 KiB
PHP

<?php
/**
* Settings storage and sanitisation.
*
* @package M365_Login
*/
defined( 'ABSPATH' ) || exit;
/**
* Reads, sanitises and writes plugin settings.
*/
class M365_Login_Settings {
/**
* Cached settings.
*
* @var array|null
*/
private $cache = null;
/**
* Set while the plugin writes already sanitised values (skips the form sanitiser).
*
* @var bool
*/
private $raw_write = false;
/**
* Default settings.
*
* @return array
*/
public function defaults() {
return array(
// Connection.
'tenant_id' => '',
'client_id' => '',
'client_secret' => '', // Stored encrypted.
'auth_method' => 'secret', // 'secret' or 'certificate'.
'cert_private_key' => '', // PEM, stored encrypted.
'cert_certificate' => '', // PEM (public).
'prompt' => 'select_account',
// Security / matching.
'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.
'button_show_icon' => 1,
'button_bg' => '#2f2f2f',
'button_bg_hover' => '#1a1a1a',
'button_color' => '#ffffff',
'button_border' => '#2f2f2f',
'button_radius' => 4,
'button_position' => 'below',
'divider_text' => __( 'or', 'm365-login' ),
// Custom login pages.
'custom_login_url' => '',
'inject_form' => 1, // Add the button to wp_login_form() output.
// User sync.
'sync_enabled' => 0, // Scheduled sync via WP-Cron.
'sync_interval' => 'daily',
'sync_guests' => 0,
'sync_scope_groups' => array(), // id => display name; empty = whole tenant.
'sync_default_role' => 'subscriber',
'sync_role_map' => array(), // id => array( 'name' => .., 'role' => .. ), in priority order.
'sync_role_mode' => 'add', // 'add' (extra roles) or 'replace' (first match replaces the default role).
'sync_manage_existing' => 0, // Also manage roles of accounts that existed before the sync.
'sync_attributes' => array( 'displayName', 'givenName', 'surname' ),
'sync_disabled_action' => 'disable', // Account disabled in Microsoft 365: none|disable|delete.
'sync_deleted_action' => 'disable', // Account deleted in Microsoft 365: none|disable|delete.
'sync_scope_action' => 'none', // Removed from the sync groups: none|disable|delete.
'sync_reassign' => 0, // User ID that receives content of deleted users.
);
}
/**
* Returns all settings merged with defaults.
*
* @return array
*/
public function all() {
if ( null === $this->cache ) {
$stored = get_option( M365_LOGIN_OPTION, array() );
$this->cache = wp_parse_args( is_array( $stored ) ? $stored : array(), $this->defaults() );
}
return $this->cache;
}
/**
* Drops the cached settings (after the option was written).
*/
public function flush() {
$this->cache = null;
}
/**
* Returns a single setting.
*
* @param string $key Setting key.
* @param mixed $default Fallback.
* @return mixed
*/
public function get( $key, $default = null ) {
$all = $this->all();
return array_key_exists( $key, $all ) ? $all[ $key ] : $default;
}
/**
* Decrypted client secret.
*
* @return string
*/
public function client_secret() {
$enc = (string) $this->get( 'client_secret', '' );
if ( '' === $enc ) {
return '';
}
$plain = M365_Login_Crypto::decrypt( $enc );
return is_string( $plain ) ? $plain : '';
}
/**
* Selected client authentication method.
*
* @return string 'secret' or 'certificate'.
*/
public function auth_method() {
return 'certificate' === $this->get( 'auth_method' ) ? 'certificate' : 'secret';
}
/**
* Decrypted certificate private key (PEM) or ''.
*
* @return string
*/
public function certificate_key() {
$enc = (string) $this->get( 'cert_private_key', '' );
if ( '' === $enc ) {
return '';
}
$plain = M365_Login_Crypto::decrypt( $enc );
return is_string( $plain ) ? $plain : '';
}
/**
* Certificate PEM (public part) or ''.
*
* @return string
*/
public function certificate_pem() {
return (string) $this->get( 'cert_certificate', '' );
}
/**
* Whether a usable certificate + key pair is stored.
*
* @return bool
*/
public function has_certificate() {
return '' !== $this->certificate_pem() && '' !== $this->certificate_key();
}
/**
* Parsed certificate metadata or null.
*
* @return array|null
*/
public function certificate_info() {
return $this->has_certificate() ? M365_Login_Certificate::info( $this->certificate_pem() ) : null;
}
/**
* Whether the plugin has everything it needs to start a login.
*
* @return bool
*/
public function is_configured() {
if ( '' === $this->get( 'tenant_id' ) || '' === $this->get( 'client_id' ) ) {
return false;
}
if ( 'certificate' === $this->auth_method() ) {
$info = $this->certificate_info();
return null !== $info && ( 0 === $info['not_after'] || $info['not_after'] > time() );
}
return '' !== $this->client_secret();
}
/**
* Client authentication parameters for the token endpoint (secret or signed assertion).
*
* @param string $token_endpoint Token endpoint URL (assertion audience).
* @return array|WP_Error
*/
public function client_auth_params( $token_endpoint ) {
if ( 'certificate' === $this->auth_method() ) {
$assertion = M365_Login_Certificate::assertion( $this->certificate_key(), $this->certificate_pem(), (string) $this->get( 'client_id' ), $token_endpoint );
if ( is_wp_error( $assertion ) ) {
return $assertion;
}
return array(
'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
'client_assertion' => $assertion,
);
}
return array( 'client_secret' => $this->client_secret() );
}
/**
* Stores a validated key/certificate pair (key encrypted).
*
* @param array $pair array( 'private_key' => PEM, 'certificate' => PEM ).
* @return true|WP_Error
*/
public function store_certificate( $pair ) {
$enc = M365_Login_Crypto::encrypt( $pair['private_key'] );
if ( false === $enc ) {
return new WP_Error( 'encrypt', __( 'The private key could not be encrypted. Is the OpenSSL extension available?', 'm365-login' ) );
}
$all = $this->all();
$all['cert_private_key'] = $enc;
$all['cert_certificate'] = $pair['certificate'];
$this->write( $all );
return true;
}
/**
* Removes the stored certificate and key.
*/
public function remove_certificate() {
$all = $this->all();
$all['cert_private_key'] = '';
$all['cert_certificate'] = '';
$this->write( $all );
}
/**
* Stores already sanitised settings.
*
* The option is registered with sanitize() as callback, which expects raw form input
* (it would, for example, encrypt the stored client secret a second time).
*
* @param array $all Complete settings.
*/
private function write( $all ) {
$this->raw_write = true;
update_option( M365_LOGIN_OPTION, $all );
$this->raw_write = false;
$this->cache = null;
}
/**
* Tenant segment used in Microsoft endpoints.
*
* @return string
*/
public function tenant() {
$tenant = (string) $this->get( 'tenant_id', '' );
return '' === $tenant ? 'organizations' : $tenant;
}
/**
* Whether sign-ins from more than one tenant are accepted (no tenant GUID pinned).
*
* @return bool
*/
public function is_multi_tenant() {
return ! self::is_guid( $this->tenant() );
}
/**
* Redirect URI registered in Entra ID.
*
* @return string
*/
public function redirect_uri() {
if ( $this->uses_pretty_callback() ) {
$uri = home_url( '/m365-login/callback' );
} else {
$uri = add_query_arg( 'm365-login', 'callback', home_url( '/' ) );
}
/**
* Filters the redirect URI registered in Entra ID.
*
* @param string $uri Redirect URI.
*/
return (string) apply_filters( 'm365_login_redirect_uri', $uri );
}
/**
* Whether the callback can use a path (requires rewrite rules) instead of a query argument.
*
* @return bool
*/
public function uses_pretty_callback() {
return '' !== (string) get_option( 'permalink_structure', '' );
}
/**
* Allowed e-mail domains as an array (lowercase, no leading @).
*
* @return string[]
*/
public function allowed_domains() {
$raw = (string) $this->get( 'allowed_domains', '' );
if ( '' === trim( $raw ) ) {
return array();
}
$parts = preg_split( '/[\s,;]+/', strtolower( $raw ) );
$out = array();
foreach ( $parts as $p ) {
$p = ltrim( trim( $p ), '@' );
if ( '' !== $p ) {
$out[] = $p;
}
}
return array_values( array_unique( $out ) );
}
/**
* Allowed Entra group IDs (lowercase GUIDs) mapped to display names.
*
* @return array
*/
public function allowed_groups() {
return self::guid_map( $this->get( 'allowed_groups', array() ) );
}
/**
* Groups that limit the user sync (lowercase GUID => name); empty = whole tenant.
*
* @return array
*/
public function sync_scope_groups() {
return self::guid_map( $this->get( 'sync_scope_groups', array() ) );
}
/**
* Group → role mapping in priority order.
*
* @return array lowercase GUID => array( 'name' => string, 'role' => string ).
*/
public function sync_role_map() {
$raw = $this->get( 'sync_role_map', array() );
$out = array();
if ( is_array( $raw ) ) {
foreach ( $raw as $id => $row ) {
$id = strtolower( (string) $id );
if ( self::is_guid( $id ) && is_array( $row ) && ! empty( $row['role'] ) ) {
$out[ $id ] = array(
'name' => isset( $row['name'] ) ? (string) $row['name'] : $id,
'role' => (string) $row['role'],
);
}
}
}
return $out;
}
/**
* Keeps GUID keys (lowercased) of an id => name array.
*
* @param mixed $raw Stored value.
* @return array
*/
private static function guid_map( $raw ) {
$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, $this->login_page_url() );
}
/**
* URL of a custom login page (same site), or empty string.
*
* @return string
*/
public function custom_login_url() {
$url = (string) $this->get( 'custom_login_url', '' );
if ( '' === $url ) {
return '';
}
$validated = wp_validate_redirect( $url, '' );
return is_string( $validated ) ? $validated : '';
}
/**
* Where users land after a failed Microsoft sign-in (custom page or wp-login.php).
*
* @return string
*/
public function login_page_url() {
$custom = $this->custom_login_url();
return '' !== $custom ? $custom : 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.
*
* @param array $input Raw input.
* @return array
*/
public function sanitize( $input ) {
if ( $this->raw_write ) {
return $input;
}
$defaults = $this->defaults();
$current = $this->all();
$input = is_array( $input ) ? $input : array();
$out = $current;
// Tenant: GUID or one of the well-known aliases.
$tenant = isset( $input['tenant_id'] ) ? trim( sanitize_text_field( wp_unslash( $input['tenant_id'] ) ) ) : '';
$tenant = strtolower( $tenant );
if ( '' !== $tenant && ! self::is_valid_tenant( $tenant ) ) {
add_settings_error( M365_LOGIN_OPTION, 'tenant_id', __( 'The tenant ID must be a GUID (e.g. 1a2b3c4d-…) or one of "organizations", "common", "consumers".', 'm365-login' ) );
$tenant = $current['tenant_id'];
}
$out['tenant_id'] = $tenant;
// Client ID: GUID.
$client_id = isset( $input['client_id'] ) ? trim( sanitize_text_field( wp_unslash( $input['client_id'] ) ) ) : '';
if ( '' !== $client_id && ! self::is_guid( $client_id ) ) {
add_settings_error( M365_LOGIN_OPTION, 'client_id', __( 'The application (client) ID must be a GUID.', 'm365-login' ) );
$client_id = $current['client_id'];
}
$out['client_id'] = strtolower( $client_id );
// Client secret: only replaced when a new value was entered.
$secret_input = isset( $input['client_secret'] ) ? (string) wp_unslash( $input['client_secret'] ) : '';
$secret_input = trim( $secret_input );
if ( ! empty( $input['client_secret_clear'] ) ) {
$out['client_secret'] = '';
} elseif ( '' !== $secret_input ) {
if ( strlen( $secret_input ) > 512 || preg_match( '/[\x00-\x1F\x7F]/', $secret_input ) ) {
add_settings_error( M365_LOGIN_OPTION, 'client_secret', __( 'The client secret contains invalid characters.', 'm365-login' ) );
} else {
$enc = M365_Login_Crypto::encrypt( $secret_input );
if ( false === $enc ) {
add_settings_error( M365_LOGIN_OPTION, 'client_secret', __( 'The client secret could not be encrypted. Is the OpenSSL extension available?', 'm365-login' ) );
} else {
$out['client_secret'] = $enc;
}
}
}
$method = isset( $input['auth_method'] ) ? sanitize_key( $input['auth_method'] ) : 'secret';
$out['auth_method'] = 'certificate' === $method ? 'certificate' : 'secret';
// Certificate: keep the stored pair unless a new one is pasted or removal is requested.
$out['cert_private_key'] = $current['cert_private_key'];
$out['cert_certificate'] = $current['cert_certificate'];
$pasted_key = isset( $input['cert_key_pem'] ) ? trim( (string) wp_unslash( $input['cert_key_pem'] ) ) : '';
$pasted_cert = isset( $input['cert_cert_pem'] ) ? trim( (string) wp_unslash( $input['cert_cert_pem'] ) ) : '';
if ( ! empty( $input['cert_remove'] ) ) {
$out['cert_private_key'] = '';
$out['cert_certificate'] = '';
} elseif ( '' !== $pasted_key || '' !== $pasted_cert ) {
if ( '' === $pasted_key || '' === $pasted_cert ) {
add_settings_error( M365_LOGIN_OPTION, 'certificate', __( 'Please paste both the private key and the certificate.', 'm365-login' ) );
} elseif ( strlen( $pasted_key ) > 20000 || strlen( $pasted_cert ) > 20000 ) {
add_settings_error( M365_LOGIN_OPTION, 'certificate', __( 'The pasted key or certificate is too large.', 'm365-login' ) );
} else {
$pair = M365_Login_Certificate::from_pem( $pasted_key, $pasted_cert );
if ( is_wp_error( $pair ) ) {
add_settings_error( M365_LOGIN_OPTION, 'certificate', $pair->get_error_message() );
} else {
$enc = M365_Login_Crypto::encrypt( $pair['private_key'] );
if ( false === $enc ) {
add_settings_error( M365_LOGIN_OPTION, 'certificate', __( 'The private key could not be encrypted. Is the OpenSSL extension available?', 'm365-login' ) );
} else {
$out['cert_private_key'] = $enc;
$out['cert_certificate'] = $pair['certificate'];
}
}
}
}
if ( 'certificate' === $out['auth_method'] && '' === $out['cert_certificate'] ) {
add_settings_error( M365_LOGIN_OPTION, 'auth_method', __( 'Certificate authentication is selected but no certificate is stored yet. Generate one or paste your own; the Microsoft button stays hidden until then.', 'm365-login' ), 'warning' );
}
$prompt = isset( $input['prompt'] ) ? sanitize_key( $input['prompt'] ) : '';
$out['prompt'] = in_array( $prompt, array( 'none', 'select_account', 'login' ), true ) ? $prompt : 'none';
$out['upn_fallback'] = empty( $input['upn_fallback'] ) ? 0 : 1;
$out['bind_oid'] = empty( $input['bind_oid'] ) ? 0 : 1;
$out['remember_me'] = empty( $input['remember_me'] ) ? 0 : 1;
$domains = isset( $input['allowed_domains'] ) ? sanitize_textarea_field( wp_unslash( $input['allowed_domains'] ) ) : '';
$domains = preg_replace( '/[^a-z0-9.\-@,;\s]/i', '', $domains );
$out['allowed_domains'] = trim( (string) $domains );
// Allowed groups: GUID => name.
$out['allowed_groups'] = self::sanitize_group_list( isset( $input['allowed_groups'] ) ? $input['allowed_groups'] : array() );
// 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;
// Custom login page (must be on this site).
$custom = isset( $input['custom_login_url'] ) ? esc_url_raw( trim( wp_unslash( $input['custom_login_url'] ) ) ) : '';
if ( '' !== $custom ) {
if ( 0 === strpos( $custom, '/' ) ) {
$custom = home_url( $custom );
}
if ( '' === wp_validate_redirect( $custom, '' ) ) {
add_settings_error( M365_LOGIN_OPTION, 'custom_login_url', __( 'The custom login page must be a URL on this site.', 'm365-login' ) );
$custom = $current['custom_login_url'];
}
}
$out['custom_login_url'] = $custom;
$out['inject_form'] = empty( $input['inject_form'] ) ? 0 : 1;
// 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 );
$icon = isset( $input['button_icon'] ) ? esc_url_raw( trim( wp_unslash( $input['button_icon'] ) ) ) : '';
$out['button_icon'] = self::is_safe_image_url( $icon ) ? $icon : '';
$out['button_show_icon'] = empty( $input['button_show_icon'] ) ? 0 : 1;
foreach ( array( 'button_bg', 'button_bg_hover', 'button_color', 'button_border' ) as $color_key ) {
$color = isset( $input[ $color_key ] ) ? sanitize_hex_color( trim( wp_unslash( $input[ $color_key ] ) ) ) : '';
$out[ $color_key ] = $color ? $color : $defaults[ $color_key ];
}
$radius = isset( $input['button_radius'] ) ? absint( $input['button_radius'] ) : $defaults['button_radius'];
$out['button_radius'] = min( 50, $radius );
$position = isset( $input['button_position'] ) ? sanitize_key( $input['button_position'] ) : 'below';
$out['button_position'] = in_array( $position, array( 'above', 'below' ), true ) ? $position : 'below';
$divider = isset( $input['divider_text'] ) ? sanitize_text_field( wp_unslash( $input['divider_text'] ) ) : '';
$out['divider_text'] = mb_substr( $divider, 0, 40 );
$out = $this->sanitize_sync( $input, $out );
$this->cache = null;
return $out;
}
/**
* Sanitises the user sync settings.
*
* @param array $input Raw input.
* @param array $out Settings sanitised so far.
* @return array
*/
private function sanitize_sync( $input, $out ) {
$defaults = $this->defaults();
$out['sync_enabled'] = empty( $input['sync_enabled'] ) ? 0 : 1;
$out['sync_guests'] = empty( $input['sync_guests'] ) ? 0 : 1;
$out['sync_manage_existing'] = empty( $input['sync_manage_existing'] ) ? 0 : 1;
$interval = isset( $input['sync_interval'] ) ? sanitize_key( $input['sync_interval'] ) : '';
$out['sync_interval'] = in_array( $interval, array( 'hourly', 'twicedaily', 'daily' ), true ) ? $interval : $defaults['sync_interval'];
$mode = isset( $input['sync_role_mode'] ) ? sanitize_key( $input['sync_role_mode'] ) : '';
$out['sync_role_mode'] = in_array( $mode, array( 'add', 'replace' ), true ) ? $mode : $defaults['sync_role_mode'];
$role = isset( $input['sync_default_role'] ) ? sanitize_key( $input['sync_default_role'] ) : '';
$out['sync_default_role'] = '' !== $role && get_role( $role ) ? $role : $defaults['sync_default_role'];
$out['sync_scope_groups'] = self::sanitize_group_list( isset( $input['sync_scope_groups'] ) ? $input['sync_scope_groups'] : array() );
$map = array();
if ( ! empty( $input['sync_role_map'] ) && is_array( $input['sync_role_map'] ) ) {
foreach ( $input['sync_role_map'] as $id => $row ) {
$id = strtolower( trim( sanitize_text_field( wp_unslash( (string) $id ) ) ) );
if ( ! self::is_guid( $id ) || ! is_array( $row ) ) {
continue;
}
$map_role = isset( $row['role'] ) ? sanitize_key( $row['role'] ) : '';
if ( '' === $map_role || ! get_role( $map_role ) ) {
continue;
}
$name = isset( $row['name'] ) ? sanitize_text_field( wp_unslash( (string) $row['name'] ) ) : '';
$map[ $id ] = array(
'name' => '' === $name ? $id : mb_substr( $name, 0, 120 ),
'role' => $map_role,
);
if ( count( $map ) >= 100 ) {
break;
}
}
}
$out['sync_role_map'] = $map;
$attributes = array();
if ( ! empty( $input['sync_attributes'] ) && is_array( $input['sync_attributes'] ) ) {
$known = array_keys( M365_Login_Sync::attributes() );
foreach ( $input['sync_attributes'] as $attribute ) {
$attribute = sanitize_text_field( wp_unslash( (string) $attribute ) );
if ( in_array( $attribute, $known, true ) ) {
$attributes[] = $attribute;
}
}
}
$out['sync_attributes'] = array_values( array_unique( $attributes ) );
foreach ( array( 'sync_disabled_action', 'sync_deleted_action', 'sync_scope_action' ) as $key ) {
$action = isset( $input[ $key ] ) ? sanitize_key( $input[ $key ] ) : '';
$out[ $key ] = in_array( $action, array( 'none', 'disable', 'delete' ), true ) ? $action : $defaults[ $key ];
}
$reassign = isset( $input['sync_reassign'] ) ? absint( $input['sync_reassign'] ) : 0;
$out['sync_reassign'] = $reassign && get_userdata( $reassign ) ? $reassign : 0;
$deletes = in_array( 'delete', array( $out['sync_disabled_action'], $out['sync_deleted_action'], $out['sync_scope_action'] ), true );
if ( $deletes && ! $out['sync_reassign'] ) {
add_settings_error( M365_LOGIN_OPTION, 'sync_reassign', __( 'User sync: "Delete" needs a user who receives the posts of deleted accounts. Until one is selected, accounts are deactivated instead.', 'm365-login' ), 'warning' );
}
return $out;
}
/**
* Sanitises a GUID => name list posted by a group picker.
*
* @param mixed $raw Raw input.
* @return array
*/
private static function sanitize_group_list( $raw ) {
$groups = array();
if ( empty( $raw ) || ! is_array( $raw ) ) {
return $groups;
}
foreach ( $raw 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;
}
}
return $groups;
}
/**
* Checks a GUID.
*
* @param string $value Value.
* @return bool
*/
public static function is_guid( $value ) {
return (bool) preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $value );
}
/**
* Checks a tenant identifier.
*
* @param string $value Value.
* @return bool
*/
public static function is_valid_tenant( $value ) {
return self::is_guid( $value ) || in_array( $value, array( 'organizations', 'common', 'consumers' ), true );
}
/**
* Only allows http(s) image URLs with a known image extension.
*
* @param string $url URL.
* @return bool
*/
public static function is_safe_image_url( $url ) {
if ( '' === $url ) {
return false;
}
$parts = wp_parse_url( $url );
if ( empty( $parts['scheme'] ) || ! in_array( strtolower( $parts['scheme'] ), array( 'http', 'https' ), true ) ) {
return false;
}
$path = isset( $parts['path'] ) ? strtolower( $parts['path'] ) : '';
return (bool) preg_match( '/\.(png|jpe?g|gif|svg|webp)$/', $path );
}
}