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

@ -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;
}
}