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
|
|
@ -30,6 +30,9 @@ class M365_Login_Settings {
|
|||
'tenant_id' => '',
|
||||
'client_id' => '',
|
||||
'client_secret' => '', // Stored encrypted.
|
||||
'auth_method' => 'secret', // 'secret' or 'certificate'.
|
||||
'cert_private_key' => '', // PEM, stored encrypted.
|
||||
'cert_certificate' => '', // PEM (public).
|
||||
'prompt' => 'select_account',
|
||||
// Security / matching.
|
||||
'upn_fallback' => 1,
|
||||
|
|
@ -96,13 +99,120 @@ class M365_Login_Settings {
|
|||
return is_string( $plain ) ? $plain : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Selected client authentication method.
|
||||
*
|
||||
* @return string 'secret' or 'certificate'.
|
||||
*/
|
||||
public function auth_method() {
|
||||
return 'certificate' === $this->get( 'auth_method' ) ? 'certificate' : 'secret';
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypted certificate private key (PEM) or ''.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function certificate_key() {
|
||||
$enc = (string) $this->get( 'cert_private_key', '' );
|
||||
if ( '' === $enc ) {
|
||||
return '';
|
||||
}
|
||||
$plain = M365_Login_Crypto::decrypt( $enc );
|
||||
return is_string( $plain ) ? $plain : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificate PEM (public part) or ''.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function certificate_pem() {
|
||||
return (string) $this->get( 'cert_certificate', '' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a usable certificate + key pair is stored.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function has_certificate() {
|
||||
return '' !== $this->certificate_pem() && '' !== $this->certificate_key();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed certificate metadata or null.
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public function certificate_info() {
|
||||
return $this->has_certificate() ? M365_Login_Certificate::info( $this->certificate_pem() ) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the plugin has everything it needs to start a login.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_configured() {
|
||||
return '' !== $this->get( 'tenant_id' ) && '' !== $this->get( 'client_id' ) && '' !== $this->client_secret();
|
||||
if ( '' === $this->get( 'tenant_id' ) || '' === $this->get( 'client_id' ) ) {
|
||||
return false;
|
||||
}
|
||||
if ( 'certificate' === $this->auth_method() ) {
|
||||
$info = $this->certificate_info();
|
||||
return null !== $info && ( 0 === $info['not_after'] || $info['not_after'] > time() );
|
||||
}
|
||||
return '' !== $this->client_secret();
|
||||
}
|
||||
|
||||
/**
|
||||
* Client authentication parameters for the token endpoint (secret or signed assertion).
|
||||
*
|
||||
* @param string $token_endpoint Token endpoint URL (assertion audience).
|
||||
* @return array|WP_Error
|
||||
*/
|
||||
public function client_auth_params( $token_endpoint ) {
|
||||
if ( 'certificate' === $this->auth_method() ) {
|
||||
$assertion = M365_Login_Certificate::assertion( $this->certificate_key(), $this->certificate_pem(), (string) $this->get( 'client_id' ), $token_endpoint );
|
||||
if ( is_wp_error( $assertion ) ) {
|
||||
return $assertion;
|
||||
}
|
||||
return array(
|
||||
'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
||||
'client_assertion' => $assertion,
|
||||
);
|
||||
}
|
||||
return array( 'client_secret' => $this->client_secret() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a validated key/certificate pair (key encrypted).
|
||||
*
|
||||
* @param array $pair array( 'private_key' => PEM, 'certificate' => PEM ).
|
||||
* @return true|WP_Error
|
||||
*/
|
||||
public function store_certificate( $pair ) {
|
||||
$enc = M365_Login_Crypto::encrypt( $pair['private_key'] );
|
||||
if ( false === $enc ) {
|
||||
return new WP_Error( 'encrypt', __( 'The private key could not be encrypted. Is the OpenSSL extension available?', 'm365-login' ) );
|
||||
}
|
||||
$all = $this->all();
|
||||
$all['cert_private_key'] = $enc;
|
||||
$all['cert_certificate'] = $pair['certificate'];
|
||||
update_option( M365_LOGIN_OPTION, $all );
|
||||
$this->cache = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the stored certificate and key.
|
||||
*/
|
||||
public function remove_certificate() {
|
||||
$all = $this->all();
|
||||
$all['cert_private_key'] = '';
|
||||
$all['cert_certificate'] = '';
|
||||
update_option( M365_LOGIN_OPTION, $all );
|
||||
$this->cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -115,6 +225,15 @@ class M365_Login_Settings {
|
|||
return '' === $tenant ? 'organizations' : $tenant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether sign-ins from more than one tenant are accepted (no tenant GUID pinned).
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_multi_tenant() {
|
||||
return ! self::is_guid( $this->tenant() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect URI registered in Entra ID.
|
||||
*
|
||||
|
|
@ -300,6 +419,41 @@ class M365_Login_Settings {
|
|||
}
|
||||
}
|
||||
|
||||
$method = isset( $input['auth_method'] ) ? sanitize_key( $input['auth_method'] ) : 'secret';
|
||||
$out['auth_method'] = 'certificate' === $method ? 'certificate' : 'secret';
|
||||
|
||||
// Certificate: keep the stored pair unless a new one is pasted or removal is requested.
|
||||
$out['cert_private_key'] = $current['cert_private_key'];
|
||||
$out['cert_certificate'] = $current['cert_certificate'];
|
||||
$pasted_key = isset( $input['cert_key_pem'] ) ? trim( (string) wp_unslash( $input['cert_key_pem'] ) ) : '';
|
||||
$pasted_cert = isset( $input['cert_cert_pem'] ) ? trim( (string) wp_unslash( $input['cert_cert_pem'] ) ) : '';
|
||||
if ( ! empty( $input['cert_remove'] ) ) {
|
||||
$out['cert_private_key'] = '';
|
||||
$out['cert_certificate'] = '';
|
||||
} elseif ( '' !== $pasted_key || '' !== $pasted_cert ) {
|
||||
if ( '' === $pasted_key || '' === $pasted_cert ) {
|
||||
add_settings_error( M365_LOGIN_OPTION, 'certificate', __( 'Please paste both the private key and the certificate.', 'm365-login' ) );
|
||||
} elseif ( strlen( $pasted_key ) > 20000 || strlen( $pasted_cert ) > 20000 ) {
|
||||
add_settings_error( M365_LOGIN_OPTION, 'certificate', __( 'The pasted key or certificate is too large.', 'm365-login' ) );
|
||||
} else {
|
||||
$pair = M365_Login_Certificate::from_pem( $pasted_key, $pasted_cert );
|
||||
if ( is_wp_error( $pair ) ) {
|
||||
add_settings_error( M365_LOGIN_OPTION, 'certificate', $pair->get_error_message() );
|
||||
} else {
|
||||
$enc = M365_Login_Crypto::encrypt( $pair['private_key'] );
|
||||
if ( false === $enc ) {
|
||||
add_settings_error( M365_LOGIN_OPTION, 'certificate', __( 'The private key could not be encrypted. Is the OpenSSL extension available?', 'm365-login' ) );
|
||||
} else {
|
||||
$out['cert_private_key'] = $enc;
|
||||
$out['cert_certificate'] = $pair['certificate'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ( 'certificate' === $out['auth_method'] && '' === $out['cert_certificate'] ) {
|
||||
add_settings_error( M365_LOGIN_OPTION, 'auth_method', __( 'Certificate authentication is selected but no certificate is stored yet. Generate one or paste your own; the Microsoft button stays hidden until then.', 'm365-login' ), 'warning' );
|
||||
}
|
||||
|
||||
$prompt = isset( $input['prompt'] ) ? sanitize_key( $input['prompt'] ) : '';
|
||||
$out['prompt'] = in_array( $prompt, array( 'none', 'select_account', 'login' ), true ) ? $prompt : 'none';
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue