wp-m365-login/includes/class-m365-login-settings.php
Friederich Loheide 1708bae91a Fix certificate storage being undone by the settings sanitiser
store_certificate() and remove_certificate() wrote the option with
update_option(), which runs the registered sanitize() callback in the
admin (including admin-ajax). sanitize() expects raw form input, so it
restored the previous certificate fields and encrypted the stored client
secret a second time: "Generate certificate" did not keep the new
certificate and broke an existing client secret.

Internal writes now bypass the form sanitiser.

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

596 lines
18 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.
);
}
/**
* 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;
}
/**
* 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() {
$raw = $this->get( 'allowed_groups', array() );
$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.
$groups = array();
if ( ! empty( $input['allowed_groups'] ) && is_array( $input['allowed_groups'] ) ) {
foreach ( $input['allowed_groups'] 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;
}
}
}
$out['allowed_groups'] = $groups;
// 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 );
$this->cache = null;
return $out;
}
/**
* 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 );
}
}