Add certificate authentication, in-app setup guides and security audit

Certificate (RFC 7523 client assertion) as an alternative to the client
secret: one-click generation of a 3072-bit RSA key pair with a
self-signed certificate, .cer download (public part only), own PEM
upload with validation, expiry display, encrypted key storage. Both the
authorization code exchange and the Graph client-credentials request
use the selected method. Step-by-step guides for secret, certificate
and the app registration are shown in the settings.

Security audit (docs/security-audit.md) and fixes:
- Multi-tenant mode ignored the unverified email claim: matching now
  uses the UPN only, or the email claim when xms_edov is true.
- Login starts are rate limited per client (30 per 10 minutes).
- Optional trusted proxy header for client IPs
  (M365_LOGIN_CLIENT_IP_HEADER / filter).

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 15:04:32 +00:00
parent 966164177d
commit 8766927123
No known key found for this signature in database
19 changed files with 2515 additions and 644 deletions

View file

@ -16,6 +16,8 @@ class M365_Login_Admin {
const GROUP = 'm365_login';
const AJAX_TEST = 'm365_login_test_connection';
const AJAX_GROUPS = 'm365_login_search_groups';
const AJAX_CERT = 'm365_login_certificate';
const POST_CERT = 'm365_login_download_cert';
const NONCE_TEST = 'm365_login_test';
/**
@ -63,6 +65,8 @@ class M365_Login_Admin {
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
add_action( 'wp_ajax_' . self::AJAX_TEST, array( $this, 'ajax_test_connection' ) );
add_action( 'wp_ajax_' . self::AJAX_GROUPS, array( $this, 'ajax_search_groups' ) );
add_action( 'wp_ajax_' . self::AJAX_CERT, array( $this, 'ajax_certificate' ) );
add_action( 'admin_post_' . self::POST_CERT, array( $this, 'download_certificate' ) );
add_action( 'update_option_' . M365_LOGIN_OPTION, array( $this->graph, 'flush_token' ) );
add_action( 'admin_notices', array( $this, 'setup_notice' ) );
}
@ -140,6 +144,7 @@ class M365_Login_Admin {
'nonce' => wp_create_nonce( self::NONCE_TEST ),
'action' => self::AJAX_TEST,
'groupAction' => self::AJAX_GROUPS,
'certAction' => self::AJAX_CERT,
'defaultLogo' => M365_Login_Button::microsoft_logo(),
'i18n' => array(
'chooseIcon' => __( 'Choose button icon', 'm365-login' ),
@ -154,6 +159,9 @@ class M365_Login_Admin {
'remove' => __( 'Remove', 'm365-login' ),
'saveFirst' => __( 'Save the connection settings first, then search for groups.', 'm365-login' ),
'confirmKey' => __( 'Generate a new fallback key on save? The old link stops working.', 'm365-login' ),
'generating' => __( 'Generating a 3072-bit key pair, this takes a moment…', 'm365-login' ),
'confirmCert' => __( 'Replace the stored certificate? Sign-in stops working until the new certificate is uploaded to Entra ID.', 'm365-login' ),
'confirmCertRemove' => __( 'Remove the stored certificate when saving? Sign-in with the certificate method stops working.', 'm365-login' ),
),
)
);
@ -224,6 +232,63 @@ class M365_Login_Admin {
wp_send_json_success( array( 'groups' => $groups ) );
}
/**
* AJAX: generate a new self-signed certificate and store it (key encrypted).
*/
public function ajax_certificate() {
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 );
}
$op = isset( $_POST['op'] ) ? sanitize_key( wp_unslash( $_POST['op'] ) ) : '';
if ( 'generate' !== $op ) {
wp_send_json_error( array( 'message' => __( 'Unknown operation.', 'm365-login' ) ) );
}
$host = wp_parse_url( home_url(), PHP_URL_HOST );
$pair = M365_Login_Certificate::generate( is_string( $host ) ? $host : 'wordpress' );
if ( is_wp_error( $pair ) ) {
wp_send_json_error( array( 'message' => $pair->get_error_message() ) );
}
$stored = $this->settings->store_certificate( $pair );
if ( is_wp_error( $stored ) ) {
wp_send_json_error( array( 'message' => $stored->get_error_message() ) );
}
$this->graph->flush_token();
$info = M365_Login_Certificate::info( $pair['certificate'] );
wp_send_json_success(
array(
'message' => __( 'Certificate generated and stored. Download the .cer file and upload it in Entra ID.', 'm365-login' ),
'thumbprint' => $info ? $info['thumbprint'] : '',
)
);
}
/**
* Sends the public certificate as a .cer download (never the private key).
*/
public function download_certificate() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You are not allowed to do this.', 'm365-login' ), 403 );
}
check_admin_referer( self::POST_CERT );
$pem = $this->settings->certificate_pem();
if ( '' === $pem ) {
wp_die( esc_html__( 'No certificate is stored.', 'm365-login' ), 404 );
}
$host = wp_parse_url( home_url(), PHP_URL_HOST );
$name = 'm365-login-' . sanitize_file_name( is_string( $host ) ? $host : 'wordpress' ) . '.cer';
nocache_headers();
header( 'Content-Type: application/x-x509-ca-cert' );
header( 'Content-Disposition: attachment; filename="' . $name . '"' );
header( 'Content-Length: ' . strlen( $pem ) );
echo $pem; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- PEM text, public certificate only.
exit;
}
/**
* Renders the settings screen.
*/
@ -235,6 +300,8 @@ class M365_Login_Admin {
$s = $this->settings->all();
$configured = $this->settings->is_configured();
$has_secret = '' !== $this->settings->client_secret();
$method = $this->settings->auth_method();
$cert_info = $this->settings->certificate_info();
$option = M365_LOGIN_OPTION;
$field = function ( $key ) use ( $option ) {
return esc_attr( $option . '[' . $key . ']' );
@ -280,6 +347,9 @@ class M365_Login_Admin {
<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>
<?php if ( $this->settings->is_multi_tenant() && '' !== $s['tenant_id'] ) : ?>
<p class="m365-warning m365-warning--strong"><?php esc_html_e( 'Multi-tenant mode: accounts from any Microsoft tenant can sign in. Their "email" attribute is not verified, so the plugin matches on the user principal name (verified domain) only and ignores the e-mail claim unless Microsoft marks it as domain-verified. Use the e-mail domain allow-list on the Security tab, or better, pin your tenant GUID.', 'm365-login' ); ?></p>
<?php endif; ?>
<div id="m365-test-result" class="m365-inline-result" hidden></div>
</div>
@ -289,18 +359,132 @@ class M365_Login_Admin {
</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' ); ?>
<span class="m365-field__label"><?php esc_html_e( 'How should WordPress authenticate to Microsoft?', 'm365-login' ); ?></span>
<div class="m365-method">
<label class="m365-method__option" data-method="secret">
<input type="radio" name="<?php echo $field( 'auth_method' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="secret" <?php checked( $method, 'secret' ); ?> />
<span>
<strong><?php esc_html_e( 'Client secret', 'm365-login' ); ?></strong>
<em><?php esc_html_e( 'Quick to set up. A password-like value created in Entra ID that expires after 624 months and must be renewed.', 'm365-login' ); ?></em>
</span>
</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>
<label class="m365-method__option" data-method="certificate">
<input type="radio" name="<?php echo $field( 'auth_method' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="certificate" <?php checked( $method, 'certificate' ); ?> />
<span>
<strong><?php esc_html_e( 'Certificate', 'm365-login' ); ?><span class="m365-method__badge"><?php esc_html_e( 'Recommended', 'm365-login' ); ?></span></strong>
<em><?php esc_html_e( 'The private key never leaves this server; only the public certificate is uploaded to Entra ID. Generated here with one click, valid for 2 years.', 'm365-login' ); ?></em>
</span>
</label>
</div>
</div>
<!-- Secret -->
<div class="m365-auth-panel" data-method="secret">
<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>
<details class="m365-guide" <?php echo $has_secret ? '' : 'open'; ?>>
<summary><?php esc_html_e( 'Step-by-step: create a client secret in Entra ID', 'm365-login' ); ?></summary>
<div class="m365-guide__body">
<ol>
<li><?php esc_html_e( 'Open entra.microsoft.com and sign in with an account that has the "Application Administrator" or "Global Administrator" role.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Go to Identity → Applications → App registrations and open your app (or create it first, see the general guide in the sidebar).', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'In the left menu choose Certificates & secrets, then the tab Client secrets, and click New client secret.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Enter a description such as "WordPress login" and pick an expiry. Microsoft allows at most 24 months; put a reminder in your calendar two weeks before.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Click Add. Copy the Value column immediately it is shown only once. The Secret ID column is NOT what you need.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Paste the value into the Client secret field above and save this page.', 'm365-login' ); ?></li>
</ol>
<p class="m365-guide__note"><?php esc_html_e( 'When the secret expires, sign-ins fail with "Could not complete the sign-in with Microsoft". Create a new secret, paste it here, save, then delete the old one in Entra ID.', 'm365-login' ); ?></p>
</div>
</details>
</div>
<!-- Certificate -->
<div class="m365-auth-panel" data-method="certificate">
<div class="m365-cert">
<?php if ( $cert_info ) : ?>
<?php
$days_left = (int) floor( ( $cert_info['not_after'] - time() ) / DAY_IN_SECONDS );
if ( $days_left < 0 ) {
$status_class = 'is-bad';
$status_text = __( 'Expired', 'm365-login' );
} elseif ( $days_left < 30 ) {
$status_class = 'is-warn';
/* translators: %d: number of days */
$status_text = sprintf( __( 'Expires in %d days', 'm365-login' ), $days_left );
} else {
$status_class = 'is-ok';
$status_text = __( 'Valid', 'm365-login' );
}
?>
<span class="m365-cert__status <?php echo esc_attr( $status_class ); ?>"><?php echo esc_html( $status_text ); ?></span>
<dl class="m365-cert__grid">
<dt><?php esc_html_e( 'Thumbprint (SHA-1)', 'm365-login' ); ?></dt>
<dd><code id="m365-cert-thumbprint"><?php echo esc_html( $cert_info['thumbprint'] ); ?></code> <button type="button" class="button button-small m365-copy__button" data-copy="m365-cert-thumbprint"><?php esc_html_e( 'Copy', 'm365-login' ); ?></button></dd>
<dt><?php esc_html_e( 'Subject', 'm365-login' ); ?></dt>
<dd><?php echo esc_html( $cert_info['subject'] ); ?></dd>
<dt><?php esc_html_e( 'Key size', 'm365-login' ); ?></dt>
<dd><?php echo esc_html( $cert_info['bits'] ); ?> Bit RSA</dd>
<dt><?php esc_html_e( 'Valid until', 'm365-login' ); ?></dt>
<dd><?php echo esc_html( wp_date( get_option( 'date_format' ), $cert_info['not_after'] ) ); ?></dd>
</dl>
<div class="m365-cert__actions">
<a class="button button-primary" href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=' . self::POST_CERT ), self::POST_CERT ) ); ?>"><?php esc_html_e( 'Download certificate (.cer)', 'm365-login' ); ?></a>
<button type="button" class="button" id="m365-cert-generate" data-replace="1"><?php esc_html_e( 'Generate new certificate', 'm365-login' ); ?></button>
<label class="m365-check m365-check--inline">
<input type="checkbox" name="<?php echo $field( 'cert_remove' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" id="m365-cert-remove" />
<?php esc_html_e( 'Remove certificate when saving', 'm365-login' ); ?>
</label>
</div>
<?php else : ?>
<p class="m365-cert__empty"><?php esc_html_e( 'No certificate stored yet.', 'm365-login' ); ?></p>
<div class="m365-cert__actions">
<button type="button" class="button button-primary" id="m365-cert-generate"><?php esc_html_e( 'Generate certificate', 'm365-login' ); ?></button>
<span class="description"><?php esc_html_e( '3072-bit RSA, self-signed, valid for 2 years. The private key is stored encrypted and never shown or downloadable.', 'm365-login' ); ?></span>
</div>
<?php endif; ?>
<div id="m365-cert-result" class="m365-inline-result" hidden></div>
<p class="description" style="margin-top:12px"><a href="#" id="m365-cert-paste-toggle"><?php esc_html_e( 'Use your own certificate instead (paste PEM)', 'm365-login' ); ?></a></p>
<div id="m365-cert-paste" hidden>
<div class="m365-field">
<label for="m365-cert-key"><?php esc_html_e( 'Private key (PEM, unencrypted)', 'm365-login' ); ?></label>
<textarea id="m365-cert-key" name="<?php echo $field( 'cert_key_pem' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" class="large-text m365-pem" rows="6" placeholder="-----BEGIN PRIVATE KEY-----" autocomplete="off" spellcheck="false"></textarea>
</div>
<div class="m365-field">
<label for="m365-cert-cert"><?php esc_html_e( 'Certificate (PEM)', 'm365-login' ); ?></label>
<textarea id="m365-cert-cert" name="<?php echo $field( 'cert_cert_pem' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" class="large-text m365-pem" rows="6" placeholder="-----BEGIN CERTIFICATE-----" spellcheck="false"></textarea>
<p class="description"><?php esc_html_e( 'RSA, at least 2048 bits. The pair is validated and the key is encrypted when you save. Both fields stay empty afterwards.', 'm365-login' ); ?></p>
</div>
</div>
</div>
<details class="m365-guide" <?php echo $cert_info ? 'open' : ''; ?>>
<summary><?php esc_html_e( 'Step-by-step: register the certificate in Entra ID', 'm365-login' ); ?></summary>
<div class="m365-guide__body">
<ol>
<li><?php esc_html_e( 'Click Generate certificate above (or paste your own). Then click Download certificate (.cer) the file contains only the public part.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Open entra.microsoft.com → Identity → Applications → App registrations and open your app.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Choose Certificates & secrets in the left menu, then the tab Certificates, and click Upload certificate.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Select the downloaded .cer file, add a description such as "WordPress login" and click Add.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Compare the thumbprint Entra ID shows with the thumbprint above they must match exactly.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Make sure Certificate is selected above and save this page. If a client secret was stored before, you may delete it in Entra ID now.', 'm365-login' ); ?></li>
</ol>
<p class="m365-guide__note"><?php esc_html_e( 'How it works: for every token request WordPress signs a short-lived JWT (client assertion) with the private key; Microsoft verifies it with the uploaded certificate. Nothing secret is ever transmitted.', 'm365-login' ); ?></p>
<p class="m365-guide__warn"><?php esc_html_e( 'Before the certificate expires: generate a new one here, upload it to Entra ID (both may be registered at the same time), save, then remove the old one from Entra ID. Sign-ins keep working during the switch.', 'm365-login' ); ?></p>
</div>
</details>
</div>
<div class="m365-field">
@ -580,13 +764,16 @@ class M365_Login_Admin {
</div>
<div class="m365-card">
<h2 class="m365-card__title"><?php esc_html_e( 'Setup in 5 steps', 'm365-login' ); ?></h2>
<h2 class="m365-card__title"><?php esc_html_e( 'Setup guide: app registration', '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>
<li><?php esc_html_e( 'Open entra.microsoft.com → Identity → Applications → App registrations → New registration.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Name: e.g. "WordPress login". Supported account types: "Accounts in this organizational directory only" (single tenant).', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Redirect URI: choose the platform Web and paste the URI shown above. Then click Register.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'On the Overview page copy the Application (client) ID and the Directory (tenant) ID into the Connection tab.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Authentication: leave "ID tokens" unchecked (the plugin uses the authorization code flow) and "Allow public client flows" on No.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Token configuration → Add optional claim → ID → tick "email" → Add. Confirm the API permission prompt.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Pick the authentication method on the Connection tab and follow its step-by-step guide (client secret or certificate).', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Optional: restrict who may use the app under Enterprise applications → your app → Properties → "Assignment required" = Yes, then assign users/groups.', 'm365-login' ); ?></li>
</ol>
<p class="description"><?php esc_html_e( 'Required API permission: openid, profile, email (delegated) granted by default.', 'm365-login' ); ?></p>
<p class="description"><?php esc_html_e( 'Optional, for group restrictions: application permissions GroupMember.Read.All and User.Read.All (Microsoft Graph) with admin consent.', 'm365-login' ); ?></p>