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

@ -179,7 +179,23 @@ class M365_Login_Auth {
* @return string
*/
private function client_ip() {
return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '0.0.0.0';
$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 ] ) ) {
$candidate = trim( explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ) )[0] );
if ( filter_var( $candidate, FILTER_VALIDATE_IP ) ) {
$ip = $candidate;
}
}
return $ip;
}
/* ------------------------------------------------------------------ */
@ -257,6 +273,14 @@ class M365_Login_Auth {
$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 >= 30 ) {
$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'] ) ), '' ) : '';
@ -489,19 +513,26 @@ class M365_Login_Auth {
* @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(
'client_id' => $this->settings->get( 'client_id' ),
'client_secret' => $this->settings->client_secret(),
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $this->settings->redirect_uri(),
'code_verifier' => $verifier,
'scope' => 'openid profile email',
'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
),
)
);
@ -658,11 +689,26 @@ class M365_Login_Auth {
*/
private function email_from_claims( $claims ) {
$candidates = array();
if ( ! empty( $claims['email'] ) && is_string( $claims['email'] ) ) {
$candidates[] = $claims['email'];
}
if ( $this->settings->get( 'upn_fallback' ) && ! empty( $claims['preferred_username'] ) && is_string( $claims['preferred_username'] ) ) {
$candidates[] = $claims['preferred_username'];
$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 ) {
@ -810,6 +856,7 @@ class M365_Login_Auth {
'group_check_failed' => __( 'Your group membership could not be verified. Please contact an administrator.', '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' ),
);
}