Fix the findings of a full second security audit
Some checks are pending
CI / PHP lint (7.4) (pull_request) Waiting to run
CI / PHP lint (8.0) (pull_request) Waiting to run
CI / PHP lint (8.1) (pull_request) Waiting to run
CI / PHP lint (8.2) (pull_request) Waiting to run
CI / PHP lint (8.3) (pull_request) Waiting to run
CI / PHP lint (8.4) (pull_request) Waiting to run
CI / WordPress Coding Standards (pull_request) Waiting to run
CI / WordPress.org Plugin Check (pull_request) Waiting to run
Some checks are pending
CI / PHP lint (7.4) (pull_request) Waiting to run
CI / PHP lint (8.0) (pull_request) Waiting to run
CI / PHP lint (8.1) (pull_request) Waiting to run
CI / PHP lint (8.2) (pull_request) Waiting to run
CI / PHP lint (8.3) (pull_request) Waiting to run
CI / PHP lint (8.4) (pull_request) Waiting to run
CI / WordPress Coding Standards (pull_request) Waiting to run
CI / WordPress.org Plugin Check (pull_request) Waiting to run
Four-part audit (OIDC/JWT/crypto, user sync, admin UI, login bypasses) with dynamic PoCs against a real WordPress install; every fix is covered by a regression test. Report: docs/security-audit.md, section 6. Critical/High - Multisite: settings, AJAX actions and certificate download require manage_network_options (site admins could sign in as super admin). - Privileged accounts are only linked (sync and first sign-in) via a matching UPN of a member account, never via the settable mail attribute; the sync never changes their e-mail address; e-mail change notifications stay on. - Button-only mode exempts by credential (application passwords, WP-CLI) instead of request context, closing bypasses through xmlrpc.php and REST login handlers; API requests never receive login cookies. - Multi-tenant mode refuses guest/external identities. Medium/Low - Same message for right and wrong passwords; button-only no longer switches off when the connection breaks; server-side fallback cookie expiry; correct fallback key beats IP lockouts; right-most proxy hop; higher start limit; one object ID per account. - Deactivation sets a random password, revokes application passwords and removes the role (restored on reactivation); disabled people are deactivated even when their mail vanished; duplicate bindings handled. - Sync: abort on empty directory answer, no deprovisioning right after a tenant change, atomic run lock, strict photo path validation. - Certificates: key bundles refused, clean re-exported certificate. - Array-safe sanitising, encoded redirect_to, per-action nonces, escaped role lists, no Graph sleeps during sign-in, warnings for public groups, multi-tenant group rules and missing salts, uninstall clears the token. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
791f43a80b
commit
850f0dcd54
18 changed files with 1908 additions and 1270 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -24,6 +24,17 @@ All notable changes to this project are documented in this file. The format foll
|
|||
### Changed
|
||||
- The group picker is reusable (security groups, sync groups, role mapping).
|
||||
|
||||
### Security
|
||||
Fixes from a full second security audit (details: docs/security-audit.md, section 6):
|
||||
- Multisite: settings, connection test, certificates and user sync require `manage_network_options` (a site admin could otherwise sign in as the super admin via an own tenant).
|
||||
- Administrator accounts are only linked (sync and first sign-in) through a matching user principal name of a member account, never through the freely settable `mail` attribute; their e-mail address is never changed by the sync; e-mail change notifications stay on for other accounts.
|
||||
- Button-only mode: exemption by credential (application passwords, WP-CLI) instead of request context – closes bypasses through `xmlrpc.php` and REST login handlers of other plugins; API requests never receive login cookies; same message for right and wrong passwords; mode no longer switches off when the connection breaks; fallback cookie expires on the server; the correct fallback key works despite IP lockouts; right-most proxy header entry; start limit raised to 300.
|
||||
- Multi-tenant mode refuses guest/external identities; one Microsoft object ID can only be bound to one WordPress account.
|
||||
- Deactivation also sets a random password, revokes application passwords and removes the role (restored on reactivation); linked people disabled in Microsoft 365 are deactivated even if their e-mail vanished or changed domain; duplicate bindings are all deprovisioned.
|
||||
- Sync safety: abort on an empty directory answer, no deprovisioning in the first run after a tenant change, atomic run lock, strict photo path validation.
|
||||
- Certificates: key+certificate bundles are refused in the certificate field and only a clean re-exported certificate is stored.
|
||||
- Robust sanitising of array input, encoded `redirect_to` in the button URL, separate nonces per AJAX action, escaped role lists, no Graph retries/sleeps during sign-in, warnings for public Microsoft 365 groups, group rules in multi-tenant mode and salts missing from wp-config.php, cached Graph token removed on uninstall.
|
||||
|
||||
### Fixed
|
||||
- Failed Microsoft sign-ins (token exchange, token verification, object ID mismatch, group checks) ended in a PHP fatal error because the auth component's log helper had been removed in 1.0.0 development.
|
||||
- "Generate certificate" and removing the certificate did not keep the change and encrypted a stored client secret a second time (internal settings writes ran through the form sanitiser).
|
||||
|
|
|
|||
29
README.md
29
README.md
|
|
@ -259,8 +259,14 @@ Im Tab *Sicherheit* → **Button-only mode**:
|
|||
|
||||
- Blendet Benutzername/Passwort-Felder und den „Passwort vergessen?“-Link aus.
|
||||
- **Sperrt Passwort-Logins serverseitig**, nicht nur per CSS – auf `wp-login.php` und in jedem eigenen Login-Formular (`authenticate`-Filter).
|
||||
- Application Passwords, REST API, XML-RPC, WP-CLI und Cron sind nicht betroffen. Einzelne Ausnahmen per Filter `m365_login_block_password_login`.
|
||||
- Wird erst aktiv, wenn die Verbindung vollständig konfiguriert ist.
|
||||
- Unterschieden wird nach **Zugangsdaten, nicht nach Anfrage-Typ**: Das normale Passwort wird überall abgelehnt – auch über
|
||||
XML-RPC und in Login-Handlern anderer Plugins, die in `xmlrpc.php` oder einer REST-Anfrage laufen. Application Passwords
|
||||
(REST, XML-RPC) und WP-CLI funktionieren weiter; API-Anfragen bekommen aber nie ein Login-Cookie.
|
||||
- Falsches und richtiges Passwort erhalten dieselbe Meldung (kein Passwort-Orakel).
|
||||
- Einzelne Ausnahmen per Filter `m365_login_block_password_login`.
|
||||
- Bleibt aktiv, auch wenn die Verbindung zu Microsoft kaputtgeht (abgelaufenes Zertifikat, rotierte Salts) – dann gibt es
|
||||
einen roten Hinweis im Backend, und nur der Fallback-Link oder die Konstante helfen. Passwort-Logins schalten sich nie
|
||||
stillschweigend wieder ein.
|
||||
|
||||
**Fallback (Notausgang):** Beim Speichern erzeugt das Plugin einen geheimen Schlüssel und zeigt den Fallback-Link an:
|
||||
|
||||
|
|
@ -298,6 +304,13 @@ Plugins, die `wp-login.php` umbenennen (z. B. WPS Hide Login), sind kompatibel,
|
|||
|
||||
### Benutzer-Sync
|
||||
|
||||
> **Administrator-Konten** (alle mit `manage_options`, `promote_users`, `edit_users` oder Super-Admin) werden nie über das
|
||||
> `mail`-Attribut verknüpft, sondern nur, wenn der **User Principal Name** eines Mitglieds (kein Gast) exakt ihrer
|
||||
> WordPress-E-Mail entspricht – das `mail`-Attribut kann jeder Benutzer- oder Exchange-Admin des Tenants frei setzen, der
|
||||
> UPN nur auf verifizierten Domains. Dieselbe Regel gilt für die erste Microsoft-Anmeldung eines Administrators. Ihre
|
||||
> E-Mail-Adresse ändert der Sync nie automatisch. Bei allen anderen Konten informiert WordPress die alte Adresse über eine
|
||||
> Änderung.
|
||||
|
||||
Tab *Benutzer-Sync*. Legt WordPress-Konten für Microsoft-365-Benutzer an und hält sie aktuell – manuell per Knopfdruck,
|
||||
automatisch per WP-Cron (stündlich, zweimal täglich, täglich) oder per WP-CLI.
|
||||
|
||||
|
|
@ -346,7 +359,9 @@ höchstens 500 pro Lauf, der Rest folgt im nächsten (Filter `m365_login_sync_ph
|
|||
wenn Microsoft ausdrücklich „kein Foto“ meldet. **Abgewählte** Felder und Bilder werden beim nächsten Lauf aus den Profilen
|
||||
entfernt (Vor-, Nach- und Anzeigename bleiben stehen); wird ein Benutzer in WordPress gelöscht, wird auch sein Bild gelöscht.
|
||||
|
||||
**Deaktivierte Konten** können sich überhaupt nicht mehr anmelden – weder per Microsoft noch per Passwort,
|
||||
**Deaktivierte Konten** bekommen ein Zufallspasswort, verlieren ihre Rolle auf der Site (sie wird gemerkt und bei der
|
||||
Reaktivierung zurückgegeben) und alle Application Passwords – so bleiben sie auch gesperrt, wenn das Plugin einmal
|
||||
deaktiviert wird. Sie können sich überhaupt nicht mehr anmelden – weder per Microsoft noch per Passwort,
|
||||
Anwendungspasswort oder bestehender Session (alle Sessions werden beendet). In der Benutzerliste zeigt die Spalte
|
||||
*Microsoft 365* den Status; per Zeilenaktion lassen sich Konten auch von Hand deaktivieren und reaktivieren.
|
||||
|
||||
|
|
@ -474,7 +489,7 @@ add_filter( 'm365_login_sync_protect_user', function ( $protected, WP_User $user
|
|||
// Sicherheitsstopp anheben (Standard: 20 % der verknüpften Konten, mindestens 5)
|
||||
add_filter( 'm365_login_sync_deprovision_limit', fn( $limit, $linked ) => max( 10, $linked * 0.5 ), 10, 2 );
|
||||
|
||||
// Weitere: m365_login_sync_email, m365_login_sync_photo_limit, m365_login_sync_photo_interval
|
||||
// Weitere: m365_login_sync_email, m365_login_sync_photo_limit, m365_login_sync_photo_interval, m365_login_is_privileged_user
|
||||
// Actions: m365_login_sync_user_created, m365_login_sync_finished, m365_login_user_disabled, m365_login_user_enabled
|
||||
```
|
||||
|
||||
|
|
@ -565,12 +580,14 @@ Ja, Tenant auf <code>consumers</code> oder <code>common</code> stellen. Microsof
|
|||
|
||||
<details>
|
||||
<summary><strong>Multisite?</strong></summary>
|
||||
Ja. Einstellungen gelten pro Site; der Benutzer muss Mitglied der Site (oder Super-Admin) sein.
|
||||
Ja. Einstellungen gelten pro Site, dürfen aber **nur von Super-Admins** geändert werden: Sie entscheiden, welche
|
||||
Microsoft-Identität sich als welcher (netzwerkweite) WordPress-Benutzer anmelden darf. Ein Site-Admin könnte sonst einen
|
||||
eigenen Tenant eintragen und sich als Super-Admin anmelden. Der Benutzer muss Mitglied der Site (oder Super-Admin) sein.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Was passiert beim Deinstallieren?</strong></summary>
|
||||
Einstellungen, Caches (Transients), Sync-Protokoll, Cron-Termin, gespeicherte Profilbilder und die pro Benutzer gespeicherten Plugin-Daten (Objekt-ID, Deaktivierungs-Status) werden entfernt – auch in Multisite. Importierte Konten und übernommene Profilfelder (<code>m365_*</code>) bleiben erhalten. Deaktivierte Konten sind danach wieder aktiv; wer sie sperren will, sollte sie vorher löschen.
|
||||
Einstellungen, Caches (Transients), Sync-Protokoll, Cron-Termin, gespeicherte Profilbilder und die pro Benutzer gespeicherten Plugin-Daten (Objekt-ID, Deaktivierungs-Status) werden entfernt – auch in Multisite. Importierte Konten und übernommene Profilfelder (<code>m365_*</code>) bleiben erhalten. Deaktivierte Konten bleiben ohne Rolle, mit Zufallspasswort und ohne Application Passwords – der Deaktivierungs-Vermerk selbst wird entfernt.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
'use strict';
|
||||
|
||||
var cfg = window.m365LoginAdmin || {};
|
||||
cfg.nonces = cfg.nonces || {};
|
||||
var i18n = cfg.i18n || {};
|
||||
|
||||
$( function () {
|
||||
|
|
@ -166,7 +167,7 @@
|
|||
$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 ) {
|
||||
$.post( cfg.ajaxUrl, { action: cfg.certAction, nonce: cfg.nonces.cert, op: 'generate' } ).done( function ( res ) {
|
||||
if ( res && res.success ) {
|
||||
$out.addClass( 'is-success' ).text( res.data.message );
|
||||
window.setTimeout( function () { window.location.reload(); }, 800 );
|
||||
|
|
@ -312,7 +313,7 @@
|
|||
|
||||
$.post( cfg.ajaxUrl, {
|
||||
action: cfg.groupAction,
|
||||
nonce: cfg.nonce,
|
||||
nonce: cfg.nonces.groups,
|
||||
query: query
|
||||
} ).done( function ( res ) {
|
||||
if ( ! res || ! res.success ) {
|
||||
|
|
@ -369,7 +370,7 @@
|
|||
$( '.m365-sync-run' ).prop( 'disabled', true );
|
||||
$report.html( $( '<p class="m365-inline-result">' ).text( i18n.syncRunning ) );
|
||||
|
||||
$.post( cfg.ajaxUrl, { action: cfg.syncAction, nonce: cfg.nonce, op: op } ).done( function ( res ) {
|
||||
$.post( cfg.ajaxUrl, { action: cfg.syncAction, nonce: cfg.nonces.sync, op: op } ).done( function ( res ) {
|
||||
if ( res && res.success ) {
|
||||
$report.html( res.data.html );
|
||||
} else {
|
||||
|
|
@ -400,7 +401,7 @@
|
|||
|
||||
$.post( cfg.ajaxUrl, {
|
||||
action: cfg.action,
|
||||
nonce: cfg.nonce,
|
||||
nonce: cfg.nonces.test,
|
||||
tenant: $( '#m365-tenant' ).val()
|
||||
} ).done( function ( res ) {
|
||||
if ( res && res.success ) {
|
||||
|
|
|
|||
|
|
@ -1,28 +1,33 @@
|
|||
# Security-Audit: M365 Login 1.1.0
|
||||
|
||||
**Stand:** 22.09.2026, Nachtrag Benutzer-Sync 23.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,
|
||||
**Stand:** 23.09.2026 (Erst-Audit 22.09.2026, vollständiges Zweit-Audit 23.09.2026) · **Umfang:** gesamter Plugin-Code
|
||||
(PHP, JS, CSS) inkl. Benutzer-Sync, Konfiguration, Deployment-Hinweise ·
|
||||
**Methode:** 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.
|
||||
V6 Cryptography), WordPress Plugin Handbook „Security“ – und **dynamische Tests** in einer echten WordPress-Installation
|
||||
(7.1, SQLite, PHP 8.3): selbst signierte ID-Tokens mit eigenem JWKS gegen den echten Callback, simulierte Graph-API,
|
||||
echte HTTP-Requests (PHP-Webserver + curl) gegen `wp-login.php`, `xmlrpc.php` und die REST-API.
|
||||
|
||||
> 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.
|
||||
> Der Benutzer-Sync (N-7) wurde zusätzlich in einer echten WordPress-Installation (7.1, SQLite) gegen eine simulierte
|
||||
> Graph-API getestet (Import, Paging, Rollen, Profilfelder, Fotos, Deaktivierung, Löschen, Sicherheitsstopp, Graph-Fehler).
|
||||
> Das Zweit-Audit (Abschnitt 6) wurde in vier getrennten Prüfbereichen durchgeführt: (1) OIDC/JWT/Kryptografie,
|
||||
> (2) Benutzer-Sync und Graph-Client, (3) Admin-Oberfläche/XSS/CSRF, (4) Umgehung von Nur-Button-Modus, Deaktivierung
|
||||
> und Gruppenregeln. Jeder Befund wurde mit einem Proof of Concept bestätigt oder als „unbestätigt“ markiert, und jeder
|
||||
> Fix ist durch einen Regressionstest belegt. Nicht getestet: echter Entra-Tenant, echte Multisite-Installation
|
||||
> (Multisite-Befunde über Code-Analyse und eine konvertierte Testinstanz), echter Browser.
|
||||
|
||||
## 1. Zusammenfassung
|
||||
|
||||
| Schweregrad | Gefunden | Behoben | Offen (mit Empfehlung) |
|
||||
| --- | --- | --- | --- |
|
||||
| Hoch | 1 | 1 | 0 |
|
||||
| Mittel | 3 | 3 | 0 |
|
||||
| Niedrig | 5 | 3 | 2 |
|
||||
| Hinweis | 6 | – | 6 |
|
||||
| Schweregrad | Erst-Audit | Zweit-Audit | Behoben | Akzeptiert / dokumentiert |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Kritisch | 0 | 1 (nur Multisite) | 1 | 0 |
|
||||
| Hoch | 1 | 4 | 5 | 0 |
|
||||
| Mittel | 3 | 9 | 12 | 0 |
|
||||
| Niedrig | 5 | 11 | 12 | 4 |
|
||||
| Hinweis | 6 | 11 | 4 | 13 |
|
||||
|
||||
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.
|
||||
Nach beiden Audits sind **keine offenen kritischen, hohen oder mittleren Befunde** bekannt. Die schwersten Funde des
|
||||
Zweit-Audits betrafen nicht den OIDC-Kern (der hielt allen Angriffen stand), sondern die Ränder: Umgehung des
|
||||
Nur-Button-Modus über `xmlrpc.php`/REST, die Verknüpfung von Administrator-Konten über das frei setzbare
|
||||
`mail`-Attribut und Multisite-Rechte.
|
||||
|
||||
## 2. Bedrohungsmodell
|
||||
|
||||
|
|
@ -55,7 +60,8 @@ Sicherheit bleibt die Tenant-GUID die Empfehlung.
|
|||
|
||||
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
|
||||
interaktive Passwort-Anmeldung; ausgenommen sind XML-RPC, REST (Application Passwords), WP-CLI und Cron (im Zweit-Audit als
|
||||
umgehbar erkannt und auf „nur Application Passwords und WP-CLI“ verschärft, siehe Z-3), plus ein
|
||||
Opt-out-Filter für vertrauenswürdige Plugins. Tests: `password login blocked without fallback cookie`,
|
||||
`forged fallback cookie rejected`, `REST requests exempt`.
|
||||
|
||||
|
|
@ -180,7 +186,77 @@ gebraucht werden; die Auswahl ist bewusst Opt-in (Standard: nur Namen).
|
|||
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
|
||||
## 6. Zweit-Audit 1.1.0 (23.09.2026)
|
||||
|
||||
Status: ✅ behoben (mit Regressionstest) · 📄 akzeptiert/dokumentiert
|
||||
|
||||
### Kritisch / Hoch
|
||||
|
||||
| ID | Befund | Status und Fix |
|
||||
| --- | --- | --- |
|
||||
| Z-1 | **Multisite: Site-Admin → Super-Admin-Übernahme.** Einstellungen verlangten nur `manage_options`. Ein Site-Admin konnte einen eigenen Tenant eintragen, einem Benutzer dort `mail` = Adresse des Super-Admins geben und sich per Microsoft als Super-Admin anmelden; der Sync verknüpfte bzw. überschrieb netzwerkweite Konten. | ✅ Auf Multisite verlangen Einstellungsseite, `options.php` (`option_page_capability_m365_login`), alle AJAX-Aktionen und der Zertifikats-Download `manage_network_options`. |
|
||||
| Z-2 | **Kontoübernahme über das `mail`-Attribut (Sync und Login).** Ein Benutzer-/Exchange-Admin des Tenants setzt `mail` = E-Mail eines noch nicht verknüpften WordPress-Admins → Sync bzw. erste Anmeldung verknüpft das Admin-Konto mit seiner Objekt-ID; bei verknüpften Konten schrieb der Sync die WordPress-E-Mail ohne Benachrichtigung um (→ Passwort-Reset). | ✅ Privilegierte Konten (`manage_options`, `promote_users`, `edit_users`, Super-Admin) werden nur verknüpft, wenn der UPN eines Mitglieds (kein Gast, keine externe Identität) exakt ihrer E-Mail entspricht – im Sync und bei der ersten Anmeldung (`privileged_unlinked`). Ihre E-Mail ändert der Sync nie; bei anderen Konten bleibt die Änderungs-Mail an die alte Adresse aktiv. Filter `m365_login_is_privileged_user`. |
|
||||
| Z-3 | **Nur-Button-Modus über `xmlrpc.php` umgehbar.** Die Ausnahme hing am Anfragekontext (`XMLRPC_REQUEST`), nicht an der Art der Zugangsdaten. Ein Login-Formular eines anderen Plugins (z. B. WooCommerce auf `wp_loaded`), per POST an `/xmlrpc.php` geschickt, lieferte ein vollwertiges Admin-Cookie; außerdem akzeptierte XML-RPC das normale Passwort. | ✅ Ausnahme nur noch nach Zugangsdaten: Application Password (`application_password_did_authenticate`) oder WP-CLI. Normales Passwort überall abgelehnt. Zusätzlich setzt der Filter `send_auth_cookies` in XML-RPC-/REST-Anfragen nie ein Login-Cookie. Per HTTP nachgetestet. |
|
||||
| Z-4 | **Multi-Tenant: Übernahme über `preferred_username` externer Identitäten.** Gäste/föderierte Identitäten eines fremden Tenants konnten einen beliebigen Namen tragen. | ✅ Im Multi-Tenant-Modus werden Tokens mit abweichendem `idp` abgelehnt (`external_identity`); privilegierte Konten sind dort gar nicht erstmals verknüpfbar (Z-2). |
|
||||
| Z-5 | **Fehlende Log-Methode** (seit Umstellung auf eigene Login-Seiten): jeder fehlgeschlagene Microsoft-Login endete in einem PHP-Fatal-Error statt einer Meldung. | ✅ `log()` wiederhergestellt (eigener Commit). |
|
||||
|
||||
### Mittel
|
||||
|
||||
| ID | Befund | Status und Fix |
|
||||
| --- | --- | --- |
|
||||
| Z-6 | Application Password → interaktive Session: `POST /wp/v2/users/me {password}` setzt in Core ein Login-Cookie. | ✅ `send_auth_cookies`-Sperre in REST/XML-RPC (Z-3). |
|
||||
| Z-7 | REST-Login-Endpunkte anderer Plugins (`wp_signon` in einer Route) waren durch die `REST_REQUEST`-Ausnahme freigestellt. | ✅ Ausnahme entfernt (Z-3). |
|
||||
| Z-8 | Passwort-Orakel: falsches Passwort → „incorrect“, richtiges → „Password sign-in is disabled“. | ✅ Im Nur-Button-Modus dieselbe Meldung für jeden Passwortversuch. |
|
||||
| Z-9 | In Entra deaktivierte, verknüpfte Personen wurden nicht deaktiviert, wenn ihre Mail leer war oder auf eine nicht erlaubte Domain wechselte (typisch beim Offboarding). | ✅ Kontostatus verknüpfter Personen wird vor jeder E-Mail-/Domain-Prüfung ausgewertet. |
|
||||
| Z-10 | Deaktivierung hielt nur, solange das Plugin aktiv war (Passwort und Application Passwords blieben gültig; Deinstallation entsperrte). | ✅ Deaktivierung setzt ein Zufallspasswort, löscht alle Application Passwords, entzieht die Rolle (gemerkt, bei Reaktivierung zurück) und beendet alle Sessions. |
|
||||
| Z-11 | Multisite: Deaktivierung durch eine Unter-Site galt netzwerkweit, „Löschen“ entfernte nur aus der Site. | ✅ Durch Z-1 nur noch von Super-Admins konfigurierbar; Verhalten dokumentiert (README, FAQ). |
|
||||
| Z-12 | Private Schlüssel im Zertifikatsfeld: ein eingefügtes Key+Cert-Bündel wurde als „Zertifikat“ im Klartext gespeichert und im `.cer`-Download ausgeliefert. | ✅ Zertifikatsfeld mit `PRIVATE KEY` wird abgelehnt; gespeichert wird nur das per `openssl_x509_export` neu exportierte Einzelzertifikat. |
|
||||
| Z-13 | Nur-Button-Modus schaltete sich ab, sobald die Verbindung als „nicht konfiguriert“ galt (abgelaufenes Zertifikat, rotierte Salts) → Passwort-Login ohne MFA kam still zurück. | ✅ Modus hängt nur an Einstellung, Tenant/Client-ID und Fallback-Key. Bei kaputter Verbindung roter Admin-Hinweis und Meldung auf der Login-Seite. |
|
||||
| Z-14 | IP-Limits als globaler DoS hinter NAT/Proxy (30 Login-Starts/10 Min.; 10 falsche Fallback-Keys sperrten auch den richtigen); linker, vom Client gesetzter XFF-Eintrag. | ✅ Start-Limit 300; der richtige Fallback-Key funktioniert immer; aus dem Proxy-Header wird der rechte (vom Proxy geschriebene) Eintrag genommen. |
|
||||
|
||||
### Niedrig
|
||||
|
||||
| ID | Befund | Status und Fix |
|
||||
| --- | --- | --- |
|
||||
| Z-15 | Fallback-Cookie ohne serverseitiges Ablaufdatum (konstanter HMAC). | ✅ Cookie = `Zeit|HMAC(Zeit, Key)`, Alter wird serverseitig geprüft (30 Min.). |
|
||||
| Z-16 | Eine Objekt-ID an zwei Konten gebunden → nur eines wurde deprovisioniert; der Login band ohne Eindeutigkeitsprüfung. | ✅ Sync behandelt alle Konten einer Objekt-ID; der Login verweigert die Bindung einer bereits vergebenen Objekt-ID. |
|
||||
| Z-17 | Foto-Löschung prüfte den gespeicherten Pfad nur per Präfix (`../` möglich) – ausnutzbar nur mit Schreibzugriff auf Meta. | ✅ Strenge Prüfung gegen das Dateinamensmuster überall (Löschen, Avatar-URL, Vergleich). |
|
||||
| Z-18 | Sicherheitsstopp umgehbar durch leere Graph-Antwort oder Tenant-Wechsel (alle Objekt-IDs → 404 → „gelöscht“). | ✅ Leere Liste bei vorhandenen Verknüpfungen bricht ab; nach einem Tenant-Wechsel wird im ersten Lauf nichts deprovisioniert. |
|
||||
| Z-19 | Lauf-Sperre nicht atomar (Transient), lief bei langen Läufen ab. | ✅ Atomare Sperre per `add_option` mit Token, 2 h, Übernahme nur bei veralteter Sperre. |
|
||||
| Z-20 | Query-Parameter-Injektion in die Start-URL des Buttons (`add_query_arg` kodiert nicht). | ✅ `redirect_to` wird validiert und URL-kodiert. |
|
||||
| Z-21 | `sanitize()` stürzte bei Array-Eingaben ab bzw. speicherte `"Array"` als Secret (nur durch Admin manipulierbar). | ✅ Skalar-Prüfung für alle Textfelder. |
|
||||
| Z-22 | Graph-Retries mit `sleep` im interaktiven Login (bis ~30 s pro Prüfung). | ✅ Keine Retries im Login-Pfad, nur im Sync. |
|
||||
| Z-23 | Gruppenregeln im Multi-Tenant-Modus blockieren jede Anmeldung (fail closed, aber unerwartet). | ✅ Warnhinweis im Tab Sicherheit. |
|
||||
| Z-24 | Rollen können nach einer Entra-Gruppe vergeben werden, deren Mitglieder sich selbst eintragen können (öffentliche Microsoft-365-Gruppen, Teams). | 📄 Gruppensuche kennzeichnet öffentliche Microsoft-365-Gruppen; Warnhinweis bei der Rollen-Zuordnung (Sicherheitsgruppen, ideal rollenzuweisbar, verwenden). |
|
||||
| Z-25 | Bestehende Administratoren werden bei Austritt nicht automatisch deaktiviert (nur protokolliert). | 📄 Bewusst (Schutz vor Aussperren); im Protokoll als „geschützt“ gemeldet. |
|
||||
|
||||
### Hinweise
|
||||
|
||||
| ID | Hinweis | Status |
|
||||
| --- | --- | --- |
|
||||
| Z-26 | Ein AJAX-Nonce für alle Aktionen (auch destruktive). | ✅ Eigener Nonce pro Aktion. |
|
||||
| Z-27 | `wp_dropdown_roles()` gibt Rollennamen unescaped aus. | ✅ Eigene, escapte Optionsliste. |
|
||||
| Z-28 | Uninstall ließ den Graph-Token in einem persistenten Object Cache. | ✅ `delete_transient()` für die bekannten Schlüssel; Sperre und Tenant-Option werden entfernt. |
|
||||
| Z-29 | Verschlüsselung nutzlos, wenn die Salts nicht in `wp-config.php` stehen (dann in der Datenbank). | ✅ Doku korrigiert, Warnhinweis im Backend. |
|
||||
| Z-30 | Button-Icon darf eine externe http(s)-URL sein (Tracking/Mixed Content). | 📄 Admin-Entscheidung; Mediathek wird empfohlen. |
|
||||
| Z-31 | JWKS-`issuer` wird im Multi-Tenant-Modus nicht geprüft. | 📄 Risiko minimal (Token kommt direkt vom Token-Endpunkt über TLS). |
|
||||
| Z-32 | Gruppenregeln wirken nur bei der Microsoft-Anmeldung; bestehende Sessions (bis 14 Tage), Application Passwords laufen weiter; Deaktivierung aus Entra greift erst beim nächsten Sync. | 📄 Dokumentiert; Sync-Intervall und „Angemeldet bleiben“ entsprechend wählen. |
|
||||
| Z-33 | Plugins, die selbst `wp_set_current_user()`/`wp_set_auth_cookie()` nach eigener Passwortprüfung aufrufen, umgehen jede Login-Sperre. | 📄 Außerhalb der Kontrolle des Plugins. |
|
||||
| Z-34 | Fallback-Key steht in der URL (Webserver-/Proxy-Logs). | 📄 Link nach Nutzung neu erzeugen; Logs schützen. |
|
||||
| Z-35 | Im Single-Tenant-Modus melden sich B2B-Gäste über ihren `email`-Claim an. | 📄 Für normale Konten gewollt; privilegierte Konten sind für Gäste nicht verknüpfbar (Z-2). |
|
||||
| Z-36 | Profilbilder öffentlich, mit EXIF-Daten, auch nach Deaktivierung; Benutzernamen aus dem lokalen Teil der E-Mail. | 📄 Dokumentiert; Fotos/Felder sind Opt-in und werden beim Abwählen entfernt. |
|
||||
|
||||
### Geprüft und ohne Befund (Auszug)
|
||||
|
||||
- **OIDC-Kern:** State 256 Bit, einmalig, HMAC-Transient-Schlüssel, an HttpOnly/SameSite-Cookie gebunden; `error` erst nach State-Prüfung; PKCE S256; Nonce mit `hash_equals`; kein Login-CSRF, keine Code-Injection.
|
||||
- **JWT:** nur RS256 (`none`/HS256/Array-`kid` abgelehnt), `openssl_verify === 1`, `aud` exakt, `tid`-Pinning, `iss` aus `tid` gebildet, `exp`/`nbf`/`iat` mit 120 s Toleranz, JWKS-Refresh nur bei unbekanntem `kid`.
|
||||
- **Krypto:** AES-256-GCM mit frischem 96-Bit-IV und 16-Byte-Tag, HKDF-SHA256; manipulierte Tags abgelehnt. Client Assertion mit korrektem `aud`, `jti`, `exp`, `x5t`/`x5t#S256`.
|
||||
- **Gruppen:** Ausschluss fail closed, Overage/gefilterter Claim → Graph, Großschreibung normalisiert.
|
||||
- **Admin:** jede AJAX-Aktion mit Nonce + Capability; kein Stored/DOM-XSS (Graph-Gruppennamen, Sync-Attribute, Protokoll, Rollenname per jsdom-Fuzzing geprüft); keine CSS-Injection; kein SSRF (Tenant nur GUID/Alias); keine offenen Redirects (`//evil`, `/\evil`, `user@evil`, `javascript:`).
|
||||
- **Deaktivierte Konten:** Passwort, XML-RPC (Passwort und Application Password), REST, bestehende Cookies, Microsoft-Callback, Super-Admin – alle abgewiesen; keine Selbst-Reaktivierung möglich.
|
||||
- **Graph-Client:** Paging-Links auf `https://graph.microsoft.com/v1.0/` festgelegt, 1000-Seiten-Limit, jeder Fehler bricht vor Änderungen ab.
|
||||
|
||||
## 7. Nicht im Umfang
|
||||
|
||||
Sicherheit der Microsoft-Seite (Entra ID, Graph), WordPress-Core, Hosting-Umgebung, andere Plugins/Themes,
|
||||
Schwachstellen in PHP/OpenSSL.
|
||||
|
|
|
|||
|
|
@ -12,15 +12,18 @@ defined( 'ABSPATH' ) || exit;
|
|||
*/
|
||||
class M365_Login_Admin {
|
||||
|
||||
const PAGE = 'm365-login';
|
||||
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 AJAX_SYNC = 'm365_login_sync_run';
|
||||
const POST_CERT = 'm365_login_download_cert';
|
||||
const MENU_ICON = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMCAyMCI+PHBhdGggZmlsbD0iYmxhY2siIGQ9Ik0yIDJoNy41djcuNUgyek0xMC41IDJIMTh2Ny41aC03LjV6TTIgMTAuNWg3LjVWMThIMnpNMTAuNSAxMC41SDE4VjE4aC03LjV6Ii8+PC9zdmc+';
|
||||
const NONCE_TEST = 'm365_login_test';
|
||||
const PAGE = 'm365-login';
|
||||
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 AJAX_SYNC = 'm365_login_sync_run';
|
||||
const POST_CERT = 'm365_login_download_cert';
|
||||
const MENU_ICON = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMCAyMCI+PHBhdGggZmlsbD0iYmxhY2siIGQ9Ik0yIDJoNy41djcuNUgyek0xMC41IDJIMTh2Ny41aC03LjV6TTIgMTAuNWg3LjVWMThIMnpNMTAuNSAxMC41SDE4VjE4aC03LjV6Ii8+PC9zdmc+';
|
||||
const NONCE_TEST = 'm365_login_test';
|
||||
const NONCE_GROUPS = 'm365_login_groups';
|
||||
const NONCE_CERT = 'm365_login_cert';
|
||||
const NONCE_SYNC = 'm365_login_sync';
|
||||
|
||||
/**
|
||||
* Settings.
|
||||
|
|
@ -82,6 +85,19 @@ class M365_Login_Admin {
|
|||
add_action( 'update_option_' . M365_LOGIN_OPTION, array( $this->graph, 'flush_token' ) );
|
||||
add_action( 'admin_notices', array( $this, 'setup_notice' ) );
|
||||
add_filter( 'submenu_file', array( $this, 'highlight_submenu' ) );
|
||||
add_filter( 'option_page_capability_' . self::GROUP, array( __CLASS__, 'capability' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability needed for the settings, the connection test, certificates and the user sync.
|
||||
*
|
||||
* On multisite only super admins: the settings decide which Microsoft identities may sign in
|
||||
* as which (network-wide) WordPress users, so a site administrator must not control them.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function capability() {
|
||||
return is_multisite() ? 'manage_network_options' : 'manage_options';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -91,7 +107,7 @@ class M365_Login_Admin {
|
|||
$this->hook = add_menu_page(
|
||||
__( 'M365 Login', 'm365-login' ),
|
||||
__( 'M365 Login', 'm365-login' ),
|
||||
'manage_options',
|
||||
self::capability(),
|
||||
self::PAGE,
|
||||
array( $this, 'render' ),
|
||||
self::MENU_ICON,
|
||||
|
|
@ -103,7 +119,7 @@ class M365_Login_Admin {
|
|||
self::PAGE,
|
||||
$label . ' – ' . __( 'M365 Login', 'm365-login' ),
|
||||
$label,
|
||||
'manage_options',
|
||||
self::capability(),
|
||||
'connection' === $tab ? self::PAGE : 'admin.php?page=' . self::PAGE . '&tab=' . $tab
|
||||
);
|
||||
}
|
||||
|
|
@ -182,7 +198,19 @@ class M365_Login_Admin {
|
|||
* Nudges administrators to finish the setup.
|
||||
*/
|
||||
public function setup_notice() {
|
||||
if ( $this->settings->is_configured() || ! current_user_can( 'manage_options' ) ) {
|
||||
if ( ! current_user_can( self::capability() ) ) {
|
||||
return;
|
||||
}
|
||||
if ( ! $this->settings->is_configured() && $this->settings->button_only() ) {
|
||||
printf(
|
||||
'<div class="notice notice-error"><p>%s <a href="%s">%s</a></p></div>',
|
||||
esc_html__( 'M365 Login: button-only mode is on, but the connection to Microsoft is broken (missing or undecryptable secret, or expired certificate). Nobody can sign in except through the fallback link.', 'm365-login' ),
|
||||
esc_url( self::url() ),
|
||||
esc_html__( 'Open the settings', 'm365-login' )
|
||||
);
|
||||
return;
|
||||
}
|
||||
if ( $this->settings->is_configured() ) {
|
||||
return;
|
||||
}
|
||||
$screen = get_current_screen();
|
||||
|
|
@ -220,7 +248,12 @@ class M365_Login_Admin {
|
|||
'm365LoginAdmin',
|
||||
array(
|
||||
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||
'nonce' => wp_create_nonce( self::NONCE_TEST ),
|
||||
'nonces' => array(
|
||||
'test' => wp_create_nonce( self::NONCE_TEST ),
|
||||
'groups' => wp_create_nonce( self::NONCE_GROUPS ),
|
||||
'cert' => wp_create_nonce( self::NONCE_CERT ),
|
||||
'sync' => wp_create_nonce( self::NONCE_SYNC ),
|
||||
),
|
||||
'action' => self::AJAX_TEST,
|
||||
'groupAction' => self::AJAX_GROUPS,
|
||||
'certAction' => self::AJAX_CERT,
|
||||
|
|
@ -257,7 +290,7 @@ class M365_Login_Admin {
|
|||
*/
|
||||
public function ajax_test_connection() {
|
||||
check_ajax_referer( self::NONCE_TEST, 'nonce' );
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
if ( ! current_user_can( self::capability() ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'm365-login' ) ), 403 );
|
||||
}
|
||||
|
||||
|
|
@ -295,8 +328,8 @@ class M365_Login_Admin {
|
|||
* AJAX: search Entra groups through Microsoft Graph.
|
||||
*/
|
||||
public function ajax_search_groups() {
|
||||
check_ajax_referer( self::NONCE_TEST, 'nonce' );
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
check_ajax_referer( self::NONCE_GROUPS, 'nonce' );
|
||||
if ( ! current_user_can( self::capability() ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'm365-login' ) ), 403 );
|
||||
}
|
||||
if ( ! $this->settings->is_configured() ) {
|
||||
|
|
@ -321,8 +354,8 @@ class M365_Login_Admin {
|
|||
* 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' ) ) {
|
||||
check_ajax_referer( self::NONCE_CERT, 'nonce' );
|
||||
if ( ! current_user_can( self::capability() ) ) {
|
||||
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'] ) ) : '';
|
||||
|
|
@ -354,8 +387,8 @@ class M365_Login_Admin {
|
|||
* AJAX: run the user sync (dry run or live) with the saved settings.
|
||||
*/
|
||||
public function ajax_sync() {
|
||||
check_ajax_referer( self::NONCE_TEST, 'nonce' );
|
||||
if ( ! current_user_can( 'manage_options' ) || ! current_user_can( 'create_users' ) ) {
|
||||
check_ajax_referer( self::NONCE_SYNC, 'nonce' );
|
||||
if ( ! current_user_can( self::capability() ) || ! current_user_can( 'create_users' ) ) {
|
||||
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'] ) ) : '';
|
||||
|
|
@ -486,7 +519,7 @@ class M365_Login_Admin {
|
|||
<?php if ( 'roles' === $mode ) : ?>
|
||||
<span class="m365-group-chip__arrow" aria-hidden="true">→</span>
|
||||
<select name="<?php echo esc_attr( $base . '[' . $gid . '][role]' ); ?>" aria-label="<?php esc_attr_e( 'WordPress role', 'm365-login' ); ?>">
|
||||
<?php wp_dropdown_roles( $value['role'] ); ?>
|
||||
<?php $this->role_options( $value['role'] ); ?>
|
||||
</select>
|
||||
<input type="hidden" name="<?php echo esc_attr( $base . '[' . $gid . '][name]' ); ?>" value="<?php echo esc_attr( $gname ); ?>" />
|
||||
<?php else : ?>
|
||||
|
|
@ -498,7 +531,7 @@ class M365_Login_Admin {
|
|||
</ul>
|
||||
<?php if ( 'roles' === $mode ) : ?>
|
||||
<select class="m365-picker__role-template" hidden disabled>
|
||||
<?php wp_dropdown_roles( 'editor' ); ?>
|
||||
<?php $this->role_options( 'editor' ); ?>
|
||||
</select>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
|
@ -506,11 +539,30 @@ class M365_Login_Admin {
|
|||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints <option> elements for all editable roles (escaped, unlike wp_dropdown_roles()).
|
||||
*
|
||||
* @param string $selected Selected role slug.
|
||||
*/
|
||||
private function role_options( $selected ) {
|
||||
if ( ! function_exists( 'get_editable_roles' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/user.php';
|
||||
}
|
||||
foreach ( array_reverse( get_editable_roles() ) as $role => $details ) {
|
||||
printf(
|
||||
'<option value="%s"%s>%s</option>',
|
||||
esc_attr( $role ),
|
||||
selected( $selected, $role, false ),
|
||||
esc_html( translate_user_role( $details['name'] ) )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the public certificate as a .cer download (never the private key).
|
||||
*/
|
||||
public function download_certificate() {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
if ( ! current_user_can( self::capability() ) ) {
|
||||
wp_die( esc_html__( 'You are not allowed to do this.', 'm365-login' ), 403 );
|
||||
}
|
||||
check_admin_referer( self::POST_CERT );
|
||||
|
|
@ -599,13 +651,14 @@ class M365_Login_Admin {
|
|||
<div class="m365-field">
|
||||
<label for="m365-sync-role"><?php esc_html_e( 'Default role', 'm365-login' ); ?></label>
|
||||
<select id="m365-sync-role" name="<?php echo $field( 'sync_default_role' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>">
|
||||
<?php wp_dropdown_roles( $s['sync_default_role'] ); ?>
|
||||
<?php $this->role_options( $s['sync_default_role'] ); ?>
|
||||
</select>
|
||||
<p class="description"><?php esc_html_e( 'Every imported user gets this role. The sync manages the roles of imported accounts – manual role changes are overwritten on the next run.', 'm365-login' ); ?></p>
|
||||
</div>
|
||||
|
||||
<h3 class="m365-card__subtitle"><?php esc_html_e( 'Additional roles from Microsoft 365 groups', 'm365-login' ); ?></h3>
|
||||
<p class="description"><?php esc_html_e( 'Members of a group (nested memberships count) get the role next to it. If a person leaves the group, the role is removed again on the next sync.', 'm365-login' ); ?></p>
|
||||
<p class="m365-warning"><?php esc_html_e( 'Whoever can change a group\'s members controls the mapped role. For roles with administrative rights use security groups (ideally role-assignable ones) – never public Microsoft 365 groups or Teams, which members can join themselves.', 'm365-login' ); ?></p>
|
||||
<?php $this->group_picker( 'sync_role_map', $this->settings->sync_role_map(), 'roles', __( 'No group mapping – everybody gets the default role.', 'm365-login' ) ); ?>
|
||||
|
||||
<div class="m365-field">
|
||||
|
|
@ -703,7 +756,7 @@ class M365_Login_Admin {
|
|||
* Renders the settings screen.
|
||||
*/
|
||||
public function render() {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
if ( ! current_user_can( self::capability() ) ) {
|
||||
wp_die( esc_html__( 'You are not allowed to access this page.', 'm365-login' ) );
|
||||
}
|
||||
|
||||
|
|
@ -806,6 +859,9 @@ class M365_Login_Admin {
|
|||
</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>
|
||||
<?php if ( ! defined( 'AUTH_KEY' ) || ! defined( 'SECURE_AUTH_KEY' ) ) : ?>
|
||||
<p class="m365-warning"><?php esc_html_e( 'AUTH_KEY and SECURE_AUTH_KEY are not defined in wp-config.php, so WordPress keeps its salts in the database – right next to the encrypted secret. Add the salts to wp-config.php to make the encryption effective.', 'm365-login' ); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<details class="m365-guide" <?php echo $has_secret ? '' : 'open'; ?>>
|
||||
|
|
@ -1085,6 +1141,9 @@ class M365_Login_Admin {
|
|||
<h2 class="m365-card__title"><?php esc_html_e( 'Excluded Entra groups (optional)', 'm365-login' ); ?></h2>
|
||||
<p class="m365-card__intro"><?php esc_html_e( 'Members of these groups can never sign in with Microsoft – even if they are in an allowed group. Nested memberships count.', 'm365-login' ); ?></p>
|
||||
|
||||
<?php if ( $this->settings->is_multi_tenant() ) : ?>
|
||||
<p class="m365-warning m365-warning--strong"><?php esc_html_e( 'Group rules need a pinned tenant ID (GUID) on the Connection tab. In multi-tenant mode the group check cannot ask Microsoft Graph, so every sign-in is refused while groups are selected here or above.', 'm365-login' ); ?></p>
|
||||
<?php endif; ?>
|
||||
<?php $this->group_picker( 'denied_groups', $this->settings->denied_groups(), 'chips', __( 'No groups excluded.', 'm365-login' ) ); ?>
|
||||
<div class="m365-field">
|
||||
<p class="description"><?php esc_html_e( 'The plugin asks Microsoft Graph on every sign-in (application permission "User.Read.All" or "Directory.Read.All"), because a "groups" claim may be filtered and cannot prove that someone is not a member. If the check fails, the sign-in is refused. Password sign-in is not affected – combine with button-only mode if needed.', 'm365-login' ); ?></p>
|
||||
|
|
@ -1093,7 +1152,7 @@ class M365_Login_Admin {
|
|||
|
||||
<div class="m365-card">
|
||||
<h2 class="m365-card__title"><?php esc_html_e( 'Button-only mode', 'm365-login' ); ?></h2>
|
||||
<p class="m365-card__intro"><?php esc_html_e( 'Hides the username/password fields (on wp-login.php and in wp_login_form() forms) and refuses every interactive password sign-in on the site, including custom login forms. Application passwords, REST, XML-RPC and WP-CLI are not affected.', 'm365-login' ); ?></p>
|
||||
<p class="m365-card__intro"><?php esc_html_e( 'Hides the username/password fields (on wp-login.php and in wp_login_form() forms) and refuses every sign-in with a normal password on the site – custom login forms, XML-RPC and login endpoints of other plugins included. Application passwords (REST, XML-RPC) and WP-CLI keep working; API requests never receive a login cookie.', 'm365-login' ); ?></p>
|
||||
|
||||
<label class="m365-check m365-check--block">
|
||||
<input type="checkbox" name="<?php echo $field( 'button_only' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['button_only'] ); ?> id="m365-button-only" />
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ class M365_Login_Auth {
|
|||
add_action( 'init', array( $this, 'maybe_accept_fallback_key' ), 6 );
|
||||
// Runs after core's username/password handlers (priority 20), which would otherwise overwrite an early WP_Error.
|
||||
add_filter( 'authenticate', array( $this, 'block_password_login' ), 99, 3 );
|
||||
// No session cookies from API contexts (XML-RPC, REST) while password sign-in is disabled.
|
||||
add_filter( 'send_auth_cookies', array( $this, 'block_api_auth_cookies' ), 99, 4 );
|
||||
|
||||
// Custom login page: send people back there after logging out.
|
||||
add_filter( 'logout_redirect', array( $this, 'logout_redirect' ), 10, 3 );
|
||||
|
|
@ -74,16 +76,25 @@ class M365_Login_Auth {
|
|||
return true; // Nothing is hidden, the form is always available.
|
||||
}
|
||||
$cookie = isset( $_COOKIE[ self::FALLBACK_COOKIE ] ) ? sanitize_text_field( wp_unslash( $_COOKIE[ self::FALLBACK_COOKIE ] ) ) : '';
|
||||
return '' !== $cookie && hash_equals( $this->fallback_cookie_value(), $cookie );
|
||||
$parts = explode( '|', $cookie );
|
||||
if ( 2 !== count( $parts ) || ! ctype_digit( $parts[0] ) ) {
|
||||
return false;
|
||||
}
|
||||
$issued = (int) $parts[0];
|
||||
if ( $issued > time() + 60 || time() - $issued > self::FALLBACK_TTL ) {
|
||||
return false; // Expired on the server side, whatever the browser keeps.
|
||||
}
|
||||
return hash_equals( $this->fallback_cookie_value( $issued ), $cookie );
|
||||
}
|
||||
|
||||
/**
|
||||
* Expected fallback cookie value (HMAC of the key, so the key itself never sits in the cookie).
|
||||
* Fallback cookie value for an issue time: "time|HMAC" (the key itself never sits in the cookie).
|
||||
*
|
||||
* @param int $issued Issue timestamp.
|
||||
* @return string
|
||||
*/
|
||||
private function fallback_cookie_value() {
|
||||
return hash_hmac( 'sha256', 'fallback|' . $this->settings->fallback_key(), wp_salt( 'auth' ) );
|
||||
private function fallback_cookie_value( $issued ) {
|
||||
return $issued . '|' . hash_hmac( 'sha256', 'fallback|' . $issued . '|' . $this->settings->fallback_key(), wp_salt( 'auth' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -99,20 +110,21 @@ class M365_Login_Auth {
|
|||
return;
|
||||
}
|
||||
|
||||
// Slow down brute force attempts on the key.
|
||||
$ip_key = 'm365_login_fb_' . md5( $this->client_ip() );
|
||||
$attempts = (int) get_transient( $ip_key );
|
||||
if ( $attempts >= 10 ) {
|
||||
$this->fail( 'fallback_locked' );
|
||||
}
|
||||
|
||||
// The correct key always works (a shared office IP must not lock the administrator out);
|
||||
// wrong keys are slowed down per IP. The key has ~139 bits, the limit only reduces log noise.
|
||||
$ip_key = 'm365_login_fb_' . md5( $this->client_ip() );
|
||||
if ( ! hash_equals( $this->settings->fallback_key(), $given ) ) {
|
||||
$attempts = (int) get_transient( $ip_key );
|
||||
if ( $attempts >= 10 ) {
|
||||
$this->fail( 'fallback_locked' );
|
||||
}
|
||||
set_transient( $ip_key, $attempts + 1, 15 * MINUTE_IN_SECONDS );
|
||||
$this->fail( 'fallback_invalid' );
|
||||
}
|
||||
|
||||
delete_transient( $ip_key );
|
||||
$this->send_cookie( self::FALLBACK_COOKIE, $this->fallback_cookie_value(), time() + self::FALLBACK_TTL );
|
||||
$issued = time();
|
||||
$this->send_cookie( self::FALLBACK_COOKIE, $this->fallback_cookie_value( $issued ), $issued + self::FALLBACK_TTL );
|
||||
nocache_headers();
|
||||
wp_safe_redirect( add_query_arg( 'm365_fallback', 'on', $this->settings->login_page_url() ) );
|
||||
exit;
|
||||
|
|
@ -149,16 +161,12 @@ class M365_Login_Auth {
|
|||
if ( '' === (string) $username && '' === (string) $password ) {
|
||||
return $user; // Initial form render or cookie auth, no password attempt.
|
||||
}
|
||||
// Interactive password logins only: XML-RPC, REST (application passwords), WP-CLI and cron keep working.
|
||||
if ( ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST )
|
||||
|| ( defined( 'REST_REQUEST' ) && REST_REQUEST )
|
||||
|| ( defined( 'WP_CLI' ) && WP_CLI )
|
||||
|| wp_doing_cron() ) {
|
||||
// Exempt by credential, not by request context: application passwords (XML-RPC, REST) and
|
||||
// WP-CLI keep working. A normal password is refused everywhere – also in forms of other
|
||||
// plugins that happen to run inside xmlrpc.php or a REST request.
|
||||
if ( ( defined( 'WP_CLI' ) && WP_CLI ) || ( $user instanceof WP_User && did_action( 'application_password_did_authenticate' ) ) ) {
|
||||
return $user;
|
||||
}
|
||||
if ( ! $user instanceof WP_User ) {
|
||||
return $user; // Already failed for another reason; keep core's message.
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows exempting a password sign-in from button-only mode (e.g. a trusted membership plugin).
|
||||
|
|
@ -166,13 +174,36 @@ class M365_Login_Auth {
|
|||
* @param bool $block Whether to block. Default true.
|
||||
* @param WP_User $user Authenticated user.
|
||||
*/
|
||||
if ( ! apply_filters( 'm365_login_block_password_login', true, $user ) ) {
|
||||
if ( $user instanceof WP_User && ! apply_filters( 'm365_login_block_password_login', true, $user ) ) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
// Same answer for right and wrong passwords: the form must not become a password oracle.
|
||||
return new WP_Error( 'm365_login_button_only', __( 'Password sign-in is disabled on this site. Please use the Microsoft button.', 'm365-login' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* While button-only mode is active, API requests (XML-RPC, REST) never receive session cookies.
|
||||
*
|
||||
* Core can set cookies there, e.g. when an application password is used to change the account
|
||||
* password via REST, or when another plugin's login handler runs inside xmlrpc.php.
|
||||
*
|
||||
* @param bool $send Whether to send the cookies.
|
||||
* @param int $expire Expiry (unused).
|
||||
* @param int $expiration Expiration (unused).
|
||||
* @param int $user_id User ID (0 when cookies are cleared).
|
||||
* @return bool
|
||||
*/
|
||||
public function block_api_auth_cookies( $send, $expire = 0, $expiration = 0, $user_id = 0 ) {
|
||||
if ( ! $send || ! $user_id || ! $this->settings->button_only() || $this->fallback_active() ) {
|
||||
return $send;
|
||||
}
|
||||
if ( ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
|
||||
return false;
|
||||
}
|
||||
return $send;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort client IP for rate limiting.
|
||||
*
|
||||
|
|
@ -190,7 +221,10 @@ class M365_Login_Auth {
|
|||
*/
|
||||
$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] );
|
||||
// Proxies append to the list: the right-most entry was written by the trusted proxy,
|
||||
// entries further left are supplied by the client.
|
||||
$hops = explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ) );
|
||||
$candidate = trim( (string) end( $hops ) );
|
||||
if ( filter_var( $candidate, FILTER_VALIDATE_IP ) ) {
|
||||
$ip = $candidate;
|
||||
}
|
||||
|
|
@ -254,9 +288,10 @@ class M365_Login_Auth {
|
|||
* @return string
|
||||
*/
|
||||
public function start_url( $redirect_to = '' ) {
|
||||
$args = array( 'action' => self::ACTION_START );
|
||||
$args = array( 'action' => self::ACTION_START );
|
||||
$redirect_to = '' !== (string) $redirect_to ? wp_validate_redirect( (string) $redirect_to, '' ) : '';
|
||||
if ( '' !== $redirect_to ) {
|
||||
$args['redirect_to'] = $redirect_to;
|
||||
$args['redirect_to'] = rawurlencode( $redirect_to ); // add_query_arg() does not encode values.
|
||||
}
|
||||
return add_query_arg( $args, wp_login_url() );
|
||||
}
|
||||
|
|
@ -276,7 +311,7 @@ class M365_Login_Auth {
|
|||
// 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 ) {
|
||||
if ( $starts >= 300 ) {
|
||||
$this->fail( 'too_many_attempts' );
|
||||
}
|
||||
set_transient( $throttle_key, $starts + 1, self::STATE_TTL );
|
||||
|
|
@ -421,6 +456,11 @@ class M365_Login_Auth {
|
|||
$this->fail( 'invalid_token' );
|
||||
}
|
||||
|
||||
// In multi-tenant mode a guest or federated identity could present any user name.
|
||||
if ( $this->settings->is_multi_tenant() && $this->is_external_identity( $claims ) ) {
|
||||
$this->fail( 'external_identity' );
|
||||
}
|
||||
|
||||
$email = $this->email_from_claims( $claims );
|
||||
if ( '' === $email ) {
|
||||
$this->fail( 'no_email' );
|
||||
|
|
@ -454,18 +494,41 @@ class M365_Login_Auth {
|
|||
}
|
||||
|
||||
// Bind the account to the immutable Microsoft object ID after first login.
|
||||
$stored = strtolower( (string) get_user_meta( $user->ID, self::META_OID, true ) );
|
||||
if ( $this->settings->get( 'bind_oid' ) ) {
|
||||
if ( '' === $oid || ! M365_Login_Settings::is_guid( $oid ) ) {
|
||||
$this->fail( 'invalid_token' );
|
||||
}
|
||||
$stored = (string) get_user_meta( $user->ID, self::META_OID, true );
|
||||
if ( '' !== $stored && ! hash_equals( $stored, $oid ) ) {
|
||||
$this->log( sprintf( 'Object ID mismatch for user #%d.', $user->ID ) );
|
||||
$this->fail( 'oid_mismatch' );
|
||||
}
|
||||
if ( '' === $stored ) {
|
||||
update_user_meta( $user->ID, self::META_OID, $oid );
|
||||
}
|
||||
|
||||
// Privileged accounts that are not bound yet: only the user principal name of a member
|
||||
// account may claim them (its domain is verified in the tenant, the e-mail attribute is not).
|
||||
if ( '' === $stored && M365_Login_Sync::is_privileged( $user ) && ! $this->may_claim_privileged( $claims, $user ) ) {
|
||||
$this->log( sprintf( 'Refused first sign-in of privileged user #%d without a matching user principal name.', $user->ID ) );
|
||||
$this->fail( 'privileged_unlinked' );
|
||||
}
|
||||
|
||||
if ( $this->settings->get( 'bind_oid' ) && '' === $stored ) {
|
||||
// One Microsoft identity, one WordPress account.
|
||||
$taken = get_users(
|
||||
array(
|
||||
'meta_key' => self::META_OID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
|
||||
'meta_value' => $oid, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
|
||||
'exclude' => array( $user->ID ),
|
||||
'fields' => 'ID',
|
||||
'number' => 1,
|
||||
'blog_id' => 0,
|
||||
)
|
||||
);
|
||||
if ( ! empty( $taken ) ) {
|
||||
$this->log( sprintf( 'Object ID is already bound to another account (user #%d).', $user->ID ) );
|
||||
$this->fail( 'oid_mismatch' );
|
||||
}
|
||||
update_user_meta( $user->ID, self::META_OID, $oid );
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -642,6 +705,35 @@ class M365_Login_Auth {
|
|||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the token comes from a guest or another external identity provider.
|
||||
*
|
||||
* Entra only adds the "idp" claim when the identity provider differs from the issuer.
|
||||
*
|
||||
* @param array $claims Verified claims.
|
||||
* @return bool
|
||||
*/
|
||||
private function is_external_identity( $claims ) {
|
||||
$idp = isset( $claims['idp'] ) && is_string( $claims['idp'] ) ? $claims['idp'] : '';
|
||||
$iss = isset( $claims['iss'] ) && is_string( $claims['iss'] ) ? $claims['iss'] : '';
|
||||
return '' !== $idp && $idp !== $iss;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a token may claim a privileged account that is not bound yet.
|
||||
*
|
||||
* @param array $claims Verified claims.
|
||||
* @param WP_User $user Matched account.
|
||||
* @return bool
|
||||
*/
|
||||
private function may_claim_privileged( $claims, $user ) {
|
||||
$upn = ! empty( $claims['preferred_username'] ) && is_string( $claims['preferred_username'] ) ? strtolower( trim( $claims['preferred_username'] ) ) : '';
|
||||
return '' !== $upn
|
||||
&& ! $this->settings->is_multi_tenant()
|
||||
&& ! $this->is_external_identity( $claims )
|
||||
&& hash_equals( strtolower( $user->user_email ), $upn );
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the Entra group rules: members of an excluded group are refused,
|
||||
* everybody else needs membership in one of the allowed groups (if any are set).
|
||||
|
|
@ -699,7 +791,7 @@ class M365_Login_Auth {
|
|||
return 'invalid_token';
|
||||
}
|
||||
|
||||
$matches = $this->graph->check_member_groups( $oid, $denied );
|
||||
$matches = $this->graph->check_member_groups( $oid, $denied, false );
|
||||
if ( is_wp_error( $matches ) ) {
|
||||
$this->log( 'Excluded-group check via Microsoft Graph failed: ' . $matches->get_error_message() );
|
||||
return 'group_check_failed';
|
||||
|
|
@ -741,7 +833,7 @@ class M365_Login_Auth {
|
|||
return 'invalid_token';
|
||||
}
|
||||
|
||||
$matches = $this->graph->check_member_groups( $oid, $allowed );
|
||||
$matches = $this->graph->check_member_groups( $oid, $allowed, false );
|
||||
if ( is_wp_error( $matches ) ) {
|
||||
$this->log( 'Group check via Microsoft Graph failed: ' . $matches->get_error_message() );
|
||||
return 'group_check_failed';
|
||||
|
|
@ -899,6 +991,13 @@ class M365_Login_Auth {
|
|||
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
||||
|
||||
$out = array();
|
||||
if ( $this->settings->button_only() && ! $this->settings->is_configured() && ! $this->fallback_active() ) {
|
||||
$out[] = array(
|
||||
'type' => 'error',
|
||||
'code' => 'unavailable',
|
||||
'text' => __( 'Microsoft sign-in is temporarily unavailable. Please contact an administrator.', 'm365-login' ),
|
||||
);
|
||||
}
|
||||
if ( $fallback_on && $this->settings->button_only() && $this->fallback_active() ) {
|
||||
$out[] = array(
|
||||
'type' => 'message',
|
||||
|
|
@ -924,24 +1023,26 @@ class M365_Login_Auth {
|
|||
*/
|
||||
private function error_messages() {
|
||||
return array(
|
||||
'not_configured' => __( 'Microsoft login is not configured yet.', 'm365-login' ),
|
||||
'invalid_state' => __( 'The login request expired or was invalid. Please try again.', 'm365-login' ),
|
||||
'access_denied' => __( 'Microsoft sign-in was cancelled.', 'm365-login' ),
|
||||
'provider_error' => __( 'Microsoft returned an error. Please try again.', 'm365-login' ),
|
||||
'token_exchange' => __( 'Could not complete the sign-in with Microsoft. Please try again or contact an administrator.', 'm365-login' ),
|
||||
'invalid_token' => __( 'The Microsoft sign-in could not be verified.', 'm365-login' ),
|
||||
'no_email' => __( 'Your Microsoft account did not provide an e-mail address.', 'm365-login' ),
|
||||
'domain_not_allowed' => __( 'Your e-mail domain is not allowed to sign in here.', 'm365-login' ),
|
||||
'no_user' => __( 'No WordPress account exists for your Microsoft e-mail address.', 'm365-login' ),
|
||||
'oid_mismatch' => __( 'This WordPress account is linked to a different Microsoft account. Please contact an administrator.', 'm365-login' ),
|
||||
'not_allowed' => __( 'You are not allowed to sign in with this account.', 'm365-login' ),
|
||||
'not_in_group' => __( 'Your Microsoft account is not a member of a group that is allowed to sign in here.', 'm365-login' ),
|
||||
'group_check_failed' => __( 'Your group membership could not be verified. Please contact an administrator.', 'm365-login' ),
|
||||
'in_denied_group' => __( 'Your Microsoft account is a member of a group that is not allowed to sign in here.', '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' ),
|
||||
'account_disabled' => __( 'This account has been deactivated.', 'm365-login' ),
|
||||
'not_configured' => __( 'Microsoft login is not configured yet.', 'm365-login' ),
|
||||
'invalid_state' => __( 'The login request expired or was invalid. Please try again.', 'm365-login' ),
|
||||
'access_denied' => __( 'Microsoft sign-in was cancelled.', 'm365-login' ),
|
||||
'provider_error' => __( 'Microsoft returned an error. Please try again.', 'm365-login' ),
|
||||
'token_exchange' => __( 'Could not complete the sign-in with Microsoft. Please try again or contact an administrator.', 'm365-login' ),
|
||||
'invalid_token' => __( 'The Microsoft sign-in could not be verified.', 'm365-login' ),
|
||||
'no_email' => __( 'Your Microsoft account did not provide an e-mail address.', 'm365-login' ),
|
||||
'domain_not_allowed' => __( 'Your e-mail domain is not allowed to sign in here.', 'm365-login' ),
|
||||
'no_user' => __( 'No WordPress account exists for your Microsoft e-mail address.', 'm365-login' ),
|
||||
'oid_mismatch' => __( 'This WordPress account is linked to a different Microsoft account. Please contact an administrator.', 'm365-login' ),
|
||||
'not_allowed' => __( 'You are not allowed to sign in with this account.', 'm365-login' ),
|
||||
'not_in_group' => __( 'Your Microsoft account is not a member of a group that is allowed to sign in here.', 'm365-login' ),
|
||||
'group_check_failed' => __( 'Your group membership could not be verified. Please contact an administrator.', 'm365-login' ),
|
||||
'in_denied_group' => __( 'Your Microsoft account is a member of a group that is not allowed to sign in here.', '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' ),
|
||||
'account_disabled' => __( 'This account has been deactivated.', 'm365-login' ),
|
||||
'privileged_unlinked' => __( 'For security reasons this administrator account can only be linked to a Microsoft account whose user principal name equals the WordPress e-mail address. Please contact an administrator.', 'm365-login' ),
|
||||
'external_identity' => __( 'Guest and external accounts cannot sign in here.', 'm365-login' ),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -97,6 +97,9 @@ final class M365_Login_Certificate {
|
|||
return new WP_Error( 'key_bits', __( 'The RSA key must have at least 2048 bits.', 'm365-login' ) );
|
||||
}
|
||||
|
||||
if ( false !== strpos( $cert_pem, 'PRIVATE KEY' ) ) {
|
||||
return new WP_Error( 'cert_has_key', __( 'The certificate field contains a private key. Paste only the certificate (-----BEGIN CERTIFICATE-----) there.', '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' ) ) );
|
||||
|
|
@ -110,9 +113,15 @@ final class M365_Login_Certificate {
|
|||
return new WP_Error( 'cert_expired', __( 'The certificate has already expired.', 'm365-login' ) );
|
||||
}
|
||||
|
||||
// Store exactly one clean certificate (drops chains, bundles and surrounding text).
|
||||
$clean = '';
|
||||
if ( ! openssl_x509_export( $cert, $clean ) || '' === $clean ) {
|
||||
return new WP_Error( 'cert_invalid', self::openssl_error( __( 'The certificate could not be read. Paste it in PEM format (-----BEGIN CERTIFICATE-----).', 'm365-login' ) ) );
|
||||
}
|
||||
|
||||
return array(
|
||||
'private_key' => $key_pem,
|
||||
'certificate' => $cert_pem,
|
||||
'certificate' => self::normalise_pem( $clean ),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ defined( 'ABSPATH' ) || exit;
|
|||
* AES-256-GCM helper keyed from the WordPress salts.
|
||||
*
|
||||
* The key is derived from AUTH_KEY / SECURE_AUTH_KEY (via wp_salt()), so the
|
||||
* stored client secret is useless without access to wp-config.php.
|
||||
* stored client secret is useless without access to wp-config.php – provided the
|
||||
* salts are defined there. Without them wp_salt() keeps generated salts in the
|
||||
* database next to the ciphertext; the settings screen warns about that.
|
||||
*/
|
||||
final class M365_Login_Crypto {
|
||||
|
||||
|
|
|
|||
|
|
@ -107,9 +107,10 @@ class M365_Login_Graph {
|
|||
* @param string $path Path relative to the v1.0 base (with query string) or an absolute Graph URL (paging links).
|
||||
* @param array|null $json JSON body for POST requests.
|
||||
* @param array $headers Extra headers.
|
||||
* @param bool $retry Retry on 429/503/504.
|
||||
* @return array|WP_Error Response array from wp_remote_request().
|
||||
*/
|
||||
private function raw_request( $method, $path, $json = null, $headers = array() ) {
|
||||
private function raw_request( $method, $path, $json = null, $headers = array(), $retry = true ) {
|
||||
$url = 0 === strpos( $path, self::GRAPH_BASE . '/' ) ? $path : self::GRAPH_BASE . $path;
|
||||
if ( 0 !== strpos( $url, self::GRAPH_BASE . '/' ) ) {
|
||||
return new WP_Error( 'graph_bad_url', 'Refusing to call a non-Graph URL.' );
|
||||
|
|
@ -146,7 +147,7 @@ class M365_Login_Graph {
|
|||
if ( 401 === $code ) {
|
||||
$this->flush_token();
|
||||
}
|
||||
if ( $attempt < 4 && in_array( $code, array( 429, 503, 504 ), true ) ) {
|
||||
if ( $retry && $attempt < 4 && in_array( $code, array( 429, 503, 504 ), true ) ) {
|
||||
$wait = (int) wp_remote_retrieve_header( $response, 'retry-after' );
|
||||
sleep( max( 1, min( 10, $wait > 0 ? $wait : $attempt * 2 ) ) );
|
||||
continue;
|
||||
|
|
@ -162,10 +163,11 @@ class M365_Login_Graph {
|
|||
* @param string $path Path relative to the v1.0 base (with query string).
|
||||
* @param array|null $json JSON body for POST requests.
|
||||
* @param array $headers Extra headers.
|
||||
* @param bool $retry Retry on 429/503/504.
|
||||
* @return array|WP_Error Decoded JSON. Errors carry array( 'status' => HTTP code ) as data.
|
||||
*/
|
||||
private function request( $method, $path, $json = null, $headers = array() ) {
|
||||
$response = $this->raw_request( $method, $path, $json, $headers );
|
||||
private function request( $method, $path, $json = null, $headers = array(), $retry = true ) {
|
||||
$response = $this->raw_request( $method, $path, $json, $headers, $retry );
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
|
@ -390,10 +392,10 @@ class M365_Login_Graph {
|
|||
*/
|
||||
public function search_groups( $query ) {
|
||||
$query = trim( (string) $query );
|
||||
$select = '$select=id,displayName,description,securityEnabled,mailEnabled&$top=25&$orderby=displayName';
|
||||
$select = '$select=id,displayName,description,securityEnabled,mailEnabled,groupTypes,visibility&$top=25&$orderby=displayName';
|
||||
|
||||
if ( '' !== $query && M365_Login_Settings::is_guid( $query ) ) {
|
||||
$path = '/groups/' . rawurlencode( strtolower( $query ) ) . '?$select=id,displayName,description,securityEnabled,mailEnabled';
|
||||
$path = '/groups/' . rawurlencode( strtolower( $query ) ) . '?$select=id,displayName,description,securityEnabled,mailEnabled,groupTypes,visibility';
|
||||
$item = $this->request( 'GET', $path );
|
||||
if ( is_wp_error( $item ) ) {
|
||||
return $item;
|
||||
|
|
@ -437,6 +439,10 @@ class M365_Login_Graph {
|
|||
} elseif ( ! empty( $item['mailEnabled'] ) ) {
|
||||
$type = __( 'Microsoft 365 group', 'm365-login' );
|
||||
}
|
||||
$unified = isset( $item['groupTypes'] ) && is_array( $item['groupTypes'] ) && in_array( 'Unified', $item['groupTypes'], true );
|
||||
if ( $unified && isset( $item['visibility'] ) && 'Public' === $item['visibility'] ) {
|
||||
$type = __( 'Public Microsoft 365 group – anyone in the organisation can join', 'm365-login' );
|
||||
}
|
||||
return array(
|
||||
'id' => strtolower( (string) $item['id'] ),
|
||||
'name' => isset( $item['displayName'] ) ? (string) $item['displayName'] : (string) $item['id'],
|
||||
|
|
@ -450,9 +456,10 @@ class M365_Login_Graph {
|
|||
*
|
||||
* @param string $user_oid User object ID.
|
||||
* @param string[] $group_ids Group object IDs (any count; chunked by 20).
|
||||
* @param bool $retry Retry on throttling (off in the interactive sign-in).
|
||||
* @return string[]|WP_Error Matching group IDs.
|
||||
*/
|
||||
public function check_member_groups( $user_oid, $group_ids ) {
|
||||
public function check_member_groups( $user_oid, $group_ids, $retry = true ) {
|
||||
if ( ! M365_Login_Settings::is_guid( $user_oid ) ) {
|
||||
return new WP_Error( 'graph_bad_oid', 'Invalid user object ID.' );
|
||||
}
|
||||
|
|
@ -462,7 +469,9 @@ class M365_Login_Graph {
|
|||
$result = $this->request(
|
||||
'POST',
|
||||
'/users/' . rawurlencode( strtolower( $user_oid ) ) . '/checkMemberGroups',
|
||||
array( 'groupIds' => $chunk )
|
||||
array( 'groupIds' => $chunk ),
|
||||
array(),
|
||||
$retry
|
||||
);
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
|
|
|
|||
|
|
@ -402,7 +402,10 @@ class M365_Login_Settings {
|
|||
if ( defined( 'M365_LOGIN_DISABLE_BUTTON_ONLY' ) && M365_LOGIN_DISABLE_BUTTON_ONLY ) {
|
||||
return false;
|
||||
}
|
||||
return $this->is_configured() && (bool) $this->get( 'button_only' ) && '' !== $this->fallback_key();
|
||||
// Deliberately not tied to is_configured(): an expired certificate or rotated salts must not
|
||||
// silently re-enable password sign-in. The fallback link and the constant stay available.
|
||||
return '' !== (string) $this->get( 'tenant_id' ) && '' !== (string) $this->get( 'client_id' )
|
||||
&& (bool) $this->get( 'button_only' ) && '' !== $this->fallback_key();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -497,7 +500,7 @@ class M365_Login_Settings {
|
|||
$out['client_id'] = strtolower( $client_id );
|
||||
|
||||
// Client secret: only replaced when a new value was entered.
|
||||
$secret_input = isset( $input['client_secret'] ) ? (string) wp_unslash( $input['client_secret'] ) : '';
|
||||
$secret_input = self::scalar( $input, 'client_secret' );
|
||||
$secret_input = trim( $secret_input );
|
||||
if ( ! empty( $input['client_secret_clear'] ) ) {
|
||||
$out['client_secret'] = '';
|
||||
|
|
@ -520,8 +523,8 @@ class M365_Login_Settings {
|
|||
// 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'] ) ) : '';
|
||||
$pasted_key = trim( self::scalar( $input, 'cert_key_pem' ) );
|
||||
$pasted_cert = trim( self::scalar( $input, 'cert_cert_pem' ) );
|
||||
if ( ! empty( $input['cert_remove'] ) ) {
|
||||
$out['cert_private_key'] = '';
|
||||
$out['cert_certificate'] = '';
|
||||
|
|
@ -573,7 +576,7 @@ class M365_Login_Settings {
|
|||
$out['fallback_key'] = $key;
|
||||
|
||||
// Custom login page (must be on this site).
|
||||
$custom = isset( $input['custom_login_url'] ) ? esc_url_raw( trim( wp_unslash( $input['custom_login_url'] ) ) ) : '';
|
||||
$custom = esc_url_raw( trim( self::scalar( $input, 'custom_login_url' ) ) );
|
||||
if ( '' !== $custom ) {
|
||||
if ( 0 === strpos( $custom, '/' ) ) {
|
||||
$custom = home_url( $custom );
|
||||
|
|
@ -590,13 +593,13 @@ class M365_Login_Settings {
|
|||
$text = isset( $input['button_text'] ) ? sanitize_text_field( wp_unslash( $input['button_text'] ) ) : '';
|
||||
$out['button_text'] = '' === trim( $text ) ? $defaults['button_text'] : mb_substr( $text, 0, 80 );
|
||||
|
||||
$icon = isset( $input['button_icon'] ) ? esc_url_raw( trim( wp_unslash( $input['button_icon'] ) ) ) : '';
|
||||
$icon = esc_url_raw( trim( self::scalar( $input, 'button_icon' ) ) );
|
||||
$out['button_icon'] = self::is_safe_image_url( $icon ) ? $icon : '';
|
||||
|
||||
$out['button_show_icon'] = empty( $input['button_show_icon'] ) ? 0 : 1;
|
||||
|
||||
foreach ( array( 'button_bg', 'button_bg_hover', 'button_color', 'button_border' ) as $color_key ) {
|
||||
$color = isset( $input[ $color_key ] ) ? sanitize_hex_color( trim( wp_unslash( $input[ $color_key ] ) ) ) : '';
|
||||
$color = sanitize_hex_color( trim( self::scalar( $input, $color_key ) ) );
|
||||
$out[ $color_key ] = $color ? $color : $defaults[ $color_key ];
|
||||
}
|
||||
|
||||
|
|
@ -692,6 +695,17 @@ class M365_Login_Settings {
|
|||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unslashed string value of a posted field ('' for missing or non-scalar values such as arrays).
|
||||
*
|
||||
* @param array $input Raw input.
|
||||
* @param string $key Field.
|
||||
* @return string
|
||||
*/
|
||||
private static function scalar( $input, $key ) {
|
||||
return isset( $input[ $key ] ) && is_scalar( $input[ $key ] ) ? (string) wp_unslash( $input[ $key ] ) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitises a GUID => name list posted by a group picker.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -15,8 +15,10 @@ defined( 'ABSPATH' ) || exit;
|
|||
class M365_Login_Sync {
|
||||
|
||||
const CRON_HOOK = 'm365_login_sync';
|
||||
const LOCK = 'm365_login_sync_lock';
|
||||
const LOCK_TTL = 30 * MINUTE_IN_SECONDS;
|
||||
const LOCK = 'm365_login_sync_lock'; // Option (atomic via add_option), value "token|time".
|
||||
const LOCK_TTL = 2 * HOUR_IN_SECONDS;
|
||||
const TENANT_OPTION = 'm365_login_sync_tenant'; // Tenant of the last successful live run.
|
||||
const PHOTO_FILE = '#^m365-login-avatars/m365-[a-f0-9]{16}-[a-f0-9]{8}\.(?:jpg|png|gif)$#';
|
||||
const REPORT_OPTION = 'm365_login_sync_report';
|
||||
const META_SYNCED = '_m365_login_synced'; // Account was created by the sync.
|
||||
const META_DISABLED = '_m365_login_disabled'; // Time, origin (sync or manual) and reason.
|
||||
|
|
@ -55,6 +57,13 @@ class M365_Login_Sync {
|
|||
*/
|
||||
private $dry = false;
|
||||
|
||||
/**
|
||||
* Further accounts bound to the same object ID as the one in linked_users() (oid => user IDs).
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private $duplicates = array();
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
|
|
@ -305,11 +314,11 @@ class M365_Login_Sync {
|
|||
'log' => array(),
|
||||
);
|
||||
|
||||
if ( get_transient( self::LOCK ) ) {
|
||||
$lock = $this->acquire_lock();
|
||||
if ( '' === $lock ) {
|
||||
$this->log( 'error', __( 'Another sync is still running. Please try again in a few minutes.', 'm365-login' ) );
|
||||
return $this->finish( 'locked', false );
|
||||
}
|
||||
set_transient( self::LOCK, time(), self::LOCK_TTL );
|
||||
|
||||
if ( function_exists( 'set_time_limit' ) ) {
|
||||
set_time_limit( 0 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- long-running directory sync.
|
||||
|
|
@ -317,21 +326,51 @@ class M365_Login_Sync {
|
|||
wp_raise_memory_limit( 'admin' );
|
||||
require_once ABSPATH . 'wp-admin/includes/user.php';
|
||||
|
||||
// No "your e-mail/password changed" mails for changes made by the sync.
|
||||
add_filter( 'send_email_change_email', '__return_false', 99 );
|
||||
// No "your password changed" mails for the random passwords of deactivated accounts.
|
||||
// E-mail change notifications stay on: the previous address is told about the change.
|
||||
add_filter( 'send_password_change_email', '__return_false', 99 );
|
||||
|
||||
try {
|
||||
$status = $this->sync();
|
||||
} finally {
|
||||
remove_filter( 'send_email_change_email', '__return_false', 99 );
|
||||
remove_filter( 'send_password_change_email', '__return_false', 99 );
|
||||
delete_transient( self::LOCK );
|
||||
$this->release_lock( $lock );
|
||||
}
|
||||
|
||||
return $this->finish( $status, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the run lock atomically (add_option fails when the row exists).
|
||||
*
|
||||
* @return string Lock token, or '' when another run holds the lock.
|
||||
*/
|
||||
private function acquire_lock() {
|
||||
$token = wp_generate_password( 20, false );
|
||||
if ( add_option( self::LOCK, $token . '|' . time(), '', 'no' ) ) {
|
||||
return $token;
|
||||
}
|
||||
$held = explode( '|', (string) get_option( self::LOCK, '' ) );
|
||||
if ( isset( $held[1] ) && time() - (int) $held[1] < self::LOCK_TTL ) {
|
||||
return '';
|
||||
}
|
||||
// Stale lock of a crashed run.
|
||||
delete_option( self::LOCK );
|
||||
return add_option( self::LOCK, $token . '|' . time(), '', 'no' ) ? $token : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases the run lock if this run still holds it.
|
||||
*
|
||||
* @param string $token Lock token.
|
||||
*/
|
||||
private function release_lock( $token ) {
|
||||
$held = explode( '|', (string) get_option( self::LOCK, '' ) );
|
||||
if ( $held[0] === $token ) {
|
||||
delete_option( self::LOCK );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores and returns the report.
|
||||
*
|
||||
|
|
@ -392,7 +431,12 @@ class M365_Login_Sync {
|
|||
}
|
||||
|
||||
// 2. Create, link and update accounts.
|
||||
$linked = $this->linked_users();
|
||||
$linked = $this->linked_users();
|
||||
if ( empty( $people ) && ! empty( $linked ) ) {
|
||||
$this->log( 'error', __( 'Microsoft 365 returned no users at all while accounts are linked. Nothing was changed. Check the tenant and the sync groups.', 'm365-login' ) );
|
||||
return 'aborted';
|
||||
}
|
||||
|
||||
$seen = array();
|
||||
$pending = array(); // Deactivations/deletions, applied after the safety check.
|
||||
$photo_of = array(); // oid => WP_User whose photo is kept in sync.
|
||||
|
|
@ -404,7 +448,7 @@ class M365_Login_Sync {
|
|||
|
||||
$result = $this->sync_person( $person, $linked, $memberships );
|
||||
if ( is_array( $result ) ) {
|
||||
$pending[] = $result;
|
||||
$pending = array_merge( $pending, $result );
|
||||
} elseif ( $result instanceof WP_User ) {
|
||||
$photo_of[ $oid ] = $result;
|
||||
}
|
||||
|
|
@ -428,13 +472,22 @@ class M365_Login_Sync {
|
|||
return 'failed';
|
||||
}
|
||||
if ( null !== $action ) {
|
||||
$pending[] = $action;
|
||||
$pending = array_merge( $pending, $action );
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Safety net: never deactivate or delete a large part of the linked accounts in one go.
|
||||
$pending = array_values( array_filter( $pending, array( $this, 'is_effective_action' ) ) );
|
||||
$limit = (int) apply_filters( 'm365_login_sync_deprovision_limit', max( 5, (int) ceil( count( $linked ) * 0.2 ) ), count( $linked ) );
|
||||
$tenant = strtolower( $this->settings->tenant() );
|
||||
$before = (string) get_option( self::TENANT_OPTION, '' );
|
||||
if ( $pending && '' !== $before && $before !== $tenant ) {
|
||||
$this->log( 'warning', __( 'The tenant ID changed since the last sync. Accounts linked in the old tenant are not found in the new one, so no account was deactivated or deleted in this run. Run the sync again to apply deactivations.', 'm365-login' ) );
|
||||
$pending = array();
|
||||
}
|
||||
if ( ! $this->dry ) {
|
||||
update_option( self::TENANT_OPTION, $tenant, false );
|
||||
}
|
||||
$limit = (int) apply_filters( 'm365_login_sync_deprovision_limit', max( 5, (int) ceil( count( $linked ) * 0.2 ) ), count( $linked ) );
|
||||
if ( count( $pending ) > $limit ) {
|
||||
$this->log(
|
||||
'error',
|
||||
|
|
@ -519,7 +572,7 @@ class M365_Login_Sync {
|
|||
* @return array oid => user ID.
|
||||
*/
|
||||
private function linked_users() {
|
||||
$users = get_users(
|
||||
$users = get_users(
|
||||
array(
|
||||
'meta_key' => M365_Login_Auth::META_OID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
|
||||
'meta_compare' => 'EXISTS',
|
||||
|
|
@ -527,10 +580,16 @@ class M365_Login_Sync {
|
|||
'number' => -1,
|
||||
)
|
||||
);
|
||||
$out = array();
|
||||
$out = array();
|
||||
$this->duplicates = array();
|
||||
foreach ( $users as $row ) {
|
||||
$oid = strtolower( (string) get_user_meta( (int) $row->ID, M365_Login_Auth::META_OID, true ) );
|
||||
if ( M365_Login_Settings::is_guid( $oid ) ) {
|
||||
if ( ! M365_Login_Settings::is_guid( $oid ) ) {
|
||||
continue;
|
||||
}
|
||||
if ( isset( $out[ $oid ] ) ) {
|
||||
$this->duplicates[ $oid ][] = (int) $row->ID;
|
||||
} else {
|
||||
$out[ $oid ] = (int) $row->ID;
|
||||
}
|
||||
}
|
||||
|
|
@ -556,6 +615,12 @@ class M365_Login_Sync {
|
|||
|
||||
$user = isset( $linked[ $oid ] ) ? get_userdata( $linked[ $oid ] ) : false;
|
||||
|
||||
// A linked person disabled in Microsoft 365 is handled before anything else, so a
|
||||
// removed or changed e-mail address during offboarding cannot keep the account alive.
|
||||
if ( $user && ! $enabled ) {
|
||||
return $this->action( (string) $this->settings->get( 'sync_disabled_action' ), $user, 'disabled', __( 'disabled in Microsoft 365', 'm365-login' ), $oid );
|
||||
}
|
||||
|
||||
if ( ! $user && isset( $person['userType'] ) && 'Guest' === $person['userType'] && ! $this->settings->get( 'sync_guests' ) ) {
|
||||
return null; // Guests are not imported (they may still be linked through a sign-in).
|
||||
}
|
||||
|
|
@ -586,6 +651,11 @@ class M365_Login_Sync {
|
|||
$this->skip( sprintf( __( '%s: the WordPress account with this e-mail address is linked to a different Microsoft account, skipped.', 'm365-login' ), $email ) );
|
||||
return null;
|
||||
}
|
||||
if ( ! $this->may_link( $by_mail, $person, $email ) ) {
|
||||
/* translators: %s: e-mail address */
|
||||
$this->skip( sprintf( __( '%s: privileged WordPress account – it is only linked when the Microsoft user principal name equals its e-mail address (member account, no guest). Skipped.', 'm365-login' ), $email ) );
|
||||
return null;
|
||||
}
|
||||
$user = $by_mail;
|
||||
$this->log( 'info', sprintf( /* translators: %s: e-mail address */ __( '%s: existing account linked.', 'm365-login' ), $email ) );
|
||||
$this->count( 'linked' );
|
||||
|
|
@ -601,7 +671,7 @@ class M365_Login_Sync {
|
|||
if ( ! $user ) {
|
||||
return null; // Nothing to create for disabled people.
|
||||
}
|
||||
return $this->action( (string) $this->settings->get( 'sync_disabled_action' ), $user, 'disabled', __( 'disabled in Microsoft 365', 'm365-login' ) );
|
||||
return $this->action( (string) $this->settings->get( 'sync_disabled_action' ), $user, 'disabled', __( 'disabled in Microsoft 365', 'm365-login' ), $oid );
|
||||
}
|
||||
|
||||
if ( ! $user ) {
|
||||
|
|
@ -626,7 +696,7 @@ class M365_Login_Sync {
|
|||
}
|
||||
|
||||
$changes = $this->update_profile( $user, $person, $email );
|
||||
if ( $this->manages_roles( $user ) ) {
|
||||
if ( $this->manages_roles( $user ) && ! self::disabled_info( $user->ID ) ) {
|
||||
$changes = array_merge( $changes, $this->update_roles( $user, $this->desired_roles( $oid, $memberships ) ) );
|
||||
}
|
||||
|
||||
|
|
@ -741,7 +811,10 @@ class M365_Login_Sync {
|
|||
|
||||
if ( strtolower( $user->user_email ) !== $email ) {
|
||||
$owner = get_user_by( 'email', $email );
|
||||
if ( $owner && $owner->ID !== $user->ID ) {
|
||||
if ( self::is_privileged( $user ) ) {
|
||||
/* translators: 1: current e-mail address, 2: e-mail address in Microsoft 365 */
|
||||
$this->log( 'warning', sprintf( __( '%1$s: the e-mail address in Microsoft 365 changed to %2$s. It is not changed automatically for privileged accounts – update it by hand if intended.', 'm365-login' ), $user->user_email, $email ) );
|
||||
} elseif ( $owner && $owner->ID !== $user->ID ) {
|
||||
/* translators: %s: e-mail address */
|
||||
$this->log( 'warning', sprintf( __( '%s: e-mail address is used by another WordPress account and was not changed.', 'm365-login' ), $email ) );
|
||||
} else {
|
||||
|
|
@ -935,7 +1008,7 @@ class M365_Login_Sync {
|
|||
*
|
||||
* @param string $oid Object ID.
|
||||
* @param int $user_id User ID.
|
||||
* @return array|null|WP_Error Pending action, null for none.
|
||||
* @return array[]|null|WP_Error Pending actions, null for none.
|
||||
*/
|
||||
private function classify_missing( $oid, $user_id ) {
|
||||
$user = get_userdata( $user_id );
|
||||
|
|
@ -947,15 +1020,15 @@ class M365_Login_Sync {
|
|||
$person = $this->graph->get_user( $oid, array( 'id', 'accountEnabled', 'userType' ) );
|
||||
if ( is_wp_error( $person ) ) {
|
||||
if ( M365_Login_Graph::is_not_found( $person ) ) {
|
||||
return $this->action( (string) $this->settings->get( 'sync_deleted_action' ), $user, 'deleted', __( 'deleted in Microsoft 365', 'm365-login' ) );
|
||||
return $this->action( (string) $this->settings->get( 'sync_deleted_action' ), $user, 'deleted', __( 'deleted in Microsoft 365', 'm365-login' ), $oid );
|
||||
}
|
||||
return $person;
|
||||
}
|
||||
if ( isset( $person['accountEnabled'] ) && false === $person['accountEnabled'] ) {
|
||||
return $this->action( (string) $this->settings->get( 'sync_disabled_action' ), $user, 'disabled', __( 'disabled in Microsoft 365', 'm365-login' ) );
|
||||
return $this->action( (string) $this->settings->get( 'sync_disabled_action' ), $user, 'disabled', __( 'disabled in Microsoft 365', 'm365-login' ), $oid );
|
||||
}
|
||||
if ( $this->settings->sync_scope_groups() && ( ! isset( $person['userType'] ) || 'Guest' !== $person['userType'] || $this->settings->get( 'sync_guests' ) ) ) {
|
||||
return $this->action( (string) $this->settings->get( 'sync_scope_action' ), $user, 'scope', __( 'no longer a member of the sync groups', 'm365-login' ) );
|
||||
return $this->action( (string) $this->settings->get( 'sync_scope_action' ), $user, 'scope', __( 'no longer a member of the sync groups', 'm365-login' ), $oid );
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -967,23 +1040,79 @@ class M365_Login_Sync {
|
|||
* @param WP_User $user User.
|
||||
* @param string $reason Machine reason.
|
||||
* @param string $label Human reason.
|
||||
* @return array|null
|
||||
* @param string $oid Object ID (further accounts bound to it get the same action).
|
||||
* @return array[]|null Pending actions.
|
||||
*/
|
||||
private function action( $what, $user, $reason, $label ) {
|
||||
private function action( $what, $user, $reason, $label, $oid = '' ) {
|
||||
if ( ! in_array( $what, array( 'disable', 'delete' ), true ) ) {
|
||||
return null;
|
||||
}
|
||||
if ( $this->is_protected( $user ) ) {
|
||||
/* translators: 1: e-mail address, 2: reason */
|
||||
$this->skip( sprintf( __( '%1$s: %2$s, but the account is protected (administrator or your own account) and was not changed.', 'm365-login' ), $user->user_email, $label ) );
|
||||
return null;
|
||||
$users = array( $user );
|
||||
if ( '' !== $oid && ! empty( $this->duplicates[ $oid ] ) ) {
|
||||
foreach ( $this->duplicates[ $oid ] as $user_id ) {
|
||||
$other = get_userdata( $user_id );
|
||||
if ( $other && $other->ID !== $user->ID ) {
|
||||
$users[] = $other;
|
||||
}
|
||||
}
|
||||
}
|
||||
return array(
|
||||
'what' => $what,
|
||||
'user' => $user,
|
||||
'reason' => $reason,
|
||||
'label' => $label,
|
||||
);
|
||||
$out = array();
|
||||
foreach ( $users as $target ) {
|
||||
if ( $this->is_protected( $target ) ) {
|
||||
/* translators: 1: e-mail address, 2: reason */
|
||||
$this->skip( sprintf( __( '%1$s: %2$s, but the account is protected (administrator or your own account) and was not changed.', 'm365-login' ), $target->user_email, $label ) );
|
||||
continue;
|
||||
}
|
||||
$out[] = array(
|
||||
'what' => $what,
|
||||
'user' => $target,
|
||||
'reason' => $reason,
|
||||
'label' => $label,
|
||||
);
|
||||
}
|
||||
return $out ? $out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an existing account may be linked to a directory user by e-mail address.
|
||||
*
|
||||
* Privileged accounts are only linked through the user principal name of a member
|
||||
* account: its domain must be verified in the tenant, whereas the "mail" attribute can
|
||||
* be set to any address by a user or Exchange administrator of the tenant.
|
||||
*
|
||||
* @param WP_User $user Existing account.
|
||||
* @param array $person Graph user.
|
||||
* @param string $email Address the account was found by.
|
||||
* @return bool
|
||||
*/
|
||||
private function may_link( $user, $person, $email ) {
|
||||
if ( ! self::is_privileged( $user ) ) {
|
||||
return true;
|
||||
}
|
||||
$upn = isset( $person['userPrincipalName'] ) ? strtolower( (string) $person['userPrincipalName'] ) : '';
|
||||
$guest = isset( $person['userType'] ) && 'Guest' === $person['userType'];
|
||||
return ! $guest && '' !== $upn && false === strpos( $upn, '#ext#' ) && strtolower( $user->user_email ) === $upn && $upn === $email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accounts with administrative capabilities (they get extra protection against linking by e-mail).
|
||||
*
|
||||
* @param WP_User $user User.
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_privileged( $user ) {
|
||||
$privileged = is_super_admin( $user->ID )
|
||||
|| user_can( $user, 'manage_options' )
|
||||
|| user_can( $user, 'promote_users' )
|
||||
|| user_can( $user, 'edit_users' );
|
||||
|
||||
/**
|
||||
* Filters whether an account counts as privileged (linked only via a matching user principal name).
|
||||
*
|
||||
* @param bool $privileged Whether the account is privileged.
|
||||
* @param WP_User $user User.
|
||||
*/
|
||||
return (bool) apply_filters( 'm365_login_is_privileged_user', $privileged, $user );
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1215,7 +1344,7 @@ class M365_Login_Sync {
|
|||
continue;
|
||||
}
|
||||
|
||||
if ( ! empty( $stored['file'] ) && isset( $stored['etag'] ) && $stored['etag'] === $version && file_exists( self::photo_path( $stored['file'] ) ) ) {
|
||||
if ( ! empty( $stored['file'] ) && isset( $stored['etag'] ) && $stored['etag'] === $version && self::is_photo_file( $stored['file'] ) && file_exists( self::photo_path( $stored['file'] ) ) ) {
|
||||
if ( ! $this->dry ) {
|
||||
$stored['checked'] = time();
|
||||
update_user_meta( $user->ID, self::META_PHOTO, $stored );
|
||||
|
|
@ -1256,7 +1385,7 @@ class M365_Login_Sync {
|
|||
continue;
|
||||
}
|
||||
|
||||
if ( ! empty( $stored['file'] ) && $stored['file'] !== $file ) {
|
||||
if ( ! empty( $stored['file'] ) && $stored['file'] !== $file && self::is_photo_file( $stored['file'] ) ) {
|
||||
wp_delete_file( self::photo_path( $stored['file'] ) );
|
||||
}
|
||||
update_user_meta(
|
||||
|
|
@ -1388,6 +1517,16 @@ class M365_Login_Sync {
|
|||
return trailingslashit( $uploads['basedir'] ) . ltrim( $file, '/' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a stored photo path is one the plugin wrote (no traversal, fixed folder and pattern).
|
||||
*
|
||||
* @param mixed $file Relative path from user meta.
|
||||
* @return bool
|
||||
*/
|
||||
private static function is_photo_file( $file ) {
|
||||
return is_string( $file ) && (bool) preg_match( self::PHOTO_FILE, $file );
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a user's stored photo (also hooked to user deletion).
|
||||
*
|
||||
|
|
@ -1395,7 +1534,7 @@ class M365_Login_Sync {
|
|||
*/
|
||||
public function delete_photo( $user_id ) {
|
||||
$stored = get_user_meta( $user_id, self::META_PHOTO, true );
|
||||
if ( is_array( $stored ) && ! empty( $stored['file'] ) && 0 === strpos( $stored['file'], self::PHOTO_DIR . '/' ) ) {
|
||||
if ( is_array( $stored ) && ! empty( $stored['file'] ) && self::is_photo_file( $stored['file'] ) ) {
|
||||
wp_delete_file( self::photo_path( $stored['file'] ) );
|
||||
}
|
||||
delete_user_meta( $user_id, self::META_PHOTO );
|
||||
|
|
@ -1431,7 +1570,7 @@ class M365_Login_Sync {
|
|||
}
|
||||
|
||||
$stored = get_user_meta( $user_id, self::META_PHOTO, true );
|
||||
if ( ! is_array( $stored ) || empty( $stored['file'] ) ) {
|
||||
if ( ! is_array( $stored ) || empty( $stored['file'] ) || ! self::is_photo_file( $stored['file'] ) ) {
|
||||
return $args;
|
||||
}
|
||||
$uploads = wp_get_upload_dir();
|
||||
|
|
@ -1472,6 +1611,9 @@ class M365_Login_Sync {
|
|||
* @param string $reason Machine reason.
|
||||
*/
|
||||
public static function disable( $user_id, $by, $reason = '' ) {
|
||||
$user = get_userdata( $user_id );
|
||||
$roles = $user ? array_values( $user->roles ) : array();
|
||||
|
||||
update_user_meta(
|
||||
$user_id,
|
||||
self::META_DISABLED,
|
||||
|
|
@ -1479,9 +1621,20 @@ class M365_Login_Sync {
|
|||
'time' => time(),
|
||||
'by' => $by,
|
||||
'reason' => $reason,
|
||||
'roles' => $roles,
|
||||
)
|
||||
);
|
||||
|
||||
// Lock the account for good, also without this plugin: no sessions, no role on this
|
||||
// site, a random password nobody knows and no application passwords.
|
||||
WP_Session_Tokens::get_instance( $user_id )->destroy_all();
|
||||
if ( class_exists( 'WP_Application_Passwords' ) ) {
|
||||
WP_Application_Passwords::delete_all_application_passwords( $user_id );
|
||||
}
|
||||
wp_set_password( wp_generate_password( 64, true, true ), $user_id );
|
||||
if ( $user ) {
|
||||
$user->set_role( '' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires after an account was deactivated.
|
||||
|
|
@ -1499,8 +1652,19 @@ class M365_Login_Sync {
|
|||
* @param int $user_id User ID.
|
||||
*/
|
||||
public static function enable( $user_id ) {
|
||||
$info = self::disabled_info( $user_id );
|
||||
delete_user_meta( $user_id, self::META_DISABLED );
|
||||
|
||||
// Give back the roles taken away on deactivation (the sync may adjust them afterwards).
|
||||
$user = get_userdata( $user_id );
|
||||
if ( $user && empty( $user->roles ) && $info && ! empty( $info['roles'] ) && is_array( $info['roles'] ) ) {
|
||||
foreach ( $info['roles'] as $role ) {
|
||||
if ( is_string( $role ) && get_role( $role ) ) {
|
||||
$user->add_role( $role );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires after an account was reactivated.
|
||||
*
|
||||
|
|
|
|||
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
|
|
@ -34,7 +34,8 @@ The plugin is deliberately small and strict:
|
|||
* Optional **tenant pinning**: when a tenant GUID is configured, tokens from any other tenant are rejected.
|
||||
* **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.
|
||||
* **Button-only mode** blocks password sign-in server-side, not just visually – everywhere, XML-RPC and other plugins' login handlers included (application passwords and WP-CLI keep working, API requests never get a login cookie). Right and wrong passwords get the same answer. The fallback cookie carries its issue time and expires on the server.
|
||||
* Administrator accounts are only linked (by sign-in or sync) through a matching user principal name of a member account, never through the freely settable e-mail attribute; one Microsoft identity can only be bound to one WordPress account.
|
||||
* **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.
|
||||
|
|
@ -132,11 +133,11 @@ Yes. Forms rendered with `wp_login_form()` get the button automatically. For pag
|
|||
|
||||
= Does it support multisite? =
|
||||
|
||||
Yes. Settings are per site; a user must be a member of the site (or a super admin) to sign in.
|
||||
Yes. Settings are per site but can only be changed by super admins, because they decide which Microsoft identity may sign in as which network-wide WordPress user. A user must be a member of the site (or a super admin) to sign in.
|
||||
|
||||
= What happens on uninstall? =
|
||||
|
||||
The settings, cached data, the sync report and schedule, stored profile photos and the per-user plugin data (Microsoft object ID, deactivation status) are removed. Imported accounts and copied profile fields (`m365_*` user meta) are kept. Deactivated accounts become active again, so delete them first if they must stay locked.
|
||||
The settings, cached data, the sync report and schedule, stored profile photos and the per-user plugin data (Microsoft object ID, deactivation status) are removed. Imported accounts and copied profile fields (`m365_*` user meta) are kept. Deactivated accounts stay without a role, with a random password and without application passwords.
|
||||
|
||||
== Screenshots ==
|
||||
|
||||
|
|
@ -156,6 +157,7 @@ The settings, cached data, the sync report and schedule, stored profile photos a
|
|||
* New: `wp m365-login sync [--dry-run]` WP-CLI command and scheduled sync via WP-Cron.
|
||||
* New: excluded Entra groups – their members can never sign in with Microsoft.
|
||||
* Fix: failed Microsoft sign-ins (e.g. expired secret, group not allowed) ended in a PHP fatal error instead of the error message.
|
||||
* Security: fixes from a full security audit – see docs/security-audit.md (button-only bypasses via XML-RPC/REST, administrator linking, multisite settings restricted to super admins, deactivation hardening, and more).
|
||||
* Fix: generating or removing the certificate in the settings did not keep the change and broke a stored client secret.
|
||||
|
||||
= 1.0.0 =
|
||||
|
|
|
|||
|
|
@ -17,7 +17,18 @@ global $wpdb;
|
|||
function m365_login_uninstall_site() {
|
||||
global $wpdb;
|
||||
|
||||
// Cached Graph app token (may live in a persistent object cache instead of the options table).
|
||||
$settings = get_option( 'm365_login_settings', array() );
|
||||
if ( is_array( $settings ) && ! empty( $settings['client_id'] ) ) {
|
||||
$tenant = ! empty( $settings['tenant_id'] ) ? $settings['tenant_id'] : 'organizations';
|
||||
foreach ( array( 'secret', 'certificate' ) as $method ) {
|
||||
delete_transient( 'm365_login_apptoken_' . md5( $tenant . '|' . $settings['client_id'] . '|' . $method ) );
|
||||
}
|
||||
}
|
||||
|
||||
delete_option( 'm365_login_settings' );
|
||||
delete_option( 'm365_login_sync_lock' );
|
||||
delete_option( 'm365_login_sync_tenant' );
|
||||
delete_option( 'm365_login_sync_report' );
|
||||
wp_clear_scheduled_hook( 'm365_login_sync' );
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue