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:
parent
966164177d
commit
8766927123
19 changed files with 2515 additions and 644 deletions
|
|
@ -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' ),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue