Some checks are pending
CI / PHP lint (7.4) (pull_request) Waiting to run
CI / PHP lint (8.0) (pull_request) Waiting to run
CI / PHP lint (8.1) (pull_request) Waiting to run
CI / PHP lint (8.2) (pull_request) Waiting to run
CI / PHP lint (8.3) (pull_request) Waiting to run
CI / PHP lint (8.4) (pull_request) Waiting to run
CI / WordPress Coding Standards (pull_request) Waiting to run
CI / WordPress.org Plugin Check (pull_request) Waiting to run
Four-part audit (OIDC/JWT/crypto, user sync, admin UI, login bypasses) with dynamic PoCs against a real WordPress install; every fix is covered by a regression test. Report: docs/security-audit.md, section 6. Critical/High - Multisite: settings, AJAX actions and certificate download require manage_network_options (site admins could sign in as super admin). - Privileged accounts are only linked (sync and first sign-in) via a matching UPN of a member account, never via the settable mail attribute; the sync never changes their e-mail address; e-mail change notifications stay on. - Button-only mode exempts by credential (application passwords, WP-CLI) instead of request context, closing bypasses through xmlrpc.php and REST login handlers; API requests never receive login cookies. - Multi-tenant mode refuses guest/external identities. Medium/Low - Same message for right and wrong passwords; button-only no longer switches off when the connection breaks; server-side fallback cookie expiry; correct fallback key beats IP lockouts; right-most proxy hop; higher start limit; one object ID per account. - Deactivation sets a random password, revokes application passwords and removes the role (restored on reactivation); disabled people are deactivated even when their mail vanished; duplicate bindings handled. - Sync: abort on empty directory answer, no deprovisioning right after a tenant change, atomic run lock, strict photo path validation. - Certificates: key bundles refused, clean re-exported certificate. - Array-safe sanitising, encoded redirect_to, per-action nonces, escaped role lists, no Graph sleeps during sign-in, warnings for public groups, multi-tenant group rules and missing salts, uninstall clears the token. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1064 lines
38 KiB
PHP
1064 lines
38 KiB
PHP
<?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 FALLBACK_COOKIE = 'm365_login_fallback';
|
||
const FALLBACK_TTL = 30 * MINUTE_IN_SECONDS;
|
||
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;
|
||
|
||
/**
|
||
* Graph client.
|
||
*
|
||
* @var M365_Login_Graph
|
||
*/
|
||
private $graph;
|
||
|
||
/**
|
||
* Constructor.
|
||
*
|
||
* @param M365_Login_Settings $settings Settings.
|
||
* @param M365_Login_Graph $graph Graph client.
|
||
*/
|
||
public function __construct( M365_Login_Settings $settings, M365_Login_Graph $graph ) {
|
||
$this->settings = $settings;
|
||
$this->graph = $graph;
|
||
|
||
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 );
|
||
|
||
// 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 );
|
||
// No session cookies from API contexts (XML-RPC, REST) while password sign-in is disabled.
|
||
add_filter( 'send_auth_cookies', array( $this, 'block_api_auth_cookies' ), 99, 4 );
|
||
|
||
// Custom login page: send people back there after logging out.
|
||
add_filter( 'logout_redirect', array( $this, 'logout_redirect' ), 10, 3 );
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Button-only mode */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
/**
|
||
* Whether the current browser presented the fallback key (cookie set for 30 minutes).
|
||
*
|
||
* @return bool
|
||
*/
|
||
public function fallback_active() {
|
||
if ( ! $this->settings->button_only() ) {
|
||
return true; // Nothing is hidden, the form is always available.
|
||
}
|
||
$cookie = isset( $_COOKIE[ self::FALLBACK_COOKIE ] ) ? sanitize_text_field( wp_unslash( $_COOKIE[ self::FALLBACK_COOKIE ] ) ) : '';
|
||
$parts = explode( '|', $cookie );
|
||
if ( 2 !== count( $parts ) || ! ctype_digit( $parts[0] ) ) {
|
||
return false;
|
||
}
|
||
$issued = (int) $parts[0];
|
||
if ( $issued > time() + 60 || time() - $issued > self::FALLBACK_TTL ) {
|
||
return false; // Expired on the server side, whatever the browser keeps.
|
||
}
|
||
return hash_equals( $this->fallback_cookie_value( $issued ), $cookie );
|
||
}
|
||
|
||
/**
|
||
* Fallback cookie value for an issue time: "time|HMAC" (the key itself never sits in the cookie).
|
||
*
|
||
* @param int $issued Issue timestamp.
|
||
* @return string
|
||
*/
|
||
private function fallback_cookie_value( $issued ) {
|
||
return $issued . '|' . hash_hmac( 'sha256', 'fallback|' . $issued . '|' . $this->settings->fallback_key(), wp_salt( 'auth' ) );
|
||
}
|
||
|
||
/**
|
||
* ?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() ) {
|
||
return;
|
||
}
|
||
// 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 || 'on' === $given ) {
|
||
return;
|
||
}
|
||
|
||
// The correct key always works (a shared office IP must not lock the administrator out);
|
||
// wrong keys are slowed down per IP. The key has ~139 bits, the limit only reduces log noise.
|
||
$ip_key = 'm365_login_fb_' . md5( $this->client_ip() );
|
||
if ( ! hash_equals( $this->settings->fallback_key(), $given ) ) {
|
||
$attempts = (int) get_transient( $ip_key );
|
||
if ( $attempts >= 10 ) {
|
||
$this->fail( 'fallback_locked' );
|
||
}
|
||
set_transient( $ip_key, $attempts + 1, 15 * MINUTE_IN_SECONDS );
|
||
$this->fail( 'fallback_invalid' );
|
||
}
|
||
|
||
delete_transient( $ip_key );
|
||
$issued = time();
|
||
$this->send_cookie( self::FALLBACK_COOKIE, $this->fallback_cookie_value( $issued ), $issued + self::FALLBACK_TTL );
|
||
nocache_headers();
|
||
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.
|
||
*
|
||
* @param null|WP_User|WP_Error $user Result so far.
|
||
* @param string $username Username.
|
||
* @param string $password Password.
|
||
* @return null|WP_User|WP_Error
|
||
*/
|
||
public function block_password_login( $user, $username, $password ) {
|
||
if ( ! $this->settings->button_only() || $this->fallback_active() ) {
|
||
return $user;
|
||
}
|
||
if ( '' === (string) $username && '' === (string) $password ) {
|
||
return $user; // Initial form render or cookie auth, no password attempt.
|
||
}
|
||
// Exempt by credential, not by request context: application passwords (XML-RPC, REST) and
|
||
// WP-CLI keep working. A normal password is refused everywhere – also in forms of other
|
||
// plugins that happen to run inside xmlrpc.php or a REST request.
|
||
if ( ( defined( 'WP_CLI' ) && WP_CLI ) || ( $user instanceof WP_User && did_action( 'application_password_did_authenticate' ) ) ) {
|
||
return $user;
|
||
}
|
||
|
||
/**
|
||
* 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 ( $user instanceof WP_User && ! apply_filters( 'm365_login_block_password_login', true, $user ) ) {
|
||
return $user;
|
||
}
|
||
|
||
// Same answer for right and wrong passwords: the form must not become a password oracle.
|
||
return new WP_Error( 'm365_login_button_only', __( 'Password sign-in is disabled on this site. Please use the Microsoft button.', 'm365-login' ) );
|
||
}
|
||
|
||
/**
|
||
* While button-only mode is active, API requests (XML-RPC, REST) never receive session cookies.
|
||
*
|
||
* Core can set cookies there, e.g. when an application password is used to change the account
|
||
* password via REST, or when another plugin's login handler runs inside xmlrpc.php.
|
||
*
|
||
* @param bool $send Whether to send the cookies.
|
||
* @param int $expire Expiry (unused).
|
||
* @param int $expiration Expiration (unused).
|
||
* @param int $user_id User ID (0 when cookies are cleared).
|
||
* @return bool
|
||
*/
|
||
public function block_api_auth_cookies( $send, $expire = 0, $expiration = 0, $user_id = 0 ) {
|
||
if ( ! $send || ! $user_id || ! $this->settings->button_only() || $this->fallback_active() ) {
|
||
return $send;
|
||
}
|
||
if ( ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
|
||
return false;
|
||
}
|
||
return $send;
|
||
}
|
||
|
||
/**
|
||
* Best-effort client IP for rate limiting.
|
||
*
|
||
* @return string
|
||
*/
|
||
private function client_ip() {
|
||
$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '0.0.0.0';
|
||
|
||
/**
|
||
* Name of a trusted proxy header (e.g. 'HTTP_CF_CONNECTING_IP' or 'HTTP_X_REAL_IP') that carries the
|
||
* real client IP. Only set this when every request passes through that proxy; the header is
|
||
* client-controlled otherwise. Defaults to the M365_LOGIN_CLIENT_IP_HEADER constant or none.
|
||
*
|
||
* @param string $header $_SERVER key or ''.
|
||
*/
|
||
$header = apply_filters( 'm365_login_client_ip_header', defined( 'M365_LOGIN_CLIENT_IP_HEADER' ) ? M365_LOGIN_CLIENT_IP_HEADER : '' );
|
||
if ( '' !== $header && ! empty( $_SERVER[ $header ] ) ) {
|
||
// Proxies append to the list: the right-most entry was written by the trusted proxy,
|
||
// entries further left are supplied by the client.
|
||
$hops = explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ) );
|
||
$candidate = trim( (string) end( $hops ) );
|
||
if ( filter_var( $candidate, FILTER_VALIDATE_IP ) ) {
|
||
$ip = $candidate;
|
||
}
|
||
}
|
||
return $ip;
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* 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 );
|
||
$redirect_to = '' !== (string) $redirect_to ? wp_validate_redirect( (string) $redirect_to, '' ) : '';
|
||
if ( '' !== $redirect_to ) {
|
||
$args['redirect_to'] = rawurlencode( $redirect_to ); // add_query_arg() does not encode values.
|
||
}
|
||
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' );
|
||
}
|
||
|
||
// Cap the number of pending login attempts one client can create (state records are stored server-side).
|
||
$throttle_key = 'm365_login_start_' . md5( $this->client_ip() );
|
||
$starts = (int) get_transient( $throttle_key );
|
||
if ( $starts >= 300 ) {
|
||
$this->fail( 'too_many_attempts' );
|
||
}
|
||
set_transient( $throttle_key, $starts + 1, self::STATE_TTL );
|
||
|
||
// 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' );
|
||
}
|
||
|
||
// In multi-tenant mode a guest or federated identity could present any user name.
|
||
if ( $this->settings->is_multi_tenant() && $this->is_external_identity( $claims ) ) {
|
||
$this->fail( 'external_identity' );
|
||
}
|
||
|
||
$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' );
|
||
}
|
||
|
||
if ( M365_Login_Sync::disabled_info( $user->ID ) ) {
|
||
$this->fail( 'account_disabled' );
|
||
}
|
||
|
||
$oid = isset( $claims['oid'] ) && is_string( $claims['oid'] ) ? strtolower( $claims['oid'] ) : '';
|
||
|
||
// Entra group restriction.
|
||
$group_check = $this->check_groups( $claims, $oid );
|
||
if ( true !== $group_check ) {
|
||
$this->fail( $group_check );
|
||
}
|
||
|
||
// Bind the account to the immutable Microsoft object ID after first login.
|
||
$stored = strtolower( (string) get_user_meta( $user->ID, self::META_OID, true ) );
|
||
if ( $this->settings->get( 'bind_oid' ) ) {
|
||
if ( '' === $oid || ! M365_Login_Settings::is_guid( $oid ) ) {
|
||
$this->fail( 'invalid_token' );
|
||
}
|
||
if ( '' !== $stored && ! hash_equals( $stored, $oid ) ) {
|
||
$this->log( sprintf( 'Object ID mismatch for user #%d.', $user->ID ) );
|
||
$this->fail( 'oid_mismatch' );
|
||
}
|
||
}
|
||
|
||
// Privileged accounts that are not bound yet: only the user principal name of a member
|
||
// account may claim them (its domain is verified in the tenant, the e-mail attribute is not).
|
||
if ( '' === $stored && M365_Login_Sync::is_privileged( $user ) && ! $this->may_claim_privileged( $claims, $user ) ) {
|
||
$this->log( sprintf( 'Refused first sign-in of privileged user #%d without a matching user principal name.', $user->ID ) );
|
||
$this->fail( 'privileged_unlinked' );
|
||
}
|
||
|
||
if ( $this->settings->get( 'bind_oid' ) && '' === $stored ) {
|
||
// One Microsoft identity, one WordPress account.
|
||
$taken = get_users(
|
||
array(
|
||
'meta_key' => self::META_OID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
|
||
'meta_value' => $oid, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
|
||
'exclude' => array( $user->ID ),
|
||
'fields' => 'ID',
|
||
'number' => 1,
|
||
'blog_id' => 0,
|
||
)
|
||
);
|
||
if ( ! empty( $taken ) ) {
|
||
$this->log( sprintf( 'Object ID is already bound to another account (user #%d).', $user->ID ) );
|
||
$this->fail( 'oid_mismatch' );
|
||
}
|
||
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 ) {
|
||
$auth = $this->settings->client_auth_params( $this->token_endpoint() );
|
||
if ( is_wp_error( $auth ) ) {
|
||
return $auth;
|
||
}
|
||
|
||
$response = wp_remote_post(
|
||
$this->token_endpoint(),
|
||
array(
|
||
'timeout' => self::HTTP_TIMEOUT,
|
||
'headers' => array( 'Accept' => 'application/json' ),
|
||
'body' => array_merge(
|
||
array(
|
||
'client_id' => $this->settings->get( 'client_id' ),
|
||
'grant_type' => 'authorization_code',
|
||
'code' => $code,
|
||
'redirect_uri' => $this->settings->redirect_uri(),
|
||
'code_verifier' => $verifier,
|
||
'scope' => 'openid profile email',
|
||
),
|
||
$auth
|
||
),
|
||
)
|
||
);
|
||
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Whether the token comes from a guest or another external identity provider.
|
||
*
|
||
* Entra only adds the "idp" claim when the identity provider differs from the issuer.
|
||
*
|
||
* @param array $claims Verified claims.
|
||
* @return bool
|
||
*/
|
||
private function is_external_identity( $claims ) {
|
||
$idp = isset( $claims['idp'] ) && is_string( $claims['idp'] ) ? $claims['idp'] : '';
|
||
$iss = isset( $claims['iss'] ) && is_string( $claims['iss'] ) ? $claims['iss'] : '';
|
||
return '' !== $idp && $idp !== $iss;
|
||
}
|
||
|
||
/**
|
||
* Whether a token may claim a privileged account that is not bound yet.
|
||
*
|
||
* @param array $claims Verified claims.
|
||
* @param WP_User $user Matched account.
|
||
* @return bool
|
||
*/
|
||
private function may_claim_privileged( $claims, $user ) {
|
||
$upn = ! empty( $claims['preferred_username'] ) && is_string( $claims['preferred_username'] ) ? strtolower( trim( $claims['preferred_username'] ) ) : '';
|
||
return '' !== $upn
|
||
&& ! $this->settings->is_multi_tenant()
|
||
&& ! $this->is_external_identity( $claims )
|
||
&& hash_equals( strtolower( $user->user_email ), $upn );
|
||
}
|
||
|
||
/**
|
||
* Applies the Entra group rules: members of an excluded group are refused,
|
||
* everybody else needs membership in one of the allowed groups (if any are set).
|
||
*
|
||
* @param array $claims Verified claims.
|
||
* @param string $oid User object ID.
|
||
* @return true|string True, or an error code for fail().
|
||
*/
|
||
private function check_groups( $claims, $oid ) {
|
||
$denied = $this->check_denied_groups( $claims, $oid );
|
||
if ( true !== $denied ) {
|
||
return $denied;
|
||
}
|
||
return $this->check_allowed_groups( $claims, $oid );
|
||
}
|
||
|
||
/**
|
||
* Group IDs from the "groups" claim, or null when the token has no complete list (claim missing or overage).
|
||
*
|
||
* @param array $claims Verified claims.
|
||
* @return string[]|null
|
||
*/
|
||
private function token_groups( $claims ) {
|
||
$overage = ! empty( $claims['_claim_names'] ) || ! empty( $claims['hasgroups'] );
|
||
if ( $overage || ! isset( $claims['groups'] ) || ! is_array( $claims['groups'] ) ) {
|
||
return null;
|
||
}
|
||
return array_map( 'strtolower', array_filter( $claims['groups'], 'is_string' ) );
|
||
}
|
||
|
||
/**
|
||
* Refuses members of an excluded group (fails closed).
|
||
*
|
||
* A "groups" claim can be filtered in the app registration (e.g. only groups assigned to the
|
||
* application), so it can prove membership but never non-membership: without a match in the
|
||
* token the plugin always asks Microsoft Graph.
|
||
*
|
||
* @param array $claims Verified claims.
|
||
* @param string $oid User object ID.
|
||
* @return true|string True, or an error code for fail().
|
||
*/
|
||
private function check_denied_groups( $claims, $oid ) {
|
||
$denied = array_keys( $this->settings->denied_groups() );
|
||
if ( empty( $denied ) ) {
|
||
return true;
|
||
}
|
||
|
||
$token_groups = $this->token_groups( $claims );
|
||
if ( null !== $token_groups && array_intersect( $denied, $token_groups ) ) {
|
||
$this->log( 'User is a member of an excluded group (token claim).' );
|
||
return 'in_denied_group';
|
||
}
|
||
|
||
if ( '' === $oid || ! M365_Login_Settings::is_guid( $oid ) ) {
|
||
return 'invalid_token';
|
||
}
|
||
|
||
$matches = $this->graph->check_member_groups( $oid, $denied, false );
|
||
if ( is_wp_error( $matches ) ) {
|
||
$this->log( 'Excluded-group check via Microsoft Graph failed: ' . $matches->get_error_message() );
|
||
return 'group_check_failed';
|
||
}
|
||
if ( ! empty( $matches ) ) {
|
||
$this->log( 'User is a member of an excluded group (Graph).' );
|
||
return 'in_denied_group';
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Verifies membership in one of the allowed Entra groups.
|
||
*
|
||
* Uses the "groups" claim when the token carries one (and is not in overage),
|
||
* otherwise asks Microsoft Graph (transitive check, needs application permissions).
|
||
*
|
||
* @param array $claims Verified claims.
|
||
* @param string $oid User object ID.
|
||
* @return true|string True, or an error code for fail().
|
||
*/
|
||
private function check_allowed_groups( $claims, $oid ) {
|
||
$allowed = array_keys( $this->settings->allowed_groups() );
|
||
if ( empty( $allowed ) ) {
|
||
return true;
|
||
}
|
||
|
||
$token_groups = $this->token_groups( $claims );
|
||
if ( null !== $token_groups ) {
|
||
if ( array_intersect( $allowed, $token_groups ) ) {
|
||
return true;
|
||
}
|
||
// The claim is authoritative when present: no need to ask Graph.
|
||
$this->log( 'User is not a member of an allowed group (token claim).' );
|
||
return 'not_in_group';
|
||
}
|
||
|
||
if ( '' === $oid || ! M365_Login_Settings::is_guid( $oid ) ) {
|
||
return 'invalid_token';
|
||
}
|
||
|
||
$matches = $this->graph->check_member_groups( $oid, $allowed, false );
|
||
if ( is_wp_error( $matches ) ) {
|
||
$this->log( 'Group check via Microsoft Graph failed: ' . $matches->get_error_message() );
|
||
return 'group_check_failed';
|
||
}
|
||
if ( empty( $matches ) ) {
|
||
$this->log( 'User is not a member of an allowed group (Graph).' );
|
||
return 'not_in_group';
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 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();
|
||
$email = ! empty( $claims['email'] ) && is_string( $claims['email'] ) ? $claims['email'] : '';
|
||
$upn = ! empty( $claims['preferred_username'] ) && is_string( $claims['preferred_username'] ) ? $claims['preferred_username'] : '';
|
||
|
||
if ( $this->settings->is_multi_tenant() ) {
|
||
// In multi-tenant mode any tenant admin can set an arbitrary "email" attribute on their users.
|
||
// The UPN domain, on the other hand, must be verified in the issuing tenant, so it comes first;
|
||
// the e-mail claim is only used when Microsoft marks its domain as owner-verified (xms_edov).
|
||
if ( '' !== $upn ) {
|
||
$candidates[] = $upn;
|
||
}
|
||
if ( '' !== $email && ! empty( $claims['xms_edov'] ) && true === $claims['xms_edov'] ) {
|
||
$candidates[] = $email;
|
||
}
|
||
} else {
|
||
if ( '' !== $email ) {
|
||
$candidates[] = $email;
|
||
}
|
||
if ( $this->settings->get( 'upn_fallback' ) && '' !== $upn ) {
|
||
$candidates[] = $upn;
|
||
}
|
||
}
|
||
|
||
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( self::STATE_COOKIE, $token, time() + self::STATE_TTL );
|
||
}
|
||
|
||
/**
|
||
* Removes the state cookie.
|
||
*/
|
||
private function clear_state_cookie() {
|
||
$this->send_cookie( self::STATE_COOKIE, '', time() - YEAR_IN_SECONDS );
|
||
}
|
||
|
||
/**
|
||
* Cookie helper: HttpOnly, SameSite=Lax (needed for the top-level redirect back), Secure on HTTPS.
|
||
*
|
||
* @param string $name Cookie name.
|
||
* @param string $value Value.
|
||
* @param int $expires Expiry timestamp.
|
||
*/
|
||
private function send_cookie( $name, $value, $expires ) {
|
||
$path = wp_parse_url( home_url( '/' ), PHP_URL_PATH );
|
||
$path = is_string( $path ) && '' !== $path ? $path : '/';
|
||
|
||
setcookie(
|
||
$name,
|
||
$value,
|
||
array(
|
||
'expires' => $expires,
|
||
'path' => $path,
|
||
'domain' => COOKIE_DOMAIN ? COOKIE_DOMAIN : '',
|
||
'secure' => is_ssl(),
|
||
'httponly' => true,
|
||
'samesite' => 'Lax',
|
||
)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Writes a diagnostic line to the debug log (only with WP_DEBUG and WP_DEBUG_LOG; never tokens or secrets).
|
||
*
|
||
* @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
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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();
|
||
nocache_headers();
|
||
wp_safe_redirect( add_query_arg( 'm365_error', rawurlencode( $code ), $this->settings->login_page_url() ) );
|
||
exit;
|
||
}
|
||
|
||
/**
|
||
* Messages for the current request (from ?m365_error and ?m365_fallback=on), for any login page.
|
||
*
|
||
* @return array[] Each item: array( 'type' => 'error'|'message', 'code' => string, 'text' => string ).
|
||
*/
|
||
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 ( $this->settings->button_only() && ! $this->settings->is_configured() && ! $this->fallback_active() ) {
|
||
$out[] = array(
|
||
'type' => 'error',
|
||
'code' => 'unavailable',
|
||
'text' => __( 'Microsoft sign-in is temporarily unavailable. Please contact an administrator.', 'm365-login' ),
|
||
);
|
||
}
|
||
if ( $fallback_on && $this->settings->button_only() && $this->fallback_active() ) {
|
||
$out[] = array(
|
||
'type' => 'message',
|
||
'code' => 'fallback_on',
|
||
'text' => __( 'Password sign-in is temporarily enabled for this browser (30 minutes).', 'm365-login' ),
|
||
);
|
||
}
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 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' ),
|
||
'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' ),
|
||
'not_in_group' => __( 'Your Microsoft account is not a member of a group that is allowed to sign in here.', 'm365-login' ),
|
||
'group_check_failed' => __( 'Your group membership could not be verified. Please contact an administrator.', 'm365-login' ),
|
||
'in_denied_group' => __( 'Your Microsoft account is a member of a group that is not allowed to sign in here.', 'm365-login' ),
|
||
'fallback_invalid' => __( 'The fallback key is not valid.', 'm365-login' ),
|
||
'fallback_locked' => __( 'Too many attempts. Please wait 15 minutes.', 'm365-login' ),
|
||
'too_many_attempts' => __( 'Too many sign-in attempts from your connection. Please wait a few minutes and try again.', 'm365-login' ),
|
||
'account_disabled' => __( 'This account has been deactivated.', 'm365-login' ),
|
||
'privileged_unlinked' => __( 'For security reasons this administrator account can only be linked to a Microsoft account whose user principal name equals the WordPress e-mail address. Please contact an administrator.', 'm365-login' ),
|
||
'external_identity' => __( 'Guest and external accounts cannot sign in here.', 'm365-login' ),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
}
|