Support custom login pages

- 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
This commit is contained in:
friloo 2026-09-22 14:52:11 +00:00
parent e5db0d49be
commit 701e85ab88
No known key found for this signature in database
15 changed files with 804 additions and 300 deletions

View file

@ -408,6 +408,33 @@ class M365_Login_Admin {
<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>
<div class="m365-card">
<h2 class="m365-card__title"><?php esc_html_e( 'Custom login page', 'm365-login' ); ?></h2>
<p class="m365-card__intro"><?php esc_html_e( 'Using your own login page instead of wp-login.php? Tell the plugin where it is so error messages, the fallback link and the post-logout redirect point there.', 'm365-login' ); ?></p>
<div class="m365-field">
<label for="m365-custom-login"><?php esc_html_e( 'URL of your login page', 'm365-login' ); ?></label>
<input type="url" id="m365-custom-login" name="<?php echo $field( 'custom_login_url' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="<?php echo esc_url( $s['custom_login_url'] ); ?>" class="regular-text code" placeholder="<?php echo esc_attr( home_url( '/login/' ) ); ?>" />
<p class="description"><?php esc_html_e( 'Must be on this site. Leave empty to use wp-login.php.', 'm365-login' ); ?></p>
</div>
<label class="m365-check m365-check--block">
<input type="checkbox" name="<?php echo $field( 'inject_form' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['inject_form'] ); ?> />
<span>
<strong><?php esc_html_e( 'Add the button to every wp_login_form() form automatically', 'm365-login' ); ?></strong>
<em><?php esc_html_e( 'Covers themes and plugins that use the WordPress login form function. Page-builder widgets need the shortcode or the template function below.', 'm365-login' ); ?></em>
</span>
</label>
<div class="m365-field">
<span class="m365-field__label"><?php esc_html_e( 'Manual placement', 'm365-login' ); ?></span>
<p class="description"><?php esc_html_e( 'Shortcode (block editor, page builders):', 'm365-login' ); ?></p>
<code>[m365_login_button redirect="/dashboard/" divider="yes"]</code>
<p class="description"><?php esc_html_e( 'Template function (theme files):', 'm365-login' ); ?></p>
<code>&lt;?php m365_login_button( array( 'redirect' =&gt; '/dashboard/' ) ); ?&gt;</code>
<p class="description"><?php esc_html_e( 'Both show the error messages of the last attempt; use m365_login_messages() to place them separately.', 'm365-login' ); ?></p>
</div>
</div>
</section>
<!-- Security -->
@ -483,7 +510,7 @@ class M365_Login_Admin {
<div class="m365-card">
<h2 class="m365-card__title"><?php esc_html_e( 'Button-only mode', 'm365-login' ); ?></h2>
<p class="m365-card__intro"><?php esc_html_e( 'Hide the username/password form and the "Lost your password?" link, and refuse password sign-ins on the login page. Application passwords, REST and XML-RPC are not affected.', 'm365-login' ); ?></p>
<p class="m365-card__intro"><?php esc_html_e( 'Hides the username/password fields (on wp-login.php and in wp_login_form() forms) and refuses every interactive password sign-in on the site, including custom login forms. Application passwords, REST, XML-RPC and WP-CLI are not affected.', 'm365-login' ); ?></p>
<label class="m365-check m365-check--block">
<input type="checkbox" name="<?php echo $field( 'button_only' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['button_only'] ); ?> id="m365-button-only" />
@ -495,7 +522,7 @@ class M365_Login_Admin {
<div class="m365-fallback">
<span class="m365-field__label"><?php esc_html_e( 'Fallback link (keep it secret)', 'm365-login' ); ?></span>
<p class="description"><?php esc_html_e( 'Opening this link shows the password form again in that browser for 30 minutes. Bookmark it somewhere safe it is your way back in if Microsoft sign-in ever breaks.', 'm365-login' ); ?></p>
<p class="description"><?php esc_html_e( 'Opening this link shows the password form again in that browser for 30 minutes and allows password sign-in there. Bookmark it somewhere safe it is your way back in if Microsoft sign-in ever breaks.', 'm365-login' ); ?></p>
<?php if ( '' !== $this->settings->fallback_url() ) : ?>
<div class="m365-copy">
<code id="m365-fallback-url"><?php echo esc_html( $this->settings->fallback_url() ); ?></code>
@ -569,6 +596,7 @@ class M365_Login_Admin {
<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>
<p class="description"><?php esc_html_e( 'More options on the Button tab under "Custom login page".', 'm365-login' ); ?></p>
</div>
</aside>
</div>

View file

@ -51,10 +51,13 @@ class M365_Login_Auth {
add_action( 'init', array( $this, 'maybe_handle_callback' ), 5 );
add_filter( 'wp_login_errors', array( $this, 'login_errors' ), 10, 1 );
// Button-only mode.
add_action( 'login_init', array( $this, 'maybe_accept_fallback_key' ) );
// Button-only mode (works on wp-login.php and on custom login pages).
add_action( 'init', array( $this, 'maybe_accept_fallback_key' ), 6 );
// Runs after core's username/password handlers (priority 20), which would otherwise overwrite an early WP_Error.
add_filter( 'authenticate', array( $this, 'block_password_login' ), 99, 3 );
// Custom login page: send people back there after logging out.
add_filter( 'logout_redirect', array( $this, 'logout_redirect' ), 10, 3 );
}
/* ------------------------------------------------------------------ */
@ -84,7 +87,7 @@ class M365_Login_Auth {
}
/**
* wp-login.php?m365_fallback=KEY sets the fallback cookie and reloads without the key in the URL.
* ?m365_fallback=KEY (on any page) sets the fallback cookie and reloads the login page without the key in the URL.
*/
public function maybe_accept_fallback_key() {
if ( ! $this->settings->button_only() ) {
@ -92,7 +95,7 @@ class M365_Login_Auth {
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the key itself is the secret.
$given = isset( $_GET['m365_fallback'] ) ? sanitize_text_field( wp_unslash( $_GET['m365_fallback'] ) ) : '';
if ( '' === $given ) {
if ( '' === $given || 'on' === $given ) {
return;
}
@ -111,10 +114,26 @@ class M365_Login_Auth {
delete_transient( $ip_key );
$this->send_cookie( self::FALLBACK_COOKIE, $this->fallback_cookie_value(), time() + self::FALLBACK_TTL );
nocache_headers();
wp_safe_redirect( add_query_arg( 'm365_fallback', 'on', wp_login_url() ) );
wp_safe_redirect( add_query_arg( 'm365_fallback', 'on', $this->settings->login_page_url() ) );
exit;
}
/**
* After logout, return to the custom login page instead of wp-login.php.
*
* @param string $redirect_to Requested redirect.
* @param string $requested_redirect_to Raw requested redirect.
* @param WP_User|WP_Error $user User.
* @return string
*/
public function logout_redirect( $redirect_to, $requested_redirect_to, $user ) {
$custom = $this->settings->custom_login_url();
if ( '' === $custom || '' !== (string) $requested_redirect_to ) {
return $redirect_to;
}
return add_query_arg( 'loggedout', 'true', $custom );
}
/**
* Refuses username/password sign-in on wp-login.php while button-only mode is active.
*
@ -130,13 +149,27 @@ class M365_Login_Auth {
if ( '' === (string) $username && '' === (string) $password ) {
return $user; // Initial form render or cookie auth, no password attempt.
}
// Only the interactive login form is affected: XML-RPC, REST and application passwords keep working.
if ( ! isset( $GLOBALS['pagenow'] ) || 'wp-login.php' !== $GLOBALS['pagenow'] ) {
// Interactive password logins only: XML-RPC, REST (application passwords), WP-CLI and cron keep working.
if ( ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST )
|| ( defined( 'REST_REQUEST' ) && REST_REQUEST )
|| ( defined( 'WP_CLI' ) && WP_CLI )
|| wp_doing_cron() ) {
return $user;
}
if ( ! $user instanceof WP_User ) {
return $user; // Already failed for another reason; keep core's message.
}
/**
* Allows exempting a password sign-in from button-only mode (e.g. a trusted membership plugin).
*
* @param bool $block Whether to block. Default true.
* @param WP_User $user Authenticated user.
*/
if ( ! apply_filters( 'm365_login_block_password_login', true, $user ) ) {
return $user;
}
return new WP_Error( 'm365_login_button_only', __( 'Password sign-in is disabled on this site. Please use the Microsoft button.', 'm365-login' ) );
}
@ -720,45 +753,48 @@ class M365_Login_Auth {
*/
private function fail( $code ) {
$this->clear_state_cookie();
wp_safe_redirect( add_query_arg( 'm365_error', rawurlencode( $code ), wp_login_url() ) );
nocache_headers();
wp_safe_redirect( add_query_arg( 'm365_error', rawurlencode( $code ), $this->settings->login_page_url() ) );
exit;
}
/**
* Writes to the PHP error log when WP_DEBUG_LOG is enabled.
* Messages for the current request (from ?m365_error and ?m365_fallback=on), for any login page.
*
* @param string $message Message.
* @return array[] Each item: array( 'type' => 'error'|'message', 'code' => string, 'text' => string ).
*/
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 ) {
if ( ! $errors instanceof WP_Error ) {
$errors = new WP_Error();
}
public function current_messages() {
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only display of whitelisted flags.
$code = isset( $_GET['m365_error'] ) ? sanitize_key( wp_unslash( $_GET['m365_error'] ) ) : '';
$fallback_on = isset( $_GET['m365_fallback'] ) && 'on' === $_GET['m365_fallback'];
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$out = array();
if ( $fallback_on && $this->settings->button_only() && $this->fallback_active() ) {
$errors->add( 'm365_login_fallback_on', __( 'Password sign-in is temporarily enabled for this browser (30 minutes).', 'm365-login' ), 'message' );
$out[] = array(
'type' => 'message',
'code' => 'fallback_on',
'text' => __( 'Password sign-in is temporarily enabled for this browser (30 minutes).', 'm365-login' ),
);
}
if ( '' === $code ) {
return $errors;
if ( '' !== $code ) {
$messages = $this->error_messages();
$out[] = array(
'type' => 'access_denied' === $code ? 'message' : 'error',
'code' => $code,
'text' => isset( $messages[ $code ] ) ? $messages[ $code ] : $messages['provider_error'],
);
}
return $out;
}
$messages = array(
/**
* Error code translated message map.
*
* @return string[]
*/
private function error_messages() {
return 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' ),
@ -775,13 +811,21 @@ class M365_Login_Auth {
'fallback_invalid' => __( 'The fallback key is not valid.', 'm365-login' ),
'fallback_locked' => __( 'Too many attempts. Please wait 15 minutes.', 'm365-login' ),
);
}
$errors->add(
'm365_login_' . $code,
isset( $messages[ $code ] ) ? $messages[ $code ] : $messages['provider_error'],
'access_denied' === $code ? 'message' : 'error'
);
/**
* Adds the current messages to the wp-login.php error object.
*
* @param WP_Error $errors Login errors.
* @return WP_Error
*/
public function login_errors( $errors ) {
if ( ! $errors instanceof WP_Error ) {
$errors = new WP_Error();
}
foreach ( $this->current_messages() as $msg ) {
$errors->add( 'm365_login_' . $msg['code'], $msg['text'], $msg['type'] );
}
return $errors;
}
}

View file

@ -32,6 +32,133 @@ class M365_Login_Button {
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' ) );
// Custom login pages built with wp_login_form().
add_filter( 'login_form_top', array( $this, 'form_top' ), 10, 2 );
add_filter( 'login_form_bottom', array( $this, 'form_bottom' ), 10, 2 );
}
/**
* Whether the plugin should hook into wp_login_form() output.
*
* @return bool
*/
private function inject_into_forms() {
return $this->settings->is_configured() && (bool) $this->settings->get( 'inject_form' ) && ! is_user_logged_in();
}
/**
* wp_login_form(): messages above the fields; in button-only mode the fields are wrapped and hidden.
*
* @param string $content Existing content.
* @param array $args wp_login_form() arguments.
* @return string
*/
public function form_top( $content, $args = array() ) {
if ( ! $this->inject_into_forms() ) {
return $content;
}
$this->enqueue_frontend();
$content .= $this->messages_markup();
if ( $this->password_login_hidden() ) {
$content .= '<div class="m365-login__hidden-fields" hidden>';
}
return $content;
}
/**
* wp_login_form(): closes the wrapper and appends the button.
*
* @param string $content Existing content.
* @param array $args wp_login_form() arguments.
* @return string
*/
public function form_bottom( $content, $args = array() ) {
if ( ! $this->inject_into_forms() ) {
return $content;
}
$hidden = $this->password_login_hidden();
$redirect_to = isset( $args['redirect'] ) ? (string) $args['redirect'] : '';
if ( $hidden ) {
$content .= '</div>';
}
$divider = $hidden ? '' : $this->divider_markup();
return $content . '<div class="m365-login m365-login--form">' . $divider . $this->button( $redirect_to ) . '</div>';
}
/**
* Whether password fields should be hidden right now (button-only mode without fallback).
*
* @return bool
*/
private function password_login_hidden() {
return $this->settings->button_only() && ! M365_Login::instance()->auth->fallback_active();
}
/**
* Enqueues the stylesheet on the front end (custom login pages).
*/
public function enqueue_frontend() {
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() );
}
/**
* Divider line markup ('' when the divider text is empty).
*
* @return string
*/
private function divider_markup() {
$divider = (string) $this->settings->get( 'divider_text' );
return '' === trim( $divider ) ? '' : '<div class="m365-login__divider" aria-hidden="true"><span>' . esc_html( $divider ) . '</span></div>';
}
/**
* Error / info messages for custom login pages.
*
* @return string
*/
public function messages_markup() {
$html = '';
foreach ( M365_Login::instance()->auth->current_messages() as $msg ) {
$html .= '<div class="m365-login__notice m365-login__notice--' . esc_attr( $msg['type'] ) . '" role="' . ( 'error' === $msg['type'] ? 'alert' : 'status' ) . '">' . esc_html( $msg['text'] ) . '</div>';
}
return $html;
}
/**
* Full markup for templates and shortcodes.
*
* @param array $args {
* @type string $redirect Destination after login.
* @type bool $show_messages Show error/info messages. Default true.
* @type bool $show_divider Show the divider line. Default false.
* }
* @return string
*/
public function standalone( $args = array() ) {
if ( ! $this->settings->is_configured() || is_user_logged_in() ) {
return '';
}
$args = wp_parse_args(
$args,
array(
'redirect' => '',
'show_messages' => true,
'show_divider' => false,
)
);
$this->enqueue_frontend();
$html = '<div class="m365-login m365-login--shortcode">';
if ( $args['show_messages'] ) {
$html .= $this->messages_markup();
}
if ( $args['show_divider'] && ! $this->password_login_hidden() ) {
$html .= $this->divider_markup();
}
$html .= $this->button( esc_url_raw( (string) $args['redirect'] ) );
return $html . '</div>';
}
/**
@ -142,15 +269,22 @@ class M365_Login_Button {
* @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>';
$atts = shortcode_atts(
array(
'redirect' => '',
'messages' => 'yes',
'divider' => 'no',
),
$atts,
'm365_login_button'
);
return $this->standalone(
array(
'redirect' => $atts['redirect'],
'show_messages' => 'no' !== strtolower( (string) $atts['messages'] ),
'show_divider' => 'yes' === strtolower( (string) $atts['divider'] ),
)
);
}
/**
@ -163,8 +297,7 @@ class M365_Login_Button {
// 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 = $this->form_hidden() ? '' : (string) $this->settings->get( 'divider_text' );
$divider = '' === trim( $divider ) ? '' : '<div class="m365-login__divider" aria-hidden="true"><span>' . esc_html( $divider ) . '</span></div>';
$divider = $this->form_hidden() ? '' : $this->divider_markup();
$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 );

View file

@ -51,6 +51,9 @@ class M365_Login_Settings {
'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.
);
}
@ -209,7 +212,31 @@ class M365_Login_Settings {
*/
public function fallback_url() {
$key = $this->fallback_key();
return '' === $key ? '' : add_query_arg( 'm365_fallback', $key, wp_login_url() );
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();
}
/**
@ -309,6 +336,20 @@ class M365_Login_Settings {
}
$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 );

49
includes/functions.php Normal file
View file

@ -0,0 +1,49 @@
<?php
/**
* Template functions.
*
* @package M365_Login
*/
defined( 'ABSPATH' ) || exit;
if ( ! function_exists( 'm365_login_button' ) ) {
/**
* Outputs (or returns) the "Sign in with Microsoft" button for custom login templates.
*
* Usage in a theme template:
* <?php m365_login_button( array( 'redirect' => home_url( '/dashboard/' ) ) ); ?>
*
* @param array $args {
* @type string $redirect Destination after login. Default: admin dashboard.
* @type bool $show_messages Show error/info messages from a previous attempt. Default true.
* @type bool $show_divider Show the "or" divider above the button. Default false.
* }
* @param bool $display Echo (true) or return (false). Default true.
* @return string
*/
function m365_login_button( $args = array(), $display = true ) {
$plugin = M365_Login::instance();
$html = $plugin->button->standalone( $args );
if ( $display ) {
echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- markup is escaped where it is built.
}
return $html;
}
}
if ( ! function_exists( 'm365_login_messages' ) ) {
/**
* Outputs (or returns) the error/info messages of the last Microsoft sign-in attempt.
*
* @param bool $display Echo (true) or return (false). Default true.
* @return string
*/
function m365_login_messages( $display = true ) {
$html = M365_Login::instance()->button->messages_markup();
if ( $display ) {
echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- markup is escaped where it is built.
}
return $html;
}
}