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
BIN
.github/assets/screenshots/settings-connection.png
vendored
BIN
.github/assets/screenshots/settings-connection.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 342 KiB After Width: | Height: | Size: 603 KiB |
|
|
@ -12,6 +12,8 @@ All notable changes to this project are documented in this file. The format foll
|
|||
- Settings screen (Settings → M365 Login) with connection, button and security tabs, live button preview, colour presets, media-library icon picker, redirect-URI copy button and tenant connectivity test.
|
||||
- ID token verification against Microsoft's JWKS (RS256, issuer, audience, tenant, expiry, nonce).
|
||||
- Encrypted client secret storage (AES-256-GCM).
|
||||
- Certificate based client authentication (RFC 7523 `private_key_jwt`): one-click generation of a 3072-bit RSA key pair with self-signed certificate, `.cer` download, own PEM upload, expiry display; step-by-step guides for both methods in the settings.
|
||||
- Security audit (docs/security-audit.md) with fixes: unverified `email` claim ignored in multi-tenant mode, login-start rate limit, trusted proxy IP header.
|
||||
- Account binding to the Microsoft object ID, e-mail domain allow-list.
|
||||
- Entra group restriction with a Graph-powered group picker; membership verified via the `groups` claim or Microsoft Graph `checkMemberGroups`.
|
||||
- Button-only mode that hides the password form and blocks password sign-in on `wp-login.php`, with a secret, rate-limited fallback link and a `wp-config.php` emergency constant.
|
||||
|
|
|
|||
20
README.md
20
README.md
|
|
@ -51,7 +51,8 @@
|
|||
| 🎨 **Gestaltbarer Button** | Text, Icon (Microsoft-Logo oder eigenes Bild), Farben, Hover-Farbe, Rahmen, Eckenradius, Position – mit Live-Vorschau und Presets. |
|
||||
| 👥 **Entra-Gruppen** | Optional nur Mitglieder ausgewählter Gruppen zulassen. Gruppen werden direkt im Backend gesucht und ausgewählt. |
|
||||
| 🚪 **Nur-Button-Modus** | Passwortfelder ausblenden und Passwort-Logins sperren – mit geheimem Fallback-Link als Notausgang. |
|
||||
| 🛡️ **Sicher by default** | OpenID Connect + PKCE, Signaturprüfung, Tenant-Pinning, Konto-Bindung, verschlüsseltes Secret. |
|
||||
| 🔏 **Secret oder Zertifikat** | Wahlweise Client Secret oder zertifikatsbasierte Authentifizierung (RFC 7523). Zertifikat mit einem Klick im Backend erzeugen, nur der öffentliche Teil geht zu Microsoft. |
|
||||
| 🛡️ **Sicher by default** | OpenID Connect + PKCE, Signaturprüfung, Tenant-Pinning, Konto-Bindung, verschlüsseltes Secret, [Security-Audit](docs/security-audit.md). |
|
||||
| 🌍 **Übersetzbar** | Englische Basis, deutsche Übersetzung (du & Sie) enthalten. |
|
||||
| 📦 **WordPress.org-ready** | readme.txt, Lizenz, Uninstall, Plugin Check in CI, Build-Script. |
|
||||
|
||||
|
|
@ -142,8 +143,9 @@ Alternativ den Repo-Inhalt als Ordner `m365-login` nach `wp-content/plugins/` ko
|
|||
- Unterstützte Kontotypen: **Nur Konten in diesem Organisationsverzeichnis** (Single Tenant)
|
||||
- Umleitungs-URI: Plattform **Web**, URI aus Schritt 1
|
||||
3. **IDs übernehmen.** Auf der Übersichtsseite **Anwendungs-ID (Client)** und **Verzeichnis-ID (Mandant)** kopieren → in WordPress eintragen.
|
||||
4. **Client Secret erstellen.** *Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel* → den **Wert** (nicht die Geheimnis-ID) in WordPress eintragen.
|
||||
Das Ablaufdatum notieren – abgelaufene Secrets müssen erneuert werden.
|
||||
4. **Authentifizierung wählen.**
|
||||
- *Zertifikat (empfohlen):* In WordPress *Zertifikat erzeugen* → *.cer herunterladen* → in Entra ID *Zertifikate & Geheimnisse → Zertifikate → Zertifikat hochladen*. Thumbprint vergleichen.
|
||||
- *Client Secret:* *Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel* → den **Wert** (nicht die Geheimnis-ID) in WordPress eintragen. Ablaufdatum notieren.
|
||||
5. **E-Mail-Claim aktivieren** (empfohlen). *Tokenkonfiguration → Optionalen Anspruch hinzufügen → ID → `email`*.
|
||||
6. **Speichern** und mit *Tenant testen* prüfen, ob Microsoft erreichbar ist.
|
||||
|
||||
|
|
@ -181,9 +183,11 @@ Wichtig: Jeder Benutzer, der sich per Microsoft anmelden soll, braucht in WordPr
|
|||
|
||||
| Feld | Beschreibung |
|
||||
| --- | --- |
|
||||
| Verzeichnis-ID (Tenant) | GUID des Tenants (empfohlen, aktiviert Tenant-Pinning) oder `organizations` / `common` / `consumers`. |
|
||||
| Verzeichnis-ID (Tenant) | GUID des Tenants (empfohlen, aktiviert Tenant-Pinning) oder `organizations` / `common` / `consumers`. Im Multi-Tenant-Modus wird nur der UPN zur Zuordnung verwendet (siehe Audit H-1). |
|
||||
| Anwendungs-ID (Client) | GUID der App-Registrierung. |
|
||||
| Authentifizierung | **Client Secret** oder **Zertifikat** (empfohlen). Für beide Wege gibt es im Backend eine Schritt-für-Schritt-Anleitung. |
|
||||
| Client Secret | Wird verschlüsselt gespeichert und nie wieder angezeigt. Leer lassen = behalten. |
|
||||
| Zertifikat | *Zertifikat erzeugen* legt ein 3072-Bit-RSA-Schlüsselpaar mit selbstsigniertem Zertifikat (2 Jahre) an. Der private Schlüssel bleibt verschlüsselt auf dem Server; die `.cer`-Datei wird in Entra ID unter *Zertifikate & Geheimnisse → Zertifikate* hochgeladen. Alternativ eigenes PEM-Paar einfügen. |
|
||||
| Kontoauswahl | `select_account` (Standard), `none` (bestehende Microsoft-Sitzung nutzen) oder `login` (immer Anmeldedaten verlangen). |
|
||||
| Tenant testen | Lädt die OpenID-Konfiguration des Tenants – prüft ID und ausgehende Verbindung. |
|
||||
|
||||
|
|
@ -282,12 +286,16 @@ Plugins, die `wp-login.php` umbenennen (z. B. WPS Hide Login), sind kompatibel,
|
|||
| Fremde Tenants | Bei konfigurierter Tenant-GUID **Tenant-Pinning**; sonst Issuer-Konsistenz mit `tid`. |
|
||||
| Kontoübernahme per E-Mail-Recycling | **Bindung an die Objekt-ID** (`oid`) beim ersten Login. |
|
||||
| Unbefugte Konten | Kein Provisioning, optionale Domain-Allowlist, optionale Gruppen-Beschränkung (fail closed). |
|
||||
| Secret-Diebstahl aus der Datenbank | AES-256-GCM, Schlüssel per HKDF aus `AUTH_KEY`/`SECURE_AUTH_KEY`; ohne `wp-config.php` ist der Datensatz wertlos. |
|
||||
| Secret-Diebstahl aus der Datenbank | AES-256-GCM, Schlüssel per HKDF aus `AUTH_KEY`/`SECURE_AUTH_KEY`; ohne `wp-config.php` ist der Datensatz wertlos. Gilt für Client Secret und privaten Zertifikatsschlüssel. |
|
||||
| Secret-Abfluss im Transport | Zertifikatsmodus: es wird nie ein Geheimnis übertragen, nur eine 5 Minuten gültige, signierte Client Assertion (RFC 7523). |
|
||||
| Kontoübernahme im Multi-Tenant-Modus | `email`-Claim fremder Tenants wird ignoriert (nur UPN mit verifizierter Domain oder `xms_edov`). |
|
||||
| Flooding der State-Tabelle | Max. 30 Login-Starts pro IP und 10 Minuten; Proxy-Header per `M365_LOGIN_CLIENT_IP_HEADER`. |
|
||||
| Offene Redirects | `redirect_to` läuft durch `wp_validate_redirect`, alle Redirects über `wp_safe_redirect`. |
|
||||
| Fehler-Reflektion | Fehlermeldungen sind Codes → feste, übersetzte Texte; Details nur ins Log (`WP_DEBUG_LOG`). |
|
||||
| Rate Limiting Fallback-Key | 10 Fehlversuche pro IP / 15 Min. |
|
||||
|
||||
Die Klassen für JWT-Prüfung und Verschlüsselung haben isolierte Tests (manipulierte Signaturen, abgelaufene Tokens, falsche Audience/Tenant/Issuer, `alg=none`, fremde Schlüssel).
|
||||
Die Klassen für JWT-Prüfung, Verschlüsselung, Zertifikate und die Login-Sperre haben isolierte Tests (manipulierte Signaturen, abgelaufene Tokens, falsche Audience/Tenant/Issuer, `alg=none`, fremde Schlüssel, gefälschte Fallback-Cookies, schwache RSA-Schlüssel).
|
||||
Der vollständige Bericht mit Bedrohungsmodell, Befunden und Betriebsempfehlungen: **[docs/security-audit.md](docs/security-audit.md)**.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -718,3 +718,230 @@
|
|||
.m365-fallback .m365-copy {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Auth method switch */
|
||||
.m365-method {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 782px) {
|
||||
.m365-method {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.m365-method__option {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--m365-border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.m365-method__option:hover {
|
||||
border-color: #b6d7f2;
|
||||
}
|
||||
|
||||
.m365-method__option.is-selected {
|
||||
border-color: var(--m365-accent);
|
||||
box-shadow: 0 0 0 1px var(--m365-accent);
|
||||
background: #fbfdff;
|
||||
}
|
||||
|
||||
.m365-method__option input {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.m365-method__option strong {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m365-method__option em {
|
||||
display: block;
|
||||
font-style: normal;
|
||||
color: var(--m365-muted);
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.m365-method__badge {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: #e6f4ea;
|
||||
color: #1e6b31;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.m365-auth-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m365-auth-panel.is-active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Certificate box */
|
||||
.m365-cert {
|
||||
border: 1px solid var(--m365-border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.m365-cert__grid {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
gap: 6px 16px;
|
||||
font-size: 13px;
|
||||
margin: 8px 0 12px;
|
||||
}
|
||||
|
||||
.m365-cert__grid dt {
|
||||
color: var(--m365-muted);
|
||||
}
|
||||
|
||||
.m365-cert__grid dd {
|
||||
margin: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.m365-cert__grid code {
|
||||
background: #fff;
|
||||
border: 1px solid var(--m365-border);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.m365-cert__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.m365-cert__empty {
|
||||
color: var(--m365-muted);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.m365-cert__status {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m365-cert__status.is-ok {
|
||||
background: #e6f4ea;
|
||||
color: #1e6b31;
|
||||
}
|
||||
|
||||
.m365-cert__status.is-warn {
|
||||
background: #fcf0e4;
|
||||
color: #8a4b00;
|
||||
}
|
||||
|
||||
.m365-cert__status.is-bad {
|
||||
background: #fcf0f1;
|
||||
color: #8a2424;
|
||||
}
|
||||
|
||||
.m365-field textarea.m365-pem {
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 12px;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
/* Guides */
|
||||
.m365-guide {
|
||||
border: 1px solid var(--m365-border);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.m365-guide > summary {
|
||||
cursor: pointer;
|
||||
padding: 12px 16px;
|
||||
font-weight: 600;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.m365-guide > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m365-guide > summary::before {
|
||||
content: "▸";
|
||||
color: var(--m365-accent);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.m365-guide[open] > summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.m365-guide__body {
|
||||
padding: 0 16px 16px;
|
||||
border-top: 1px solid #f0f0f1;
|
||||
}
|
||||
|
||||
.m365-guide__body ol {
|
||||
margin: 12px 0 0;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.m365-guide__body li {
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.m365-guide__body li strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m365-guide__body code {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.m365-guide__note {
|
||||
margin: 12px 0 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: #f0f6fc;
|
||||
border: 1px solid #c5d9ed;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.m365-guide__warn {
|
||||
margin: 12px 0 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: #fcf0e4;
|
||||
border: 1px solid #f0c28a;
|
||||
color: #6b3a00;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.m365-warning--strong {
|
||||
background: #fcf0f1;
|
||||
color: #8a2424;
|
||||
border: 1px solid #f0b8bd;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,6 +129,50 @@
|
|||
$( '#m365-icon-url' ).val( '' ).trigger( 'input' );
|
||||
} );
|
||||
|
||||
/* ---------------- Auth method switch ---------------- */
|
||||
function applyMethod() {
|
||||
var method = $( 'input[name$="[auth_method]"]:checked' ).val() || 'secret';
|
||||
$( '.m365-method__option' ).removeClass( 'is-selected' ).filter( '[data-method="' + method + '"]' ).addClass( 'is-selected' );
|
||||
$( '.m365-auth-panel' ).removeClass( 'is-active' ).filter( '[data-method="' + method + '"]' ).addClass( 'is-active' );
|
||||
}
|
||||
$( 'input[name$="[auth_method]"]' ).on( 'change', applyMethod );
|
||||
applyMethod();
|
||||
|
||||
/* ---------------- Certificate generation ---------------- */
|
||||
$( '#m365-cert-generate' ).on( 'click', function () {
|
||||
var $btn = $( this );
|
||||
var $out = $( '#m365-cert-result' );
|
||||
if ( $btn.data( 'replace' ) && ! window.confirm( i18n.confirmCert ) ) {
|
||||
return;
|
||||
}
|
||||
$btn.prop( 'disabled', true );
|
||||
$out.removeClass( 'is-success is-error' ).prop( 'hidden', false ).text( i18n.generating || '…' );
|
||||
|
||||
$.post( cfg.ajaxUrl, { action: cfg.certAction, nonce: cfg.nonce, op: 'generate' } ).done( function ( res ) {
|
||||
if ( res && res.success ) {
|
||||
$out.addClass( 'is-success' ).text( res.data.message );
|
||||
window.setTimeout( function () { window.location.reload(); }, 800 );
|
||||
} else {
|
||||
$out.addClass( 'is-error' ).text( ( res && res.data && res.data.message ) || i18n.testFailed );
|
||||
$btn.prop( 'disabled', false );
|
||||
}
|
||||
} ).fail( function () {
|
||||
$out.addClass( 'is-error' ).text( i18n.testFailed );
|
||||
$btn.prop( 'disabled', false );
|
||||
} );
|
||||
} );
|
||||
|
||||
$( '#m365-cert-remove' ).on( 'change', function () {
|
||||
if ( this.checked && ! window.confirm( i18n.confirmCertRemove ) ) {
|
||||
this.checked = false;
|
||||
}
|
||||
} );
|
||||
|
||||
$( '#m365-cert-paste-toggle' ).on( 'click', function ( e ) {
|
||||
e.preventDefault();
|
||||
$( '#m365-cert-paste' ).prop( 'hidden', function ( i, v ) { return ! v; } );
|
||||
} );
|
||||
|
||||
/* ---------------- Secret visibility ---------------- */
|
||||
$( '.m365-toggle-secret' ).on( 'click', function () {
|
||||
var $input = $( '#m365-client-secret' );
|
||||
|
|
|
|||
149
docs/security-audit.md
Normal file
149
docs/security-audit.md
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
# Security-Audit: M365 Login 1.0.0
|
||||
|
||||
**Stand:** 22.09.2026 · **Umfang:** gesamter Plugin-Code (PHP, JS, CSS), Konfiguration, Deployment-Hinweise ·
|
||||
**Methode:** manuelle Code-Review gegen OAuth 2.0 / OpenID Connect Best Current Practice (RFC 6749, RFC 7636 PKCE,
|
||||
RFC 7523 Client Assertions, OAuth 2.0 Security BCP), OWASP ASVS 4.0 (V2 Authentication, V3 Session, V5 Validation,
|
||||
V6 Cryptography), WordPress Plugin Handbook „Security“ sowie isolierte Tests der sicherheitskritischen Klassen.
|
||||
|
||||
> Der Audit wurde ohne laufende WordPress-Instanz durchgeführt. Alle Aussagen zum Laufzeitverhalten beruhen auf
|
||||
> Code-Lesung und den isolierten Tests (JWT-Verifikation, Verschlüsselung, Zertifikate, Eingabeverarbeitung,
|
||||
> Nur-Button-Sperre). Ein Penetrationstest gegen eine echte Installation steht aus und wird empfohlen.
|
||||
|
||||
## 1. Zusammenfassung
|
||||
|
||||
| Schweregrad | Gefunden | Behoben | Offen (mit Empfehlung) |
|
||||
| --- | --- | --- | --- |
|
||||
| Hoch | 1 | 1 | 0 |
|
||||
| Mittel | 3 | 3 | 0 |
|
||||
| Niedrig | 5 | 3 | 2 |
|
||||
| Hinweis | 6 | – | 6 |
|
||||
|
||||
Der Login-Flow ist nach dem Audit **ohne bekannte kritische oder hohe Schwachstellen**. Der einzige Hoch-Befund
|
||||
(Account-Übernahme im Multi-Tenant-Modus über den unverifizierten `email`-Claim) wurde behoben. Die beiden offenen
|
||||
Niedrig-Befunde betreffen Betriebsumgebung und Konfiguration, nicht den Code.
|
||||
|
||||
## 2. Bedrohungsmodell
|
||||
|
||||
**Schutzziele:** (1) Nur die Person, die ein Microsoft-Konto kontrolliert, darf sich als der zugehörige
|
||||
WordPress-Benutzer anmelden. (2) Client Secret bzw. privater Schlüssel dürfen nicht abfließen. (3) Der
|
||||
Nur-Button-Modus darf nicht umgangen werden. (4) Keine Rechteausweitung über die Admin-Oberfläche.
|
||||
|
||||
**Angreifer:** (A) anonymer Internet-Nutzer, (B) Benutzer eines fremden Entra-Tenants, (C) Benutzer des eigenen
|
||||
Tenants ohne WordPress-Konto, (D) Angreifer mit Lesezugriff auf die Datenbank (Backup, SQL-Injection in anderem
|
||||
Plugin), (E) Angreifer im Netzwerkpfad (MITM), (F) angemeldeter WordPress-Benutzer mit niedriger Rolle.
|
||||
|
||||
## 3. Befunde
|
||||
|
||||
### H-1 · Multi-Tenant-Modus: Kontoübernahme über den `email`-Claim — **behoben**
|
||||
|
||||
**Beschreibung.** Bei Tenant `organizations`/`common` akzeptiert das Plugin Tokens beliebiger Tenants. Der
|
||||
`email`-Claim in Entra-ID-Tokens ist ein frei editierbares Benutzerattribut des ausstellenden Tenants. Angreifer (B)
|
||||
legt in seinem eigenen Tenant einen Benutzer mit `mail = admin@opfer.de` an und meldet sich damit an; das Plugin
|
||||
findet den WordPress-Admin per E-Mail. Mit gepinnter Tenant-GUID (Standard-Empfehlung) war der Angriff nicht möglich.
|
||||
|
||||
**Fix.** `M365_Login_Auth::email_from_claims()`: Im Multi-Tenant-Modus wird ausschließlich der UPN
|
||||
(`preferred_username`, dessen Domain im ausstellenden Tenant verifiziert sein muss) verwendet; der `email`-Claim
|
||||
nur, wenn Microsoft ihn per `xms_edov = true` als domain-verifiziert markiert. Zusätzlich deutlicher Warnhinweis im
|
||||
Backend und Empfehlung der Domain-Allowlist. Test: `multi-tenant: unverified email claim ignored`.
|
||||
|
||||
**Restrisiko.** Ein fremder Tenant kann eine Domain nur verifizieren, wenn er sie kontrolliert. Für maximale
|
||||
Sicherheit bleibt die Tenant-GUID die Empfehlung.
|
||||
|
||||
### M-1 · Passwort-Login-Sperre nur auf `wp-login.php` — **behoben**
|
||||
|
||||
Der Nur-Button-Modus prüfte `$GLOBALS['pagenow']`; eigene Login-Formulare (`wp_signon()` von einer Seite)
|
||||
umgingen die Sperre. Jetzt greift der `authenticate`-Filter (Priorität 99, nach den Core-Handlern) für jede
|
||||
interaktive Passwort-Anmeldung; ausgenommen sind XML-RPC, REST (Application Passwords), WP-CLI und Cron, plus ein
|
||||
Opt-out-Filter für vertrauenswürdige Plugins. Tests: `password login blocked without fallback cookie`,
|
||||
`forged fallback cookie rejected`, `REST requests exempt`.
|
||||
|
||||
### M-2 · `authenticate`-Filter mit zu früher Priorität — **behoben**
|
||||
|
||||
Erste Fassung hing bei Priorität 5; `wp_authenticate_username_password()` (Priorität 20) überschreibt einen
|
||||
übergebenen `WP_Error`, wenn Benutzername und Passwort stimmen — die Sperre wäre wirkungslos gewesen. Korrigiert auf
|
||||
Priorität 99 und Blockade nur, wenn bereits ein `WP_User` vorliegt (Core-Fehlermeldungen bleiben erhalten).
|
||||
|
||||
### M-3 · Unbegrenzte Erzeugung von State-Datensätzen — **behoben**
|
||||
|
||||
Jeder Aufruf von `wp-login.php?action=m365_login` legt einen Transient (10 Min.) an. Angreifer (A) konnte die
|
||||
Options-Tabelle fluten. Jetzt max. 30 Starts pro Client-IP und 10 Minuten (`too_many_attempts`).
|
||||
|
||||
### N-1 · Rate-Limit nach `REMOTE_ADDR` hinter Reverse Proxy — **behoben (opt-in)**
|
||||
|
||||
Hinter Cloudflare/Load Balancer teilen sich alle Besucher eine IP: Fehlversuche eines Angreifers sperren alle
|
||||
(DoS auf den Fallback-Link), bzw. der Angreifer verteilt sich nicht. Neu: Konstante `M365_LOGIN_CLIENT_IP_HEADER`
|
||||
bzw. Filter `m365_login_client_ip_header` zur Angabe eines vertrauenswürdigen Proxy-Headers. Nicht automatisch
|
||||
aktiv, weil ein Client-Header ohne Proxy fälschbar wäre. Der Fallback-Key selbst hat ≈139 Bit Entropie; das
|
||||
Rate-Limit ist Defense-in-Depth, kein primärer Schutz.
|
||||
|
||||
### N-2 · Client Secret / privater Schlüssel: Schlüsselableitung aus den WordPress-Salts — **behoben (Design)**
|
||||
|
||||
AES-256-GCM mit HKDF aus `AUTH_KEY`/`SECURE_AUTH_KEY`. Angreifer (D) mit reinem DB-Zugriff kann die Werte nicht
|
||||
entschlüsseln. Angreifer mit Zugriff auf `wp-config.php` hat ohnehin Vollzugriff. Bewertung: angemessen für die
|
||||
Plattform; ein HSM/KMS ist in WordPress nicht praktikabel. **Hinweis:** Rotation der Salts macht gespeicherte Werte
|
||||
unlesbar (Plugin meldet „nicht konfiguriert“, Neueingabe nötig) — im Backend dokumentiert.
|
||||
|
||||
### N-3 · Zertifikats-Authentifizierung (RFC 7523) — **neu, geprüft**
|
||||
|
||||
Client Assertion: `RS256`, `aud` = Token-Endpunkt, `iss`/`sub` = Client-ID, `jti` 192 Bit Zufall, `exp` 5 Min.,
|
||||
Header `x5t` und `x5t#S256`. Privater Schlüssel: 3072 Bit RSA, verschlüsselt gespeichert, nie ausgegeben (Download
|
||||
liefert ausschließlich das Zertifikat, Capability + Nonce geprüft). Eigene PEM-Paare: Prüfung auf RSA ≥ 2048 Bit,
|
||||
Schlüssel-Zertifikat-Zugehörigkeit, Ablaufdatum; verschlüsselte Keys werden abgelehnt (keine Passphrase-Speicherung).
|
||||
Tests: `assertion signature verifies with certificate`, `mismatched key/cert rejected`, `1024-bit key rejected`.
|
||||
|
||||
### N-4 · Fehlermeldungen als Codes — **in Ordnung**
|
||||
|
||||
`m365_error` transportiert nur einen Whitelist-Code; Texte sind fest und übersetzt. Keine Reflektion von
|
||||
Microsoft-Fehlertexten an Endnutzer (nur ins Debug-Log). Kein Unterschied zwischen „Benutzer existiert nicht“ und
|
||||
anderen Fehlern gegenüber Angreifer (C)? — Doch: `no_user` ist unterscheidbar. Bewertung: akzeptabel, weil der
|
||||
Angreifer dafür bereits ein gültiges Konto des Tenants braucht und die Information (welche E-Mails ein WP-Konto
|
||||
haben) für Tenant-Mitglieder unkritisch ist. `wp_login_failed` wird ausgelöst, damit Limit-Login-Plugins zählen.
|
||||
|
||||
### N-5 · Transient-Verlust bei Object-Cache-Eviction — **offen, Hinweis**
|
||||
|
||||
State-Datensätze liegen in Transients. Bei einem Object Cache mit aggressiver Eviction (kleiner Redis) kann ein
|
||||
State vor Ablauf verschwinden → „Login request expired“. Kein Sicherheitsproblem (fail closed), aber ein
|
||||
Verfügbarkeitsthema. Empfehlung: ausreichende Cache-Größe oder `m365_login_*`-Keys von der Eviction ausnehmen.
|
||||
|
||||
### N-6 · `https_ssl_verify` durch Dritte deaktivierbar — **offen, Hinweis**
|
||||
|
||||
Alle Requests laufen über die WordPress-HTTP-API mit Zertifikatsprüfung. Setzt ein anderes Plugin
|
||||
`add_filter('https_ssl_verify','__return_false')`, wäre Angreifer (E) in der Lage, JWKS und Token-Endpunkt zu
|
||||
fälschen. Das Plugin erzwingt `sslverify => true` für seine eigenen Requests nicht explizit, weil WordPress-Konventionen
|
||||
den Site-Betreiber entscheiden lassen. Empfehlung: `https_ssl_verify` nie global deaktivieren.
|
||||
|
||||
## 4. Geprüfte Kontrollen (ohne Befund)
|
||||
|
||||
| Bereich | Kontrolle | Ergebnis |
|
||||
| --- | --- | --- |
|
||||
| Autorisierungsanfrage | `state` 256 Bit, `nonce` 256 Bit, PKCE-Verifier 512 Bit (S256), `response_mode=query` | ✔ |
|
||||
| State-Bindung | HMAC-Schlüssel in DB, Klartext nur in URL; HttpOnly/SameSite=Lax/Secure-Cookie mit separatem Token, Hash im Datensatz; einmalige Einlösung (Delete vor Prüfung); TTL 10 Min. | ✔ Login-CSRF und Replay ausgeschlossen |
|
||||
| Token-Austausch | Server-zu-Server, Secret/Assertion nie im Browser; `redirect_uri` fest aus `home_url()` | ✔ |
|
||||
| ID-Token | Nur `RS256`; `alg=none`/HMAC abgelehnt; `kid` Pflicht; JWKS über HTTPS, Cache 12 h, Refresh bei unbekanntem `kid`; `iss` gegen `tid` gebildet, `aud`, `tid` (Pinning), `exp`/`nbf`/`iat` mit 120 s Toleranz, `nonce` mit `hash_equals` | ✔ 11 Negativtests |
|
||||
| Benutzerzuordnung | Kein Provisioning; E-Mail lowercase + `is_email`; Domain-Allowlist; Gruppen-Check fail closed; `oid`-Bindung; Multisite-Mitgliedschaft | ✔ |
|
||||
| Session | `wp_set_auth_cookie` nach Erfolg (neues Session-Token, keine Fixation); `login_redirect`-Filter; `wp_safe_redirect` überall | ✔ |
|
||||
| Offene Redirects | `redirect_to` → `wp_validate_redirect`; Custom-Login-URL → `wp_validate_redirect` beim Speichern und beim Lesen | ✔ |
|
||||
| SSRF | Tenant nur GUID oder Whitelist-Wort, `rawurlencode`; Graph-Pfade mit `rawurlencode`; keine benutzerkontrollierten Hosts | ✔ |
|
||||
| Admin-Oberfläche | `manage_options` überall; Settings-API-Nonce; AJAX `check_ajax_referer` + Capability; Download `check_admin_referer` + Capability; alle Ausgaben `esc_*`; JS nutzt `.text()`/DOM-APIs statt HTML-Strings mit Nutzerdaten | ✔ |
|
||||
| Eingaben | GUID-Regex, Hex-Farben, Radius-Cap, Icon-URL nur http(s) + Bildendung, Gruppen-IDs GUID, PEM-Größenlimit 20 KB | ✔ |
|
||||
| Secrets in Logs | Nie geloggt; Token-Fehler nur als Fehlercode; Log nur bei `WP_DEBUG_LOG` | ✔ |
|
||||
| Nur-Button-Fallback | Key 24 Zeichen/55er-Alphabet (≈139 Bit); Cookie enthält HMAC, nicht den Key; 30 Min.; 10 Versuche/IP/15 Min.; Rotation; Notschalter-Konstante | ✔ |
|
||||
| Deinstallation | Option, Transients (prepared LIKE), User-Meta, Multisite-Loop | ✔ |
|
||||
| Abhängigkeiten | Keine externen Bibliotheken, keine CDNs; OpenSSL-Pflicht bei Aktivierung geprüft | ✔ |
|
||||
|
||||
## 5. Empfehlungen für den Betrieb
|
||||
|
||||
1. **Tenant-GUID eintragen** (kein `organizations`/`common`), Domain-Allowlist setzen.
|
||||
2. **Zertifikat statt Secret** verwenden; Ablauf im Kalender notieren (Backend zeigt Restlaufzeit).
|
||||
3. In Entra ID **„Zuweisung erforderlich“** für die Enterprise-Anwendung aktivieren und Benutzer/Gruppen zuweisen — zweite Schranke neben der WordPress-Benutzerliste.
|
||||
4. Gruppen-Beschränkung mit `groups`-Claim *und* Graph-Berechtigung einrichten (Overage-Fall).
|
||||
5. **Conditional Access / MFA** im Tenant erzwingen; das Plugin erbt die Stärke der Microsoft-Anmeldung.
|
||||
6. HTTPS mit HSTS; `COOKIE_DOMAIN` korrekt; hinter Proxy `M365_LOGIN_CLIENT_IP_HEADER` setzen.
|
||||
7. Nur-Button-Modus erst nach erfolgreichem eigenen Microsoft-Login aktivieren; Fallback-Link im Passwortmanager ablegen.
|
||||
8. WordPress-Salts nicht ohne Neueingabe von Secret/Zertifikat rotieren.
|
||||
9. Vor Produktivgang: Durchlauf in einer Staging-Installation inkl. der Fehlerfälle (falsche E-Mail, fremde Gruppe, abgelaufenes Secret).
|
||||
|
||||
## 6. Nicht im Umfang
|
||||
|
||||
Sicherheit der Microsoft-Seite (Entra ID, Graph), WordPress-Core, Hosting-Umgebung, andere Plugins/Themes,
|
||||
Schwachstellen in PHP/OpenSSL.
|
||||
|
|
@ -33,6 +33,7 @@ erfüllt. Vor der Einreichung unter <https://wordpress.org/plugins/developers/ad
|
|||
| Keine minifizierten Dateien ohne Quelle | Alle Assets liegen unminifiziert vor |
|
||||
| `Requires PHP` / `Requires at least` | 7.4 / 6.0 |
|
||||
| Übersetzbar | Text Domain `m365-login`, `languages/m365-login.pot`, deutsche Übersetzung |
|
||||
| Security-Review | `docs/security-audit.md` (Bedrohungsmodell, Befunde, Fixes) |
|
||||
|
||||
## 4. Nach der Freigabe
|
||||
|
||||
|
|
|
|||
|
|
@ -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 6–24 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>
|
||||
|
|
|
|||
|
|
@ -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' ),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
226
includes/class-m365-login-certificate.php
Normal file
226
includes/class-m365-login-certificate.php
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
<?php
|
||||
/**
|
||||
* Certificate based client authentication (private_key_jwt / RFC 7523).
|
||||
*
|
||||
* @package M365_Login
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Generates, validates and uses an RSA key pair + X.509 certificate for
|
||||
* authenticating the app registration without a client secret.
|
||||
*/
|
||||
final class M365_Login_Certificate {
|
||||
|
||||
const KEY_BITS = 3072;
|
||||
const VALID_DAYS = 730;
|
||||
const ASSERTION_TTL = 300; // Seconds; Microsoft allows up to 10 minutes.
|
||||
const EXPIRY_WARNING = 30 * DAY_IN_SECONDS;
|
||||
|
||||
/**
|
||||
* Generates a new self-signed certificate for the given site.
|
||||
*
|
||||
* @param string $common_name Subject CN (host name of the site).
|
||||
* @return array|WP_Error array( 'private_key' => PEM, 'certificate' => PEM ).
|
||||
*/
|
||||
public static function generate( $common_name ) {
|
||||
if ( ! function_exists( 'openssl_pkey_new' ) ) {
|
||||
return new WP_Error( 'no_openssl', __( 'The PHP OpenSSL extension is not available.', 'm365-login' ) );
|
||||
}
|
||||
|
||||
$common_name = preg_replace( '/[^A-Za-z0-9.\-]/', '', (string) $common_name );
|
||||
$common_name = '' === $common_name ? 'wordpress' : substr( $common_name, 0, 64 );
|
||||
|
||||
$key = openssl_pkey_new(
|
||||
array(
|
||||
'private_key_bits' => self::KEY_BITS,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
)
|
||||
);
|
||||
if ( false === $key ) {
|
||||
return new WP_Error( 'keygen', self::openssl_error( __( 'Key generation failed.', 'm365-login' ) ) );
|
||||
}
|
||||
|
||||
$dn = array(
|
||||
'CN' => $common_name,
|
||||
'O' => 'M365 Login for WordPress',
|
||||
);
|
||||
$csr = openssl_csr_new( $dn, $key, array( 'digest_alg' => 'sha256' ) );
|
||||
if ( false === $csr ) {
|
||||
return new WP_Error( 'csr', self::openssl_error( __( 'Certificate request failed.', 'm365-login' ) ) );
|
||||
}
|
||||
|
||||
$cert = openssl_csr_sign( $csr, null, $key, self::VALID_DAYS, array( 'digest_alg' => 'sha256' ), (int) ( time() % PHP_INT_MAX ) );
|
||||
if ( false === $cert ) {
|
||||
return new WP_Error( 'sign', self::openssl_error( __( 'Certificate signing failed.', 'm365-login' ) ) );
|
||||
}
|
||||
|
||||
$key_pem = '';
|
||||
$cert_pem = '';
|
||||
if ( ! openssl_pkey_export( $key, $key_pem ) || ! openssl_x509_export( $cert, $cert_pem ) ) {
|
||||
return new WP_Error( 'export', self::openssl_error( __( 'Exporting the certificate failed.', 'm365-login' ) ) );
|
||||
}
|
||||
|
||||
return array(
|
||||
'private_key' => $key_pem,
|
||||
'certificate' => $cert_pem,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a user supplied key/certificate pair.
|
||||
*
|
||||
* @param string $key_pem Private key PEM (unencrypted).
|
||||
* @param string $cert_pem Certificate PEM.
|
||||
* @return array|WP_Error Normalised pair.
|
||||
*/
|
||||
public static function from_pem( $key_pem, $cert_pem ) {
|
||||
$key_pem = self::normalise_pem( $key_pem );
|
||||
$cert_pem = self::normalise_pem( $cert_pem );
|
||||
|
||||
if ( ! preg_match( '/-----BEGIN (RSA |EC |ENCRYPTED )?PRIVATE KEY-----/', $key_pem ) ) {
|
||||
return new WP_Error( 'key_format', __( 'The private key must be in PEM format (-----BEGIN PRIVATE KEY-----).', 'm365-login' ) );
|
||||
}
|
||||
if ( false !== strpos( $key_pem, 'ENCRYPTED PRIVATE KEY' ) ) {
|
||||
return new WP_Error( 'key_encrypted', __( 'Password protected private keys are not supported. Export the key without a passphrase.', 'm365-login' ) );
|
||||
}
|
||||
$key = openssl_pkey_get_private( $key_pem );
|
||||
if ( false === $key ) {
|
||||
return new WP_Error( 'key_invalid', self::openssl_error( __( 'The private key could not be read.', 'm365-login' ) ) );
|
||||
}
|
||||
$details = openssl_pkey_get_details( $key );
|
||||
if ( ! is_array( $details ) || OPENSSL_KEYTYPE_RSA !== $details['type'] ) {
|
||||
return new WP_Error( 'key_type', __( 'Only RSA keys are supported.', 'm365-login' ) );
|
||||
}
|
||||
if ( $details['bits'] < 2048 ) {
|
||||
return new WP_Error( 'key_bits', __( 'The RSA key must have at least 2048 bits.', 'm365-login' ) );
|
||||
}
|
||||
|
||||
$cert = openssl_x509_read( $cert_pem );
|
||||
if ( false === $cert ) {
|
||||
return new WP_Error( 'cert_invalid', self::openssl_error( __( 'The certificate could not be read. Paste it in PEM format (-----BEGIN CERTIFICATE-----).', 'm365-login' ) ) );
|
||||
}
|
||||
if ( ! openssl_x509_check_private_key( $cert, $key ) ) {
|
||||
return new WP_Error( 'cert_mismatch', __( 'The certificate does not belong to this private key.', 'm365-login' ) );
|
||||
}
|
||||
|
||||
$info = openssl_x509_parse( $cert );
|
||||
if ( is_array( $info ) && ! empty( $info['validTo_time_t'] ) && (int) $info['validTo_time_t'] < time() ) {
|
||||
return new WP_Error( 'cert_expired', __( 'The certificate has already expired.', 'm365-login' ) );
|
||||
}
|
||||
|
||||
return array(
|
||||
'private_key' => $key_pem,
|
||||
'certificate' => $cert_pem,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalises line endings and trims a PEM block.
|
||||
*
|
||||
* @param string $pem PEM.
|
||||
* @return string
|
||||
*/
|
||||
private static function normalise_pem( $pem ) {
|
||||
$pem = str_replace( array( "\r\n", "\r" ), "\n", trim( (string) $pem ) );
|
||||
return $pem . "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificate metadata for display.
|
||||
*
|
||||
* @param string $cert_pem Certificate PEM.
|
||||
* @return array|null array( 'thumbprint' => hex SHA-1, 'thumbprint_sha256' => hex, 'subject' => string, 'not_before' => ts, 'not_after' => ts, 'bits' => int ).
|
||||
*/
|
||||
public static function info( $cert_pem ) {
|
||||
if ( '' === (string) $cert_pem ) {
|
||||
return null;
|
||||
}
|
||||
$cert = openssl_x509_read( $cert_pem );
|
||||
if ( false === $cert ) {
|
||||
return null;
|
||||
}
|
||||
$parsed = openssl_x509_parse( $cert );
|
||||
$der = self::der( $cert_pem );
|
||||
$public = openssl_pkey_get_public( $cert );
|
||||
$detail = $public ? openssl_pkey_get_details( $public ) : null;
|
||||
|
||||
return array(
|
||||
'thumbprint' => strtoupper( sha1( $der ) ),
|
||||
'thumbprint_sha256' => strtoupper( hash( 'sha256', $der ) ),
|
||||
'subject' => isset( $parsed['subject']['CN'] ) ? (string) $parsed['subject']['CN'] : '',
|
||||
'not_before' => isset( $parsed['validFrom_time_t'] ) ? (int) $parsed['validFrom_time_t'] : 0,
|
||||
'not_after' => isset( $parsed['validTo_time_t'] ) ? (int) $parsed['validTo_time_t'] : 0,
|
||||
'bits' => is_array( $detail ) && isset( $detail['bits'] ) ? (int) $detail['bits'] : 0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DER bytes of a PEM certificate.
|
||||
*
|
||||
* @param string $cert_pem PEM.
|
||||
* @return string
|
||||
*/
|
||||
private static function der( $cert_pem ) {
|
||||
$body = preg_replace( '/-----[^-]+-----|\s+/', '', (string) $cert_pem );
|
||||
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
|
||||
$der = base64_decode( $body, true );
|
||||
return false === $der ? '' : $der;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the signed client assertion for the token endpoint.
|
||||
*
|
||||
* @param string $key_pem Private key PEM.
|
||||
* @param string $cert_pem Certificate PEM (for the x5t header).
|
||||
* @param string $client_id Application (client) ID.
|
||||
* @param string $token_endpoint Audience.
|
||||
* @return string|WP_Error Compact JWS.
|
||||
*/
|
||||
public static function assertion( $key_pem, $cert_pem, $client_id, $token_endpoint ) {
|
||||
$key = openssl_pkey_get_private( $key_pem );
|
||||
if ( false === $key ) {
|
||||
return new WP_Error( 'key_invalid', 'Private key could not be loaded.' );
|
||||
}
|
||||
$der = self::der( $cert_pem );
|
||||
if ( '' === $der ) {
|
||||
return new WP_Error( 'cert_invalid', 'Certificate could not be decoded.' );
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$header = array(
|
||||
'alg' => 'RS256',
|
||||
'typ' => 'JWT',
|
||||
'x5t' => M365_Login_JWT::b64url_encode( sha1( $der, true ) ),
|
||||
'x5t#S256' => M365_Login_JWT::b64url_encode( hash( 'sha256', $der, true ) ),
|
||||
);
|
||||
$claims = array(
|
||||
'aud' => $token_endpoint,
|
||||
'iss' => $client_id,
|
||||
'sub' => $client_id,
|
||||
'jti' => M365_Login_JWT::b64url_encode( random_bytes( 24 ) ),
|
||||
'nbf' => $now - 30,
|
||||
'iat' => $now,
|
||||
'exp' => $now + self::ASSERTION_TTL,
|
||||
);
|
||||
|
||||
$signing_input = M365_Login_JWT::b64url_encode( wp_json_encode( $header ) ) . '.' . M365_Login_JWT::b64url_encode( wp_json_encode( $claims ) );
|
||||
$signature = '';
|
||||
if ( ! openssl_sign( $signing_input, $signature, $key, OPENSSL_ALGO_SHA256 ) ) {
|
||||
return new WP_Error( 'sign', 'Signing the client assertion failed.' );
|
||||
}
|
||||
return $signing_input . '.' . M365_Login_JWT::b64url_encode( $signature );
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefixes the last OpenSSL error to a message (for admins).
|
||||
*
|
||||
* @param string $message Message.
|
||||
* @return string
|
||||
*/
|
||||
private static function openssl_error( $message ) {
|
||||
$detail = openssl_error_string();
|
||||
return $detail ? $message . ' (' . $detail . ')' : $message;
|
||||
}
|
||||
}
|
||||
|
|
@ -38,7 +38,7 @@ class M365_Login_Graph {
|
|||
* @return string
|
||||
*/
|
||||
private function token_cache_key() {
|
||||
return 'm365_login_apptoken_' . md5( $this->settings->tenant() . '|' . $this->settings->get( 'client_id' ) );
|
||||
return 'm365_login_apptoken_' . md5( $this->settings->tenant() . '|' . $this->settings->get( 'client_id' ) . '|' . $this->settings->auth_method() );
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -63,16 +63,24 @@ class M365_Login_Graph {
|
|||
return new WP_Error( 'graph_not_configured', __( 'Microsoft login is not configured yet.', 'm365-login' ) );
|
||||
}
|
||||
|
||||
$token_endpoint = 'https://login.microsoftonline.com/' . rawurlencode( $this->settings->tenant() ) . '/oauth2/v2.0/token';
|
||||
$auth = $this->settings->client_auth_params( $token_endpoint );
|
||||
if ( is_wp_error( $auth ) ) {
|
||||
return $auth;
|
||||
}
|
||||
|
||||
$response = wp_remote_post(
|
||||
'https://login.microsoftonline.com/' . rawurlencode( $this->settings->tenant() ) . '/oauth2/v2.0/token',
|
||||
$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' => 'client_credentials',
|
||||
'scope' => 'https://graph.microsoft.com/.default',
|
||||
'body' => array_merge(
|
||||
array(
|
||||
'client_id' => $this->settings->get( 'client_id' ),
|
||||
'grant_type' => 'client_credentials',
|
||||
'scope' => 'https://graph.microsoft.com/.default',
|
||||
),
|
||||
$auth
|
||||
),
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
Binary file not shown.
File diff suppressed because it is too large
Load diff
Binary file not shown.
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -25,6 +25,7 @@ define( 'M365_LOGIN_OPTION', 'm365_login_settings' );
|
|||
require_once M365_LOGIN_DIR . 'includes/class-m365-login-settings.php';
|
||||
require_once M365_LOGIN_DIR . 'includes/class-m365-login-crypto.php';
|
||||
require_once M365_LOGIN_DIR . 'includes/class-m365-login-jwt.php';
|
||||
require_once M365_LOGIN_DIR . 'includes/class-m365-login-certificate.php';
|
||||
require_once M365_LOGIN_DIR . 'includes/class-m365-login-graph.php';
|
||||
require_once M365_LOGIN_DIR . 'includes/class-m365-login-auth.php';
|
||||
require_once M365_LOGIN_DIR . 'includes/class-m365-login-button.php';
|
||||
|
|
|
|||
11
readme.txt
11
readme.txt
|
|
@ -33,7 +33,10 @@ The plugin is deliberately small and strict:
|
|||
* **Account binding**: on first sign-in the immutable Microsoft object ID is stored with the user; later sign-ins with the same e-mail but a different Microsoft identity are refused.
|
||||
* Optional **e-mail domain allow-list** and **group allow-list** (fails closed when membership cannot be verified).
|
||||
* **Button-only mode** blocks password sign-in server-side, not just visually; the fallback key is rate limited and never stored in a cookie.
|
||||
* The **client secret is encrypted at rest** (AES-256-GCM, key derived from your WordPress salts) and never displayed again.
|
||||
* **Certificate authentication** (RFC 7523 client assertion) as an alternative to a client secret: generate a 3072-bit key pair in the settings, upload only the public certificate to Entra ID. Nothing secret is ever transmitted.
|
||||
* The **client secret / private key is encrypted at rest** (AES-256-GCM, key derived from your WordPress salts) and never displayed again.
|
||||
* In multi-tenant mode the unverified `email` claim is ignored; matching uses the user principal name (verified domain) only.
|
||||
* Login starts and fallback-key attempts are rate limited per client.
|
||||
* Every setting is sanitised, every output escaped, every admin request nonce- and capability-checked.
|
||||
|
||||
= Developer hooks =
|
||||
|
|
@ -71,7 +74,7 @@ Microsoft terms and privacy: [Microsoft Services Agreement](https://www.microsof
|
|||
2. Go to **Settings → M365 Login** and copy the **Redirect URI** shown in the sidebar.
|
||||
3. In the [Microsoft Entra admin center](https://entra.microsoft.com/) open **App registrations → New registration**. Choose *Accounts in this organizational directory only*, select the **Web** platform and paste the redirect URI.
|
||||
4. From the app's overview page copy the **Application (client) ID** and the **Directory (tenant) ID** into the plugin settings.
|
||||
5. Under **Certificates & secrets** create a client secret and paste its *value* into the plugin settings.
|
||||
5. Pick the authentication method: either generate a certificate in the plugin and upload the downloaded `.cer` under **Certificates & secrets → Certificates**, or create a client secret under **Certificates & secrets → Client secrets** and paste its *value*. Both methods have a step-by-step guide in the settings.
|
||||
6. Under **Token configuration** add the optional claim **email** for ID tokens (recommended). The delegated permissions `openid`, `profile` and `email` are granted by default.
|
||||
7. Save. The button now appears on `wp-login.php`. Customise it on the **Button** tab.
|
||||
|
||||
|
|
@ -79,6 +82,10 @@ Make sure every user who should be able to sign in has the same e-mail address i
|
|||
|
||||
== Frequently Asked Questions ==
|
||||
|
||||
= Client secret or certificate? =
|
||||
|
||||
Both work. A certificate is recommended: the private key stays on your server (encrypted), only the public certificate is uploaded to Entra ID, and every token request is signed with a short-lived assertion instead of sending a shared secret. The plugin generates the certificate for you and shows its expiry date.
|
||||
|
||||
= Does the plugin create users? =
|
||||
|
||||
No. Users must already exist in WordPress. The e-mail address is the only link between the Microsoft account and the WordPress account. This is intentional – it keeps the administrator in control of who can access the site.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue