Add M365 Login plugin: Microsoft Entra ID sign-in for existing users
Adds a WordPress plugin that places a customisable "Sign in with Microsoft" button on wp-login.php and signs existing users in via the OpenID Connect authorization code flow with PKCE. Users are matched by e-mail address only; no accounts are created. Security: single-use state/nonce bound to an HttpOnly cookie, ID token signature verification against Microsoft's JWKS (RS256 only) with issuer/audience/tenant/expiry/nonce checks, optional tenant pinning, account binding to the Microsoft object ID, e-mail domain allow-list, client secret encrypted at rest (AES-256-GCM). Admin: settings screen with connection, button and security tabs, live button preview, colour presets, media-library icon picker, redirect URI copy button and tenant connectivity test. Packaging for WordPress.org: readme.txt with External services section, GPL-2.0 license, uninstall.php, POT + German translations, .distignore, build script, PHPCS config and CI running Plugin Check. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJxAHYdMfKPoN4koRc4Ci2
This commit is contained in:
commit
3e3e87b399
35 changed files with 5413 additions and 0 deletions
460
includes/class-m365-login-admin.php
Normal file
460
includes/class-m365-login-admin.php
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
<?php
|
||||
/**
|
||||
* Admin settings screen.
|
||||
*
|
||||
* @package M365_Login
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Registers and renders the settings page.
|
||||
*/
|
||||
class M365_Login_Admin {
|
||||
|
||||
const PAGE = 'm365-login';
|
||||
const GROUP = 'm365_login';
|
||||
const AJAX_TEST = 'm365_login_test_connection';
|
||||
const NONCE_TEST = 'm365_login_test';
|
||||
|
||||
/**
|
||||
* Settings.
|
||||
*
|
||||
* @var M365_Login_Settings
|
||||
*/
|
||||
private $settings;
|
||||
|
||||
/**
|
||||
* Auth component (for endpoint URLs and discovery).
|
||||
*
|
||||
* @var M365_Login_Auth
|
||||
*/
|
||||
private $auth;
|
||||
|
||||
/**
|
||||
* Screen hook suffix.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $hook = '';
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param M365_Login_Settings $settings Settings.
|
||||
* @param M365_Login_Auth $auth Auth.
|
||||
*/
|
||||
public function __construct( M365_Login_Settings $settings, M365_Login_Auth $auth ) {
|
||||
$this->settings = $settings;
|
||||
$this->auth = $auth;
|
||||
|
||||
add_action( 'admin_menu', array( $this, 'menu' ) );
|
||||
add_action( 'admin_init', array( $this, 'register' ) );
|
||||
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
|
||||
add_action( 'wp_ajax_' . self::AJAX_TEST, array( $this, 'ajax_test_connection' ) );
|
||||
add_action( 'admin_notices', array( $this, 'setup_notice' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the menu entry under Settings.
|
||||
*/
|
||||
public function menu() {
|
||||
$this->hook = add_options_page(
|
||||
__( 'M365 Login', 'm365-login' ),
|
||||
__( 'M365 Login', 'm365-login' ),
|
||||
'manage_options',
|
||||
self::PAGE,
|
||||
array( $this, 'render' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the option with the Settings API.
|
||||
*/
|
||||
public function register() {
|
||||
register_setting(
|
||||
self::GROUP,
|
||||
M365_LOGIN_OPTION,
|
||||
array(
|
||||
'type' => 'array',
|
||||
'sanitize_callback' => array( $this->settings, 'sanitize' ),
|
||||
'default' => $this->settings->defaults(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nudges administrators to finish the setup.
|
||||
*/
|
||||
public function setup_notice() {
|
||||
if ( $this->settings->is_configured() || ! current_user_can( 'manage_options' ) ) {
|
||||
return;
|
||||
}
|
||||
$screen = get_current_screen();
|
||||
if ( $screen && $this->hook === $screen->id ) {
|
||||
return;
|
||||
}
|
||||
if ( ! $screen || ! in_array( $screen->id, array( 'plugins', 'dashboard' ), true ) ) {
|
||||
return;
|
||||
}
|
||||
printf(
|
||||
'<div class="notice notice-info is-dismissible"><p>%s <a href="%s">%s</a></p></div>',
|
||||
esc_html__( 'M365 Login is active but not connected to Microsoft Entra ID yet.', 'm365-login' ),
|
||||
esc_url( admin_url( 'options-general.php?page=' . self::PAGE ) ),
|
||||
esc_html__( 'Open the settings', 'm365-login' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads assets on our screen only.
|
||||
*
|
||||
* @param string $hook Current screen hook.
|
||||
*/
|
||||
public function enqueue( $hook ) {
|
||||
if ( $hook !== $this->hook ) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_enqueue_media();
|
||||
wp_enqueue_style( 'wp-color-picker' );
|
||||
wp_enqueue_style( 'm365-login-admin', M365_LOGIN_URL . 'assets/css/admin.css', array( 'wp-color-picker' ), M365_LOGIN_VERSION );
|
||||
wp_enqueue_script( 'm365-login-admin', M365_LOGIN_URL . 'assets/js/admin.js', array( 'jquery', 'wp-color-picker' ), M365_LOGIN_VERSION, true );
|
||||
|
||||
wp_localize_script(
|
||||
'm365-login-admin',
|
||||
'm365LoginAdmin',
|
||||
array(
|
||||
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||
'nonce' => wp_create_nonce( self::NONCE_TEST ),
|
||||
'action' => self::AJAX_TEST,
|
||||
'defaultLogo' => M365_Login_Button::microsoft_logo(),
|
||||
'i18n' => array(
|
||||
'chooseIcon' => __( 'Choose button icon', 'm365-login' ),
|
||||
'useIcon' => __( 'Use this icon', 'm365-login' ),
|
||||
'copied' => __( 'Copied!', 'm365-login' ),
|
||||
'copy' => __( 'Copy', 'm365-login' ),
|
||||
'testing' => __( 'Testing…', 'm365-login' ),
|
||||
'testFailed' => __( 'The tenant could not be reached. Check the tenant ID and the server’s outgoing connections.', 'm365-login' ),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: fetch the OpenID configuration for the tenant typed into the form.
|
||||
*/
|
||||
public function ajax_test_connection() {
|
||||
check_ajax_referer( self::NONCE_TEST, 'nonce' );
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'm365-login' ) ), 403 );
|
||||
}
|
||||
|
||||
$tenant = isset( $_POST['tenant'] ) ? strtolower( sanitize_text_field( wp_unslash( $_POST['tenant'] ) ) ) : '';
|
||||
if ( '' === $tenant || ! M365_Login_Settings::is_valid_tenant( $tenant ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Please enter a valid tenant ID first.', 'm365-login' ) ) );
|
||||
}
|
||||
|
||||
$url = 'https://login.microsoftonline.com/' . rawurlencode( $tenant ) . '/v2.0/.well-known/openid-configuration';
|
||||
$response = wp_remote_get( $url, array( 'timeout' => M365_Login_Auth::HTTP_TIMEOUT ) );
|
||||
if ( is_wp_error( $response ) ) {
|
||||
wp_send_json_error( array( 'message' => $response->get_error_message() ) );
|
||||
}
|
||||
$code = (int) wp_remote_retrieve_response_code( $response );
|
||||
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
if ( 200 !== $code || ! is_array( $body ) || empty( $body['issuer'] ) ) {
|
||||
wp_send_json_error(
|
||||
array(
|
||||
/* translators: %d: HTTP status code */
|
||||
'message' => sprintf( __( 'Microsoft answered with HTTP %d. Is the tenant ID correct?', 'm365-login' ), $code ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'issuer' => esc_url_raw( $body['issuer'] ),
|
||||
'endpoint' => isset( $body['authorization_endpoint'] ) ? esc_url_raw( $body['authorization_endpoint'] ) : '',
|
||||
'message' => __( 'Tenant reachable. The OpenID configuration was loaded successfully.', 'm365-login' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the settings screen.
|
||||
*/
|
||||
public function render() {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_die( esc_html__( 'You are not allowed to access this page.', 'm365-login' ) );
|
||||
}
|
||||
|
||||
$s = $this->settings->all();
|
||||
$configured = $this->settings->is_configured();
|
||||
$has_secret = '' !== $this->settings->client_secret();
|
||||
$option = M365_LOGIN_OPTION;
|
||||
$field = function ( $key ) use ( $option ) {
|
||||
return esc_attr( $option . '[' . $key . ']' );
|
||||
};
|
||||
?>
|
||||
<div class="wrap m365-admin">
|
||||
<header class="m365-admin__header">
|
||||
<div class="m365-admin__brand">
|
||||
<span class="m365-admin__logo"><?php echo M365_Login_Button::microsoft_logo(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG. ?></span>
|
||||
<div>
|
||||
<h1><?php esc_html_e( 'M365 Login', 'm365-login' ); ?></h1>
|
||||
<p><?php esc_html_e( 'Let existing users sign in with their Microsoft 365 / Entra ID account.', 'm365-login' ); ?></p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="m365-admin__status <?php echo $configured ? 'is-ok' : 'is-pending'; ?>">
|
||||
<span class="m365-admin__status-dot"></span>
|
||||
<?php echo $configured ? esc_html__( 'Connected', 'm365-login' ) : esc_html__( 'Setup incomplete', 'm365-login' ); ?>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<form method="post" action="options.php" class="m365-admin__form" novalidate>
|
||||
<?php settings_fields( self::GROUP ); ?>
|
||||
|
||||
<nav class="m365-admin__tabs" role="tablist">
|
||||
<button type="button" class="m365-admin__tab is-active" role="tab" data-tab="connection" aria-selected="true"><?php esc_html_e( 'Connection', 'm365-login' ); ?></button>
|
||||
<button type="button" class="m365-admin__tab" role="tab" data-tab="button" aria-selected="false"><?php esc_html_e( 'Button', 'm365-login' ); ?></button>
|
||||
<button type="button" class="m365-admin__tab" role="tab" data-tab="security" aria-selected="false"><?php esc_html_e( 'Security', 'm365-login' ); ?></button>
|
||||
</nav>
|
||||
|
||||
<div class="m365-admin__layout">
|
||||
<div class="m365-admin__main">
|
||||
|
||||
<!-- Connection -->
|
||||
<section class="m365-admin__panel is-active" data-panel="connection">
|
||||
<div class="m365-card">
|
||||
<h2 class="m365-card__title"><?php esc_html_e( 'Microsoft Entra ID app registration', 'm365-login' ); ?></h2>
|
||||
<p class="m365-card__intro"><?php esc_html_e( 'Enter the values from your app registration in the Microsoft Entra admin center.', 'm365-login' ); ?></p>
|
||||
|
||||
<div class="m365-field">
|
||||
<label for="m365-tenant"><?php esc_html_e( 'Directory (tenant) ID', 'm365-login' ); ?></label>
|
||||
<div class="m365-field__row">
|
||||
<input type="text" id="m365-tenant" name="<?php echo $field( 'tenant_id' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="<?php echo esc_attr( $s['tenant_id'] ); ?>" class="regular-text code" placeholder="00000000-0000-0000-0000-000000000000" autocomplete="off" spellcheck="false" />
|
||||
<button type="button" class="button" id="m365-test"><?php esc_html_e( 'Test tenant', 'm365-login' ); ?></button>
|
||||
</div>
|
||||
<p class="description"><?php esc_html_e( 'Recommended: the GUID of your tenant. Only sign-ins from this tenant are accepted. "organizations" allows any work or school account.', 'm365-login' ); ?></p>
|
||||
<div id="m365-test-result" class="m365-inline-result" hidden></div>
|
||||
</div>
|
||||
|
||||
<div class="m365-field">
|
||||
<label for="m365-client-id"><?php esc_html_e( 'Application (client) ID', 'm365-login' ); ?></label>
|
||||
<input type="text" id="m365-client-id" name="<?php echo $field( 'client_id' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="<?php echo esc_attr( $s['client_id'] ); ?>" class="regular-text code" placeholder="00000000-0000-0000-0000-000000000000" autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
|
||||
<div class="m365-field">
|
||||
<label for="m365-client-secret"><?php esc_html_e( 'Client secret', 'm365-login' ); ?></label>
|
||||
<div class="m365-field__row">
|
||||
<input type="password" id="m365-client-secret" name="<?php echo $field( 'client_secret' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="" class="regular-text code" autocomplete="new-password" placeholder="<?php echo $has_secret ? esc_attr__( '•••••••••••• (stored, leave empty to keep)', 'm365-login' ) : esc_attr__( 'Paste the secret value', 'm365-login' ); ?>" />
|
||||
<button type="button" class="button m365-toggle-secret" aria-label="<?php esc_attr_e( 'Show secret', 'm365-login' ); ?>"><span class="dashicons dashicons-visibility"></span></button>
|
||||
</div>
|
||||
<?php if ( $has_secret ) : ?>
|
||||
<label class="m365-check m365-check--inline">
|
||||
<input type="checkbox" name="<?php echo $field( 'client_secret_clear' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" />
|
||||
<?php esc_html_e( 'Remove the stored secret', 'm365-login' ); ?>
|
||||
</label>
|
||||
<?php endif; ?>
|
||||
<p class="description"><?php esc_html_e( 'Stored encrypted (AES-256-GCM, key derived from your WordPress salts) and never displayed again. Client secrets expire – note the expiry date in Entra ID.', 'm365-login' ); ?></p>
|
||||
</div>
|
||||
|
||||
<div class="m365-field">
|
||||
<label for="m365-prompt"><?php esc_html_e( 'Account prompt', 'm365-login' ); ?></label>
|
||||
<select id="m365-prompt" name="<?php echo $field( 'prompt' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>">
|
||||
<option value="select_account" <?php selected( $s['prompt'], 'select_account' ); ?>><?php esc_html_e( 'Always let the user pick an account (recommended)', 'm365-login' ); ?></option>
|
||||
<option value="none" <?php selected( $s['prompt'], 'none' ); ?>><?php esc_html_e( 'Use the current Microsoft session if available', 'm365-login' ); ?></option>
|
||||
<option value="login" <?php selected( $s['prompt'], 'login' ); ?>><?php esc_html_e( 'Always require re-entering credentials', 'm365-login' ); ?></option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Button -->
|
||||
<section class="m365-admin__panel" data-panel="button">
|
||||
<div class="m365-card">
|
||||
<h2 class="m365-card__title"><?php esc_html_e( 'Appearance', 'm365-login' ); ?></h2>
|
||||
|
||||
<div class="m365-preview">
|
||||
<span class="m365-preview__label"><?php esc_html_e( 'Live preview', 'm365-login' ); ?></span>
|
||||
<div class="m365-preview__stage">
|
||||
<div class="m365-login m365-login--preview" id="m365-preview" style="<?php echo esc_attr( str_replace( array( '.m365-login{', '}' ), '', M365_Login::instance()->button->css_variables() ) ); ?>">
|
||||
<div class="m365-login__divider"><span id="m365-preview-divider"><?php echo esc_html( $s['divider_text'] ); ?></span></div>
|
||||
<a class="m365-login__button" href="#" onclick="return false;" id="m365-preview-button">
|
||||
<span id="m365-preview-icon"><?php echo M365_Login_Button::microsoft_logo(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG. ?></span>
|
||||
<span class="m365-login__label" id="m365-preview-text"><?php echo esc_html( $s['button_text'] ); ?></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m365-grid">
|
||||
<div class="m365-field">
|
||||
<label for="m365-button-text"><?php esc_html_e( 'Button text', 'm365-login' ); ?></label>
|
||||
<input type="text" id="m365-button-text" name="<?php echo $field( 'button_text' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="<?php echo esc_attr( $s['button_text'] ); ?>" class="regular-text" maxlength="80" data-preview="text" />
|
||||
</div>
|
||||
<div class="m365-field">
|
||||
<label for="m365-divider-text"><?php esc_html_e( 'Divider text', 'm365-login' ); ?></label>
|
||||
<input type="text" id="m365-divider-text" name="<?php echo $field( 'divider_text' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="<?php echo esc_attr( $s['divider_text'] ); ?>" class="regular-text" maxlength="40" data-preview="divider" />
|
||||
<p class="description"><?php esc_html_e( 'Leave empty to hide the divider line.', 'm365-login' ); ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m365-field">
|
||||
<span class="m365-field__label"><?php esc_html_e( 'Icon', 'm365-login' ); ?></span>
|
||||
<label class="m365-check">
|
||||
<input type="checkbox" name="<?php echo $field( 'button_show_icon' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['button_show_icon'] ); ?> data-preview="show-icon" />
|
||||
<?php esc_html_e( 'Show an icon on the button', 'm365-login' ); ?>
|
||||
</label>
|
||||
<div class="m365-icon-picker">
|
||||
<div class="m365-icon-picker__thumb" id="m365-icon-thumb">
|
||||
<?php if ( '' !== $s['button_icon'] ) : ?>
|
||||
<img src="<?php echo esc_url( $s['button_icon'] ); ?>" alt="" />
|
||||
<?php else : ?>
|
||||
<?php echo M365_Login_Button::microsoft_logo(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG. ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="m365-icon-picker__controls">
|
||||
<input type="url" id="m365-icon-url" name="<?php echo $field( 'button_icon' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="<?php echo esc_url( $s['button_icon'] ); ?>" class="regular-text code" placeholder="<?php esc_attr_e( 'Default: Microsoft logo', 'm365-login' ); ?>" data-preview="icon" />
|
||||
<div class="m365-field__row">
|
||||
<button type="button" class="button" id="m365-icon-choose"><?php esc_html_e( 'Choose from media library', 'm365-login' ); ?></button>
|
||||
<button type="button" class="button-link m365-link-danger" id="m365-icon-reset"><?php esc_html_e( 'Use Microsoft logo', 'm365-login' ); ?></button>
|
||||
</div>
|
||||
<p class="description"><?php esc_html_e( 'PNG, SVG, JPG or WebP. Square images (e.g. 64×64 px) work best.', 'm365-login' ); ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m365-grid m365-grid--4">
|
||||
<?php
|
||||
$colors = array(
|
||||
'button_bg' => __( 'Background', 'm365-login' ),
|
||||
'button_bg_hover' => __( 'Background (hover)', 'm365-login' ),
|
||||
'button_color' => __( 'Text colour', 'm365-login' ),
|
||||
'button_border' => __( 'Border', 'm365-login' ),
|
||||
);
|
||||
foreach ( $colors as $key => $label ) :
|
||||
?>
|
||||
<div class="m365-field">
|
||||
<label for="m365-<?php echo esc_attr( $key ); ?>"><?php echo esc_html( $label ); ?></label>
|
||||
<input type="text" id="m365-<?php echo esc_attr( $key ); ?>" name="<?php echo $field( $key ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="<?php echo esc_attr( $s[ $key ] ); ?>" class="m365-color" data-default-color="<?php echo esc_attr( $this->settings->defaults()[ $key ] ); ?>" data-preview="<?php echo esc_attr( str_replace( 'button_', '', $key ) ); ?>" />
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<div class="m365-grid">
|
||||
<div class="m365-field">
|
||||
<label for="m365-radius"><?php esc_html_e( 'Corner radius', 'm365-login' ); ?> <span class="m365-range-value" id="m365-radius-value"><?php echo esc_html( $s['button_radius'] ); ?> px</span></label>
|
||||
<input type="range" id="m365-radius" name="<?php echo $field( 'button_radius' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="<?php echo esc_attr( $s['button_radius'] ); ?>" min="0" max="50" step="1" data-preview="radius" />
|
||||
</div>
|
||||
<div class="m365-field">
|
||||
<label for="m365-position"><?php esc_html_e( 'Position on the login page', 'm365-login' ); ?></label>
|
||||
<select id="m365-position" name="<?php echo $field( 'button_position' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>">
|
||||
<option value="below" <?php selected( $s['button_position'], 'below' ); ?>><?php esc_html_e( 'Below the login form', 'm365-login' ); ?></option>
|
||||
<option value="above" <?php selected( $s['button_position'], 'above' ); ?>><?php esc_html_e( 'Above the login form', 'm365-login' ); ?></option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m365-presets">
|
||||
<span class="m365-field__label"><?php esc_html_e( 'Quick presets', 'm365-login' ); ?></span>
|
||||
<button type="button" class="m365-preset" data-preset='{"bg":"#2f2f2f","bg_hover":"#1a1a1a","color":"#ffffff","border":"#2f2f2f"}'><span style="background:#2f2f2f"></span><?php esc_html_e( 'Microsoft dark', 'm365-login' ); ?></button>
|
||||
<button type="button" class="m365-preset" data-preset='{"bg":"#ffffff","bg_hover":"#f3f3f3","color":"#5e5e5e","border":"#8c8c8c"}'><span style="background:#ffffff;border-color:#8c8c8c"></span><?php esc_html_e( 'Microsoft light', 'm365-login' ); ?></button>
|
||||
<button type="button" class="m365-preset" data-preset='{"bg":"#0078d4","bg_hover":"#106ebe","color":"#ffffff","border":"#0078d4"}'><span style="background:#0078d4"></span><?php esc_html_e( 'Azure blue', 'm365-login' ); ?></button>
|
||||
<button type="button" class="m365-preset" data-preset='{"bg":"#2271b1","bg_hover":"#135e96","color":"#ffffff","border":"#2271b1"}'><span style="background:#2271b1"></span><?php esc_html_e( 'WordPress blue', 'm365-login' ); ?></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Security -->
|
||||
<section class="m365-admin__panel" data-panel="security">
|
||||
<div class="m365-card">
|
||||
<h2 class="m365-card__title"><?php esc_html_e( 'User matching & hardening', 'm365-login' ); ?></h2>
|
||||
<p class="m365-card__intro"><?php esc_html_e( 'Users are never created automatically. A Microsoft sign-in only succeeds when a WordPress user with the same e-mail address already exists.', 'm365-login' ); ?></p>
|
||||
|
||||
<label class="m365-check m365-check--block">
|
||||
<input type="checkbox" name="<?php echo $field( 'bind_oid' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['bind_oid'] ); ?> />
|
||||
<span>
|
||||
<strong><?php esc_html_e( 'Bind WordPress accounts to the Microsoft object ID', 'm365-login' ); ?></strong>
|
||||
<em><?php esc_html_e( 'On first sign-in the immutable Microsoft object ID is stored with the user. Later sign-ins with the same e-mail but a different Microsoft identity are rejected. Strongly recommended.', 'm365-login' ); ?></em>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="m365-check m365-check--block">
|
||||
<input type="checkbox" name="<?php echo $field( 'upn_fallback' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['upn_fallback'] ); ?> />
|
||||
<span>
|
||||
<strong><?php esc_html_e( 'Fall back to the user principal name (UPN)', 'm365-login' ); ?></strong>
|
||||
<em><?php esc_html_e( 'If the token contains no "email" claim, use the UPN (e.g. jane@contoso.com) when it is a valid e-mail address. Usually required for work accounts.', 'm365-login' ); ?></em>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="m365-check m365-check--block">
|
||||
<input type="checkbox" name="<?php echo $field( 'remember_me' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['remember_me'] ); ?> />
|
||||
<span>
|
||||
<strong><?php esc_html_e( 'Keep users signed in ("Remember me")', 'm365-login' ); ?></strong>
|
||||
<em><?php esc_html_e( 'Issues a 14-day WordPress session instead of a browser session.', 'm365-login' ); ?></em>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="m365-field">
|
||||
<label for="m365-domains"><?php esc_html_e( 'Allowed e-mail domains (optional)', 'm365-login' ); ?></label>
|
||||
<textarea id="m365-domains" name="<?php echo $field( 'allowed_domains' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" rows="3" class="large-text code" placeholder="contoso.com, contoso.de"><?php echo esc_textarea( $s['allowed_domains'] ); ?></textarea>
|
||||
<p class="description"><?php esc_html_e( 'One or more domains separated by commas or new lines. Leave empty to allow any domain of your tenant.', 'm365-login' ); ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m365-card m365-card--muted">
|
||||
<h2 class="m365-card__title"><?php esc_html_e( 'What the plugin does to keep sign-ins safe', 'm365-login' ); ?></h2>
|
||||
<ul class="m365-list">
|
||||
<li><?php esc_html_e( 'OpenID Connect authorization code flow with PKCE (S256) – no tokens ever pass through the browser.', 'm365-login' ); ?></li>
|
||||
<li><?php esc_html_e( 'Single-use state and nonce values bound to the browser via an HttpOnly cookie (CSRF and replay protection).', 'm365-login' ); ?></li>
|
||||
<li><?php esc_html_e( 'ID token signature verified against Microsoft’s published signing keys; issuer, audience, tenant, expiry and nonce are checked.', 'm365-login' ); ?></li>
|
||||
<li><?php esc_html_e( 'Client secret encrypted at rest; no accounts are created, no passwords are changed.', 'm365-login' ); ?></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="m365-admin__actions">
|
||||
<?php submit_button( __( 'Save changes', 'm365-login' ), 'primary large', 'submit', false ); ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="m365-admin__sidebar">
|
||||
<div class="m365-card m365-card--accent">
|
||||
<h2 class="m365-card__title"><?php esc_html_e( 'Redirect URI', 'm365-login' ); ?></h2>
|
||||
<p><?php esc_html_e( 'Register this URI in your app registration under Authentication → Web → Redirect URIs:', 'm365-login' ); ?></p>
|
||||
<div class="m365-copy">
|
||||
<code id="m365-redirect-uri"><?php echo esc_html( $this->settings->redirect_uri() ); ?></code>
|
||||
<button type="button" class="button button-small m365-copy__button" data-copy="m365-redirect-uri"><?php esc_html_e( 'Copy', 'm365-login' ); ?></button>
|
||||
</div>
|
||||
<?php if ( ! $this->settings->uses_pretty_callback() ) : ?>
|
||||
<p class="description"><?php esc_html_e( 'Plain permalinks are active, so the callback uses a query string. If you enable pretty permalinks later, the redirect URI changes and must be updated in Entra ID.', 'm365-login' ); ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if ( ! is_ssl() && 'https' !== wp_parse_url( home_url(), PHP_URL_SCHEME ) ) : ?>
|
||||
<p class="m365-warning"><?php esc_html_e( 'Your site does not use HTTPS. Microsoft only accepts http:// redirect URIs for localhost; production sites must use HTTPS.', 'm365-login' ); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="m365-card">
|
||||
<h2 class="m365-card__title"><?php esc_html_e( 'Setup in 5 steps', 'm365-login' ); ?></h2>
|
||||
<ol class="m365-steps">
|
||||
<li><?php esc_html_e( 'Open the Microsoft Entra admin center → App registrations → New registration.', 'm365-login' ); ?></li>
|
||||
<li><?php esc_html_e( 'Choose "Accounts in this organizational directory only", set the platform to Web and paste the redirect URI above.', 'm365-login' ); ?></li>
|
||||
<li><?php esc_html_e( 'Copy the Application (client) ID and Directory (tenant) ID from the overview page.', 'm365-login' ); ?></li>
|
||||
<li><?php esc_html_e( 'Under Certificates & secrets create a client secret and copy its value (not the ID).', 'm365-login' ); ?></li>
|
||||
<li><?php esc_html_e( 'Under Token configuration add the optional claim "email" for ID tokens (recommended), then save this page.', 'm365-login' ); ?></li>
|
||||
</ol>
|
||||
<p class="description"><?php esc_html_e( 'Required API permission: openid, profile, email (delegated) – granted by default.', 'm365-login' ); ?></p>
|
||||
</div>
|
||||
|
||||
<div class="m365-card m365-card--muted">
|
||||
<h2 class="m365-card__title"><?php esc_html_e( 'Shortcode', 'm365-login' ); ?></h2>
|
||||
<p><?php esc_html_e( 'Place the button on a custom login page:', 'm365-login' ); ?></p>
|
||||
<code>[m365_login_button redirect="/my-account/"]</code>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
617
includes/class-m365-login-auth.php
Normal file
617
includes/class-m365-login-auth.php
Normal file
|
|
@ -0,0 +1,617 @@
|
|||
<?php
|
||||
/**
|
||||
* OpenID Connect authorization code flow (with PKCE) against Microsoft Entra ID.
|
||||
*
|
||||
* @package M365_Login
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Handles the login start, the callback and user matching.
|
||||
*/
|
||||
class M365_Login_Auth {
|
||||
|
||||
const ACTION_START = 'm365_login';
|
||||
const CALLBACK_PATH = 'm365-login/callback';
|
||||
const STATE_COOKIE = 'm365_login_state';
|
||||
const STATE_TTL = 600; // 10 minutes.
|
||||
const META_OID = '_m365_login_oid';
|
||||
const META_LAST_LOGIN = '_m365_login_last_login';
|
||||
const JWKS_CACHE_TTL = 12 * HOUR_IN_SECONDS;
|
||||
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;
|
||||
|
||||
add_action( 'login_form_' . self::ACTION_START, array( $this, 'handle_start' ) );
|
||||
add_action( 'init', array( $this, 'maybe_handle_callback' ), 5 );
|
||||
add_filter( 'wp_login_errors', array( $this, 'login_errors' ), 10, 1 );
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Endpoints */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Microsoft authority base URL for the configured tenant.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function authority() {
|
||||
return 'https://login.microsoftonline.com/' . rawurlencode( $this->settings->tenant() );
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenID configuration document URL.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function discovery_url() {
|
||||
return $this->authority() . '/v2.0/.well-known/openid-configuration';
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization endpoint.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function authorize_endpoint() {
|
||||
return $this->authority() . '/oauth2/v2.0/authorize';
|
||||
}
|
||||
|
||||
/**
|
||||
* Token endpoint.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function token_endpoint() {
|
||||
return $this->authority() . '/oauth2/v2.0/token';
|
||||
}
|
||||
|
||||
/**
|
||||
* JWKS endpoint.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function jwks_endpoint() {
|
||||
return $this->authority() . '/discovery/v2.0/keys';
|
||||
}
|
||||
|
||||
/**
|
||||
* URL that starts the Microsoft login.
|
||||
*
|
||||
* @param string $redirect_to Optional destination after login.
|
||||
* @return string
|
||||
*/
|
||||
public function start_url( $redirect_to = '' ) {
|
||||
$args = array( 'action' => self::ACTION_START );
|
||||
if ( '' !== $redirect_to ) {
|
||||
$args['redirect_to'] = $redirect_to;
|
||||
}
|
||||
return add_query_arg( $args, wp_login_url() );
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Step 1: redirect to Microsoft */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Builds the authorization request and redirects the browser.
|
||||
*/
|
||||
public function handle_start() {
|
||||
if ( ! $this->settings->is_configured() ) {
|
||||
$this->fail( 'not_configured' );
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- redirect_to is validated with wp_validate_redirect() before use.
|
||||
$redirect_to = isset( $_GET['redirect_to'] ) ? wp_validate_redirect( esc_url_raw( wp_unslash( $_GET['redirect_to'] ) ), '' ) : '';
|
||||
|
||||
$state = M365_Login_JWT::b64url_encode( random_bytes( 32 ) );
|
||||
$nonce = M365_Login_JWT::b64url_encode( random_bytes( 32 ) );
|
||||
$code_verifier = M365_Login_JWT::b64url_encode( random_bytes( 64 ) );
|
||||
$cookie_token = M365_Login_JWT::b64url_encode( random_bytes( 32 ) );
|
||||
|
||||
$code_challenge = M365_Login_JWT::b64url_encode( hash( 'sha256', $code_verifier, true ) );
|
||||
|
||||
// The transient is keyed by a hash of the state, so the raw state never hits the database.
|
||||
set_transient(
|
||||
$this->state_key( $state ),
|
||||
array(
|
||||
'nonce' => $nonce,
|
||||
'verifier' => $code_verifier,
|
||||
'cookie' => hash( 'sha256', $cookie_token ),
|
||||
'redirect_to' => $redirect_to,
|
||||
'created' => time(),
|
||||
),
|
||||
self::STATE_TTL
|
||||
);
|
||||
|
||||
$this->set_state_cookie( $cookie_token );
|
||||
|
||||
$params = array(
|
||||
'client_id' => $this->settings->get( 'client_id' ),
|
||||
'response_type' => 'code',
|
||||
'redirect_uri' => $this->settings->redirect_uri(),
|
||||
'response_mode' => 'query',
|
||||
'scope' => 'openid profile email',
|
||||
'state' => $state,
|
||||
'nonce' => $nonce,
|
||||
'code_challenge' => $code_challenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
);
|
||||
|
||||
$prompt = $this->settings->get( 'prompt' );
|
||||
if ( in_array( $prompt, array( 'select_account', 'login' ), true ) ) {
|
||||
$params['prompt'] = $prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the parameters sent to the Microsoft authorization endpoint.
|
||||
*
|
||||
* @param array $params Query parameters.
|
||||
*/
|
||||
$params = apply_filters( 'm365_login_authorize_params', $params );
|
||||
|
||||
nocache_headers();
|
||||
wp_redirect( $this->authorize_endpoint() . '?' . http_build_query( $params, '', '&', PHP_QUERY_RFC3986 ) ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- external IdP redirect by design.
|
||||
exit;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Step 2: callback */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Detects a request to /m365-login/callback regardless of permalink settings.
|
||||
*/
|
||||
public function maybe_handle_callback() {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- routing only; the OAuth state is verified in handle_callback().
|
||||
$query_form = isset( $_GET['m365-login'] ) && 'callback' === sanitize_key( wp_unslash( $_GET['m365-login'] ) );
|
||||
|
||||
$path_form = false;
|
||||
if ( isset( $_SERVER['REQUEST_URI'] ) ) {
|
||||
$request_path = wp_parse_url( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ), PHP_URL_PATH );
|
||||
$expected = wp_parse_url( home_url( '/' . self::CALLBACK_PATH ), PHP_URL_PATH );
|
||||
$path_form = is_string( $request_path ) && is_string( $expected )
|
||||
&& untrailingslashit( $request_path ) === untrailingslashit( $expected );
|
||||
}
|
||||
|
||||
if ( ! $query_form && ! $path_form ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->handle_callback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the authorization response, exchanges the code, verifies the
|
||||
* ID token and signs the matching WordPress user in.
|
||||
*/
|
||||
private function handle_callback() {
|
||||
nocache_headers();
|
||||
|
||||
if ( ! $this->settings->is_configured() ) {
|
||||
$this->fail( 'not_configured' );
|
||||
}
|
||||
|
||||
// State is the CSRF token for this request; there is no WP nonce by design.
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Recommended
|
||||
$state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : '';
|
||||
$code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : '';
|
||||
$error = isset( $_GET['error'] ) ? sanitize_key( wp_unslash( $_GET['error'] ) ) : '';
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
||||
|
||||
if ( '' === $state || ! preg_match( '/^[A-Za-z0-9_\-]{20,128}$/', $state ) ) {
|
||||
$this->fail( 'invalid_state' );
|
||||
}
|
||||
|
||||
// Consume the state immediately: every state is single use.
|
||||
$key = $this->state_key( $state );
|
||||
$attempt = get_transient( $key );
|
||||
delete_transient( $key );
|
||||
|
||||
if ( ! is_array( $attempt ) || empty( $attempt['nonce'] ) || empty( $attempt['verifier'] ) || empty( $attempt['cookie'] ) ) {
|
||||
$this->fail( 'invalid_state' );
|
||||
}
|
||||
if ( empty( $attempt['created'] ) || ( time() - (int) $attempt['created'] ) > self::STATE_TTL ) {
|
||||
$this->fail( 'invalid_state' );
|
||||
}
|
||||
|
||||
// Bind the callback to the browser that started the flow.
|
||||
$cookie_token = isset( $_COOKIE[ self::STATE_COOKIE ] ) ? sanitize_text_field( wp_unslash( $_COOKIE[ self::STATE_COOKIE ] ) ) : '';
|
||||
$this->clear_state_cookie();
|
||||
if ( '' === $cookie_token || ! hash_equals( $attempt['cookie'], hash( 'sha256', $cookie_token ) ) ) {
|
||||
$this->fail( 'invalid_state' );
|
||||
}
|
||||
|
||||
if ( '' !== $error ) {
|
||||
$this->fail( 'access_denied' === $error ? 'access_denied' : 'provider_error' );
|
||||
}
|
||||
if ( '' === $code ) {
|
||||
$this->fail( 'provider_error' );
|
||||
}
|
||||
|
||||
$tokens = $this->exchange_code( $code, $attempt['verifier'] );
|
||||
if ( is_wp_error( $tokens ) ) {
|
||||
$this->log( 'Token exchange failed: ' . $tokens->get_error_message() );
|
||||
$this->fail( 'token_exchange' );
|
||||
}
|
||||
|
||||
$claims = $this->verify_id_token( $tokens['id_token'], $attempt['nonce'] );
|
||||
if ( is_wp_error( $claims ) ) {
|
||||
$this->log( 'ID token rejected: ' . $claims->get_error_message() );
|
||||
$this->fail( 'invalid_token' );
|
||||
}
|
||||
|
||||
$email = $this->email_from_claims( $claims );
|
||||
if ( '' === $email ) {
|
||||
$this->fail( 'no_email' );
|
||||
}
|
||||
|
||||
if ( ! $this->domain_allowed( $email ) ) {
|
||||
$this->fail( 'domain_not_allowed' );
|
||||
}
|
||||
|
||||
$user = get_user_by( 'email', $email );
|
||||
if ( ! $user instanceof WP_User ) {
|
||||
/** This action is documented in wp-includes/user.php */
|
||||
do_action( 'wp_login_failed', $email, new WP_Error( 'm365_login_no_user', 'No WordPress user with this e-mail address.' ) );
|
||||
$this->fail( 'no_user' );
|
||||
}
|
||||
|
||||
if ( is_multisite() && ! is_user_member_of_blog( $user->ID ) && ! is_super_admin( $user->ID ) ) {
|
||||
$this->fail( 'no_user' );
|
||||
}
|
||||
|
||||
// Bind the account to the immutable Microsoft object ID after first login.
|
||||
$oid = isset( $claims['oid'] ) && is_string( $claims['oid'] ) ? strtolower( $claims['oid'] ) : '';
|
||||
if ( $this->settings->get( 'bind_oid' ) ) {
|
||||
if ( '' === $oid || ! M365_Login_Settings::is_guid( $oid ) ) {
|
||||
$this->fail( 'invalid_token' );
|
||||
}
|
||||
$stored = (string) get_user_meta( $user->ID, self::META_OID, true );
|
||||
if ( '' !== $stored && ! hash_equals( $stored, $oid ) ) {
|
||||
$this->log( sprintf( 'Object ID mismatch for user #%d.', $user->ID ) );
|
||||
$this->fail( 'oid_mismatch' );
|
||||
}
|
||||
if ( '' === $stored ) {
|
||||
update_user_meta( $user->ID, self::META_OID, $oid );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows blocking a login after all checks passed (e.g. group membership).
|
||||
*
|
||||
* @param bool|WP_Error $allowed True to allow.
|
||||
* @param WP_User $user Matched user.
|
||||
* @param array $claims Verified ID token claims.
|
||||
*/
|
||||
$allowed = apply_filters( 'm365_login_allow_user', true, $user, $claims );
|
||||
if ( true !== $allowed ) {
|
||||
$this->fail( 'not_allowed' );
|
||||
}
|
||||
|
||||
update_user_meta( $user->ID, self::META_LAST_LOGIN, time() );
|
||||
|
||||
$remember = (bool) $this->settings->get( 'remember_me' );
|
||||
wp_set_current_user( $user->ID );
|
||||
wp_set_auth_cookie( $user->ID, $remember, is_ssl() );
|
||||
|
||||
/** This action is documented in wp-includes/user.php */
|
||||
do_action( 'wp_login', $user->user_login, $user );
|
||||
|
||||
/**
|
||||
* Fires after a successful Microsoft login.
|
||||
*
|
||||
* @param WP_User $user User.
|
||||
* @param array $claims Verified claims.
|
||||
*/
|
||||
do_action( 'm365_login_success', $user, $claims );
|
||||
|
||||
$redirect_to = ! empty( $attempt['redirect_to'] ) ? $attempt['redirect_to'] : admin_url();
|
||||
/** This filter is documented in wp-login.php */
|
||||
$redirect_to = apply_filters( 'login_redirect', $redirect_to, $redirect_to, $user );
|
||||
|
||||
wp_safe_redirect( $redirect_to );
|
||||
exit;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Exchanges the authorization code for tokens.
|
||||
*
|
||||
* @param string $code Authorization code.
|
||||
* @param string $verifier PKCE verifier.
|
||||
* @return array|WP_Error
|
||||
*/
|
||||
private function exchange_code( $code, $verifier ) {
|
||||
$response = wp_remote_post(
|
||||
$this->token_endpoint(),
|
||||
array(
|
||||
'timeout' => self::HTTP_TIMEOUT,
|
||||
'headers' => array( 'Accept' => 'application/json' ),
|
||||
'body' => array(
|
||||
'client_id' => $this->settings->get( 'client_id' ),
|
||||
'client_secret' => $this->settings->client_secret(),
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'redirect_uri' => $this->settings->redirect_uri(),
|
||||
'code_verifier' => $verifier,
|
||||
'scope' => 'openid profile email',
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
$http = (int) wp_remote_retrieve_response_code( $response );
|
||||
|
||||
if ( 200 !== $http || ! is_array( $body ) ) {
|
||||
$detail = is_array( $body ) && ! empty( $body['error'] ) ? (string) $body['error'] : 'HTTP ' . $http;
|
||||
return new WP_Error( 'token_http', $detail );
|
||||
}
|
||||
if ( empty( $body['id_token'] ) || ! is_string( $body['id_token'] ) ) {
|
||||
return new WP_Error( 'token_missing', 'No id_token in response.' );
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the ID token, refreshing the JWKS cache once on an unknown key ID.
|
||||
*
|
||||
* @param string $id_token Token.
|
||||
* @param string $nonce Expected nonce.
|
||||
* @return array|WP_Error
|
||||
*/
|
||||
private function verify_id_token( $id_token, $nonce ) {
|
||||
$tenant = $this->settings->tenant();
|
||||
$expected = array(
|
||||
'aud' => (string) $this->settings->get( 'client_id' ),
|
||||
'nonce' => $nonce,
|
||||
'tenant' => M365_Login_Settings::is_guid( $tenant ) ? $tenant : '',
|
||||
);
|
||||
|
||||
$jwks = $this->get_jwks( false );
|
||||
if ( is_wp_error( $jwks ) ) {
|
||||
return $jwks;
|
||||
}
|
||||
|
||||
$claims = M365_Login_JWT::verify( $id_token, $jwks, $expected );
|
||||
if ( is_wp_error( $claims ) && 'jwt_unknown_kid' === $claims->get_error_code() ) {
|
||||
// Key rollover: fetch a fresh key set and try once more.
|
||||
$jwks = $this->get_jwks( true );
|
||||
if ( is_wp_error( $jwks ) ) {
|
||||
return $jwks;
|
||||
}
|
||||
$claims = M365_Login_JWT::verify( $id_token, $jwks, $expected );
|
||||
}
|
||||
|
||||
return $claims;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches (and caches) the JWKS document.
|
||||
*
|
||||
* @param bool $force Bypass cache.
|
||||
* @return array|WP_Error
|
||||
*/
|
||||
private function get_jwks( $force = false ) {
|
||||
$cache_key = 'm365_login_jwks_' . md5( $this->jwks_endpoint() );
|
||||
|
||||
if ( ! $force ) {
|
||||
$cached = get_transient( $cache_key );
|
||||
if ( is_array( $cached ) && ! empty( $cached['keys'] ) ) {
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
|
||||
$response = wp_remote_get( $this->jwks_endpoint(), array( 'timeout' => self::HTTP_TIMEOUT ) );
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
|
||||
return new WP_Error( 'jwks_http', 'JWKS endpoint returned HTTP ' . wp_remote_retrieve_response_code( $response ) );
|
||||
}
|
||||
$jwks = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
if ( ! is_array( $jwks ) || empty( $jwks['keys'] ) || ! is_array( $jwks['keys'] ) ) {
|
||||
return new WP_Error( 'jwks_format', 'JWKS document is invalid.' );
|
||||
}
|
||||
|
||||
set_transient( $cache_key, $jwks, self::JWKS_CACHE_TTL );
|
||||
return $jwks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the OpenID configuration (used by the admin "test connection" button).
|
||||
*
|
||||
* @return array|WP_Error
|
||||
*/
|
||||
public function fetch_discovery() {
|
||||
$response = wp_remote_get( $this->discovery_url(), array( 'timeout' => self::HTTP_TIMEOUT ) );
|
||||
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 ( 200 !== $code || ! is_array( $body ) || empty( $body['issuer'] ) ) {
|
||||
return new WP_Error( 'discovery', sprintf( 'HTTP %d', $code ) );
|
||||
}
|
||||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the e-mail address used for matching.
|
||||
*
|
||||
* @param array $claims Verified claims.
|
||||
* @return string Lowercase e-mail or empty string.
|
||||
*/
|
||||
private function email_from_claims( $claims ) {
|
||||
$candidates = array();
|
||||
if ( ! empty( $claims['email'] ) && is_string( $claims['email'] ) ) {
|
||||
$candidates[] = $claims['email'];
|
||||
}
|
||||
if ( $this->settings->get( 'upn_fallback' ) && ! empty( $claims['preferred_username'] ) && is_string( $claims['preferred_username'] ) ) {
|
||||
$candidates[] = $claims['preferred_username'];
|
||||
}
|
||||
|
||||
foreach ( $candidates as $candidate ) {
|
||||
$candidate = strtolower( trim( $candidate ) );
|
||||
if ( is_email( $candidate ) ) {
|
||||
/**
|
||||
* Filters the e-mail address used to look up the WordPress user.
|
||||
*
|
||||
* @param string $email E-mail from the token.
|
||||
* @param array $claims Verified claims.
|
||||
*/
|
||||
return (string) apply_filters( 'm365_login_match_email', $candidate, $claims );
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the optional domain allow-list.
|
||||
*
|
||||
* @param string $email E-mail.
|
||||
* @return bool
|
||||
*/
|
||||
private function domain_allowed( $email ) {
|
||||
$allowed = $this->settings->allowed_domains();
|
||||
if ( empty( $allowed ) ) {
|
||||
return true;
|
||||
}
|
||||
$domain = strtolower( substr( strrchr( $email, '@' ), 1 ) );
|
||||
return in_array( $domain, $allowed, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Transient key for a state value.
|
||||
*
|
||||
* @param string $state State.
|
||||
* @return string
|
||||
*/
|
||||
private function state_key( $state ) {
|
||||
return 'm365_login_st_' . hash_hmac( 'sha256', $state, wp_salt( 'nonce' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the short-lived state cookie.
|
||||
*
|
||||
* @param string $token Cookie value.
|
||||
*/
|
||||
private function set_state_cookie( $token ) {
|
||||
$this->send_cookie( $token, time() + self::STATE_TTL );
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the state cookie.
|
||||
*/
|
||||
private function clear_state_cookie() {
|
||||
$this->send_cookie( '', time() - YEAR_IN_SECONDS );
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie helper: HttpOnly, SameSite=Lax (needed for the top-level redirect back), Secure on HTTPS.
|
||||
*
|
||||
* @param string $value Value.
|
||||
* @param int $expires Expiry timestamp.
|
||||
*/
|
||||
private function send_cookie( $value, $expires ) {
|
||||
$path = wp_parse_url( home_url( '/' ), PHP_URL_PATH );
|
||||
$path = is_string( $path ) && '' !== $path ? $path : '/';
|
||||
|
||||
setcookie(
|
||||
self::STATE_COOKIE,
|
||||
$value,
|
||||
array(
|
||||
'expires' => $expires,
|
||||
'path' => $path,
|
||||
'domain' => COOKIE_DOMAIN ? COOKIE_DOMAIN : '',
|
||||
'secure' => is_ssl(),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aborts the flow and shows a generic error on the login screen.
|
||||
*
|
||||
* @param string $code Error code (mapped to a translated message on the login page).
|
||||
*/
|
||||
private function fail( $code ) {
|
||||
$this->clear_state_cookie();
|
||||
wp_safe_redirect( add_query_arg( 'm365_error', rawurlencode( $code ), wp_login_url() ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes to the PHP error log when WP_DEBUG_LOG is enabled.
|
||||
*
|
||||
* @param string $message Message.
|
||||
*/
|
||||
private function log( $message ) {
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
|
||||
error_log( '[M365 Login] ' . $message ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps error codes to messages on the login screen.
|
||||
*
|
||||
* @param WP_Error $errors Login errors.
|
||||
* @return WP_Error
|
||||
*/
|
||||
public function login_errors( $errors ) {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display of a whitelisted error code.
|
||||
$code = isset( $_GET['m365_error'] ) ? sanitize_key( wp_unslash( $_GET['m365_error'] ) ) : '';
|
||||
if ( '' === $code ) {
|
||||
return $errors;
|
||||
}
|
||||
|
||||
$messages = array(
|
||||
'not_configured' => __( 'Microsoft login is not configured yet.', 'm365-login' ),
|
||||
'invalid_state' => __( 'The login request expired or was invalid. Please try again.', 'm365-login' ),
|
||||
'access_denied' => __( 'Microsoft sign-in was cancelled.', 'm365-login' ),
|
||||
'provider_error' => __( 'Microsoft returned an error. Please try again.', 'm365-login' ),
|
||||
'token_exchange' => __( 'Could not complete the sign-in with Microsoft. Please try again or contact an administrator.', 'm365-login' ),
|
||||
'invalid_token' => __( 'The Microsoft sign-in could not be verified.', 'm365-login' ),
|
||||
'no_email' => __( 'Your Microsoft account did not provide an e-mail address.', 'm365-login' ),
|
||||
'domain_not_allowed' => __( 'Your e-mail domain is not allowed to sign in here.', 'm365-login' ),
|
||||
'no_user' => __( 'No WordPress account exists for your Microsoft e-mail address.', 'm365-login' ),
|
||||
'oid_mismatch' => __( 'This WordPress account is linked to a different Microsoft account. Please contact an administrator.', 'm365-login' ),
|
||||
'not_allowed' => __( 'You are not allowed to sign in with this account.', 'm365-login' ),
|
||||
);
|
||||
|
||||
if ( ! $errors instanceof WP_Error ) {
|
||||
$errors = new WP_Error();
|
||||
}
|
||||
$errors->add(
|
||||
'm365_login_' . $code,
|
||||
isset( $messages[ $code ] ) ? $messages[ $code ] : $messages['provider_error'],
|
||||
'access_denied' === $code ? 'message' : 'error'
|
||||
);
|
||||
|
||||
return $errors;
|
||||
}
|
||||
}
|
||||
191
includes/class-m365-login-button.php
Normal file
191
includes/class-m365-login-button.php
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
<?php
|
||||
/**
|
||||
* Login page button.
|
||||
*
|
||||
* @package M365_Login
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Renders the "Sign in with Microsoft" button on wp-login.php.
|
||||
*/
|
||||
class M365_Login_Button {
|
||||
|
||||
/**
|
||||
* Settings.
|
||||
*
|
||||
* @var M365_Login_Settings
|
||||
*/
|
||||
private $settings;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param M365_Login_Settings $settings Settings.
|
||||
*/
|
||||
public function __construct( M365_Login_Settings $settings ) {
|
||||
$this->settings = $settings;
|
||||
|
||||
add_action( 'login_enqueue_scripts', array( $this, 'enqueue' ) );
|
||||
add_filter( 'login_message', array( $this, 'render_above' ), 20 );
|
||||
add_action( 'login_footer', array( $this, 'render_below' ) );
|
||||
add_shortcode( 'm365_login_button', array( $this, 'shortcode' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the button should be shown for the current login screen.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function should_render() {
|
||||
if ( ! $this->settings->is_configured() ) {
|
||||
return false;
|
||||
}
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only routing check.
|
||||
$action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : 'login';
|
||||
$interim = ! empty( $_REQUEST['interim-login'] );
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
||||
if ( $interim || ! in_array( $action, array( '', 'login' ), true ) ) {
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Filters whether the Microsoft button is displayed on the login screen.
|
||||
*
|
||||
* @param bool $show Show the button.
|
||||
*/
|
||||
return (bool) apply_filters( 'm365_login_show_button', true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueues login styles and the small positioning script.
|
||||
*/
|
||||
public function enqueue() {
|
||||
if ( ! $this->should_render() ) {
|
||||
return;
|
||||
}
|
||||
wp_enqueue_style( 'm365-login', M365_LOGIN_URL . 'assets/css/login.css', array(), M365_LOGIN_VERSION );
|
||||
wp_add_inline_style( 'm365-login', $this->css_variables() );
|
||||
|
||||
if ( 'below' === $this->settings->get( 'button_position' ) ) {
|
||||
wp_enqueue_script( 'm365-login', M365_LOGIN_URL . 'assets/js/login.js', array(), M365_LOGIN_VERSION, true );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS custom properties derived from the settings.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function css_variables() {
|
||||
$s = $this->settings->all();
|
||||
return sprintf(
|
||||
'.m365-login{--m365-bg:%1$s;--m365-bg-hover:%2$s;--m365-color:%3$s;--m365-border:%4$s;--m365-radius:%5$dpx;}',
|
||||
sanitize_hex_color( $s['button_bg'] ),
|
||||
sanitize_hex_color( $s['button_bg_hover'] ),
|
||||
sanitize_hex_color( $s['button_color'] ),
|
||||
sanitize_hex_color( $s['button_border'] ),
|
||||
absint( $s['button_radius'] )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Output above the form (via login_message).
|
||||
*
|
||||
* @param string $message Existing message HTML.
|
||||
* @return string
|
||||
*/
|
||||
public function render_above( $message ) {
|
||||
if ( 'above' !== $this->settings->get( 'button_position' ) || ! $this->should_render() ) {
|
||||
return $message;
|
||||
}
|
||||
return $message . $this->markup( 'above' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Output below the form (moved into place by login.js).
|
||||
*/
|
||||
public function render_below() {
|
||||
if ( 'below' !== $this->settings->get( 'button_position' ) || ! $this->should_render() ) {
|
||||
return;
|
||||
}
|
||||
echo $this->markup( 'below' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- markup() escapes everything.
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcode for placing the button on custom login pages.
|
||||
*
|
||||
* @param array $atts Attributes.
|
||||
* @return string
|
||||
*/
|
||||
public function shortcode( $atts ) {
|
||||
if ( ! $this->settings->is_configured() || is_user_logged_in() ) {
|
||||
return '';
|
||||
}
|
||||
$atts = shortcode_atts( array( 'redirect' => '' ), $atts, 'm365_login_button' );
|
||||
|
||||
wp_enqueue_style( 'm365-login', M365_LOGIN_URL . 'assets/css/login.css', array(), M365_LOGIN_VERSION );
|
||||
wp_add_inline_style( 'm365-login', $this->css_variables() );
|
||||
|
||||
return '<div class="m365-login m365-login--shortcode">' . $this->button( esc_url_raw( $atts['redirect'] ) ) . '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Full block: divider + button.
|
||||
*
|
||||
* @param string $position 'above' or 'below'.
|
||||
* @return string
|
||||
*/
|
||||
public function markup( $position ) {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- passed through to the flow, validated there.
|
||||
$redirect_to = isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : '';
|
||||
|
||||
$divider = (string) $this->settings->get( 'divider_text' );
|
||||
$divider = '' === trim( $divider ) ? '' : '<div class="m365-login__divider" aria-hidden="true"><span>' . esc_html( $divider ) . '</span></div>';
|
||||
|
||||
$html = '<div class="m365-login m365-login--' . esc_attr( $position ) . '" id="m365-login-block">';
|
||||
$html .= 'above' === $position ? $this->button( $redirect_to ) . $divider : $divider . $this->button( $redirect_to );
|
||||
$html .= '</div>';
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Button markup.
|
||||
*
|
||||
* @param string $redirect_to Post-login destination.
|
||||
* @return string
|
||||
*/
|
||||
public function button( $redirect_to = '' ) {
|
||||
$auth = M365_Login::instance()->auth;
|
||||
$url = $auth->start_url( $redirect_to );
|
||||
|
||||
$icon = '';
|
||||
if ( $this->settings->get( 'button_show_icon' ) ) {
|
||||
$custom = (string) $this->settings->get( 'button_icon' );
|
||||
if ( '' !== $custom && M365_Login_Settings::is_safe_image_url( $custom ) ) {
|
||||
$icon = '<img class="m365-login__icon" src="' . esc_url( $custom ) . '" alt="" width="20" height="20" loading="lazy" />';
|
||||
} else {
|
||||
$icon = self::microsoft_logo();
|
||||
}
|
||||
}
|
||||
|
||||
return '<a class="m365-login__button" href="' . esc_url( $url ) . '" rel="nofollow">'
|
||||
. $icon
|
||||
. '<span class="m365-login__label">' . esc_html( $this->settings->get( 'button_text' ) ) . '</span>'
|
||||
. '</a>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundled Microsoft logo (inline SVG, four coloured squares).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function microsoft_logo() {
|
||||
return '<svg class="m365-login__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23" width="20" height="20" aria-hidden="true" focusable="false">'
|
||||
. '<path fill="#f25022" d="M1 1h10v10H1z"/>'
|
||||
. '<path fill="#7fba00" d="M12 1h10v10H12z"/>'
|
||||
. '<path fill="#00a4ef" d="M1 12h10v10H1z"/>'
|
||||
. '<path fill="#ffb900" d="M12 12h10v10H12z"/>'
|
||||
. '</svg>';
|
||||
}
|
||||
}
|
||||
85
includes/class-m365-login-crypto.php
Normal file
85
includes/class-m365-login-crypto.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
/**
|
||||
* Symmetric encryption for secrets at rest.
|
||||
*
|
||||
* @package M365_Login
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* AES-256-GCM helper keyed from the WordPress salts.
|
||||
*
|
||||
* The key is derived from AUTH_KEY / SECURE_AUTH_KEY (via wp_salt()), so the
|
||||
* stored client secret is useless without access to wp-config.php.
|
||||
*/
|
||||
final class M365_Login_Crypto {
|
||||
|
||||
const PREFIX = 'm365v1:';
|
||||
const CIPHER = 'aes-256-gcm';
|
||||
|
||||
/**
|
||||
* Derives the encryption key.
|
||||
*
|
||||
* @return string 32 raw bytes.
|
||||
*/
|
||||
private static function key() {
|
||||
$material = wp_salt( 'auth' ) . '|' . wp_salt( 'secure_auth' ) . '|m365-login';
|
||||
if ( function_exists( 'hash_hkdf' ) ) {
|
||||
return hash_hkdf( 'sha256', $material, 32, 'm365-login-client-secret' );
|
||||
}
|
||||
return hash( 'sha256', $material, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether encryption is available.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function available() {
|
||||
return function_exists( 'openssl_encrypt' ) && in_array( self::CIPHER, openssl_get_cipher_methods(), true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a string.
|
||||
*
|
||||
* @param string $plain Plain text.
|
||||
* @return string|false
|
||||
*/
|
||||
public static function encrypt( $plain ) {
|
||||
if ( ! self::available() ) {
|
||||
return false;
|
||||
}
|
||||
$iv = random_bytes( 12 );
|
||||
$tag = '';
|
||||
$ct = openssl_encrypt( $plain, self::CIPHER, self::key(), OPENSSL_RAW_DATA, $iv, $tag, '', 16 );
|
||||
if ( false === $ct || '' === $tag ) {
|
||||
return false;
|
||||
}
|
||||
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
|
||||
return self::PREFIX . base64_encode( $iv . $tag . $ct );
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a string produced by encrypt().
|
||||
*
|
||||
* @param string $stored Stored value.
|
||||
* @return string|false
|
||||
*/
|
||||
public static function decrypt( $stored ) {
|
||||
if ( ! is_string( $stored ) || 0 !== strpos( $stored, self::PREFIX ) || ! self::available() ) {
|
||||
return false;
|
||||
}
|
||||
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
|
||||
$raw = base64_decode( substr( $stored, strlen( self::PREFIX ) ), true );
|
||||
if ( false === $raw || strlen( $raw ) < 28 ) {
|
||||
return false;
|
||||
}
|
||||
$iv = substr( $raw, 0, 12 );
|
||||
$tag = substr( $raw, 12, 16 );
|
||||
$ct = substr( $raw, 28 );
|
||||
|
||||
$plain = openssl_decrypt( $ct, self::CIPHER, self::key(), OPENSSL_RAW_DATA, $iv, $tag );
|
||||
return false === $plain ? false : $plain;
|
||||
}
|
||||
}
|
||||
236
includes/class-m365-login-jwt.php
Normal file
236
includes/class-m365-login-jwt.php
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
<?php
|
||||
/**
|
||||
* ID token validation.
|
||||
*
|
||||
* @package M365_Login
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Verifies RS256 signed JWTs against the Microsoft JWKS.
|
||||
*/
|
||||
final class M365_Login_JWT {
|
||||
|
||||
const LEEWAY = 120; // Seconds of clock skew tolerated.
|
||||
|
||||
/**
|
||||
* Base64url decode.
|
||||
*
|
||||
* @param string $data Data.
|
||||
* @return string|false
|
||||
*/
|
||||
public static function b64url_decode( $data ) {
|
||||
$data = strtr( $data, '-_', '+/' );
|
||||
$pad = strlen( $data ) % 4;
|
||||
if ( $pad ) {
|
||||
$data .= str_repeat( '=', 4 - $pad );
|
||||
}
|
||||
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
|
||||
return base64_decode( $data, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64url encode.
|
||||
*
|
||||
* @param string $data Data.
|
||||
* @return string
|
||||
*/
|
||||
public static function b64url_encode( $data ) {
|
||||
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
|
||||
return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes and verifies a JWT.
|
||||
*
|
||||
* @param string $jwt Compact serialised token.
|
||||
* @param array $jwks JWKS document (array with 'keys').
|
||||
* @param array $expected Expected claims: 'aud', 'nonce', 'tenant' (optional GUID).
|
||||
* @return array|WP_Error Claims on success.
|
||||
*/
|
||||
public static function verify( $jwt, $jwks, $expected ) {
|
||||
$parts = explode( '.', $jwt );
|
||||
if ( 3 !== count( $parts ) ) {
|
||||
return new WP_Error( 'jwt_format', 'Malformed token.' );
|
||||
}
|
||||
|
||||
list( $h64, $p64, $s64 ) = $parts;
|
||||
|
||||
$header = json_decode( self::b64url_decode( $h64 ), true );
|
||||
$claims = json_decode( self::b64url_decode( $p64 ), true );
|
||||
$signature = self::b64url_decode( $s64 );
|
||||
|
||||
if ( ! is_array( $header ) || ! is_array( $claims ) || false === $signature ) {
|
||||
return new WP_Error( 'jwt_format', 'Malformed token.' );
|
||||
}
|
||||
|
||||
// Only RS256 is accepted; refuse "none" and anything HMAC based.
|
||||
if ( empty( $header['alg'] ) || 'RS256' !== $header['alg'] ) {
|
||||
return new WP_Error( 'jwt_alg', 'Unsupported token algorithm.' );
|
||||
}
|
||||
if ( empty( $header['kid'] ) || ! is_string( $header['kid'] ) ) {
|
||||
return new WP_Error( 'jwt_kid', 'Token has no key ID.' );
|
||||
}
|
||||
|
||||
$public_key = self::find_key( $jwks, $header['kid'] );
|
||||
if ( ! $public_key ) {
|
||||
return new WP_Error( 'jwt_unknown_kid', 'Signing key not found.' );
|
||||
}
|
||||
|
||||
$ok = openssl_verify( $h64 . '.' . $p64, $signature, $public_key, OPENSSL_ALGO_SHA256 );
|
||||
if ( 1 !== $ok ) {
|
||||
return new WP_Error( 'jwt_signature', 'Token signature is invalid.' );
|
||||
}
|
||||
|
||||
$now = time();
|
||||
|
||||
if ( empty( $claims['exp'] ) || ! is_numeric( $claims['exp'] ) || ( (int) $claims['exp'] + self::LEEWAY ) < $now ) {
|
||||
return new WP_Error( 'jwt_expired', 'Token has expired.' );
|
||||
}
|
||||
if ( isset( $claims['nbf'] ) && is_numeric( $claims['nbf'] ) && ( (int) $claims['nbf'] - self::LEEWAY ) > $now ) {
|
||||
return new WP_Error( 'jwt_nbf', 'Token is not valid yet.' );
|
||||
}
|
||||
if ( isset( $claims['iat'] ) && is_numeric( $claims['iat'] ) && ( (int) $claims['iat'] - self::LEEWAY ) > $now ) {
|
||||
return new WP_Error( 'jwt_iat', 'Token issued in the future.' );
|
||||
}
|
||||
|
||||
// Audience must be our client ID.
|
||||
$aud = isset( $claims['aud'] ) ? $claims['aud'] : null;
|
||||
if ( is_array( $aud ) ) {
|
||||
$aud_ok = in_array( $expected['aud'], $aud, true );
|
||||
} else {
|
||||
$aud_ok = is_string( $aud ) && hash_equals( $expected['aud'], $aud );
|
||||
}
|
||||
if ( ! $aud_ok ) {
|
||||
return new WP_Error( 'jwt_aud', 'Token audience mismatch.' );
|
||||
}
|
||||
|
||||
// Tenant / issuer: v2.0 issuer is https://login.microsoftonline.com/{tid}/v2.0.
|
||||
if ( empty( $claims['tid'] ) || ! is_string( $claims['tid'] ) || ! M365_Login_Settings::is_guid( $claims['tid'] ) ) {
|
||||
return new WP_Error( 'jwt_tid', 'Token has no tenant ID.' );
|
||||
}
|
||||
if ( ! empty( $expected['tenant'] ) && ! hash_equals( strtolower( $expected['tenant'] ), strtolower( $claims['tid'] ) ) ) {
|
||||
return new WP_Error( 'jwt_tenant', 'Token was issued by a different tenant.' );
|
||||
}
|
||||
$expected_iss = 'https://login.microsoftonline.com/' . strtolower( $claims['tid'] ) . '/v2.0';
|
||||
if ( empty( $claims['iss'] ) || ! is_string( $claims['iss'] ) || ! hash_equals( $expected_iss, strtolower( $claims['iss'] ) ) ) {
|
||||
return new WP_Error( 'jwt_iss', 'Token issuer mismatch.' );
|
||||
}
|
||||
|
||||
// Nonce binds the token to the login attempt.
|
||||
if ( empty( $claims['nonce'] ) || ! is_string( $claims['nonce'] ) || ! hash_equals( $expected['nonce'], $claims['nonce'] ) ) {
|
||||
return new WP_Error( 'jwt_nonce', 'Token nonce mismatch.' );
|
||||
}
|
||||
|
||||
return $claims;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a key by kid and returns an OpenSSL public key resource/object.
|
||||
*
|
||||
* @param array $jwks JWKS document.
|
||||
* @param string $kid Key ID.
|
||||
* @return mixed|null
|
||||
*/
|
||||
private static function find_key( $jwks, $kid ) {
|
||||
if ( empty( $jwks['keys'] ) || ! is_array( $jwks['keys'] ) ) {
|
||||
return null;
|
||||
}
|
||||
foreach ( $jwks['keys'] as $key ) {
|
||||
if ( ! is_array( $key ) || empty( $key['kid'] ) || ! hash_equals( (string) $key['kid'], $kid ) ) {
|
||||
continue;
|
||||
}
|
||||
if ( isset( $key['kty'] ) && 'RSA' !== $key['kty'] ) {
|
||||
continue;
|
||||
}
|
||||
if ( isset( $key['use'] ) && 'sig' !== $key['use'] ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Prefer the embedded certificate, fall back to modulus/exponent.
|
||||
if ( ! empty( $key['x5c'][0] ) && is_string( $key['x5c'][0] ) ) {
|
||||
$pem = "-----BEGIN CERTIFICATE-----\n" . chunk_split( $key['x5c'][0], 64, "\n" ) . "-----END CERTIFICATE-----\n";
|
||||
$pub = openssl_pkey_get_public( $pem );
|
||||
if ( $pub ) {
|
||||
return $pub;
|
||||
}
|
||||
}
|
||||
if ( ! empty( $key['n'] ) && ! empty( $key['e'] ) ) {
|
||||
$pem = self::rsa_pem_from_components( $key['n'], $key['e'] );
|
||||
if ( $pem ) {
|
||||
$pub = openssl_pkey_get_public( $pem );
|
||||
if ( $pub ) {
|
||||
return $pub;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a PEM encoded SubjectPublicKeyInfo from JWK modulus / exponent.
|
||||
*
|
||||
* @param string $n Base64url modulus.
|
||||
* @param string $e Base64url exponent.
|
||||
* @return string|false
|
||||
*/
|
||||
private static function rsa_pem_from_components( $n, $e ) {
|
||||
$modulus = self::b64url_decode( $n );
|
||||
$exponent = self::b64url_decode( $e );
|
||||
if ( false === $modulus || false === $exponent ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$modulus = self::der_integer( $modulus );
|
||||
$exponent = self::der_integer( $exponent );
|
||||
|
||||
$rsa_key = self::der_seq( $modulus . $exponent );
|
||||
// rsaEncryption OID 1.2.840.113549.1.1.1 + NULL.
|
||||
$alg_id = self::der_seq( "\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01\x05\x00" );
|
||||
$bit_str = "\x03" . self::der_len( strlen( $rsa_key ) + 1 ) . "\x00" . $rsa_key;
|
||||
$spki = self::der_seq( $alg_id . $bit_str );
|
||||
|
||||
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
|
||||
return "-----BEGIN PUBLIC KEY-----\n" . chunk_split( base64_encode( $spki ), 64, "\n" ) . "-----END PUBLIC KEY-----\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* DER length encoding.
|
||||
*
|
||||
* @param int $len Length.
|
||||
* @return string
|
||||
*/
|
||||
private static function der_len( $len ) {
|
||||
if ( $len < 128 ) {
|
||||
return chr( $len );
|
||||
}
|
||||
$bytes = ltrim( pack( 'N', $len ), "\x00" );
|
||||
return chr( 0x80 | strlen( $bytes ) ) . $bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* DER SEQUENCE.
|
||||
*
|
||||
* @param string $content Content.
|
||||
* @return string
|
||||
*/
|
||||
private static function der_seq( $content ) {
|
||||
return "\x30" . self::der_len( strlen( $content ) ) . $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* DER INTEGER (unsigned big-endian input).
|
||||
*
|
||||
* @param string $bytes Raw bytes.
|
||||
* @return string
|
||||
*/
|
||||
private static function der_integer( $bytes ) {
|
||||
$bytes = ltrim( $bytes, "\x00" );
|
||||
if ( '' === $bytes || ( ord( $bytes[0] ) & 0x80 ) ) {
|
||||
$bytes = "\x00" . $bytes;
|
||||
}
|
||||
return "\x02" . self::der_len( strlen( $bytes ) ) . $bytes;
|
||||
}
|
||||
}
|
||||
283
includes/class-m365-login-settings.php
Normal file
283
includes/class-m365-login-settings.php
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
<?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' => '',
|
||||
'remember_me' => 0,
|
||||
// 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' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 );
|
||||
|
||||
// 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 );
|
||||
}
|
||||
}
|
||||
125
includes/class-m365-login.php
Normal file
125
includes/class-m365-login.php
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<?php
|
||||
/**
|
||||
* Plugin bootstrap.
|
||||
*
|
||||
* @package M365_Login
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Wires the individual components together.
|
||||
*/
|
||||
final class M365_Login {
|
||||
|
||||
/**
|
||||
* Singleton instance.
|
||||
*
|
||||
* @var M365_Login|null
|
||||
*/
|
||||
private static $instance = null;
|
||||
|
||||
/**
|
||||
* Settings component.
|
||||
*
|
||||
* @var M365_Login_Settings
|
||||
*/
|
||||
public $settings;
|
||||
|
||||
/**
|
||||
* Authentication component.
|
||||
*
|
||||
* @var M365_Login_Auth
|
||||
*/
|
||||
public $auth;
|
||||
|
||||
/**
|
||||
* Login button component.
|
||||
*
|
||||
* @var M365_Login_Button
|
||||
*/
|
||||
public $button;
|
||||
|
||||
/**
|
||||
* Admin component.
|
||||
*
|
||||
* @var M365_Login_Admin|null
|
||||
*/
|
||||
public $admin = null;
|
||||
|
||||
/**
|
||||
* Returns the singleton.
|
||||
*
|
||||
* @return M365_Login
|
||||
*/
|
||||
public static function instance() {
|
||||
if ( null === self::$instance ) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
private function __construct() {
|
||||
add_action( 'init', array( $this, 'load_textdomain' ) );
|
||||
|
||||
$this->settings = new M365_Login_Settings();
|
||||
$this->auth = new M365_Login_Auth( $this->settings );
|
||||
$this->button = new M365_Login_Button( $this->settings );
|
||||
|
||||
if ( is_admin() ) {
|
||||
$this->admin = new M365_Login_Admin( $this->settings, $this->auth );
|
||||
}
|
||||
|
||||
add_filter( 'plugin_action_links_' . plugin_basename( M365_LOGIN_FILE ), array( $this, 'action_links' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads bundled translations.
|
||||
*/
|
||||
public function load_textdomain() {
|
||||
load_plugin_textdomain( 'm365-login', false, dirname( plugin_basename( M365_LOGIN_FILE ) ) . '/languages' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a "Settings" link on the plugins screen.
|
||||
*
|
||||
* @param string[] $links Existing links.
|
||||
* @return string[]
|
||||
*/
|
||||
public function action_links( $links ) {
|
||||
$url = admin_url( 'options-general.php?page=m365-login' );
|
||||
array_unshift( $links, '<a href="' . esc_url( $url ) . '">' . esc_html__( 'Settings', 'm365-login' ) . '</a>' );
|
||||
return $links;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activation hook: seed defaults and check requirements.
|
||||
*/
|
||||
public static function activate() {
|
||||
if ( version_compare( PHP_VERSION, '7.4', '<' ) ) {
|
||||
deactivate_plugins( plugin_basename( M365_LOGIN_FILE ) );
|
||||
wp_die(
|
||||
esc_html__( 'M365 Login requires PHP 7.4 or newer.', 'm365-login' ),
|
||||
esc_html__( 'Plugin activation failed', 'm365-login' ),
|
||||
array( 'back_link' => true )
|
||||
);
|
||||
}
|
||||
|
||||
if ( ! function_exists( 'openssl_encrypt' ) ) {
|
||||
deactivate_plugins( plugin_basename( M365_LOGIN_FILE ) );
|
||||
wp_die(
|
||||
esc_html__( 'M365 Login requires the PHP OpenSSL extension (needed to verify Microsoft token signatures and to encrypt the client secret).', 'm365-login' ),
|
||||
esc_html__( 'Plugin activation failed', 'm365-login' ),
|
||||
array( 'back_link' => true )
|
||||
);
|
||||
}
|
||||
|
||||
$settings = new M365_Login_Settings();
|
||||
if ( false === get_option( M365_LOGIN_OPTION, false ) ) {
|
||||
add_option( M365_LOGIN_OPTION, $settings->defaults(), '', 'no' );
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue