wp-m365-login/includes/class-m365-login-button.php
friloo 3e3e87b399
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
2026-09-22 14:21:10 +00:00

191 lines
6 KiB
PHP

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