- Add the button (and error messages) to every wp_login_form() form via login_form_top/login_form_bottom; in button-only mode the password fields are wrapped and hidden there. - New template functions m365_login_button() and m365_login_messages(); the shortcode gains divider and messages attributes. - New setting for the custom login page URL: failed sign-ins, the fallback link and the logout redirect point there instead of wp-login.php. Must be a same-site URL. - Button-only mode now blocks every interactive password sign-in through the authenticate filter, not only wp-login.php; XML-RPC, REST, WP-CLI and cron are exempt, plus a filter for trusted exceptions. - Fallback key accepted on any page (init) instead of login_init only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJxAHYdMfKPoN4koRc4Ci2
418 lines
13 KiB
PHP
418 lines
13 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;
|
|
|
|
/**
|
|
* Default settings.
|
|
*
|
|
* @return array
|
|
*/
|
|
public function defaults() {
|
|
return array(
|
|
// Connection.
|
|
'tenant_id' => '',
|
|
'client_id' => '',
|
|
'client_secret' => '', // Stored encrypted.
|
|
'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 : '';
|
|
}
|
|
|
|
/**
|
|
* Whether the plugin has everything it needs to start a login.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function is_configured() {
|
|
return '' !== $this->get( 'tenant_id' ) && '' !== $this->get( 'client_id' ) && '' !== $this->client_secret();
|
|
}
|
|
|
|
/**
|
|
* Tenant segment used in Microsoft endpoints.
|
|
*
|
|
* @return string
|
|
*/
|
|
public function tenant() {
|
|
$tenant = (string) $this->get( 'tenant_id', '' );
|
|
return '' === $tenant ? 'organizations' : $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 ) {
|
|
$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;
|
|
}
|
|
}
|
|
}
|
|
|
|
$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 );
|
|
}
|
|
}
|