Compare commits
No commits in common. "feature/user-sync" and "claude/modest-hypatia-2gfg7m" have entirely different histories.
feature/us
...
claude/mod
24 changed files with 1096 additions and 7939 deletions
47
CHANGELOG.md
47
CHANGELOG.md
|
|
@ -4,53 +4,6 @@ All notable changes to this project are documented in this file. The format foll
|
|||
[Keep a Changelog](https://keepachangelog.com/) and the plugin adheres to
|
||||
[Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [1.1.0] – 2026-09-23
|
||||
|
||||
### Added
|
||||
- User sync (new "User sync" tab): imports Microsoft 365 / Entra ID users as WordPress accounts – the whole tenant or the (nested) members of selected groups, guests optional – and links existing accounts by e-mail address.
|
||||
- Default role plus group → role mapping with a Graph-powered group picker; mapped roles either added to or replacing the default role (first match wins, reorderable). Roles of accounts that existed before the sync are only managed on request.
|
||||
- Selectable profile attributes (display name, first/last name, job title, department, company, office, employee ID, phones, address, language) and the profile photo, which replaces the Gravatar.
|
||||
- Profile photos follow Microsoft 365 on every run: versions are compared via Graph `$batch` (20 users per request), changed photos are downloaded again (old file deleted, new URL), photos deleted in Microsoft 365 are deleted in WordPress. Errors never delete a photo.
|
||||
- Deselected profile fields (`m365_*`) and photos are removed from the profiles on the next run; fields cleared in Microsoft 365 are cleared in WordPress; a user's photo is deleted together with the user.
|
||||
- Deactivation or deletion of WordPress accounts whose Microsoft 365 account was disabled, deleted or removed from the sync groups; automatic reactivation. Deactivated accounts cannot sign in at all (Microsoft, password, application passwords) and lose all sessions.
|
||||
- Safeguards: dry run, safety stop above 20 % deprovisioning (at least 5 accounts), abort on any Graph error, deletion only on a 404 for the object ID, protected pre-existing administrators and own account, content reassignment required for deletion, run lock.
|
||||
- Scheduled sync via WP-Cron (hourly, twice daily, daily), `wp m365-login sync [--dry-run]`, report of the last run in the settings.
|
||||
- "Microsoft 365" column and deactivate/reactivate row actions on the users screen; read-only Microsoft 365 section on the profile screen.
|
||||
- Graph client: paging, retry on throttling (429/503/504), user, group member and photo endpoints.
|
||||
- Filters and actions for the sync (`m365_login_sync_*`, `m365_login_user_disabled`, `m365_login_user_enabled`).
|
||||
|
||||
- Excluded Entra groups (Security tab): members can never sign in with Microsoft, even when they are in an allowed group. Checked via the `groups` claim and always via Microsoft Graph `checkMemberGroups` (a filtered claim cannot prove non-membership); fails closed.
|
||||
|
||||
### Changed
|
||||
- The group picker is reusable (security groups, sync groups, role mapping).
|
||||
|
||||
- "Link Microsoft account" on the profile screen (the signed-in user binds their own Microsoft account) and an administrator-assigned Microsoft account (UPN) per user – for administrators whose user principal name differs from their e-mail address. Sign-in finds bound accounts by object ID first, then by assigned UPN, then by e-mail.
|
||||
- Privacy exporter and eraser for the data the plugin copies.
|
||||
|
||||
### Changed
|
||||
- No frame around the login box (form and Microsoft block) on wp-login.php.
|
||||
|
||||
### Fixed
|
||||
- Button-only mode: the "Lost your password?" link is removed server-side, the lost-password and reset screens redirect to the login page and password resets are refused (unless the fallback link is active); the login stylesheet is also loaded while the connection is broken, so the password form stays hidden.
|
||||
- Accounts whose WordPress e-mail is the user principal name (while the Microsoft mail differs) were not found at sign-in and got a duplicate account from the sync; sign-in and sync now try the mail address and the UPN.
|
||||
|
||||
### Security
|
||||
Third audit (details: docs/security-audit.md, section 7): XML-RPC `system.multicall` bypass of button-only mode closed; privileged-account rules extended (bind_oid off, multisite-wide capabilities, code/HTML capabilities, deactivated administrators); cookie protection on WordPress 6.0/6.1; atomic and refreshed run lock with crash report; per-account tenant for deprovisioning; demotion safety stop; safety stop based on accounts linked before the run; photos re-encoded and removed on deactivation; one-time hardening of accounts deactivated by 1.0.
|
||||
|
||||
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).
|
||||
|
||||
## [1.0.0] – 2026-09-22
|
||||
|
||||
### Added
|
||||
|
|
|
|||
211
README.md
211
README.md
|
|
@ -31,8 +31,6 @@
|
|||
- [Gruppen-Beschränkung](#gruppen-beschränkung)
|
||||
- [Nur-Button-Modus & Fallback](#nur-button-modus--fallback)
|
||||
- [Eigene Login-Seite](#eigene-login-seite)
|
||||
- [Benutzer-Sync](#benutzer-sync)
|
||||
- [Administrator-Konten verknüpfen](#administrator-konten-verknüpfen)
|
||||
- [Sicherheitskonzept](#sicherheitskonzept)
|
||||
- [Shortcode & Hooks](#shortcode--hooks)
|
||||
- [Fehlerbehebung](#fehlerbehebung)
|
||||
|
|
@ -48,10 +46,9 @@
|
|||
| | |
|
||||
| --- | --- |
|
||||
| 🔑 **Login per Microsoft** | Ein Klick auf der Anmeldeseite, Anmeldung bei Microsoft, zurück in WordPress – fertig. |
|
||||
| 📧 **Zuordnung über die E-Mail-Adresse** | Der Login legt **keine Benutzer an**. Nur wer schon ein WordPress-Konto mit derselben E-Mail hat, kommt rein. |
|
||||
| 🔄 **Benutzer-Sync (optional)** | Importiert Microsoft-365-Benutzer als WordPress-Konten – mit Standardrolle, zusätzlichen Rollen per Gruppen-Zuordnung, wählbaren Profilfeldern und Profilbild. In Microsoft 365 deaktivierte oder gelöschte Konten werden in WordPress deaktiviert oder gelöscht. |
|
||||
| 📧 **Zuordnung über die E-Mail-Adresse** | Es werden **keine Benutzer angelegt**. Nur wer schon ein WordPress-Konto mit derselben E-Mail hat, kommt rein. |
|
||||
| 🎨 **Gestaltbarer Button** | Text, Icon (Microsoft-Logo oder eigenes Bild), Farben, Hover-Farbe, Rahmen, Eckenradius, Position – mit Live-Vorschau und Presets. |
|
||||
| 👥 **Entra-Gruppen** | Optional nur Mitglieder ausgewählter Gruppen zulassen und/oder Mitglieder bestimmter Gruppen ausschließen. Gruppen werden direkt im Backend gesucht und ausgewählt. |
|
||||
| 👥 **Entra-Gruppen** | Optional nur Mitglieder ausgewählter Gruppen zulassen. Gruppen werden direkt im Backend gesucht und ausgewählt. |
|
||||
| 🚪 **Nur-Button-Modus** | Passwortfelder ausblenden und Passwort-Logins sperren – mit geheimem Fallback-Link als Notausgang. |
|
||||
| 🔏 **Secret oder Zertifikat** | Wahlweise Client Secret oder zertifikatsbasierte Authentifizierung (RFC 7523). Zertifikat mit einem Klick im Backend erzeugen, nur der öffentliche Teil geht zu Microsoft. |
|
||||
| 🛡️ **Sicher by default** | OpenID Connect + PKCE, Signaturprüfung, Tenant-Pinning, Konto-Bindung, verschlüsseltes Secret, [Security-Audit](docs/security-audit.md). |
|
||||
|
|
@ -173,19 +170,7 @@ Benutzer liefert Microsoft keinen `groups`-Claim mehr („Overage“); dann grei
|
|||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Zusätzlich für den Benutzer-Sync</strong></summary>
|
||||
|
||||
| Berechtigung (Anwendung, mit Administratorzustimmung) | Wofür |
|
||||
| --- | --- |
|
||||
| `User.Read.All` | Benutzer, Kontostatus, Profilfelder und Profilbilder lesen |
|
||||
| `GroupMember.Read.All` | Nur nötig, wenn Sync-Gruppen oder Rollen-Zuordnungen verwendet werden |
|
||||
|
||||
Außerdem muss im Tab *Verbindung* die **Tenant-GUID** eingetragen sein (nicht `organizations`/`common`).
|
||||
|
||||
</details>
|
||||
|
||||
Wichtig: Jeder Benutzer, der sich per Microsoft anmelden soll, braucht in WordPress **dieselbe E-Mail-Adresse** wie in Microsoft 365 – von Hand angelegt oder vom [Benutzer-Sync](#benutzer-sync) importiert.
|
||||
Wichtig: Jeder Benutzer, der sich per Microsoft anmelden soll, braucht in WordPress **dieselbe E-Mail-Adresse** wie in Microsoft 365.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -224,7 +209,7 @@ Alles wird live in der Vorschau angezeigt, bevor du speicherst.
|
|||
| Option | Standard | Beschreibung |
|
||||
| --- | --- | --- |
|
||||
| Konto an Microsoft-Objekt-ID binden | an | Beim ersten Login wird die `oid` gespeichert; danach muss sie übereinstimmen. Schützt vor Übernahme, wenn eine E-Mail-Adresse in Microsoft neu vergeben wird. |
|
||||
| UPN-Fallback | an | Neben dem `email`-Claim wird auch der User Principal Name gesucht – wichtig, wenn WordPress-Konten den UPN statt der Mailadresse tragen. Der Abgleich läuft in der Reihenfolge: gebundene Objekt-ID → zugewiesener UPN → Mailadresse → UPN. |
|
||||
| UPN-Fallback | an | Fehlt der `email`-Claim, wird der User Principal Name verwendet, sofern er eine gültige E-Mail-Adresse ist. |
|
||||
| Angemeldet bleiben | aus | 14-Tage-Session statt Browser-Session. |
|
||||
| Erlaubte E-Mail-Domains | leer | Kommagetrennte Liste, z. B. `contoso.com, contoso.de`. |
|
||||
|
||||
|
|
@ -244,32 +229,14 @@ Prüfreihenfolge beim Login:
|
|||
|
||||
Leere Liste = keine Beschränkung.
|
||||
|
||||
**Ausgeschlossene Entra-Gruppen** (gleicher Tab, darunter): Mitglieder dieser Gruppen können sich **nie** per Microsoft
|
||||
anmelden – auch wenn sie in einer erlaubten Gruppe sind (Ausschluss hat Vorrang, verschachtelte Mitgliedschaften zählen).
|
||||
|
||||
1. Steht eine ausgeschlossene Gruppe im `groups`-Claim → sofort abgelehnt.
|
||||
2. Sonst wird **immer** Microsoft Graph gefragt (`checkMemberGroups`, Berechtigung `User.Read.All`), denn ein `groups`-Claim
|
||||
kann in der App-Registrierung gefiltert sein und beweist nicht, dass jemand *kein* Mitglied ist.
|
||||
3. Schlägt die Graph-Prüfung fehl → Anmeldung abgelehnt (fail closed).
|
||||
|
||||
Die Passwort-Anmeldung betrifft das nicht; wer auch die sperren will, kombiniert es mit dem Nur-Button-Modus.
|
||||
|
||||
### Nur-Button-Modus & Fallback
|
||||
|
||||
Im Tab *Sicherheit* → **Button-only mode**:
|
||||
|
||||
- Blendet Benutzername/Passwort-Felder aus. Der „Passwort vergessen?“-Link wird serverseitig entfernt, die
|
||||
Passwort-vergessen-Seite leitet zur Anmeldung um, und Passwort-Resets (auch „Passwort zurücksetzen“ in der
|
||||
Benutzerliste) sind gesperrt – mit aktivem Fallback-Link funktioniert beides wie gewohnt.
|
||||
- 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).
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
**Fallback (Notausgang):** Beim Speichern erzeugt das Plugin einen geheimen Schlüssel und zeigt den Fallback-Link an:
|
||||
|
||||
|
|
@ -305,104 +272,6 @@ Das Plugin funktioniert auch, wenn die Anmeldung nicht über `wp-login.php` läu
|
|||
|
||||
Plugins, die `wp-login.php` umbenennen (z. B. WPS Hide Login), sind kompatibel, weil das Plugin durchgehend `wp_login_url()` verwendet.
|
||||
|
||||
### Benutzer-Sync
|
||||
|
||||
> **Privilegierte Konten** (Administratoren, Redakteure mit `unfiltered_html`, alle mit Rechten an Benutzern, Plugins
|
||||
> oder Themes – auf irgendeiner Site des Netzwerks – sowie deaktivierte Konten, die solche Rollen zurückbekämen) werden
|
||||
> nie über das frei setzbare `mail`-Attribut verknüpft. Siehe [Administrator-Konten verknüpfen](#administrator-konten-verknüpfen).
|
||||
> 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.
|
||||
|
||||
**Welche Benutzer?** Ohne Auswahl alle Mitglieder des Tenants; optional nur Mitglieder bestimmter Gruppen
|
||||
(verschachtelte Mitgliedschaften zählen). Gäste (B2B) nur auf Wunsch. Die Domain-Allowlist aus dem Tab *Sicherheit* gilt auch hier.
|
||||
|
||||
**Was passiert pro Benutzer?**
|
||||
|
||||
| Situation | Ergebnis |
|
||||
| --- | --- |
|
||||
| Kein WordPress-Konto vorhanden | Konto wird angelegt: Benutzername aus der E-Mail, Zufallspasswort, **keine E-Mail an den Benutzer**, Standardrolle + zugeordnete Rollen. Die Anmeldung läuft über den Microsoft-Button. |
|
||||
| Konto mit derselben E-Mail (Mailadresse **oder** UPN) existiert schon | Wird mit der Microsoft-Objekt-ID verknüpft, Profilfelder werden aktualisiert. Rollen bleiben unangetastet, außer *„Auch die Rollen von Konten verwalten, die schon vor dem Sync existierten“* ist aktiv. |
|
||||
| Bereits verknüpft | E-Mail-Adresse, Profilfelder, Profilbild und (bei importierten Konten) Rollen werden aktualisiert. |
|
||||
| In Microsoft 365 **deaktiviert** | Wahlweise nichts tun, WordPress-Konto **deaktivieren** oder **löschen**. |
|
||||
| In Microsoft 365 **gelöscht** | Wahlweise nichts tun, deaktivieren oder löschen. |
|
||||
| Nicht mehr in den Sync-Gruppen | Wahlweise nichts tun, deaktivieren oder löschen. |
|
||||
| Wieder aktiv in Microsoft 365 | Vom Sync deaktivierte Konten werden automatisch reaktiviert (von Hand deaktivierte nicht). |
|
||||
|
||||
**Rollen.** Jeder importierte Benutzer bekommt die *Standardrolle*. Darunter lassen sich Microsoft-365-Gruppen per Suche
|
||||
auswählen und je einer WordPress-Rolle zuordnen (z. B. „Redaktion“ → Redakteur). Zwei Modi:
|
||||
|
||||
- *Zusätzlich zur Standardrolle* – der Benutzer hat danach mehrere Rollen.
|
||||
- *Anstelle der Standardrolle* – die erste passende Gruppe der Liste gewinnt (Reihenfolge per ↑).
|
||||
|
||||
Verlässt jemand eine Gruppe, wird die Rolle beim nächsten Lauf entfernt. Die Rollen importierter Konten verwaltet der Sync
|
||||
vollständig – manuelle Änderungen werden überschrieben.
|
||||
|
||||
**Profilfelder.** Frei wählbar: Anzeigename, Vor- und Nachname, Profilbild, Position, Abteilung, Firma, Büro, Personalnummer,
|
||||
Telefon (geschäftlich/mobil), Adresse, Sprache. Namen landen in den normalen WordPress-Feldern, alles andere in User-Meta mit
|
||||
dem Präfix `m365_` (z. B. `m365_department`) und wird auf der Profilseite angezeigt. Das **Profilbild** wird nach
|
||||
`wp-content/uploads/m365-login-avatars/` geladen (Dateiname mit gesalzenem Hash statt Objekt-ID) und ersetzt überall den
|
||||
Gravatar. Achtung: Avatare sind öffentlich sichtbar, wo WordPress sie anzeigt.
|
||||
|
||||
Microsoft 365 hat immer Vorrang, bei jedem Lauf:
|
||||
|
||||
| In Microsoft 365 … | … in WordPress |
|
||||
| --- | --- |
|
||||
| Feld geändert | Feld wird überschrieben. |
|
||||
| Feld geleert | Feld wird geleert (`m365_*`-Meta gelöscht; ein leerer Anzeigename wird nicht übernommen). |
|
||||
| Profilbild geändert | Neues Bild wird geladen, das alte gelöscht; die Avatar-URL ändert sich, damit Browser nicht das alte Bild zeigen. |
|
||||
| Profilbild gelöscht | Bild wird gelöscht, der Avatar fällt auf Gravatar zurück. |
|
||||
|
||||
Die Bildversionen werden per Graph-`$batch` geprüft (20 Benutzer pro Anfrage); heruntergeladen werden nur geänderte Bilder,
|
||||
höchstens 500 pro Lauf, der Rest folgt im nächsten (Filter `m365_login_sync_photo_limit`, Prüfintervall per
|
||||
`m365_login_sync_photo_interval`). Bei einem Fehler von Microsoft bleibt das vorhandene Bild erhalten – gelöscht wird nur,
|
||||
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** 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.
|
||||
|
||||
**Löschen** braucht einen Benutzer, der die Beiträge übernimmt. Ohne Auswahl wird stattdessen deaktiviert – es gehen nie Inhalte verloren.
|
||||
|
||||
**Schutzmechanismen**
|
||||
|
||||
- **Testlauf:** zeigt vollständig, was angelegt, geändert, deaktiviert oder gelöscht würde – ohne etwas zu ändern.
|
||||
- **Sicherheitsstopp:** Würde ein Lauf mehr als 20 % der verknüpften Konten (mindestens 5) deaktivieren oder löschen,
|
||||
passiert gar nichts (Filter `m365_login_sync_deprovision_limit`).
|
||||
- **Fehler = Abbruch:** Schlägt eine Graph-Anfrage fehl, bricht der Lauf ab, bevor irgendein Konto deaktiviert wird.
|
||||
„Gelöscht“ gilt ein Konto nur, wenn Graph für genau diese Objekt-ID `404` liefert.
|
||||
- **Geschützte Konten:** Administratoren, die schon vor dem Sync existierten, und das eigene Konto werden nie
|
||||
deaktiviert, gelöscht oder umgestuft (Filter `m365_login_sync_protect_user`).
|
||||
- **Sperre gegen Parallelläufe**, Protokoll der letzten Ausführung im Backend.
|
||||
|
||||
**WP-CLI** – empfehlenswert für große Verzeichnisse oder exakte Zeiten per System-Cron:
|
||||
|
||||
```bash
|
||||
wp m365-login sync --dry-run # Testlauf
|
||||
wp m365-login sync # echter Lauf
|
||||
```
|
||||
|
||||
### Administrator-Konten verknüpfen
|
||||
|
||||
Das `mail`-Attribut in Entra ID kann jeder Benutzer- oder Exchange-Administrator des Tenants frei setzen – wer es auf die
|
||||
Adresse eines WordPress-Admins setzt, dürfte sonst dessen Konto übernehmen. Privilegierte Konten werden deshalb nur auf
|
||||
einem dieser Wege mit einem Microsoft-Konto verknüpft (per Anmeldung oder Sync):
|
||||
|
||||
| Weg | Wann sinnvoll |
|
||||
| --- | --- |
|
||||
| **Selbst verknüpfen:** *Profil → Microsoft 365 → „Mit Microsoft-Konto verknüpfen“*. Die Person ist in WordPress angemeldet (beweist das WordPress-Konto) und meldet sich einmal bei Microsoft an (beweist das Microsoft-Konto). | Immer – auch wenn UPN und Mailadresse völlig verschieden sind. Im Nur-Button-Modus vorher über den Fallback-Link mit Passwort anmelden. |
|
||||
| **Zuweisen:** Ein Administrator trägt beim Bearbeiten des Benutzers unter *Microsoft 365* den **UPN** ein (*Zugewiesenes Microsoft-Konto*). Anmeldung und Sync verknüpfen genau dieses Konto. | Mehrere Admins einrichten, ohne dass jeder selbst klicken muss. |
|
||||
| **Automatisch:** UPN eines Mitglieds (kein Gast) = WordPress-E-Mail. | Wenn UPN und Mailadresse bei euch gleich sind. |
|
||||
|
||||
Nach der Verknüpfung findet die Anmeldung das Konto über die unveränderliche Objekt-ID – E-Mail-Adresse oder UPN dürfen sich
|
||||
danach ändern. Eine bestehende Verknüpfung kann nur ein Administrator aufheben (*Verknüpfung mit dem Microsoft-Konto
|
||||
aufheben* im Profil); eine Person kann ihr Konto nicht selbst auf ein anderes Microsoft-Konto umhängen.
|
||||
|
||||
---
|
||||
|
||||
## Sicherheitskonzept
|
||||
|
|
@ -415,13 +284,11 @@ aufheben* im Profil); eine Person kann ihr Konto nicht selbst auf ein anderes Mi
|
|||
| Gefälschte Tokens | Signaturprüfung gegen Microsofts JWKS (`RS256` only; `alg=none`/HMAC werden abgelehnt), Schlüssel-Rollover wird automatisch nachgeladen. `iss`, `aud`, `tid`, `exp`, `nbf`, `iat` werden geprüft. |
|
||||
| Fremde Tenants | Bei konfigurierter Tenant-GUID **Tenant-Pinning**; sonst Issuer-Konsistenz mit `tid`. |
|
||||
| Kontoübernahme per E-Mail-Recycling | **Bindung an die Objekt-ID** (`oid`) beim ersten Login. |
|
||||
| Unbefugte Konten | Der Login legt keine Konten an; optionale Domain-Allowlist, optionale Gruppen-Beschränkung (fail closed). Konten entstehen nur durch den explizit gestarteten bzw. aktivierten Benutzer-Sync. |
|
||||
| Ausgeschiedene Mitarbeitende | Benutzer-Sync deaktiviert oder löscht Konten, die in Microsoft 365 deaktiviert/gelöscht wurden; deaktivierte Konten verlieren sofort alle Sessions und jeden Anmeldeweg. |
|
||||
| Massen-Deprovisionierung durch Fehlkonfiguration | Testlauf, Sicherheitsstopp (> 20 % / min. 5), Abbruch bei jedem Graph-Fehler, „gelöscht“ nur bei `404` für die konkrete Objekt-ID, geschützte Administratoren. |
|
||||
| Unbefugte Konten | Kein Provisioning, optionale Domain-Allowlist, optionale Gruppen-Beschränkung (fail closed). |
|
||||
| Secret-Diebstahl aus der Datenbank | AES-256-GCM, Schlüssel per HKDF aus `AUTH_KEY`/`SECURE_AUTH_KEY`; ohne `wp-config.php` ist der Datensatz wertlos. Gilt für Client Secret und privaten Zertifikatsschlüssel. |
|
||||
| Secret-Abfluss im Transport | Zertifikatsmodus: es wird nie ein Geheimnis übertragen, nur eine 5 Minuten gültige, signierte Client Assertion (RFC 7523). |
|
||||
| Kontoübernahme im Multi-Tenant-Modus | `email`-Claim fremder Tenants wird ignoriert (nur UPN mit verifizierter Domain oder `xms_edov`). |
|
||||
| Flooding der State-Tabelle | Max. 300 Login-Starts pro IP und 10 Minuten; Proxy-Header per `M365_LOGIN_CLIENT_IP_HEADER` – am besten ein einwertiger Header wie `HTTP_CF_CONNECTING_IP` oder `HTTP_X_REAL_IP` (bei `X-Forwarded-For` zählt der rechte, vom Proxy geschriebene Eintrag). |
|
||||
| Flooding der State-Tabelle | Max. 30 Login-Starts pro IP und 10 Minuten; Proxy-Header per `M365_LOGIN_CLIENT_IP_HEADER`. |
|
||||
| Offene Redirects | `redirect_to` läuft durch `wp_validate_redirect`, alle Redirects über `wp_safe_redirect`. |
|
||||
| Fehler-Reflektion | Fehlermeldungen sind Codes → feste, übersetzte Texte; Details nur ins Log (`WP_DEBUG_LOG`). |
|
||||
| Rate Limiting Fallback-Key | 10 Fehlversuche pro IP / 15 Min. |
|
||||
|
|
@ -479,38 +346,6 @@ add_filter( 'm365_login_block_password_login', function ( $block, WP_User $user
|
|||
add_filter( 'm365_login_redirect_uri', fn( $uri ) => 'https://www.example.com/m365-login/callback' );
|
||||
```
|
||||
|
||||
**Benutzer-Sync**
|
||||
|
||||
```php
|
||||
// Weiteres Graph-Attribut anbieten (landet in User-Meta "m365_cost_center")
|
||||
add_filter( 'm365_login_sync_attributes', function ( $attributes ) {
|
||||
$attributes['costCenter'] = array( 'label' => 'Kostenstelle', 'target' => 'm365_cost_center' );
|
||||
return $attributes;
|
||||
} );
|
||||
|
||||
// Rollen pro Person anpassen (erste Rolle = Hauptrolle)
|
||||
add_filter( 'm365_login_sync_roles', function ( array $roles, $oid ) {
|
||||
return $roles;
|
||||
}, 10, 2 );
|
||||
|
||||
// Daten für neu angelegte Konten (Argumente für wp_insert_user)
|
||||
add_filter( 'm365_login_sync_new_user_data', function ( array $data, array $person ) {
|
||||
$data['user_login'] = strtolower( $person['userPrincipalName'] );
|
||||
return $data;
|
||||
}, 10, 2 );
|
||||
|
||||
// Weitere Konten vom Sync ausnehmen
|
||||
add_filter( 'm365_login_sync_protect_user', function ( $protected, WP_User $user ) {
|
||||
return $protected || in_array( 'shop_manager', $user->roles, true );
|
||||
}, 10, 2 );
|
||||
|
||||
// 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, m365_login_is_privileged_user
|
||||
// Actions: m365_login_sync_user_created, m365_login_sync_finished, m365_login_user_disabled, m365_login_user_enabled
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fehlerbehebung
|
||||
|
|
@ -525,17 +360,7 @@ add_filter( 'm365_login_sync_deprovision_limit', fn( $limit, $linked ) => max( 1
|
|||
| *No WordPress account exists for your Microsoft e-mail address.* | E-Mail in WordPress stimmt nicht mit Microsoft überein. |
|
||||
| *This WordPress account is linked to a different Microsoft account.* | Objekt-ID weicht ab. Wenn gewollt (neues Microsoft-Konto): User-Meta `_m365_login_oid` beim Benutzer löschen. |
|
||||
| *… not a member of a group that is allowed …* | Benutzer ist in keiner der ausgewählten Gruppen. |
|
||||
| *Your group membership could not be verified.* | Graph nicht erreichbar oder Berechtigung fehlt (`User.Read.All`) – oder `groups`-Claim aktivieren. Bei ausgeschlossenen Gruppen ist Graph immer nötig. |
|
||||
| *… member of a group that is not allowed to sign in here.* | Benutzer ist Mitglied einer ausgeschlossenen Gruppe (auch verschachtelt). |
|
||||
| *This account has been deactivated.* | Das Konto wurde vom Benutzer-Sync oder von Hand deaktiviert. *Benutzer → Zeilenaktion „Reaktivieren“* – ist die Person in Microsoft 365 noch deaktiviert, deaktiviert der nächste Sync sie wieder. |
|
||||
|
||||
| Meldung im Sync-Protokoll | Ursache & Lösung |
|
||||
| --- | --- |
|
||||
| *Microsoft Graph refused the request …* | Anwendungsberechtigung `User.Read.All` (und bei Gruppen `GroupMember.Read.All`) fehlt oder keine Administratorzustimmung. |
|
||||
| *The user sync needs a pinned tenant ID …* | Im Tab *Verbindung* die Tenant-GUID statt `organizations`/`common` eintragen. |
|
||||
| *Safety stop: … accounts would be deactivated or deleted …* | Sync-Gruppen oder Tenant prüfen, Testlauf ansehen; bei gewollter Massenänderung das Limit per Filter anheben. |
|
||||
| *… is protected … and was not changed.* | Bestehender Administrator oder eigenes Konto – bewusst ausgenommen. |
|
||||
| Zeitüberschreitung beim Klick auf *Jetzt synchronisieren* | Der Lauf geht serverseitig weiter; Seite später neu laden. Für große Verzeichnisse `wp m365-login sync` verwenden. |
|
||||
| *Your group membership could not be verified.* | Graph nicht erreichbar oder Berechtigung fehlt (`User.Read.All`) – oder `groups`-Claim aktivieren. |
|
||||
|
||||
Logging: Mit `WP_DEBUG` und `WP_DEBUG_LOG` schreibt das Plugin Fehlerdetails mit Präfix `[M365 Login]` nach `wp-content/debug.log`. Es werden nie Tokens oder Secrets geloggt.
|
||||
|
||||
|
|
@ -551,8 +376,7 @@ includes/
|
|||
class-m365-login-crypto.php AES-256-GCM für das Client Secret
|
||||
class-m365-login-jwt.php RS256-Verifikation, JWKS → PEM
|
||||
class-m365-login-auth.php OAuth-Flow, Callback, Benutzerzuordnung, Nur-Button-Modus
|
||||
class-m365-login-graph.php Client-Credentials-Token, Paging, Benutzer/Gruppen/Fotos, checkMemberGroups
|
||||
class-m365-login-sync.php Benutzer-Sync, Rollen, Profilfelder/-bilder, Deaktivierung, WP-CLI
|
||||
class-m365-login-graph.php Client-Credentials-Token, Gruppensuche, checkMemberGroups
|
||||
class-m365-login-button.php Ausgabe auf wp-login.php, Shortcode
|
||||
class-m365-login-admin.php Einstellungsseite, AJAX
|
||||
assets/ CSS/JS für Login-Seite und Backend (unminifiziert)
|
||||
|
|
@ -588,7 +412,7 @@ Das Plugin bringt alles mit, was das Review-Team verlangt: `readme.txt` mit *Ext
|
|||
|
||||
<details>
|
||||
<summary><strong>Kann ich Benutzer automatisch anlegen lassen?</strong></summary>
|
||||
Ja, mit dem <a href="#benutzer-sync">Benutzer-Sync</a>: Er importiert alle (oder ausgewählte) Microsoft-365-Benutzer vorab als WordPress-Konten. Der Login selbst legt weiterhin nie Konten an – wer nicht importiert oder von Hand angelegt wurde, kommt nicht rein.
|
||||
Nein, bewusst nicht. Der Admin entscheidet, wer ein Konto hat. Wer Auto-Provisioning braucht, kann es über den Hook <code>m365_login_allow_user</code> nicht nachrüsten – das wäre ein anderes Sicherheitsmodell.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
|
@ -598,19 +422,12 @@ Ja, Tenant auf <code>consumers</code> oder <code>common</code> stellen. Microsof
|
|||
|
||||
<details>
|
||||
<summary><strong>Multisite?</strong></summary>
|
||||
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.
|
||||
Ja. Einstellungen gelten pro Site; 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 bleiben ohne Rolle, mit Zufallspasswort und ohne Application Passwords – der Deaktivierungs-Vermerk selbst wird entfernt.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Benutzer-Sync und Multisite?</strong></summary>
|
||||
Der Sync arbeitet pro Site: Neue Konten werden zur aktuellen Site hinzugefügt, „Löschen“ entfernt das Konto nur aus dieser Site. Die Deaktivierung gilt netzwerkweit, weil sie am Benutzer hängt.
|
||||
Einstellungen, Caches (Transients) und die pro Benutzer gespeicherte Objekt-ID werden entfernt – auch in Multisite.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
|
|
|||
|
|
@ -945,177 +945,3 @@
|
|||
color: #8a2424;
|
||||
border: 1px solid #f0b8bd;
|
||||
}
|
||||
|
||||
/* User sync */
|
||||
.m365-card__subtitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin: 24px 0 4px;
|
||||
}
|
||||
|
||||
.m365-picker {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.m365-group-list--roles {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.m365-group-chip--role {
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.m365-group-chip--role .m365-group-chip__name {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.m365-group-chip--role .m365-group-chip__id {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m365-field .m365-group-chip select {
|
||||
width: auto;
|
||||
min-height: 28px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.m365-group-chip__arrow {
|
||||
color: var(--m365-muted);
|
||||
}
|
||||
|
||||
.m365-group-chip__up {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
color: var(--m365-accent);
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m365-group-chip--role:first-child .m365-group-chip__up {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.m365-attributes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 20px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.m365-attributes code {
|
||||
font-size: 11px;
|
||||
color: var(--m365-muted);
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 782px) {
|
||||
.m365-attributes {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.m365-sync-report-wrap {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.m365-sync-report__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.m365-sync-report__status {
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
background: #e6f4ea;
|
||||
color: #1e6b31;
|
||||
}
|
||||
|
||||
.m365-sync-report__status.is-bad {
|
||||
background: #fcf0f1;
|
||||
color: #8a1f22;
|
||||
}
|
||||
|
||||
.m365-sync-report__dry {
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
background: #f0f6fc;
|
||||
color: #0a4b78;
|
||||
}
|
||||
|
||||
.m365-sync-report__meta {
|
||||
color: var(--m365-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.m365-sync-report__counts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 0 0 10px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.m365-sync-report__count {
|
||||
margin: 0;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--m365-border);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.m365-sync-report__count--disabled strong,
|
||||
.m365-sync-report__count--deleted strong,
|
||||
.m365-sync-report__count--errors strong {
|
||||
color: #b32d2e;
|
||||
}
|
||||
|
||||
.m365-sync-report__details summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m365-sync-log {
|
||||
margin: 8px 0 0;
|
||||
padding: 8px 12px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border: 1px solid var(--m365-border);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.m365-sync-log__item {
|
||||
margin: 0;
|
||||
padding: 3px 0 3px 10px;
|
||||
border-left: 3px solid #c5d9ed;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.m365-sync-log__item.is-warning {
|
||||
border-left-color: #dba617;
|
||||
}
|
||||
|
||||
.m365-sync-log__item.is-error {
|
||||
border-left-color: #b32d2e;
|
||||
color: #8a1f22;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,14 +12,13 @@
|
|||
background: #fff;
|
||||
padding: 0 24px 26px;
|
||||
margin: -1px 0 0;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid #c3c4c7;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
/* No frame around the login box: neither around the form nor around the Microsoft block. */
|
||||
body.m365-login-page #loginform,
|
||||
body.m365-login-attached #loginform {
|
||||
border: 0;
|
||||
border-bottom: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
|
|
@ -27,8 +26,8 @@ body.m365-login-attached #loginform {
|
|||
background: #fff;
|
||||
padding: 24px;
|
||||
margin: 20px 0 0;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid #c3c4c7;
|
||||
}
|
||||
|
||||
.m365-login--above .m365-login__divider {
|
||||
|
|
@ -124,18 +123,16 @@ body.m365-login-attached #loginform {
|
|||
|
||||
/* Button-only mode: the password form is hidden until the fallback key is used. */
|
||||
body.m365-button-only #loginform,
|
||||
body.m365-button-only #nav,
|
||||
body.m365-button-only a[href*="action=lostpassword"],
|
||||
body.m365-button-only .wp-login-lost-password {
|
||||
display: none !important;
|
||||
body.m365-button-only #nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.m365-button-only .m365-login--below,
|
||||
body.m365-button-only .m365-login--above {
|
||||
margin-top: 20px;
|
||||
padding: 26px 24px;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
border: 1px solid #c3c4c7;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* Custom login pages: wp_login_form() injection, shortcode and template function. */
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
'use strict';
|
||||
|
||||
var cfg = window.m365LoginAdmin || {};
|
||||
cfg.nonces = cfg.nonces || {};
|
||||
var i18n = cfg.i18n || {};
|
||||
|
||||
$( function () {
|
||||
|
|
@ -167,7 +166,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.nonces.cert, op: 'generate' } ).done( function ( res ) {
|
||||
$.post( cfg.ajaxUrl, { action: cfg.certAction, nonce: cfg.nonce, op: 'generate' } ).done( function ( res ) {
|
||||
if ( res && res.success ) {
|
||||
$out.addClass( 'is-success' ).text( res.data.message );
|
||||
window.setTimeout( function () { window.location.reload(); }, 800 );
|
||||
|
|
@ -222,165 +221,101 @@
|
|||
}
|
||||
} );
|
||||
|
||||
/* ---------------- Entra group pickers ---------------- */
|
||||
/* ---------------- Entra group picker ---------------- */
|
||||
var $groupList = $( '#m365-group-list' );
|
||||
var $groupResults = $( '#m365-group-results' );
|
||||
var optionName = ( $( '#m365-tenant' ).attr( 'name' ) || '' ).replace( /\[tenant_id\]$/, '' );
|
||||
var guidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
function escapeHtml( str ) {
|
||||
return $( '<div>' ).text( str || '' ).html();
|
||||
}
|
||||
|
||||
$( '.m365-picker' ).each( function () {
|
||||
var $picker = $( this );
|
||||
var field = $picker.data( 'field' );
|
||||
var mode = $picker.data( 'mode' );
|
||||
var $list = $picker.find( '.m365-picker__list' );
|
||||
var $results = $picker.find( '.m365-picker__results' );
|
||||
var $query = $picker.find( '.m365-picker__query' );
|
||||
var base = optionName + '[' + field + ']';
|
||||
function refreshGroupList() {
|
||||
$groupList.toggleClass( 'is-empty', 0 === $groupList.children( 'li' ).length );
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
$list.toggleClass( 'is-empty', 0 === $list.children( 'li' ).length );
|
||||
function addGroup( id, name ) {
|
||||
id = ( id || '' ).toLowerCase();
|
||||
if ( ! /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test( id ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
function has( id ) {
|
||||
return $list.find( 'li[data-id="' + id + '"]' ).length > 0;
|
||||
if ( $groupList.find( 'li[data-id="' + id + '"]' ).length ) {
|
||||
return;
|
||||
}
|
||||
var $li = $( '<li class="m365-group-chip">' ).attr( 'data-id', id );
|
||||
$li.append( $( '<span class="m365-group-chip__name">' ).text( name || id ) );
|
||||
$li.append( $( '<code class="m365-group-chip__id">' ).text( id ) );
|
||||
$li.append( $( '<input type="hidden">' ).attr( 'name', optionName + '[allowed_groups][' + id + ']' ).val( name || id ) );
|
||||
$li.append( $( '<button type="button" class="m365-group-chip__remove" aria-label="' + escapeHtml( i18n.remove ) + '">×</button>' ) );
|
||||
$groupList.append( $li );
|
||||
refreshGroupList();
|
||||
}
|
||||
|
||||
function add( id, name ) {
|
||||
id = ( id || '' ).toLowerCase();
|
||||
if ( ! guidPattern.test( id ) || has( id ) ) {
|
||||
$groupList.on( 'click', '.m365-group-chip__remove', function () {
|
||||
$( this ).closest( 'li' ).remove();
|
||||
refreshGroupList();
|
||||
} );
|
||||
refreshGroupList();
|
||||
|
||||
function searchGroups() {
|
||||
var query = $.trim( $( '#m365-group-search' ).val() );
|
||||
$groupResults.prop( 'hidden', false ).removeClass( 'is-error' ).html( '<p class="m365-group-results__status">' + escapeHtml( i18n.searching ) + '</p>' );
|
||||
|
||||
$.post( cfg.ajaxUrl, {
|
||||
action: cfg.groupAction,
|
||||
nonce: cfg.nonce,
|
||||
query: query
|
||||
} ).done( function ( res ) {
|
||||
if ( ! res || ! res.success ) {
|
||||
var msg = ( res && res.data && res.data.message ) || i18n.testFailed;
|
||||
$groupResults.addClass( 'is-error' ).html( '<p class="m365-group-results__status">' + escapeHtml( msg ) + '</p>' );
|
||||
// Allow adding a pasted GUID even when Graph is unavailable.
|
||||
if ( /^[0-9a-f-]{36}$/i.test( query ) ) {
|
||||
$groupResults.append( buildResult( { id: query, name: query, type: '', description: '' } ) );
|
||||
}
|
||||
return;
|
||||
}
|
||||
var $li = $( '<li class="m365-group-chip">' ).attr( 'data-id', id );
|
||||
if ( 'roles' === mode ) {
|
||||
$li.addClass( 'm365-group-chip--role' );
|
||||
$li.append( $( '<button type="button" class="m365-group-chip__up">↑</button>' ).attr( 'aria-label', i18n.moveUp || '' ) );
|
||||
var groups = res.data.groups || [];
|
||||
if ( ! groups.length ) {
|
||||
$groupResults.html( '<p class="m365-group-results__status">' + escapeHtml( i18n.noGroups ) + '</p>' );
|
||||
return;
|
||||
}
|
||||
$li.append( $( '<span class="m365-group-chip__name">' ).text( name || id ) );
|
||||
$li.append( $( '<code class="m365-group-chip__id">' ).text( id ) );
|
||||
if ( 'roles' === mode ) {
|
||||
$li.append( '<span class="m365-group-chip__arrow" aria-hidden="true">→</span>' );
|
||||
var $select = $picker.find( '.m365-picker__role-template' ).clone();
|
||||
$select.removeClass( 'm365-picker__role-template' ).prop( 'hidden', false ).prop( 'disabled', false ).attr( 'name', base + '[' + id + '][role]' );
|
||||
$li.append( $select );
|
||||
$li.append( $( '<input type="hidden">' ).attr( 'name', base + '[' + id + '][name]' ).val( name || id ) );
|
||||
} else {
|
||||
$li.append( $( '<input type="hidden">' ).attr( 'name', base + '[' + id + ']' ).val( name || id ) );
|
||||
}
|
||||
$li.append( $( '<button type="button" class="m365-group-chip__remove">×</button>' ).attr( 'aria-label', i18n.remove || '' ) );
|
||||
$list.append( $li );
|
||||
refresh();
|
||||
markDirty();
|
||||
}
|
||||
|
||||
$list.on( 'click', '.m365-group-chip__remove', function () {
|
||||
$( this ).closest( 'li' ).remove();
|
||||
refresh();
|
||||
markDirty();
|
||||
} );
|
||||
$list.on( 'click', '.m365-group-chip__up', function () {
|
||||
var $li = $( this ).closest( 'li' );
|
||||
$li.prev().before( $li );
|
||||
markDirty();
|
||||
} );
|
||||
refresh();
|
||||
|
||||
function buildResult( g ) {
|
||||
var $row = $( '<div class="m365-group-result">' );
|
||||
var $meta = $( '<div class="m365-group-result__meta">' );
|
||||
$meta.append( $( '<strong>' ).text( g.name ) );
|
||||
if ( g.type ) {
|
||||
$meta.append( $( '<span class="m365-group-result__type">' ).text( g.type ) );
|
||||
}
|
||||
$meta.append( $( '<code>' ).text( g.id ) );
|
||||
if ( g.description ) {
|
||||
$meta.append( $( '<em>' ).text( g.description ) );
|
||||
}
|
||||
var $btn = $( '<button type="button" class="button button-small">' ).text( i18n.add ).on( 'click', function () {
|
||||
add( g.id, g.name );
|
||||
$( this ).prop( 'disabled', true );
|
||||
$groupResults.empty();
|
||||
$.each( groups, function ( i, g ) {
|
||||
$groupResults.append( buildResult( g ) );
|
||||
} );
|
||||
if ( has( ( g.id || '' ).toLowerCase() ) ) {
|
||||
$btn.prop( 'disabled', true );
|
||||
}
|
||||
return $row.append( $meta ).append( $btn );
|
||||
}
|
||||
|
||||
function search() {
|
||||
var query = $.trim( $query.val() );
|
||||
$results.prop( 'hidden', false ).removeClass( 'is-error' ).html( '<p class="m365-group-results__status">' + escapeHtml( i18n.searching ) + '</p>' );
|
||||
|
||||
$.post( cfg.ajaxUrl, {
|
||||
action: cfg.groupAction,
|
||||
nonce: cfg.nonces.groups,
|
||||
query: query
|
||||
} ).done( function ( res ) {
|
||||
if ( ! res || ! res.success ) {
|
||||
var msg = ( res && res.data && res.data.message ) || i18n.testFailed;
|
||||
$results.addClass( 'is-error' ).html( '<p class="m365-group-results__status">' + escapeHtml( msg ) + '</p>' );
|
||||
// Allow adding a pasted GUID even when Graph is unavailable.
|
||||
if ( /^[0-9a-f-]{36}$/i.test( query ) ) {
|
||||
$results.append( buildResult( { id: query, name: query, type: '', description: '' } ) );
|
||||
}
|
||||
return;
|
||||
}
|
||||
var groups = res.data.groups || [];
|
||||
if ( ! groups.length ) {
|
||||
$results.html( '<p class="m365-group-results__status">' + escapeHtml( i18n.noGroups ) + '</p>' );
|
||||
return;
|
||||
}
|
||||
$results.empty();
|
||||
$.each( groups, function ( i, g ) {
|
||||
$results.append( buildResult( g ) );
|
||||
} );
|
||||
} ).fail( function () {
|
||||
$results.addClass( 'is-error' ).html( '<p class="m365-group-results__status">' + escapeHtml( i18n.testFailed ) + '</p>' );
|
||||
} );
|
||||
}
|
||||
|
||||
$picker.find( '.m365-picker__search' ).on( 'click', search );
|
||||
$query.on( 'keydown', function ( e ) {
|
||||
if ( 'Enter' === e.key ) {
|
||||
e.preventDefault();
|
||||
search();
|
||||
}
|
||||
} );
|
||||
} );
|
||||
|
||||
/* ---------------- User sync ---------------- */
|
||||
var dirty = false;
|
||||
function markDirty() {
|
||||
dirty = true;
|
||||
}
|
||||
$( '.m365-admin__form' ).on( 'change input', ':input:not(.m365-picker__query)', markDirty );
|
||||
|
||||
$( '.m365-sync-run' ).on( 'click', function () {
|
||||
var $btn = $( this );
|
||||
var op = $btn.data( 'op' );
|
||||
var $report = $( '#m365-sync-report' );
|
||||
|
||||
if ( dirty && ! window.confirm( i18n.unsaved ) ) {
|
||||
return;
|
||||
}
|
||||
if ( 'live' === op && ! window.confirm( i18n.confirmSync ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$( '.m365-sync-run' ).prop( 'disabled', true );
|
||||
$report.html( $( '<p class="m365-inline-result">' ).text( i18n.syncRunning ) );
|
||||
|
||||
$.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 {
|
||||
$report.html( $( '<p class="m365-inline-result is-error">' ).text( ( res && res.data && res.data.message ) || i18n.syncFailed ) );
|
||||
}
|
||||
} ).fail( function () {
|
||||
$report.html( $( '<p class="m365-inline-result is-error">' ).text( i18n.syncFailed ) );
|
||||
} ).always( function () {
|
||||
$( '.m365-sync-run' ).prop( 'disabled', false );
|
||||
$groupResults.addClass( 'is-error' ).html( '<p class="m365-group-results__status">' + escapeHtml( i18n.testFailed ) + '</p>' );
|
||||
} );
|
||||
}
|
||||
|
||||
function buildResult( g ) {
|
||||
var $row = $( '<div class="m365-group-result">' );
|
||||
var $meta = $( '<div class="m365-group-result__meta">' );
|
||||
$meta.append( $( '<strong>' ).text( g.name ) );
|
||||
if ( g.type ) {
|
||||
$meta.append( $( '<span class="m365-group-result__type">' ).text( g.type ) );
|
||||
}
|
||||
$meta.append( $( '<code>' ).text( g.id ) );
|
||||
if ( g.description ) {
|
||||
$meta.append( $( '<em>' ).text( g.description ) );
|
||||
}
|
||||
var $btn = $( '<button type="button" class="button button-small">' ).text( i18n.add ).on( 'click', function () {
|
||||
addGroup( g.id, g.name );
|
||||
$( this ).prop( 'disabled', true );
|
||||
} );
|
||||
if ( $groupList.find( 'li[data-id="' + ( g.id || '' ).toLowerCase() + '"]' ).length ) {
|
||||
$btn.prop( 'disabled', true );
|
||||
}
|
||||
return $row.append( $meta ).append( $btn );
|
||||
}
|
||||
|
||||
$( '#m365-group-search-btn' ).on( 'click', searchGroups );
|
||||
$( '#m365-group-search' ).on( 'keydown', function ( e ) {
|
||||
if ( 'Enter' === e.key ) {
|
||||
e.preventDefault();
|
||||
searchGroups();
|
||||
}
|
||||
} );
|
||||
|
||||
/* ---------------- Fallback key ---------------- */
|
||||
|
|
@ -401,7 +336,7 @@
|
|||
|
||||
$.post( cfg.ajaxUrl, {
|
||||
action: cfg.action,
|
||||
nonce: cfg.nonces.test,
|
||||
nonce: cfg.nonce,
|
||||
tenant: $( '#m365-tenant' ).val()
|
||||
} ).done( function ( res ) {
|
||||
if ( res && res.success ) {
|
||||
|
|
|
|||
|
|
@ -62,12 +62,12 @@ header = '''# Copyright (C) 2026 friloo
|
|||
# This file is distributed under the GPL-2.0-or-later.
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: M365 Login 1.1.0\\n"
|
||||
"Project-Id-Version: M365 Login 1.0.0\\n"
|
||||
"Report-Msgid-Bugs-To: https://github.com/friloo/wp-m365-login/issues\\n"
|
||||
"MIME-Version: 1.0\\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\\n"
|
||||
"Content-Transfer-Encoding: 8bit\\n"
|
||||
"POT-Creation-Date: 2026-09-23T00:00:00+00:00\\n"
|
||||
"POT-Creation-Date: 2026-09-22T00:00:00+00:00\\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\\n"
|
||||
|
|
|
|||
|
|
@ -1,34 +1,26 @@
|
|||
# Security-Audit: M365 Login 1.1.0
|
||||
# Security-Audit: M365 Login 1.0.0
|
||||
|
||||
**Stand:** 24.09.2026 (Erst-Audit 22.09.2026, Zweit-Audit 23.09.2026, Dritt-Audit 24.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,
|
||||
**Stand:** 22.09.2026 · **Umfang:** gesamter Plugin-Code (PHP, JS, CSS), Konfiguration, Deployment-Hinweise ·
|
||||
**Methode:** manuelle Code-Review gegen OAuth 2.0 / OpenID Connect Best Current Practice (RFC 6749, RFC 7636 PKCE,
|
||||
RFC 7523 Client Assertions, OAuth 2.0 Security BCP), OWASP ASVS 4.0 (V2 Authentication, V3 Session, V5 Validation,
|
||||
V6 Cryptography), WordPress Plugin Handbook „Security“ – 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.
|
||||
V6 Cryptography), WordPress Plugin Handbook „Security“ sowie isolierte Tests der sicherheitskritischen Klassen.
|
||||
|
||||
> 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.
|
||||
> Der Audit wurde ohne laufende WordPress-Instanz durchgeführt. Alle Aussagen zum Laufzeitverhalten beruhen auf
|
||||
> Code-Lesung und den isolierten Tests (JWT-Verifikation, Verschlüsselung, Zertifikate, Eingabeverarbeitung,
|
||||
> Nur-Button-Sperre). Ein Penetrationstest gegen eine echte Installation steht aus und wird empfohlen.
|
||||
|
||||
## 1. Zusammenfassung
|
||||
|
||||
| Schweregrad | Erst-Audit | Zweit-Audit | Dritt-Audit | Behoben | Akzeptiert / dokumentiert |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Kritisch | 0 | 1 (nur Multisite) | 0 | 1 | 0 |
|
||||
| Hoch | 1 | 4 | 3 | 8 | 0 |
|
||||
| Mittel | 3 | 9 | 5 | 17 | 0 |
|
||||
| Niedrig | 5 | 11 | 9 | 20 | 5 |
|
||||
| Hinweis | 6 | 11 | 8 | 8 | 17 |
|
||||
| Schweregrad | Gefunden | Behoben | Offen (mit Empfehlung) |
|
||||
| --- | --- | --- | --- |
|
||||
| Hoch | 1 | 1 | 0 |
|
||||
| Mittel | 3 | 3 | 0 |
|
||||
| Niedrig | 5 | 3 | 2 |
|
||||
| Hinweis | 6 | – | 6 |
|
||||
|
||||
Nach drei Audits sind **keine offenen kritischen, hohen oder mittleren Befunde** bekannt. Das Dritt-Audit hat gezielt die
|
||||
Fixes des Zweit-Audits angegriffen und dabei unter anderem eine Umgehung über XML-RPC `system.multicall` gefunden. 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.
|
||||
Der Login-Flow ist nach dem Audit **ohne bekannte kritische oder hohe Schwachstellen**. Der einzige Hoch-Befund
|
||||
(Account-Übernahme im Multi-Tenant-Modus über den unverifizierten `email`-Claim) wurde behoben. Die beiden offenen
|
||||
Niedrig-Befunde betreffen Betriebsumgebung und Konfiguration, nicht den Code.
|
||||
|
||||
## 2. Bedrohungsmodell
|
||||
|
||||
|
|
@ -61,8 +53,7 @@ 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 (im Zweit-Audit als
|
||||
umgehbar erkannt und auf „nur Application Passwords und WP-CLI“ verschärft, siehe Z-3), plus ein
|
||||
interaktive Passwort-Anmeldung; ausgenommen sind XML-RPC, REST (Application Passwords), WP-CLI und Cron, plus ein
|
||||
Opt-out-Filter für vertrauenswürdige Plugins. Tests: `password login blocked without fallback cookie`,
|
||||
`forged fallback cookie rejected`, `REST requests exempt`.
|
||||
|
||||
|
|
@ -121,41 +112,6 @@ Alle Requests laufen über die WordPress-HTTP-API mit Zertifikatsprüfung. Setzt
|
|||
fälschen. Das Plugin erzwingt `sslverify => true` für seine eigenen Requests nicht explizit, weil WordPress-Konventionen
|
||||
den Site-Betreiber entscheiden lassen. Empfehlung: `https_ssl_verify` nie global deaktivieren.
|
||||
|
||||
### N-7 · Benutzer-Sync (1.1.0) — **neu, geprüft**
|
||||
|
||||
Der Sync legt Konten an, ändert Rollen und deaktiviert bzw. löscht Konten. Geprüft und abgesichert:
|
||||
|
||||
- **Auslösung:** nur durch Administratoren (`manage_options` + `create_users`, AJAX-Nonce), per WP-Cron nach expliziter
|
||||
Aktivierung oder per WP-CLI. Der Login selbst legt weiterhin nie Konten an.
|
||||
- **Vertrauensgrenze:** Wie beim Login gilt der gepinnte Tenant als vertrauenswürdig (Sync verlangt eine Tenant-GUID).
|
||||
Bestehende Konten werden über die E-Mail-Adresse verknüpft; ein Konto mit abweichender gespeicherter Objekt-ID wird
|
||||
übersprungen, eine E-Mail-Änderung auf eine bereits vergebene Adresse abgelehnt.
|
||||
- **Fehlkonfiguration / Teilausfälle:** Jede fehlgeschlagene Graph-Anfrage bricht den Lauf vor jeder Deaktivierung ab.
|
||||
„Gelöscht“ nur bei HTTP 404 für die konkrete Objekt-ID. Mehr als 20 % (mind. 5) Deaktivierungen/Löschungen pro Lauf →
|
||||
Sicherheitsstopp ohne Änderungen. Testlauf ohne Schreibzugriffe. Sperre gegen Parallelläufe.
|
||||
- **Rechteausweitung/-entzug:** Rollen werden nur bei importierten Konten (oder auf ausdrücklichen Wunsch) verwaltet.
|
||||
Bestehende Administratoren, Super-Admins und das eigene Konto werden nie umgestuft, deaktiviert oder gelöscht.
|
||||
Rollen-Slugs werden beim Speichern gegen existierende Rollen geprüft.
|
||||
- **Deaktivierung:** blockiert Passwort- und Anwendungspasswort-Logins (`authenticate`, Priorität 100), bestehende Sessions
|
||||
(`determine_current_user`, alle Session-Tokens werden gelöscht) und den Microsoft-Login. Löschen nur mit Übernahme der
|
||||
Inhalte durch einen gültigen anderen Benutzer, sonst Deaktivierung.
|
||||
- **Profilbilder:** Größenlimit 2 MB, Typprüfung per `getimagesizefromstring` (nur JPEG/PNG/GIF), Ablage über `wp_upload_bits`
|
||||
in einem eigenen Unterordner, Dateiname aus gesalzenem Hash (keine Objekt-ID in der URL). Die Bilder sind – wie Gravatare – öffentlich.
|
||||
- **Graph-Aufrufe:** nur `https://graph.microsoft.com/v1.0/`; Paging-Links werden auf diesen Präfix geprüft, IDs sind GUIDs.
|
||||
- **Ausgabe:** Protokoll und Profilfelder werden escaped ausgegeben; Benutzer-Zeilenaktionen mit Nonce und `edit_user`.
|
||||
|
||||
### N-8 · Ausgeschlossene Gruppen und fehlende Log-Methode (1.1.0) — **neu / behoben**
|
||||
|
||||
- **Ausgeschlossene Entra-Gruppen:** Ausschluss vor Erlaubnis; Treffer im `groups`-Claim lehnt sofort ab, ohne Treffer wird
|
||||
immer Graph `checkMemberGroups` (transitiv) gefragt, weil ein gefilterter Claim Nicht-Mitgliedschaft nicht beweist;
|
||||
Graph-Fehler → Ablehnung (fail closed).
|
||||
- **Behoben:** `M365_Login_Auth::log()` fehlte seit der Umstellung auf eigene Login-Seiten; alle Fehlerpfade des Callbacks
|
||||
endeten in einem PHP-Fatal-Error (kein Sicherheitsleck – die Anmeldung scheiterte –, aber keine Fehlermeldung und ein
|
||||
500er). Durch Tests in einer echten Installation gefunden.
|
||||
|
||||
Hinweis für den Betrieb: Personenbezogene Daten (Telefon, Adresse, Foto) nur synchronisieren, wenn sie auf der Website
|
||||
gebraucht werden; die Auswahl ist bewusst Opt-in (Standard: nur Namen).
|
||||
|
||||
## 4. Geprüfte Kontrollen (ohne Befund)
|
||||
|
||||
| Bereich | Kontrolle | Ergebnis |
|
||||
|
|
@ -164,7 +120,7 @@ gebraucht werden; die Auswahl ist bewusst Opt-in (Standard: nur Namen).
|
|||
| State-Bindung | HMAC-Schlüssel in DB, Klartext nur in URL; HttpOnly/SameSite=Lax/Secure-Cookie mit separatem Token, Hash im Datensatz; einmalige Einlösung (Delete vor Prüfung); TTL 10 Min. | ✔ Login-CSRF und Replay ausgeschlossen |
|
||||
| Token-Austausch | Server-zu-Server, Secret/Assertion nie im Browser; `redirect_uri` fest aus `home_url()` | ✔ |
|
||||
| ID-Token | Nur `RS256`; `alg=none`/HMAC abgelehnt; `kid` Pflicht; JWKS über HTTPS, Cache 12 h, Refresh bei unbekanntem `kid`; `iss` gegen `tid` gebildet, `aud`, `tid` (Pinning), `exp`/`nbf`/`iat` mit 120 s Toleranz, `nonce` mit `hash_equals` | ✔ 11 Negativtests |
|
||||
| Benutzerzuordnung | Login ohne Provisioning (Sync separat, siehe N-7); E-Mail lowercase + `is_email`; Domain-Allowlist; Gruppen-Check fail closed; `oid`-Bindung; Multisite-Mitgliedschaft | ✔ |
|
||||
| Benutzerzuordnung | Kein Provisioning; E-Mail lowercase + `is_email`; Domain-Allowlist; Gruppen-Check fail closed; `oid`-Bindung; Multisite-Mitgliedschaft | ✔ |
|
||||
| Session | `wp_set_auth_cookie` nach Erfolg (neues Session-Token, keine Fixation); `login_redirect`-Filter; `wp_safe_redirect` überall | ✔ |
|
||||
| Offene Redirects | `redirect_to` → `wp_validate_redirect`; Custom-Login-URL → `wp_validate_redirect` beim Speichern und beim Lesen | ✔ |
|
||||
| SSRF | Tenant nur GUID oder Whitelist-Wort, `rawurlencode`; Graph-Pfade mit `rawurlencode`; keine benutzerkontrollierten Hosts | ✔ |
|
||||
|
|
@ -187,116 +143,7 @@ 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. 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. Dritt-Audit 1.1.0 (24.09.2026)
|
||||
|
||||
Drei unabhängige Prüfbereiche: (1) Review aller Fixes des Zweit-Audits auf Vollständigkeit, Umgehbarkeit und neue Fehler,
|
||||
(2) frischer Penetrationstest der Anmeldewege, (3) Sync, Admin-Oberfläche, Datenhaltung und Datenschutz. Jeder Befund per
|
||||
Proof of Concept bestätigt (HTTP gegen `xmlrpc.php`/`wp-login.php`, signierte Test-Tokens, simulierte Graph-API, in eine
|
||||
Multisite umgewandelte Testinstanz); jeder Fix mit Regressionstest.
|
||||
|
||||
| ID | Schwere | Befund | Status und Fix |
|
||||
| --- | --- | --- | --- |
|
||||
| D-1 | Hoch | **Nur-Button-Modus über XML-RPC `system.multicall`:** Die Ausnahme für Application Passwords nutzte `did_action()` (anfrageweit). Aufruf 1 mit einem beliebigen eigenen Application Password, Aufruf 2 mit Admin-Name und normalem Passwort → Admin-Zugriff. | ✅ Ausnahme nur für genau den Benutzer, den das Application Password im selben Anmeldedurchlauf authentifiziert hat (Reset bei Priorität 0). Per HTTP nachgetestet. |
|
||||
| D-2 | Hoch | Admin-Schutzregel griff nicht bei ausgeschalteter Objekt-ID-Bindung (`bind_oid`) bzw. bei bereits gespeicherter, aber nicht geprüfter ID. | ✅ Privilegierte Konten gelten nur bei aktiver Bindung und passender ID als verknüpft, sonst gilt immer die Regel. |
|
||||
| D-3 | Hoch (Multisite) | „Privilegiert“ wurde nur auf der aktuellen Site geprüft: Admin von Site B über Site A übernehmbar. | ✅ Rechte auf allen Sites des Benutzers zählen. |
|
||||
| D-4 | Mittel | Liste privilegierter Rechte zu eng (Redakteure mit `unfiltered_html`, Plugin-/Theme-/Benutzerrechte fehlten). | ✅ Erweitert; Filter `m365_login_is_privileged_user`. |
|
||||
| D-5 | Mittel | Deaktivierte Admins (ohne Rolle) galten als nicht privilegiert → verknüpfbar, bei Reaktivierung wieder Admin. | ✅ Gemerkte Rollen deaktivierter Konten zählen mit. |
|
||||
| D-6 | Mittel | Cookie-Sperre (Z-3/Z-6) wirkungslos unter WordPress 6.0/6.1 (Filter-Argumente erst ab 6.2). | ✅ Ohne Benutzer-ID-Argument wird in API-Kontexten immer gesperrt. |
|
||||
| D-7 | Mittel | Sicherheitsstopp durch im selben Lauf angelegte Konten verwässert; Testlauf und echter Lauf entschieden unterschiedlich. | ✅ Quote aus den vor dem Lauf verknüpften Konten. |
|
||||
| D-8 | Mittel | Rollen-Entzug ohne Sicherheitsstopp: eine geleerte Gruppe stufte alle zugeordneten Admins herab. | ✅ Eigener Stopp für den Entzug administrativer Rollen (max. 20 %, nie alle; Filter `m365_login_sync_demotion_limit`). |
|
||||
| D-9 | Niedrig | Lauf-Sperre per `add_option` nicht atomar, lief bei langen Läufen ab. | ✅ `INSERT IGNORE`, Übernahme per bedingtem `UPDATE`, Auffrischung alle 250 Benutzer. |
|
||||
| D-10 | Niedrig | Tenant-Schutz verzögerte nur um einen Lauf und griff beim ersten Sync nicht. | ✅ Tenant wird pro Konto gespeichert; deprovisioniert wird nur im eigenen Tenant; Alt-Verknüpfungen ohne Tenant, die nicht gefunden werden, bleiben unangetastet (Warnung). |
|
||||
| D-11 | Niedrig | Doppeltes Deaktivieren überschrieb gemerkte Rollen; Zeilenaktions-Nonce nicht an den Zustand gebunden. | ✅ Deaktivieren idempotent; Nonce pro Zustand. |
|
||||
| D-12 | Niedrig | Nur-Button-Modus mit kaputter Verbindung zeigte Passwortfelder, die nichts bewirkten. | ✅ Felder ausgeblendet, Hinweis „vorübergehend nicht verfügbar“. |
|
||||
| D-13 | Niedrig | Altdaten aus 1.0: deaktivierte Konten nicht gehärtet, gespeichertes Key+Cert-Bündel. | ✅ Einmalige Migration; `.cer`-Download immer neu exportiert. |
|
||||
| D-14 | Niedrig | Speicher bei sehr großen Tenants; ein Fatal Error hinterließ Sperre und keinen Bericht. | ✅ Laufzeit-Cache wird regelmäßig geleert, nur IDs gehalten; Shutdown-Handler meldet den Abbruch und gibt die Sperre frei. 📄 Für >20 000 Benutzer WP-CLI empfohlen. |
|
||||
| D-15 | Niedrig | Profilbilder nur am Header geprüft (Polyglot, Dekompressionsbombe, EXIF). | ✅ Neu kodiert (240 px JPEG/PNG), max. 4096 px, Download-Limit 2 MB, `index.php` im Ordner, Löschung bei Deaktivierung. |
|
||||
| D-16 | Niedrig | Keine Export-/Lösch-Werkzeuge (DSGVO). | ✅ Exporter und Eraser registriert. |
|
||||
| D-17 | Niedrig | Rechte-Proxy mit mehreren Stufen (CDN → Load Balancer): rechter XFF-Eintrag ist der innere Proxy. | 📄 Einwertige Header wie `HTTP_CF_CONNECTING_IP` verwenden (Doku). |
|
||||
| D-18 | Hinweis | Nicht-privilegiertes Konto, später befördert, bleibt mit seiner Verknüpfung. | 📄 Vertrauensmodell: Verknüpfung prüft beim Verknüpfen. |
|
||||
| D-19 | Hinweis | Graph-App-Token 50 Min. im Klartext-Transient. | 📄 Wie Core-Transients; Datenbank schützen. |
|
||||
| D-20 | Hinweis | Avatar-Auflösung per E-Mail-Adresse. | 📄 Bewusst (Kompatibilität mit `get_avatar( $email )`). |
|
||||
| D-21 | Hinweis | Benutzernamen aus dem lokalen Teil der E-Mail, Anzeigenamen aus Graph. | 📄 Nur kosmetisch. |
|
||||
| D-22 | Hinweis | Admins mit UPN ≠ E-Mail waren gar nicht mehr verknüpfbar. | ✅ Neu: „Mit Microsoft-Konto verknüpfen“ im Profil und zugewiesener UPN pro Benutzer; Anmeldung findet gebundene Konten über die Objekt-ID. |
|
||||
| D-23…25 | Hinweis | Kleinere Robustheitspunkte (Notices bei Array-Eingaben, Autoload der Einstellungen bei Netzwerk-Aktivierung, `fields => array('ID')` durch Abfrage-Cache als String geliefert). | ✅ Behoben. |
|
||||
|
||||
**Neue Funktion aus Sicherheitssicht (D-22):** Die Profil-Verknüpfung verlangt eine gültige WordPress-Session, einen
|
||||
Nonce, denselben Browser (State-Cookie) und dieselbe angemeldete Person beim Callback; eine bestehende Verknüpfung kann
|
||||
nur ein Administrator aufheben, eine bereits anderweitig gebundene Objekt-ID wird abgelehnt. Restrisiko: Wer eine gültige
|
||||
WordPress-Session eines noch nicht verknüpften Kontos stiehlt, kann dieses mit seinem Microsoft-Konto verknüpfen – wie
|
||||
bei jeder Selbstverwaltung eines zweiten Faktors.
|
||||
|
||||
## 8. Nicht im Umfang
|
||||
## 6. Nicht im Umfang
|
||||
|
||||
Sicherheit der Microsoft-Seite (Entra ID, Graph), WordPress-Core, Hosting-Umgebung, andere Plugins/Themes,
|
||||
Schwachstellen in PHP/OpenSSL.
|
||||
|
|
|
|||
|
|
@ -12,18 +12,14 @@ 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 NONCE_GROUPS = 'm365_login_groups';
|
||||
const NONCE_CERT = 'm365_login_cert';
|
||||
const NONCE_SYNC = 'm365_login_sync';
|
||||
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 POST_CERT = 'm365_login_download_cert';
|
||||
const MENU_ICON = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMCAyMCI+PHBhdGggZmlsbD0iYmxhY2siIGQ9Ik0yIDJoNy41djcuNUgyek0xMC41IDJIMTh2Ny41aC03LjV6TTIgMTAuNWg3LjVWMThIMnpNMTAuNSAxMC41SDE4VjE4aC03LjV6Ii8+PC9zdmc+';
|
||||
const NONCE_TEST = 'm365_login_test';
|
||||
|
||||
/**
|
||||
* Settings.
|
||||
|
|
@ -46,13 +42,6 @@ class M365_Login_Admin {
|
|||
*/
|
||||
private $graph;
|
||||
|
||||
/**
|
||||
* User sync.
|
||||
*
|
||||
* @var M365_Login_Sync
|
||||
*/
|
||||
private $sync;
|
||||
|
||||
/**
|
||||
* Screen hook suffix.
|
||||
*
|
||||
|
|
@ -66,13 +55,11 @@ class M365_Login_Admin {
|
|||
* @param M365_Login_Settings $settings Settings.
|
||||
* @param M365_Login_Auth $auth Auth.
|
||||
* @param M365_Login_Graph $graph Graph client.
|
||||
* @param M365_Login_Sync $sync User sync.
|
||||
*/
|
||||
public function __construct( M365_Login_Settings $settings, M365_Login_Auth $auth, M365_Login_Graph $graph, M365_Login_Sync $sync ) {
|
||||
public function __construct( M365_Login_Settings $settings, M365_Login_Auth $auth, M365_Login_Graph $graph ) {
|
||||
$this->settings = $settings;
|
||||
$this->auth = $auth;
|
||||
$this->graph = $graph;
|
||||
$this->sync = $sync;
|
||||
|
||||
add_action( 'admin_menu', array( $this, 'menu' ) );
|
||||
add_action( 'admin_init', array( $this, 'register' ) );
|
||||
|
|
@ -80,24 +67,10 @@ class M365_Login_Admin {
|
|||
add_action( 'wp_ajax_' . self::AJAX_TEST, array( $this, 'ajax_test_connection' ) );
|
||||
add_action( 'wp_ajax_' . self::AJAX_GROUPS, array( $this, 'ajax_search_groups' ) );
|
||||
add_action( 'wp_ajax_' . self::AJAX_CERT, array( $this, 'ajax_certificate' ) );
|
||||
add_action( 'wp_ajax_' . self::AJAX_SYNC, array( $this, 'ajax_sync' ) );
|
||||
add_action( 'admin_post_' . self::POST_CERT, array( $this, 'download_certificate' ) );
|
||||
add_action( 'update_option_' . M365_LOGIN_OPTION, array( $this->graph, 'flush_token' ) );
|
||||
add_action( 'admin_notices', array( $this, 'setup_notice' ) );
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -107,7 +80,7 @@ class M365_Login_Admin {
|
|||
$this->hook = add_menu_page(
|
||||
__( 'M365 Login', 'm365-login' ),
|
||||
__( 'M365 Login', 'm365-login' ),
|
||||
self::capability(),
|
||||
'manage_options',
|
||||
self::PAGE,
|
||||
array( $this, 'render' ),
|
||||
self::MENU_ICON,
|
||||
|
|
@ -119,7 +92,7 @@ class M365_Login_Admin {
|
|||
self::PAGE,
|
||||
$label . ' – ' . __( 'M365 Login', 'm365-login' ),
|
||||
$label,
|
||||
self::capability(),
|
||||
'manage_options',
|
||||
'connection' === $tab ? self::PAGE : 'admin.php?page=' . self::PAGE . '&tab=' . $tab
|
||||
);
|
||||
}
|
||||
|
|
@ -135,7 +108,6 @@ class M365_Login_Admin {
|
|||
'connection' => __( 'Connection', 'm365-login' ),
|
||||
'button' => __( 'Button', 'm365-login' ),
|
||||
'security' => __( 'Security', 'm365-login' ),
|
||||
'sync' => __( 'User sync', 'm365-login' ),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -198,19 +170,7 @@ class M365_Login_Admin {
|
|||
* Nudges administrators to finish the setup.
|
||||
*/
|
||||
public function setup_notice() {
|
||||
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() ) {
|
||||
if ( $this->settings->is_configured() || ! current_user_can( 'manage_options' ) ) {
|
||||
return;
|
||||
}
|
||||
$screen = get_current_screen();
|
||||
|
|
@ -248,38 +208,27 @@ class M365_Login_Admin {
|
|||
'm365LoginAdmin',
|
||||
array(
|
||||
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||
'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 ),
|
||||
),
|
||||
'nonce' => wp_create_nonce( self::NONCE_TEST ),
|
||||
'action' => self::AJAX_TEST,
|
||||
'groupAction' => self::AJAX_GROUPS,
|
||||
'certAction' => self::AJAX_CERT,
|
||||
'syncAction' => self::AJAX_SYNC,
|
||||
'defaultLogo' => M365_Login_Button::microsoft_logo(),
|
||||
'i18n' => array(
|
||||
'chooseIcon' => __( 'Choose button icon', 'm365-login' ),
|
||||
'useIcon' => __( 'Use this icon', 'm365-login' ),
|
||||
'copied' => __( 'Copied!', 'm365-login' ),
|
||||
'copy' => __( 'Copy', 'm365-login' ),
|
||||
'testing' => __( 'Testing…', 'm365-login' ),
|
||||
'testFailed' => __( 'The tenant could not be reached. Check the tenant ID and the server’s outgoing connections.', 'm365-login' ),
|
||||
'noGroups' => __( 'No groups found.', 'm365-login' ),
|
||||
'searching' => __( 'Searching…', 'm365-login' ),
|
||||
'add' => __( 'Add', 'm365-login' ),
|
||||
'remove' => __( 'Remove', 'm365-login' ),
|
||||
'saveFirst' => __( 'Save the connection settings first, then search for groups.', 'm365-login' ),
|
||||
'confirmKey' => __( 'Generate a new fallback key on save? The old link stops working.', 'm365-login' ),
|
||||
'generating' => __( 'Generating a 3072-bit key pair, this takes a moment…', 'm365-login' ),
|
||||
'confirmCert' => __( 'Replace the stored certificate? Sign-in stops working until the new certificate is uploaded to Entra ID.', 'm365-login' ),
|
||||
'chooseIcon' => __( 'Choose button icon', 'm365-login' ),
|
||||
'useIcon' => __( 'Use this icon', 'm365-login' ),
|
||||
'copied' => __( 'Copied!', 'm365-login' ),
|
||||
'copy' => __( 'Copy', 'm365-login' ),
|
||||
'testing' => __( 'Testing…', 'm365-login' ),
|
||||
'testFailed' => __( 'The tenant could not be reached. Check the tenant ID and the server’s outgoing connections.', 'm365-login' ),
|
||||
'noGroups' => __( 'No groups found.', 'm365-login' ),
|
||||
'searching' => __( 'Searching…', 'm365-login' ),
|
||||
'add' => __( 'Add', 'm365-login' ),
|
||||
'remove' => __( 'Remove', 'm365-login' ),
|
||||
'saveFirst' => __( 'Save the connection settings first, then search for groups.', 'm365-login' ),
|
||||
'confirmKey' => __( 'Generate a new fallback key on save? The old link stops working.', 'm365-login' ),
|
||||
'generating' => __( 'Generating a 3072-bit key pair, this takes a moment…', 'm365-login' ),
|
||||
'confirmCert' => __( 'Replace the stored certificate? Sign-in stops working until the new certificate is uploaded to Entra ID.', 'm365-login' ),
|
||||
'confirmCertRemove' => __( 'Remove the stored certificate when saving? Sign-in with the certificate method stops working.', 'm365-login' ),
|
||||
'syncRunning' => __( 'Sync is running, this can take a while for large directories…', 'm365-login' ),
|
||||
'confirmSync' => __( 'Run the sync now with the saved settings? Accounts are created, updated and possibly deactivated or deleted. Tip: run a dry run first.', 'm365-login' ),
|
||||
'syncFailed' => __( 'The request failed or timed out. Reload the page in a few minutes to see the report; for very large directories use "wp m365-login sync" (WP-CLI).', 'm365-login' ),
|
||||
'unsaved' => __( 'You have unsaved changes. The sync uses the saved settings – save first.', 'm365-login' ),
|
||||
'moveUp' => __( 'Move up', 'm365-login' ),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
|
@ -290,7 +239,7 @@ class M365_Login_Admin {
|
|||
*/
|
||||
public function ajax_test_connection() {
|
||||
check_ajax_referer( self::NONCE_TEST, 'nonce' );
|
||||
if ( ! current_user_can( self::capability() ) ) {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'm365-login' ) ), 403 );
|
||||
}
|
||||
|
||||
|
|
@ -328,8 +277,8 @@ class M365_Login_Admin {
|
|||
* AJAX: search Entra groups through Microsoft Graph.
|
||||
*/
|
||||
public function ajax_search_groups() {
|
||||
check_ajax_referer( self::NONCE_GROUPS, 'nonce' );
|
||||
if ( ! current_user_can( self::capability() ) ) {
|
||||
check_ajax_referer( self::NONCE_TEST, 'nonce' );
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'm365-login' ) ), 403 );
|
||||
}
|
||||
if ( ! $this->settings->is_configured() ) {
|
||||
|
|
@ -354,8 +303,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_CERT, 'nonce' );
|
||||
if ( ! current_user_can( self::capability() ) ) {
|
||||
check_ajax_referer( self::NONCE_TEST, 'nonce' );
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'm365-login' ) ), 403 );
|
||||
}
|
||||
$op = isset( $_POST['op'] ) ? sanitize_key( wp_unslash( $_POST['op'] ) ) : '';
|
||||
|
|
@ -383,192 +332,16 @@ 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_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'] ) ) : '';
|
||||
$report = $this->sync->run( 'live' !== $op, 'manual' );
|
||||
wp_send_json_success( array( 'html' => $this->report_markup( $report ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Markup of a sync report.
|
||||
*
|
||||
* @param array|null $report Report.
|
||||
* @return string
|
||||
*/
|
||||
private function report_markup( $report ) {
|
||||
if ( ! is_array( $report ) ) {
|
||||
return '<p class="m365-sync-report__empty">' . esc_html__( 'The sync has not run yet.', 'm365-login' ) . '</p>';
|
||||
}
|
||||
|
||||
$statuses = array(
|
||||
'ok' => __( 'Finished', 'm365-login' ),
|
||||
'failed' => __( 'Failed', 'm365-login' ),
|
||||
'aborted' => __( 'Stopped by the safety limit', 'm365-login' ),
|
||||
'locked' => __( 'Not started', 'm365-login' ),
|
||||
);
|
||||
$triggers = array(
|
||||
'manual' => __( 'started manually', 'm365-login' ),
|
||||
'cron' => __( 'scheduled', 'm365-login' ),
|
||||
'cli' => __( 'WP-CLI', 'm365-login' ),
|
||||
);
|
||||
$labels = array(
|
||||
'created' => $report['dry'] ? __( 'would be created', 'm365-login' ) : __( 'created', 'm365-login' ),
|
||||
'updated' => $report['dry'] ? __( 'would be updated', 'm365-login' ) : __( 'updated', 'm365-login' ),
|
||||
'linked' => $report['dry'] ? __( 'would be linked', 'm365-login' ) : __( 'linked', 'm365-login' ),
|
||||
'unchanged' => __( 'unchanged', 'm365-login' ),
|
||||
'disabled' => $report['dry'] ? __( 'would be deactivated', 'm365-login' ) : __( 'deactivated', 'm365-login' ),
|
||||
'enabled' => $report['dry'] ? __( 'would be reactivated', 'm365-login' ) : __( 'reactivated', 'm365-login' ),
|
||||
'deleted' => $report['dry'] ? __( 'would be deleted', 'm365-login' ) : __( 'deleted', 'm365-login' ),
|
||||
'photos' => __( 'photos', 'm365-login' ),
|
||||
'skipped' => __( 'skipped', 'm365-login' ),
|
||||
'errors' => __( 'errors', 'm365-login' ),
|
||||
);
|
||||
|
||||
$status = isset( $statuses[ $report['status'] ] ) ? $statuses[ $report['status'] ] : $report['status'];
|
||||
$class = 'ok' === $report['status'] ? 'is-ok' : 'is-bad';
|
||||
$when = wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), (int) $report['started'] );
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
<div class="m365-sync-report">
|
||||
<p class="m365-sync-report__head">
|
||||
<span class="m365-sync-report__status <?php echo esc_attr( $class ); ?>"><?php echo esc_html( $status ); ?></span>
|
||||
<?php if ( $report['dry'] ) : ?>
|
||||
<span class="m365-sync-report__dry"><?php esc_html_e( 'Dry run – nothing was changed', 'm365-login' ); ?></span>
|
||||
<?php endif; ?>
|
||||
<span class="m365-sync-report__meta">
|
||||
<?php
|
||||
/* translators: 1: date and time, 2: how the run was started, 3: duration in seconds */
|
||||
echo esc_html( sprintf( __( '%1$s, %2$s, %3$d s', 'm365-login' ), $when, isset( $triggers[ $report['trigger'] ] ) ? $triggers[ $report['trigger'] ] : $report['trigger'], max( 0, (int) $report['finished'] - (int) $report['started'] ) ) );
|
||||
?>
|
||||
</span>
|
||||
</p>
|
||||
<ul class="m365-sync-report__counts">
|
||||
<?php foreach ( $labels as $key => $label ) : ?>
|
||||
<?php
|
||||
$count = isset( $report['counts'][ $key ] ) ? (int) $report['counts'][ $key ] : 0;
|
||||
if ( 0 === $count && ! in_array( $key, array( 'created', 'updated', 'disabled' ), true ) ) {
|
||||
continue;
|
||||
}
|
||||
?>
|
||||
<li class="m365-sync-report__count m365-sync-report__count--<?php echo esc_attr( $key ); ?>"><strong><?php echo esc_html( number_format_i18n( $count ) ); ?></strong> <?php echo esc_html( $label ); ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php if ( ! empty( $report['log'] ) ) : ?>
|
||||
<details class="m365-sync-report__details" <?php echo 'ok' !== $report['status'] || $report['dry'] ? 'open' : ''; ?>>
|
||||
<?php /* translators: %d: number of log entries */ ?>
|
||||
<summary><?php echo esc_html( sprintf( _n( 'Log (%d entry)', 'Log (%d entries)', count( $report['log'] ), 'm365-login' ), count( $report['log'] ) ) ); ?></summary>
|
||||
<ul class="m365-sync-log">
|
||||
<?php foreach ( $report['log'] as $entry ) : ?>
|
||||
<li class="m365-sync-log__item is-<?php echo esc_attr( $entry['level'] ); ?>"><?php echo esc_html( $entry['message'] ); ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</details>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an Entra group picker.
|
||||
*
|
||||
* @param string $key Option key the picker writes to.
|
||||
* @param array $selected Selected groups: id => name (chips) or id => array( name, role ) (roles).
|
||||
* @param string $mode 'chips' or 'roles'.
|
||||
* @param string $empty_text Text shown when nothing is selected.
|
||||
*/
|
||||
private function group_picker( $key, $selected, $mode, $empty_text ) {
|
||||
$configured = $this->settings->is_configured();
|
||||
$base = M365_LOGIN_OPTION . '[' . $key . ']';
|
||||
$id = 'm365-picker-' . str_replace( '_', '-', $key );
|
||||
?>
|
||||
<div class="m365-picker" data-field="<?php echo esc_attr( $key ); ?>" data-mode="<?php echo esc_attr( $mode ); ?>">
|
||||
<div class="m365-field">
|
||||
<label for="<?php echo esc_attr( $id ); ?>"><?php esc_html_e( 'Search groups', 'm365-login' ); ?></label>
|
||||
<div class="m365-field__row">
|
||||
<input type="search" id="<?php echo esc_attr( $id ); ?>" class="regular-text m365-picker__query" placeholder="<?php esc_attr_e( 'Type a group name or paste an object ID…', 'm365-login' ); ?>" autocomplete="off" <?php disabled( ! $configured ); ?> />
|
||||
<button type="button" class="button m365-picker__search" <?php disabled( ! $configured ); ?>><?php esc_html_e( 'Search', 'm365-login' ); ?></button>
|
||||
</div>
|
||||
<?php if ( ! $configured ) : ?>
|
||||
<p class="description"><?php esc_html_e( 'Save the connection settings first, then search for groups.', 'm365-login' ); ?></p>
|
||||
<?php else : ?>
|
||||
<p class="description"><?php esc_html_e( 'Needs the application permission "GroupMember.Read.All" with admin consent. Without it you can still paste group object IDs.', 'm365-login' ); ?></p>
|
||||
<?php endif; ?>
|
||||
<div class="m365-group-results m365-picker__results" hidden></div>
|
||||
</div>
|
||||
|
||||
<div class="m365-field">
|
||||
<span class="m365-field__label"><?php esc_html_e( 'Selected groups', 'm365-login' ); ?></span>
|
||||
<ul class="m365-group-list m365-picker__list<?php echo 'roles' === $mode ? ' m365-group-list--roles' : ''; ?>" data-empty="<?php echo esc_attr( $empty_text ); ?>">
|
||||
<?php foreach ( $selected as $gid => $value ) : ?>
|
||||
<?php $gname = 'roles' === $mode ? $value['name'] : $value; ?>
|
||||
<li class="m365-group-chip<?php echo 'roles' === $mode ? ' m365-group-chip--role' : ''; ?>" data-id="<?php echo esc_attr( $gid ); ?>">
|
||||
<?php if ( 'roles' === $mode ) : ?>
|
||||
<button type="button" class="m365-group-chip__up" aria-label="<?php esc_attr_e( 'Move up', 'm365-login' ); ?>">↑</button>
|
||||
<?php endif; ?>
|
||||
<span class="m365-group-chip__name"><?php echo esc_html( $gname ); ?></span>
|
||||
<code class="m365-group-chip__id"><?php echo esc_html( $gid ); ?></code>
|
||||
<?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 $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 : ?>
|
||||
<input type="hidden" name="<?php echo esc_attr( $base . '[' . $gid . ']' ); ?>" value="<?php echo esc_attr( $gname ); ?>" />
|
||||
<?php endif; ?>
|
||||
<button type="button" class="m365-group-chip__remove" aria-label="<?php esc_attr_e( 'Remove', 'm365-login' ); ?>">×</button>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php if ( 'roles' === $mode ) : ?>
|
||||
<select class="m365-picker__role-template" hidden disabled>
|
||||
<?php $this->role_options( 'editor' ); ?>
|
||||
</select>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?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( self::capability() ) ) {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_die( esc_html__( 'You are not allowed to do this.', 'm365-login' ), 403 );
|
||||
}
|
||||
check_admin_referer( self::POST_CERT );
|
||||
|
||||
// Only ever the re-exported public certificate, whatever an older version stored.
|
||||
$pem = M365_Login_Certificate::clean_pem( $this->settings->certificate_pem() );
|
||||
$pem = $this->settings->certificate_pem();
|
||||
if ( '' === $pem ) {
|
||||
wp_die( esc_html__( 'No certificate is stored.', 'm365-login' ), 404 );
|
||||
}
|
||||
|
|
@ -583,181 +356,11 @@ class M365_Login_Admin {
|
|||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the "User sync" tab.
|
||||
*
|
||||
* @param array $s Settings.
|
||||
*/
|
||||
private function render_sync_panel( $s ) {
|
||||
$field = function ( $key ) {
|
||||
return esc_attr( M365_LOGIN_OPTION . '[' . $key . ']' );
|
||||
};
|
||||
$configured = $this->settings->is_configured();
|
||||
$next = wp_next_scheduled( M365_Login_Sync::CRON_HOOK );
|
||||
$actions = array(
|
||||
'none' => __( 'Do nothing', 'm365-login' ),
|
||||
'disable' => __( 'Deactivate the WordPress account', 'm365-login' ),
|
||||
'delete' => __( 'Delete the WordPress account', 'm365-login' ),
|
||||
);
|
||||
$selects = array(
|
||||
'sync_disabled_action' => __( 'Account disabled in Microsoft 365 (sign-in blocked)', 'm365-login' ),
|
||||
'sync_deleted_action' => __( 'Account deleted in Microsoft 365', 'm365-login' ),
|
||||
'sync_scope_action' => __( 'No longer a member of the sync groups', 'm365-login' ),
|
||||
);
|
||||
?>
|
||||
<section class="m365-admin__panel" data-panel="sync">
|
||||
<div class="m365-card">
|
||||
<h2 class="m365-card__title"><?php esc_html_e( 'Import users from Microsoft 365', 'm365-login' ); ?></h2>
|
||||
<p class="m365-card__intro"><?php esc_html_e( 'Creates a WordPress account for every Microsoft 365 user in scope, links existing accounts by e-mail address, keeps roles and profile fields up to date and deactivates or deletes accounts that were disabled or removed in Microsoft 365. New accounts get a random password and no e-mail; people sign in with the Microsoft button.', 'm365-login' ); ?></p>
|
||||
|
||||
<label class="m365-check m365-check--block">
|
||||
<input type="checkbox" name="<?php echo $field( 'sync_enabled' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['sync_enabled'] ); ?> />
|
||||
<span>
|
||||
<strong><?php esc_html_e( 'Run the sync automatically', 'm365-login' ); ?></strong>
|
||||
<em><?php esc_html_e( 'Uses WP-Cron, which runs when the site receives visits. For exact timing, trigger wp-cron.php from a real cron job or run "wp m365-login sync".', 'm365-login' ); ?></em>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="m365-grid">
|
||||
<div class="m365-field">
|
||||
<label for="m365-sync-interval"><?php esc_html_e( 'Interval', 'm365-login' ); ?></label>
|
||||
<select id="m365-sync-interval" name="<?php echo $field( 'sync_interval' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>">
|
||||
<option value="hourly" <?php selected( $s['sync_interval'], 'hourly' ); ?>><?php esc_html_e( 'Hourly', 'm365-login' ); ?></option>
|
||||
<option value="twicedaily" <?php selected( $s['sync_interval'], 'twicedaily' ); ?>><?php esc_html_e( 'Twice daily', 'm365-login' ); ?></option>
|
||||
<option value="daily" <?php selected( $s['sync_interval'], 'daily' ); ?>><?php esc_html_e( 'Daily', 'm365-login' ); ?></option>
|
||||
</select>
|
||||
<?php if ( $s['sync_enabled'] && $next ) : ?>
|
||||
<?php /* translators: %s: date and time */ ?>
|
||||
<p class="description"><?php echo esc_html( sprintf( __( 'Next run: %s', 'm365-login' ), wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $next ) ) ); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="m365-check m365-check--block">
|
||||
<input type="checkbox" name="<?php echo $field( 'sync_guests' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['sync_guests'] ); ?> />
|
||||
<span>
|
||||
<strong><?php esc_html_e( 'Also import guest users (B2B)', 'm365-login' ); ?></strong>
|
||||
<em><?php esc_html_e( 'Guests are external people invited into your tenant. Off by default.', 'm365-login' ); ?></em>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<h3 class="m365-card__subtitle"><?php esc_html_e( 'Which users? (optional)', 'm365-login' ); ?></h3>
|
||||
<p class="description"><?php esc_html_e( 'Limit the import to members of these groups (nested memberships count). Without groups, every user of the tenant is imported. The e-mail domain allow-list on the Security tab applies as well.', 'm365-login' ); ?></p>
|
||||
<?php $this->group_picker( 'sync_scope_groups', $this->settings->sync_scope_groups(), 'chips', __( 'No groups selected – all users of the tenant are imported.', 'm365-login' ) ); ?>
|
||||
</div>
|
||||
|
||||
<div class="m365-card">
|
||||
<h2 class="m365-card__title"><?php esc_html_e( 'Roles', 'm365-login' ); ?></h2>
|
||||
|
||||
<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 $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">
|
||||
<span class="m365-field__label"><?php esc_html_e( 'How are mapped roles applied?', 'm365-login' ); ?></span>
|
||||
<label class="m365-check">
|
||||
<input type="radio" name="<?php echo $field( 'sync_role_mode' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="add" <?php checked( $s['sync_role_mode'], 'add' ); ?> />
|
||||
<?php esc_html_e( 'In addition to the default role (a user can have several roles)', 'm365-login' ); ?>
|
||||
</label>
|
||||
<label class="m365-check">
|
||||
<input type="radio" name="<?php echo $field( 'sync_role_mode' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="replace" <?php checked( $s['sync_role_mode'], 'replace' ); ?> />
|
||||
<?php esc_html_e( 'Instead of the default role – the first matching group in the list wins (use ↑ to reorder)', 'm365-login' ); ?>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="m365-check m365-check--block">
|
||||
<input type="checkbox" name="<?php echo $field( 'sync_manage_existing' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['sync_manage_existing'] ); ?> />
|
||||
<span>
|
||||
<strong><?php esc_html_e( 'Also manage the roles of accounts that existed before the sync', 'm365-login' ); ?></strong>
|
||||
<em><?php esc_html_e( 'Off: existing accounts are only linked and get their profile fields updated; their roles stay as they are. Administrators that existed before the sync and your own account are never changed.', 'm365-login' ); ?></em>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="m365-card">
|
||||
<h2 class="m365-card__title"><?php esc_html_e( 'Profile fields', 'm365-login' ); ?></h2>
|
||||
<p class="m365-card__intro"><?php esc_html_e( 'Selected Microsoft 365 attributes are copied into the WordPress profile on every sync (Microsoft 365 wins). Name fields go into the standard profile fields, everything else into user meta keys starting with "m365_" – usable by themes and other plugins – and is shown on the profile screen.', 'm365-login' ); ?></p>
|
||||
<div class="m365-attributes">
|
||||
<?php foreach ( M365_Login_Sync::attributes() as $key => $attribute ) : ?>
|
||||
<label class="m365-check">
|
||||
<input type="checkbox" name="<?php echo $field( 'sync_attributes' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>[]" value="<?php echo esc_attr( $key ); ?>" <?php checked( in_array( $key, (array) $s['sync_attributes'], true ) ); ?> />
|
||||
<span><?php echo esc_html( $attribute['label'] ); ?> <code><?php echo esc_html( $key ); ?></code></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<p class="description"><?php esc_html_e( 'Profile photos are stored in wp-content/uploads/m365-login-avatars/ and replace the Gravatar. They are compared on every run: changed photos are downloaded again, photos deleted in Microsoft 365 are deleted in WordPress too. Fields and photos you deselect here are removed from the profiles on the next run (first and last name and display name stay).', 'm365-login' ); ?></p>
|
||||
</div>
|
||||
|
||||
<div class="m365-card">
|
||||
<h2 class="m365-card__title"><?php esc_html_e( 'Disabled and deleted Microsoft 365 accounts', 'm365-login' ); ?></h2>
|
||||
<p class="m365-card__intro"><?php esc_html_e( 'Applies to WordPress accounts linked to a Microsoft account (imported, or signed in with Microsoft at least once). Deactivated accounts cannot sign in at all – not with Microsoft, a password or an application password – and are signed out immediately. When the person is active in Microsoft 365 again, the sync reactivates the account.', 'm365-login' ); ?></p>
|
||||
|
||||
<?php foreach ( $selects as $key => $label ) : ?>
|
||||
<div class="m365-field">
|
||||
<label for="m365-<?php echo esc_attr( $key ); ?>"><?php echo esc_html( $label ); ?></label>
|
||||
<select id="m365-<?php echo esc_attr( $key ); ?>" name="<?php echo $field( $key ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>">
|
||||
<?php foreach ( $actions as $value => $text ) : ?>
|
||||
<option value="<?php echo esc_attr( $value ); ?>" <?php selected( $s[ $key ], $value ); ?>><?php echo esc_html( $text ); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php if ( 'sync_scope_action' === $key ) : ?>
|
||||
<p class="description"><?php esc_html_e( 'Only relevant when the import is limited to groups.', 'm365-login' ); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<div class="m365-field">
|
||||
<label for="m365-sync-reassign"><?php esc_html_e( 'Posts of deleted accounts go to', 'm365-login' ); ?></label>
|
||||
<?php
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- core function, escapes its output.
|
||||
echo wp_dropdown_users(
|
||||
array(
|
||||
'name' => M365_LOGIN_OPTION . '[sync_reassign]',
|
||||
'id' => 'm365-sync-reassign',
|
||||
'selected' => (int) $s['sync_reassign'],
|
||||
'show_option_none' => __( '— Select a user —', 'm365-login' ),
|
||||
'option_none_value' => 0,
|
||||
'capability' => array( 'edit_posts' ),
|
||||
'echo' => false,
|
||||
)
|
||||
);
|
||||
?>
|
||||
<p class="description"><?php esc_html_e( 'Required for "Delete". Without a user, accounts are deactivated instead, so no content is ever lost.', 'm365-login' ); ?></p>
|
||||
</div>
|
||||
|
||||
<p class="m365-warning"><?php esc_html_e( 'Safety stop: if a run would deactivate or delete more than 20 % of the linked accounts (at least 5), nothing is deactivated or deleted and the run is reported as stopped. A failed Microsoft Graph request also stops the run before anything is deactivated.', 'm365-login' ); ?></p>
|
||||
</div>
|
||||
|
||||
<div class="m365-card m365-card--accent">
|
||||
<h2 class="m365-card__title"><?php esc_html_e( 'Run the sync', 'm365-login' ); ?></h2>
|
||||
<p><?php esc_html_e( 'The run uses the saved settings. Start with a dry run: it reads Microsoft 365 and lists what would change, without changing anything.', 'm365-login' ); ?></p>
|
||||
<div class="m365-field__row">
|
||||
<button type="button" class="button button-primary m365-sync-run" data-op="dry" <?php disabled( ! $configured ); ?>><?php esc_html_e( 'Dry run', 'm365-login' ); ?></button>
|
||||
<button type="button" class="button m365-sync-run" data-op="live" <?php disabled( ! $configured ); ?>><?php esc_html_e( 'Sync now', 'm365-login' ); ?></button>
|
||||
</div>
|
||||
<p class="description"><?php esc_html_e( 'Required application permissions (Microsoft Graph, admin consent): User.Read.All, and GroupMember.Read.All when groups are used.', 'm365-login' ); ?></p>
|
||||
<div id="m365-sync-report" class="m365-sync-report-wrap">
|
||||
<?php echo $this->report_markup( M365_Login_Sync::last_report() ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in report_markup(). ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the settings screen.
|
||||
*/
|
||||
public function render() {
|
||||
if ( ! current_user_can( self::capability() ) ) {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_die( esc_html__( 'You are not allowed to access this page.', 'm365-login' ) );
|
||||
}
|
||||
|
||||
|
|
@ -860,9 +463,6 @@ 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'; ?>>
|
||||
|
|
@ -1095,7 +695,7 @@ class M365_Login_Admin {
|
|||
<section class="m365-admin__panel" data-panel="security">
|
||||
<div class="m365-card">
|
||||
<h2 class="m365-card__title"><?php esc_html_e( 'User matching & hardening', 'm365-login' ); ?></h2>
|
||||
<p class="m365-card__intro"><?php esc_html_e( 'Sign-in never creates users. A Microsoft sign-in only succeeds when a WordPress user with the same e-mail address already exists – created by hand or imported by the user sync.', 'm365-login' ); ?></p>
|
||||
<p class="m365-card__intro"><?php esc_html_e( 'Users are never created automatically. A Microsoft sign-in only succeeds when a WordPress user with the same e-mail address already exists.', 'm365-login' ); ?></p>
|
||||
|
||||
<label class="m365-check m365-check--block">
|
||||
<input type="checkbox" name="<?php echo $field( 'bind_oid' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['bind_oid'] ); ?> />
|
||||
|
|
@ -1132,28 +732,39 @@ class M365_Login_Admin {
|
|||
<h2 class="m365-card__title"><?php esc_html_e( 'Allowed Entra groups (optional)', 'm365-login' ); ?></h2>
|
||||
<p class="m365-card__intro"><?php esc_html_e( 'Only members of at least one of these groups may sign in. Leave empty to allow every matched user. Nested memberships count.', 'm365-login' ); ?></p>
|
||||
|
||||
<?php $this->group_picker( 'allowed_groups', $this->settings->allowed_groups(), 'chips', __( 'No groups selected – every matched user may sign in.', 'm365-login' ) ); ?>
|
||||
<div class="m365-field">
|
||||
<label for="m365-group-search"><?php esc_html_e( 'Search groups', 'm365-login' ); ?></label>
|
||||
<div class="m365-field__row">
|
||||
<input type="search" id="m365-group-search" class="regular-text" placeholder="<?php esc_attr_e( 'Type a group name or paste an object ID…', 'm365-login' ); ?>" autocomplete="off" <?php disabled( ! $configured ); ?> />
|
||||
<button type="button" class="button" id="m365-group-search-btn" <?php disabled( ! $configured ); ?>><?php esc_html_e( 'Search', 'm365-login' ); ?></button>
|
||||
</div>
|
||||
<?php if ( ! $configured ) : ?>
|
||||
<p class="description"><?php esc_html_e( 'Save the connection settings first, then search for groups.', 'm365-login' ); ?></p>
|
||||
<?php else : ?>
|
||||
<p class="description"><?php esc_html_e( 'Needs the application permission "GroupMember.Read.All" with admin consent. Without it you can still paste group object IDs.', 'm365-login' ); ?></p>
|
||||
<?php endif; ?>
|
||||
<div id="m365-group-results" class="m365-group-results" hidden></div>
|
||||
</div>
|
||||
|
||||
<div class="m365-field">
|
||||
<span class="m365-field__label"><?php esc_html_e( 'Selected groups', 'm365-login' ); ?></span>
|
||||
<ul id="m365-group-list" class="m365-group-list" data-empty="<?php esc_attr_e( 'No groups selected – every matched user may sign in.', 'm365-login' ); ?>">
|
||||
<?php foreach ( $this->settings->allowed_groups() as $gid => $gname ) : ?>
|
||||
<li class="m365-group-chip" data-id="<?php echo esc_attr( $gid ); ?>">
|
||||
<span class="m365-group-chip__name"><?php echo esc_html( $gname ); ?></span>
|
||||
<code class="m365-group-chip__id"><?php echo esc_html( $gid ); ?></code>
|
||||
<input type="hidden" name="<?php echo esc_attr( $option . '[allowed_groups][' . $gid . ']' ); ?>" value="<?php echo esc_attr( $gname ); ?>" />
|
||||
<button type="button" class="m365-group-chip__remove" aria-label="<?php esc_attr_e( 'Remove', 'm365-login' ); ?>">×</button>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<p class="description"><?php esc_html_e( 'Membership is read from the "groups" claim of the ID token when present; otherwise the plugin asks Microsoft Graph (application permission "User.Read.All" or "Directory.Read.All"). If neither works, the sign-in is refused.', 'm365-login' ); ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m365-card">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 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>
|
||||
<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>
|
||||
|
||||
<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" />
|
||||
|
|
@ -1196,13 +807,11 @@ class M365_Login_Admin {
|
|||
<li><?php esc_html_e( 'OpenID Connect authorization code flow with PKCE (S256) – no tokens ever pass through the browser.', 'm365-login' ); ?></li>
|
||||
<li><?php esc_html_e( 'Single-use state and nonce values bound to the browser via an HttpOnly cookie (CSRF and replay protection).', 'm365-login' ); ?></li>
|
||||
<li><?php esc_html_e( 'ID token signature verified against Microsoft’s published signing keys; issuer, audience, tenant, expiry and nonce are checked.', 'm365-login' ); ?></li>
|
||||
<li><?php esc_html_e( 'Client secret encrypted at rest; sign-in never creates accounts or changes passwords.', 'm365-login' ); ?></li>
|
||||
<li><?php esc_html_e( 'Client secret encrypted at rest; no accounts are created, no passwords are changed.', 'm365-login' ); ?></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php $this->render_sync_panel( $s ); ?>
|
||||
|
||||
<div class="m365-admin__actions">
|
||||
<?php submit_button( __( 'Save changes', 'm365-login' ), 'primary large', 'submit', false ); ?>
|
||||
</div>
|
||||
|
|
@ -1237,7 +846,7 @@ class M365_Login_Admin {
|
|||
<li><?php esc_html_e( 'Optional: restrict who may use the app under Enterprise applications → your app → Properties → "Assignment required" = Yes, then assign users/groups.', 'm365-login' ); ?></li>
|
||||
</ol>
|
||||
<p class="description"><?php esc_html_e( 'Required API permission: openid, profile, email (delegated) – granted by default.', 'm365-login' ); ?></p>
|
||||
<p class="description"><?php esc_html_e( 'Optional, for group restrictions and the user sync: application permissions GroupMember.Read.All and User.Read.All (Microsoft Graph) with admin consent.', 'm365-login' ); ?></p>
|
||||
<p class="description"><?php esc_html_e( 'Optional, for group restrictions: application permissions GroupMember.Read.All and User.Read.All (Microsoft Graph) with admin consent.', 'm365-login' ); ?></p>
|
||||
</div>
|
||||
|
||||
<div class="m365-card m365-card--muted">
|
||||
|
|
|
|||
|
|
@ -20,9 +20,6 @@ class M365_Login_Auth {
|
|||
const STATE_TTL = 600; // 10 minutes.
|
||||
const META_OID = '_m365_login_oid';
|
||||
const META_LAST_LOGIN = '_m365_login_last_login';
|
||||
const META_TID = '_m365_login_tid'; // Tenant the object ID belongs to.
|
||||
const META_UPN = '_m365_login_upn'; // Microsoft account (UPN) assigned by an administrator.
|
||||
const LINK_NONCE = 'm365_login_link';
|
||||
const JWKS_CACHE_TTL = 12 * HOUR_IN_SECONDS;
|
||||
const HTTP_TIMEOUT = 15;
|
||||
|
||||
|
|
@ -40,13 +37,6 @@ class M365_Login_Auth {
|
|||
*/
|
||||
private $graph;
|
||||
|
||||
/**
|
||||
* User authenticated by an application password in the current authenticate pass (0 = none).
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $app_password_user = 0;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
|
|
@ -65,17 +55,6 @@ 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 );
|
||||
// Track application-password sign-ins per authenticate pass (XML-RPC multicall runs several passes per request).
|
||||
add_filter( 'authenticate', array( $this, 'reset_app_password_user' ), 0 );
|
||||
add_action( 'application_password_did_authenticate', array( $this, 'remember_app_password_user' ) );
|
||||
// Button-only mode: no "Lost your password?" link, no reset screen, no reset e-mails.
|
||||
add_filter( 'lost_password_html_link', array( $this, 'hide_lost_password_link' ), 99 );
|
||||
add_filter( 'allow_password_reset', array( $this, 'block_password_reset' ), 99 );
|
||||
foreach ( array( 'lostpassword', 'retrievepassword', 'rp', 'resetpass' ) as $reset_action ) {
|
||||
add_action( 'login_form_' . $reset_action, array( $this, 'block_reset_screen' ) );
|
||||
}
|
||||
// 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 );
|
||||
|
|
@ -95,25 +74,16 @@ 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 ] ) ) : '';
|
||||
$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 );
|
||||
return '' !== $cookie && hash_equals( $this->fallback_cookie_value(), $cookie );
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback cookie value for an issue time: "time|HMAC" (the key itself never sits in the cookie).
|
||||
* Expected fallback cookie value (HMAC of the key, so the key itself never sits in the cookie).
|
||||
*
|
||||
* @param int $issued Issue timestamp.
|
||||
* @return string
|
||||
*/
|
||||
private function fallback_cookie_value( $issued ) {
|
||||
return $issued . '|' . hash_hmac( 'sha256', 'fallback|' . $issued . '|' . $this->settings->fallback_key(), wp_salt( 'auth' ) );
|
||||
private function fallback_cookie_value() {
|
||||
return hash_hmac( 'sha256', 'fallback|' . $this->settings->fallback_key(), wp_salt( 'auth' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -129,21 +99,20 @@ class M365_Login_Auth {
|
|||
return;
|
||||
}
|
||||
|
||||
// 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() );
|
||||
// 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' );
|
||||
}
|
||||
|
||||
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 );
|
||||
$issued = time();
|
||||
$this->send_cookie( self::FALLBACK_COOKIE, $this->fallback_cookie_value( $issued ), $issued + self::FALLBACK_TTL );
|
||||
$this->send_cookie( self::FALLBACK_COOKIE, $this->fallback_cookie_value(), time() + self::FALLBACK_TTL );
|
||||
nocache_headers();
|
||||
wp_safe_redirect( add_query_arg( 'm365_fallback', 'on', $this->settings->login_page_url() ) );
|
||||
exit;
|
||||
|
|
@ -180,14 +149,16 @@ class M365_Login_Auth {
|
|||
if ( '' === (string) $username && '' === (string) $password ) {
|
||||
return $user; // Initial form render or cookie auth, no password attempt.
|
||||
}
|
||||
// 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.
|
||||
// The application password must have authenticated exactly this user in this pass – did_action()
|
||||
// is request-global and would let a later multicall boxcar through with a normal password.
|
||||
if ( ( defined( 'WP_CLI' ) && WP_CLI ) || ( $user instanceof WP_User && $this->app_password_user && $this->app_password_user === $user->ID ) ) {
|
||||
// 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() ) {
|
||||
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).
|
||||
|
|
@ -195,102 +166,13 @@ class M365_Login_Auth {
|
|||
* @param bool $block Whether to block. Default true.
|
||||
* @param WP_User $user Authenticated user.
|
||||
*/
|
||||
if ( $user instanceof WP_User && ! apply_filters( 'm365_login_block_password_login', true, $user ) ) {
|
||||
if ( ! 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' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether password resets are switched off (button-only mode without an active fallback).
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function passwords_disabled() {
|
||||
return $this->settings->button_only() && ! $this->fallback_active();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the "Lost your password?" link from wp-login.php (server side, independent of CSS).
|
||||
*
|
||||
* @param string $html Link markup.
|
||||
* @return string
|
||||
*/
|
||||
public function hide_lost_password_link( $html ) {
|
||||
return $this->passwords_disabled() ? '' : $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses password resets (form, e-mails, "Send password reset" in the users list).
|
||||
*
|
||||
* @param bool|WP_Error $allow Whether the reset is allowed.
|
||||
* @return bool|WP_Error
|
||||
*/
|
||||
public function block_password_reset( $allow ) {
|
||||
if ( ! $this->passwords_disabled() ) {
|
||||
return $allow;
|
||||
}
|
||||
return new WP_Error( 'm365_login_no_password_reset', __( 'Passwords are not used on this site. Please sign in with the Microsoft button.', 'm365-login' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the lost-password and reset screens back to the login page.
|
||||
*/
|
||||
public function block_reset_screen() {
|
||||
if ( ! $this->passwords_disabled() ) {
|
||||
return;
|
||||
}
|
||||
nocache_headers();
|
||||
wp_safe_redirect( add_query_arg( 'm365_error', 'password_reset_disabled', $this->settings->login_page_url() ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a new authenticate pass (runs first on the authenticate filter).
|
||||
*
|
||||
* @param null|WP_User|WP_Error $user Result so far (unchanged).
|
||||
* @return null|WP_User|WP_Error
|
||||
*/
|
||||
public function reset_app_password_user( $user ) {
|
||||
$this->app_password_user = 0;
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers which user an application password authenticated in the current pass.
|
||||
*
|
||||
* @param WP_User $user Authenticated user.
|
||||
*/
|
||||
public function remember_app_password_user( $user ) {
|
||||
$this->app_password_user = $user instanceof WP_User ? (int) $user->ID : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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|null $user_id User ID (0 when cookies are cleared; not passed before WordPress 6.2).
|
||||
* @return bool
|
||||
*/
|
||||
public function block_api_auth_cookies( $send, $expire = 0, $expiration = 0, $user_id = null ) {
|
||||
// Before WordPress 6.2 the filter gets no user ID: block in API contexts anyway (also blocks
|
||||
// clearing cookies there, which is harmless).
|
||||
if ( ! $send || 0 === $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.
|
||||
*
|
||||
|
|
@ -308,10 +190,7 @@ 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 ] ) ) {
|
||||
// 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 ) );
|
||||
$candidate = trim( explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ) )[0] );
|
||||
if ( filter_var( $candidate, FILTER_VALIDATE_IP ) ) {
|
||||
$ip = $candidate;
|
||||
}
|
||||
|
|
@ -368,22 +247,6 @@ class M365_Login_Auth {
|
|||
return $this->authority() . '/discovery/v2.0/keys';
|
||||
}
|
||||
|
||||
/**
|
||||
* URL that links the signed-in user's WordPress account to a Microsoft account.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function link_url() {
|
||||
return add_query_arg(
|
||||
array(
|
||||
'action' => self::ACTION_START,
|
||||
'm365_link' => '1',
|
||||
'_wpnonce' => wp_create_nonce( self::LINK_NONCE ),
|
||||
),
|
||||
wp_login_url()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* URL that starts the Microsoft login.
|
||||
*
|
||||
|
|
@ -391,10 +254,9 @@ class M365_Login_Auth {
|
|||
* @return string
|
||||
*/
|
||||
public function start_url( $redirect_to = '' ) {
|
||||
$args = array( 'action' => self::ACTION_START );
|
||||
$redirect_to = '' !== (string) $redirect_to ? wp_validate_redirect( (string) $redirect_to, '' ) : '';
|
||||
$args = array( 'action' => self::ACTION_START );
|
||||
if ( '' !== $redirect_to ) {
|
||||
$args['redirect_to'] = rawurlencode( $redirect_to ); // add_query_arg() does not encode values.
|
||||
$args['redirect_to'] = $redirect_to;
|
||||
}
|
||||
return add_query_arg( $args, wp_login_url() );
|
||||
}
|
||||
|
|
@ -414,7 +276,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 >= 300 ) {
|
||||
if ( $starts >= 30 ) {
|
||||
$this->fail( 'too_many_attempts' );
|
||||
}
|
||||
set_transient( $throttle_key, $starts + 1, self::STATE_TTL );
|
||||
|
|
@ -422,18 +284,6 @@ class M365_Login_Auth {
|
|||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- redirect_to is validated with wp_validate_redirect() before use.
|
||||
$redirect_to = isset( $_GET['redirect_to'] ) ? wp_validate_redirect( esc_url_raw( wp_unslash( $_GET['redirect_to'] ) ), '' ) : '';
|
||||
|
||||
// "Link my Microsoft account" from the profile: the signed-in user proves ownership of the
|
||||
// WordPress account, the Microsoft sign-in proves ownership of the Microsoft account.
|
||||
$link_user = 0;
|
||||
if ( isset( $_GET['m365_link'] ) ) {
|
||||
$link_nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '';
|
||||
if ( ! is_user_logged_in() || ! wp_verify_nonce( $link_nonce, self::LINK_NONCE ) ) {
|
||||
$this->fail( 'invalid_state' );
|
||||
}
|
||||
$link_user = get_current_user_id();
|
||||
$redirect_to = admin_url( 'profile.php' );
|
||||
}
|
||||
|
||||
$state = M365_Login_JWT::b64url_encode( random_bytes( 32 ) );
|
||||
$nonce = M365_Login_JWT::b64url_encode( random_bytes( 32 ) );
|
||||
$code_verifier = M365_Login_JWT::b64url_encode( random_bytes( 64 ) );
|
||||
|
|
@ -449,7 +299,6 @@ class M365_Login_Auth {
|
|||
'verifier' => $code_verifier,
|
||||
'cookie' => hash( 'sha256', $cookie_token ),
|
||||
'redirect_to' => $redirect_to,
|
||||
'link_user' => $link_user,
|
||||
'created' => time(),
|
||||
),
|
||||
self::STATE_TTL
|
||||
|
|
@ -572,42 +421,16 @@ 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' );
|
||||
}
|
||||
|
||||
$oid = isset( $claims['oid'] ) && is_string( $claims['oid'] ) ? strtolower( $claims['oid'] ) : '';
|
||||
|
||||
if ( ! empty( $attempt['link_user'] ) ) {
|
||||
$this->link_account( (int) $attempt['link_user'], $claims, $oid );
|
||||
}
|
||||
|
||||
// E-mail and user principal name may differ: every usable address is tried (on the domain allow-list).
|
||||
$candidates = $this->email_candidates( $claims );
|
||||
$email = $candidates ? $candidates[0] : '';
|
||||
$allowed = array_values( array_filter( $candidates, array( $this, 'domain_allowed' ) ) );
|
||||
if ( $candidates && ! $allowed ) {
|
||||
if ( ! $this->domain_allowed( $email ) ) {
|
||||
$this->fail( 'domain_not_allowed' );
|
||||
}
|
||||
|
||||
// 1. An account already bound to this Microsoft identity, 2. an account the administrator
|
||||
// assigned this user principal name to, 3. the e-mail address, then the user principal name.
|
||||
$user = $this->find_bound_user( $oid );
|
||||
if ( ! $user ) {
|
||||
$user = $this->find_assigned_user( $claims );
|
||||
}
|
||||
if ( ! $user ) {
|
||||
if ( ! $allowed ) {
|
||||
$this->fail( 'no_email' );
|
||||
}
|
||||
foreach ( $allowed as $candidate ) {
|
||||
$user = get_user_by( 'email', $candidate );
|
||||
if ( $user instanceof WP_User ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$user = get_user_by( 'email', $email );
|
||||
if ( ! $user instanceof WP_User ) {
|
||||
/** This action is documented in wp-includes/user.php */
|
||||
do_action( 'wp_login_failed', $email, new WP_Error( 'm365_login_no_user', 'No WordPress user with this e-mail address.' ) );
|
||||
|
|
@ -618,9 +441,7 @@ class M365_Login_Auth {
|
|||
$this->fail( 'no_user' );
|
||||
}
|
||||
|
||||
if ( M365_Login_Sync::disabled_info( $user->ID ) ) {
|
||||
$this->fail( 'account_disabled' );
|
||||
}
|
||||
$oid = isset( $claims['oid'] ) && is_string( $claims['oid'] ) ? strtolower( $claims['oid'] ) : '';
|
||||
|
||||
// Entra group restriction.
|
||||
$group_check = $this->check_groups( $claims, $oid );
|
||||
|
|
@ -629,45 +450,17 @@ 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' );
|
||||
}
|
||||
}
|
||||
|
||||
// Privileged accounts are only safe through a verified binding (bind_oid on and the stored
|
||||
// object ID matches – checked above). Otherwise 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.
|
||||
$bound = $this->settings->get( 'bind_oid' ) && '' !== $stored;
|
||||
if ( ! $bound && 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 );
|
||||
if ( isset( $claims['tid'] ) && M365_Login_Settings::is_guid( (string) $claims['tid'] ) ) {
|
||||
update_user_meta( $user->ID, self::META_TID, strtolower( (string) $claims['tid'] ) );
|
||||
if ( '' === $stored ) {
|
||||
update_user_meta( $user->ID, self::META_OID, $oid );
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -845,248 +638,6 @@ 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 = $this->claimed_upn( $claims );
|
||||
if ( '' === $upn || $this->settings->is_multi_tenant() ) {
|
||||
return false;
|
||||
}
|
||||
$assigned = strtolower( (string) get_user_meta( $user->ID, self::META_UPN, true ) );
|
||||
return hash_equals( strtolower( $user->user_email ), $upn ) || ( '' !== $assigned && hash_equals( $assigned, $upn ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* User principal name of a member account from the token ('' for guests/external identities).
|
||||
*
|
||||
* @param array $claims Verified claims.
|
||||
* @return string
|
||||
*/
|
||||
private function claimed_upn( $claims ) {
|
||||
if ( $this->is_external_identity( $claims ) || empty( $claims['preferred_username'] ) || ! is_string( $claims['preferred_username'] ) ) {
|
||||
return '';
|
||||
}
|
||||
return strtolower( trim( $claims['preferred_username'] ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Account bound to a Microsoft object ID (network-wide), if any.
|
||||
*
|
||||
* @param string $oid Object ID.
|
||||
* @return WP_User|null
|
||||
*/
|
||||
private function find_bound_user( $oid ) {
|
||||
if ( ! $this->settings->get( 'bind_oid' ) || ! M365_Login_Settings::is_guid( $oid ) ) {
|
||||
return null;
|
||||
}
|
||||
$ids = 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
|
||||
'fields' => 'ID',
|
||||
'number' => 2,
|
||||
'blog_id' => 0,
|
||||
)
|
||||
);
|
||||
if ( 1 !== count( $ids ) ) {
|
||||
return null; // None, or ambiguous (bound twice by an older version): fall back to the other rules.
|
||||
}
|
||||
$user = get_userdata( (int) $ids[0] );
|
||||
return $user ? $user : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Account whose administrator-assigned Microsoft account matches the token's user principal name.
|
||||
*
|
||||
* @param array $claims Verified claims.
|
||||
* @return WP_User|null
|
||||
*/
|
||||
private function find_assigned_user( $claims ) {
|
||||
$upn = $this->claimed_upn( $claims );
|
||||
if ( '' === $upn || $this->settings->is_multi_tenant() ) {
|
||||
return null;
|
||||
}
|
||||
$ids = get_users(
|
||||
array(
|
||||
'meta_key' => self::META_UPN, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
|
||||
'meta_value' => $upn, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
|
||||
'fields' => 'ID',
|
||||
'number' => 2,
|
||||
'blog_id' => 0,
|
||||
)
|
||||
);
|
||||
if ( 1 !== count( $ids ) ) {
|
||||
return null;
|
||||
}
|
||||
$user = get_userdata( (int) $ids[0] );
|
||||
return $user ? $user : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the Microsoft identity to the signed-in WordPress user who started "Link my Microsoft account".
|
||||
*
|
||||
* @param int $user_id User who started the link.
|
||||
* @param array $claims Verified claims.
|
||||
* @param string $oid Object ID.
|
||||
*/
|
||||
private function link_account( $user_id, $claims, $oid ) {
|
||||
// The browser must still be signed in as the user who started the link.
|
||||
if ( ! $user_id || get_current_user_id() !== $user_id ) {
|
||||
$this->fail_link( 'link_session' );
|
||||
}
|
||||
if ( ! M365_Login_Settings::is_guid( $oid ) ) {
|
||||
$this->fail_link( 'invalid_token' );
|
||||
}
|
||||
if ( M365_Login_Sync::disabled_info( $user_id ) ) {
|
||||
$this->fail_link( 'account_disabled' );
|
||||
}
|
||||
$stored = strtolower( (string) get_user_meta( $user_id, self::META_OID, true ) );
|
||||
if ( '' !== $stored && ! hash_equals( $stored, $oid ) ) {
|
||||
$this->fail_link( 'link_other' ); // Unlinking is an administrator decision.
|
||||
}
|
||||
$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->fail_link( 'oid_mismatch' );
|
||||
}
|
||||
|
||||
update_user_meta( $user_id, self::META_OID, $oid );
|
||||
if ( isset( $claims['tid'] ) && M365_Login_Settings::is_guid( (string) $claims['tid'] ) ) {
|
||||
update_user_meta( $user_id, self::META_TID, strtolower( (string) $claims['tid'] ) );
|
||||
}
|
||||
$this->log( sprintf( 'User #%d linked a Microsoft account from the profile.', $user_id ) );
|
||||
|
||||
/**
|
||||
* Fires after a user linked a Microsoft account from the profile screen.
|
||||
*
|
||||
* @param int $user_id User ID.
|
||||
* @param array $claims Verified claims.
|
||||
*/
|
||||
do_action( 'm365_login_account_linked', $user_id, $claims );
|
||||
|
||||
wp_safe_redirect( add_query_arg( 'm365_linked', '1', admin_url( 'profile.php' ) ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends a failed profile link with a message on the profile screen.
|
||||
*
|
||||
* @param string $code Error code.
|
||||
*/
|
||||
private function fail_link( $code ) {
|
||||
$this->clear_state_cookie();
|
||||
nocache_headers();
|
||||
wp_safe_redirect( add_query_arg( 'm365_link_error', rawurlencode( $code ), admin_url( 'profile.php' ) ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translated message for a login/link error code.
|
||||
*
|
||||
* @param string $code Code.
|
||||
* @return string
|
||||
*/
|
||||
public function error_message( $code ) {
|
||||
$messages = $this->error_messages();
|
||||
return isset( $messages[ $code ] ) ? $messages[ $code ] : $messages['provider_error'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* @param array $claims Verified claims.
|
||||
* @param string $oid User object ID.
|
||||
* @return true|string True, or an error code for fail().
|
||||
*/
|
||||
private function check_groups( $claims, $oid ) {
|
||||
$denied = $this->check_denied_groups( $claims, $oid );
|
||||
if ( true !== $denied ) {
|
||||
return $denied;
|
||||
}
|
||||
return $this->check_allowed_groups( $claims, $oid );
|
||||
}
|
||||
|
||||
/**
|
||||
* Group IDs from the "groups" claim, or null when the token has no complete list (claim missing or overage).
|
||||
*
|
||||
* @param array $claims Verified claims.
|
||||
* @return string[]|null
|
||||
*/
|
||||
private function token_groups( $claims ) {
|
||||
$overage = ! empty( $claims['_claim_names'] ) || ! empty( $claims['hasgroups'] );
|
||||
if ( $overage || ! isset( $claims['groups'] ) || ! is_array( $claims['groups'] ) ) {
|
||||
return null;
|
||||
}
|
||||
return array_map( 'strtolower', array_filter( $claims['groups'], 'is_string' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses members of an excluded group (fails closed).
|
||||
*
|
||||
* A "groups" claim can be filtered in the app registration (e.g. only groups assigned to the
|
||||
* application), so it can prove membership but never non-membership: without a match in the
|
||||
* token the plugin always asks Microsoft Graph.
|
||||
*
|
||||
* @param array $claims Verified claims.
|
||||
* @param string $oid User object ID.
|
||||
* @return true|string True, or an error code for fail().
|
||||
*/
|
||||
private function check_denied_groups( $claims, $oid ) {
|
||||
$denied = array_keys( $this->settings->denied_groups() );
|
||||
if ( empty( $denied ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$token_groups = $this->token_groups( $claims );
|
||||
if ( null !== $token_groups && array_intersect( $denied, $token_groups ) ) {
|
||||
$this->log( 'User is a member of an excluded group (token claim).' );
|
||||
return 'in_denied_group';
|
||||
}
|
||||
|
||||
if ( '' === $oid || ! M365_Login_Settings::is_guid( $oid ) ) {
|
||||
return 'invalid_token';
|
||||
}
|
||||
|
||||
$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';
|
||||
}
|
||||
if ( ! empty( $matches ) ) {
|
||||
$this->log( 'User is a member of an excluded group (Graph).' );
|
||||
return 'in_denied_group';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies membership in one of the allowed Entra groups.
|
||||
*
|
||||
|
|
@ -1097,14 +648,15 @@ class M365_Login_Auth {
|
|||
* @param string $oid User object ID.
|
||||
* @return true|string True, or an error code for fail().
|
||||
*/
|
||||
private function check_allowed_groups( $claims, $oid ) {
|
||||
private function check_groups( $claims, $oid ) {
|
||||
$allowed = array_keys( $this->settings->allowed_groups() );
|
||||
if ( empty( $allowed ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$token_groups = $this->token_groups( $claims );
|
||||
if ( null !== $token_groups ) {
|
||||
$overage = ! empty( $claims['_claim_names'] ) || ! empty( $claims['hasgroups'] );
|
||||
if ( ! $overage && isset( $claims['groups'] ) && is_array( $claims['groups'] ) ) {
|
||||
$token_groups = array_map( 'strtolower', array_filter( $claims['groups'], 'is_string' ) );
|
||||
if ( array_intersect( $allowed, $token_groups ) ) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1117,7 +669,7 @@ class M365_Login_Auth {
|
|||
return 'invalid_token';
|
||||
}
|
||||
|
||||
$matches = $this->graph->check_member_groups( $oid, $allowed, false );
|
||||
$matches = $this->graph->check_member_groups( $oid, $allowed );
|
||||
if ( is_wp_error( $matches ) ) {
|
||||
$this->log( 'Group check via Microsoft Graph failed: ' . $matches->get_error_message() );
|
||||
return 'group_check_failed';
|
||||
|
|
@ -1130,12 +682,12 @@ class M365_Login_Auth {
|
|||
}
|
||||
|
||||
/**
|
||||
* Addresses used for matching, in order (e-mail claim, then user principal name).
|
||||
* Extracts the e-mail address used for matching.
|
||||
*
|
||||
* @param array $claims Verified claims.
|
||||
* @return string[] Lowercase addresses.
|
||||
* @return string Lowercase e-mail or empty string.
|
||||
*/
|
||||
private function email_candidates( $claims ) {
|
||||
private function email_from_claims( $claims ) {
|
||||
$candidates = array();
|
||||
$email = ! empty( $claims['email'] ) && is_string( $claims['email'] ) ? $claims['email'] : '';
|
||||
$upn = ! empty( $claims['preferred_username'] ) && is_string( $claims['preferred_username'] ) ? $claims['preferred_username'] : '';
|
||||
|
|
@ -1159,23 +711,19 @@ class M365_Login_Auth {
|
|||
}
|
||||
}
|
||||
|
||||
$out = array();
|
||||
foreach ( $candidates as $candidate ) {
|
||||
$candidate = strtolower( trim( $candidate ) );
|
||||
if ( is_email( $candidate ) ) {
|
||||
/**
|
||||
* Filters an e-mail address used to look up the WordPress user.
|
||||
* Filters the e-mail address used to look up the WordPress user.
|
||||
*
|
||||
* @param string $email E-mail from the token.
|
||||
* @param array $claims Verified claims.
|
||||
*/
|
||||
$candidate = strtolower( (string) apply_filters( 'm365_login_match_email', $candidate, $claims ) );
|
||||
if ( is_email( $candidate ) && ! in_array( $candidate, $out, true ) ) {
|
||||
$out[] = $candidate;
|
||||
}
|
||||
return (string) apply_filters( 'm365_login_match_email', $candidate, $claims );
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1244,17 +792,6 @@ class M365_Login_Auth {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a diagnostic line to the debug log (only with WP_DEBUG and WP_DEBUG_LOG; never tokens or secrets).
|
||||
*
|
||||
* @param string $message Message.
|
||||
*/
|
||||
private function log( $message ) {
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
|
||||
error_log( '[M365 Login] ' . $message ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aborts the flow and shows a generic error on the login screen.
|
||||
*
|
||||
|
|
@ -1279,13 +816,6 @@ 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',
|
||||
|
|
@ -1311,29 +841,22 @@ 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' ),
|
||||
'privileged_unlinked' => __( 'For security reasons this administrator account is not linked automatically. Sign in once with your password and click "Link Microsoft account" on your profile page – or ask an administrator to enter your Microsoft account (user principal name) in your WordPress profile.', 'm365-login' ),
|
||||
'link_session' => __( 'The link could not be completed because you are no longer signed in to WordPress. Please sign in and try again.', 'm365-login' ),
|
||||
'link_other' => __( 'Your WordPress account is already linked to a different Microsoft account. An administrator can remove the link in your profile.', 'm365-login' ),
|
||||
'external_identity' => __( 'Guest and external accounts cannot sign in here.', 'm365-login' ),
|
||||
'password_reset_disabled' => __( 'Passwords are not used on this site. Please sign in with the Microsoft button.', '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' ),
|
||||
'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' ),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -167,7 +167,14 @@ class M365_Login_Button {
|
|||
* @return bool
|
||||
*/
|
||||
private function should_render() {
|
||||
if ( ! $this->settings->is_configured() || ! $this->is_login_action() ) {
|
||||
if ( ! $this->settings->is_configured() ) {
|
||||
return false;
|
||||
}
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only routing check.
|
||||
$action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : 'login';
|
||||
$interim = ! empty( $_REQUEST['interim-login'] );
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
||||
if ( $interim || ! in_array( $action, array( '', 'login' ), true ) ) {
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
|
|
@ -178,31 +185,13 @@ class M365_Login_Button {
|
|||
return (bool) apply_filters( 'm365_login_show_button', true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current wp-login.php request shows the sign-in form (not interim login or another action).
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function is_login_action() {
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only routing check.
|
||||
$action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : 'login';
|
||||
$interim = ! empty( $_REQUEST['interim-login'] );
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
||||
return ! $interim && in_array( $action, array( '', 'login' ), true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the password form is hidden for this request.
|
||||
*
|
||||
* Also while the connection is broken: password sign-in is refused anyway, the fields would only mislead.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function form_hidden() {
|
||||
if ( ! $this->settings->button_only() || M365_Login::instance()->auth->fallback_active() || ! $this->is_login_action() ) {
|
||||
return false;
|
||||
}
|
||||
return $this->should_render() || ! $this->settings->is_configured();
|
||||
return $this->should_render() && $this->settings->button_only() && ! M365_Login::instance()->auth->fallback_active();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -212,9 +201,6 @@ class M365_Login_Button {
|
|||
* @return string[]
|
||||
*/
|
||||
public function body_class( $classes ) {
|
||||
if ( $this->should_render() ) {
|
||||
$classes[] = 'm365-login-page';
|
||||
}
|
||||
if ( $this->form_hidden() ) {
|
||||
$classes[] = 'm365-button-only';
|
||||
}
|
||||
|
|
@ -225,8 +211,7 @@ class M365_Login_Button {
|
|||
* Enqueues login styles and the small positioning script.
|
||||
*/
|
||||
public function enqueue() {
|
||||
// Also when only the form is hidden (e.g. broken connection): the stylesheet hides the password form and links.
|
||||
if ( ! $this->should_render() && ! $this->form_hidden() ) {
|
||||
if ( ! $this->should_render() ) {
|
||||
return;
|
||||
}
|
||||
wp_enqueue_style( 'm365-login', M365_LOGIN_URL . 'assets/css/login.css', array(), M365_LOGIN_VERSION );
|
||||
|
|
|
|||
|
|
@ -97,9 +97,6 @@ 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' ) ) );
|
||||
|
|
@ -113,36 +110,12 @@ 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' => self::normalise_pem( $clean ),
|
||||
'certificate' => $cert_pem,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exactly one certificate re-exported from a PEM text, or '' if none can be read.
|
||||
*
|
||||
* @param string $pem PEM text (may contain other blocks).
|
||||
* @return string
|
||||
*/
|
||||
public static function clean_pem( $pem ) {
|
||||
if ( ! preg_match( '/-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----/s', (string) $pem, $m ) ) {
|
||||
return '';
|
||||
}
|
||||
$cert = openssl_x509_read( $m[0] );
|
||||
$clean = '';
|
||||
if ( false === $cert || ! openssl_x509_export( $cert, $clean ) ) {
|
||||
return '';
|
||||
}
|
||||
return self::normalise_pem( $clean );
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalises line endings and trims a PEM block.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -11,9 +11,7 @@ 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 – 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.
|
||||
* stored client secret is useless without access to wp-config.php.
|
||||
*/
|
||||
final class M365_Login_Crypto {
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Obtains app-only tokens via client credentials and queries users and groups.
|
||||
* Obtains app-only tokens via client credentials and queries groups.
|
||||
*/
|
||||
class M365_Login_Graph {
|
||||
|
||||
|
|
@ -98,68 +98,6 @@ class M365_Login_Graph {
|
|||
return (string) $body['access_token'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs an authenticated Graph request and returns the raw HTTP response.
|
||||
*
|
||||
* Retries a few times when Microsoft throttles (HTTP 429) or is briefly unavailable (503/504).
|
||||
*
|
||||
* @param string $method HTTP method.
|
||||
* @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.
|
||||
* @param int $max_bytes Maximum response size (0 = unlimited).
|
||||
* @return array|WP_Error Response array from wp_remote_request().
|
||||
*/
|
||||
private function raw_request( $method, $path, $json = null, $headers = array(), $retry = true, $max_bytes = 0 ) {
|
||||
$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.' );
|
||||
}
|
||||
|
||||
for ( $attempt = 1; ; $attempt++ ) {
|
||||
$token = $this->app_token();
|
||||
if ( is_wp_error( $token ) ) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
$args = array(
|
||||
'method' => $method,
|
||||
'timeout' => self::HTTP_TIMEOUT,
|
||||
'headers' => array_merge(
|
||||
array(
|
||||
'Authorization' => 'Bearer ' . $token,
|
||||
'Accept' => 'application/json',
|
||||
),
|
||||
$headers
|
||||
),
|
||||
);
|
||||
if ( null !== $json ) {
|
||||
$args['headers']['Content-Type'] = 'application/json';
|
||||
$args['body'] = wp_json_encode( $json );
|
||||
}
|
||||
if ( $max_bytes > 0 ) {
|
||||
$args['limit_response_size'] = $max_bytes; // Stop downloading oversized bodies early.
|
||||
}
|
||||
|
||||
$response = wp_remote_request( $url, $args );
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$code = (int) wp_remote_retrieve_response_code( $response );
|
||||
if ( 401 === $code ) {
|
||||
$this->flush_token();
|
||||
}
|
||||
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;
|
||||
}
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs an authenticated Graph request.
|
||||
*
|
||||
|
|
@ -167,11 +105,31 @@ 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.
|
||||
* @return array|WP_Error Decoded JSON.
|
||||
*/
|
||||
private function request( $method, $path, $json = null, $headers = array(), $retry = true ) {
|
||||
$response = $this->raw_request( $method, $path, $json, $headers, $retry );
|
||||
private function request( $method, $path, $json = null, $headers = array() ) {
|
||||
$token = $this->app_token();
|
||||
if ( is_wp_error( $token ) ) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
$args = array(
|
||||
'method' => $method,
|
||||
'timeout' => self::HTTP_TIMEOUT,
|
||||
'headers' => array_merge(
|
||||
array(
|
||||
'Authorization' => 'Bearer ' . $token,
|
||||
'Accept' => 'application/json',
|
||||
),
|
||||
$headers
|
||||
),
|
||||
);
|
||||
if ( null !== $json ) {
|
||||
$args['headers']['Content-Type'] = 'application/json';
|
||||
$args['body'] = wp_json_encode( $json );
|
||||
}
|
||||
|
||||
$response = wp_remote_request( self::GRAPH_BASE . $path, $args );
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
|
@ -179,215 +137,18 @@ class M365_Login_Graph {
|
|||
$code = (int) wp_remote_retrieve_response_code( $response );
|
||||
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
|
||||
if ( 401 === $code ) {
|
||||
$this->flush_token();
|
||||
}
|
||||
if ( $code < 200 || $code >= 300 || ! is_array( $body ) ) {
|
||||
return $this->error_from( $code, $body );
|
||||
$graph_code = isset( $body['error']['code'] ) ? (string) $body['error']['code'] : 'HTTP ' . $code;
|
||||
$message = isset( $body['error']['message'] ) ? (string) $body['error']['message'] : '';
|
||||
return new WP_Error( 'graph_' . sanitize_key( $graph_code ), $graph_code . ( $message ? ': ' . $message : '' ) );
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a WP_Error from a failed Graph response.
|
||||
*
|
||||
* @param int $code HTTP status.
|
||||
* @param array|null $body Decoded body.
|
||||
* @return WP_Error
|
||||
*/
|
||||
private function error_from( $code, $body ) {
|
||||
$graph_code = isset( $body['error']['code'] ) ? (string) $body['error']['code'] : 'HTTP ' . $code;
|
||||
$message = isset( $body['error']['message'] ) ? (string) $body['error']['message'] : '';
|
||||
return new WP_Error( 'graph_' . sanitize_key( $graph_code ), $graph_code . ( $message ? ': ' . $message : '' ), array( 'status' => (int) $code ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a Graph error means "object does not exist".
|
||||
*
|
||||
* @param WP_Error $error Error.
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_not_found( $error ) {
|
||||
$data = $error->get_error_data();
|
||||
return is_array( $data ) && isset( $data['status'] ) && 404 === (int) $data['status'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows @odata.nextLink until every page of a collection is read.
|
||||
*
|
||||
* @param string $path First page (relative path).
|
||||
* @param array $headers Extra headers.
|
||||
* @return array[]|WP_Error All items.
|
||||
*/
|
||||
private function collect( $path, $headers = array() ) {
|
||||
$items = array();
|
||||
$next = $path;
|
||||
$pages = 0;
|
||||
while ( '' !== $next ) {
|
||||
if ( ++$pages > 1000 ) {
|
||||
return new WP_Error( 'graph_paging', 'Too many result pages.' );
|
||||
}
|
||||
$result = $this->request( 'GET', $next, null, $headers );
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
if ( isset( $result['value'] ) && is_array( $result['value'] ) ) {
|
||||
foreach ( $result['value'] as $item ) {
|
||||
if ( is_array( $item ) && ! empty( $item['id'] ) ) {
|
||||
$items[] = $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
$next = isset( $result['@odata.nextLink'] ) && is_string( $result['@odata.nextLink'] ) ? $result['@odata.nextLink'] : '';
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists every user of the tenant.
|
||||
*
|
||||
* @param string[] $select Properties to read.
|
||||
* @return array[]|WP_Error
|
||||
*/
|
||||
public function list_users( $select ) {
|
||||
return $this->collect( '/users?$select=' . rawurlencode( implode( ',', $select ) ) . '&$top=999' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the users that are (directly or through nested groups) members of a group.
|
||||
*
|
||||
* @param string $group_id Group object ID.
|
||||
* @param string[] $select Properties to read.
|
||||
* @return array[]|WP_Error
|
||||
*/
|
||||
public function list_group_users( $group_id, $select ) {
|
||||
if ( ! M365_Login_Settings::is_guid( $group_id ) ) {
|
||||
return new WP_Error( 'graph_bad_group', 'Invalid group object ID.' );
|
||||
}
|
||||
return $this->collect(
|
||||
'/groups/' . rawurlencode( strtolower( $group_id ) ) . '/transitiveMembers/microsoft.graph.user?$select=' . rawurlencode( implode( ',', $select ) ) . '&$top=999&$count=true',
|
||||
array( 'ConsistencyLevel' => 'eventual' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a single user.
|
||||
*
|
||||
* @param string $oid User object ID.
|
||||
* @param string[] $select Properties to read.
|
||||
* @return array|WP_Error WP_Error with status 404 when the user does not exist (anymore).
|
||||
*/
|
||||
public function get_user( $oid, $select ) {
|
||||
if ( ! M365_Login_Settings::is_guid( $oid ) ) {
|
||||
return new WP_Error( 'graph_bad_oid', 'Invalid user object ID.' );
|
||||
}
|
||||
return $this->request( 'GET', '/users/' . rawurlencode( strtolower( $oid ) ) . '?$select=' . rawurlencode( implode( ',', $select ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs up to 20 GET requests in one Graph JSON batch.
|
||||
*
|
||||
* @param string[] $paths Request key => path relative to the v1.0 base.
|
||||
* @return array|WP_Error Request key => array( 'status' => int, 'body' => mixed ).
|
||||
*/
|
||||
public function batch_get( $paths ) {
|
||||
$requests = array();
|
||||
foreach ( array_values( $paths ) as $i => $path ) {
|
||||
$requests[] = array(
|
||||
'id' => (string) $i,
|
||||
'method' => 'GET',
|
||||
'url' => $path,
|
||||
);
|
||||
}
|
||||
$keys = array_keys( $paths );
|
||||
if ( empty( $requests ) ) {
|
||||
return array();
|
||||
}
|
||||
if ( count( $requests ) > 20 ) {
|
||||
return new WP_Error( 'graph_batch_size', 'A Graph batch holds at most 20 requests.' );
|
||||
}
|
||||
|
||||
$result = $this->request( 'POST', '/$batch', array( 'requests' => $requests ) );
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$out = array();
|
||||
foreach ( isset( $result['responses'] ) && is_array( $result['responses'] ) ? $result['responses'] : array() as $response ) {
|
||||
$i = isset( $response['id'] ) ? (int) $response['id'] : -1;
|
||||
if ( isset( $keys[ $i ] ) ) {
|
||||
$out[ $keys[ $i ] ] = array(
|
||||
'status' => isset( $response['status'] ) ? (int) $response['status'] : 0,
|
||||
'body' => isset( $response['body'] ) ? $response['body'] : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile photo versions of several users (one batch request per 20 users).
|
||||
*
|
||||
* @param string[] $oids User object IDs.
|
||||
* @return array oid => etag string, null (user has no photo) or WP_Error (could not be checked).
|
||||
*/
|
||||
public function photo_versions( $oids ) {
|
||||
$out = array();
|
||||
foreach ( array_chunk( array_values( array_filter( $oids, array( 'M365_Login_Settings', 'is_guid' ) ) ), 20 ) as $chunk ) {
|
||||
$paths = array();
|
||||
foreach ( $chunk as $oid ) {
|
||||
$paths[ $oid ] = '/users/' . rawurlencode( strtolower( $oid ) ) . '/photo';
|
||||
}
|
||||
$responses = $this->batch_get( $paths );
|
||||
foreach ( $chunk as $oid ) {
|
||||
if ( is_wp_error( $responses ) ) {
|
||||
$out[ $oid ] = $responses;
|
||||
continue;
|
||||
}
|
||||
$response = isset( $responses[ $oid ] ) ? $responses[ $oid ] : array(
|
||||
'status' => 0,
|
||||
'body' => null,
|
||||
);
|
||||
if ( 404 === $response['status'] ) {
|
||||
$out[ $oid ] = null;
|
||||
} elseif ( 200 === $response['status'] && is_array( $response['body'] ) ) {
|
||||
$etag = isset( $response['body']['@odata.mediaEtag'] ) ? (string) $response['body']['@odata.mediaEtag'] : '';
|
||||
$out[ $oid ] = '' !== $etag ? $etag : md5( (string) wp_json_encode( $response['body'] ) );
|
||||
} else {
|
||||
$code = isset( $response['body']['error']['code'] ) ? (string) $response['body']['error']['code'] : 'HTTP ' . $response['status'];
|
||||
$out[ $oid ] = new WP_Error( 'graph_photo', $code, array( 'status' => $response['status'] ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a user's photo (240×240 rendition, else the original).
|
||||
*
|
||||
* @param string $oid User object ID.
|
||||
* @return string|null|WP_Error Binary image data, null when the user has no photo.
|
||||
*/
|
||||
public function photo_bytes( $oid ) {
|
||||
if ( ! M365_Login_Settings::is_guid( $oid ) ) {
|
||||
return new WP_Error( 'graph_bad_oid', 'Invalid user object ID.' );
|
||||
}
|
||||
$base = '/users/' . rawurlencode( strtolower( $oid ) );
|
||||
foreach ( array( $base . '/photos/240x240/$value', $base . '/photo/$value' ) as $path ) {
|
||||
$response = $this->raw_request( 'GET', $path, null, array( 'Accept' => 'image/*' ), true, 2 * MB_IN_BYTES + 1 );
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
$code = (int) wp_remote_retrieve_response_code( $response );
|
||||
$body = wp_remote_retrieve_body( $response );
|
||||
if ( 200 === $code ) {
|
||||
return $body;
|
||||
}
|
||||
if ( 404 !== $code ) {
|
||||
return $this->error_from( $code, json_decode( $body, true ) );
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches groups by display name.
|
||||
*
|
||||
|
|
@ -396,10 +157,10 @@ class M365_Login_Graph {
|
|||
*/
|
||||
public function search_groups( $query ) {
|
||||
$query = trim( (string) $query );
|
||||
$select = '$select=id,displayName,description,securityEnabled,mailEnabled,groupTypes,visibility&$top=25&$orderby=displayName';
|
||||
$select = '$select=id,displayName,description,securityEnabled,mailEnabled&$top=25&$orderby=displayName';
|
||||
|
||||
if ( '' !== $query && M365_Login_Settings::is_guid( $query ) ) {
|
||||
$path = '/groups/' . rawurlencode( strtolower( $query ) ) . '?$select=id,displayName,description,securityEnabled,mailEnabled,groupTypes,visibility';
|
||||
$path = '/groups/' . rawurlencode( strtolower( $query ) ) . '?$select=id,displayName,description,securityEnabled,mailEnabled';
|
||||
$item = $this->request( 'GET', $path );
|
||||
if ( is_wp_error( $item ) ) {
|
||||
return $item;
|
||||
|
|
@ -443,10 +204,6 @@ 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'],
|
||||
|
|
@ -460,10 +217,9 @@ 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, $retry = true ) {
|
||||
public function check_member_groups( $user_oid, $group_ids ) {
|
||||
if ( ! M365_Login_Settings::is_guid( $user_oid ) ) {
|
||||
return new WP_Error( 'graph_bad_oid', 'Invalid user object ID.' );
|
||||
}
|
||||
|
|
@ -473,9 +229,7 @@ class M365_Login_Graph {
|
|||
$result = $this->request(
|
||||
'POST',
|
||||
'/users/' . rawurlencode( strtolower( $user_oid ) ) . '/checkMemberGroups',
|
||||
array( 'groupIds' => $chunk ),
|
||||
array(),
|
||||
$retry
|
||||
array( 'groupIds' => $chunk )
|
||||
);
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
|
|
|
|||
|
|
@ -19,13 +19,6 @@ class M365_Login_Settings {
|
|||
*/
|
||||
private $cache = null;
|
||||
|
||||
/**
|
||||
* Set while the plugin writes already sanitised values (skips the form sanitiser).
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private $raw_write = false;
|
||||
|
||||
/**
|
||||
* Default settings.
|
||||
*
|
||||
|
|
@ -34,51 +27,36 @@ class M365_Login_Settings {
|
|||
public function defaults() {
|
||||
return array(
|
||||
// Connection.
|
||||
'tenant_id' => '',
|
||||
'client_id' => '',
|
||||
'client_secret' => '', // Stored encrypted.
|
||||
'auth_method' => 'secret', // 'secret' or 'certificate'.
|
||||
'cert_private_key' => '', // PEM, stored encrypted.
|
||||
'cert_certificate' => '', // PEM (public).
|
||||
'prompt' => 'select_account',
|
||||
'tenant_id' => '',
|
||||
'client_id' => '',
|
||||
'client_secret' => '', // Stored encrypted.
|
||||
'auth_method' => 'secret', // 'secret' or 'certificate'.
|
||||
'cert_private_key' => '', // PEM, stored encrypted.
|
||||
'cert_certificate' => '', // PEM (public).
|
||||
'prompt' => 'select_account',
|
||||
// Security / matching.
|
||||
'upn_fallback' => 1,
|
||||
'bind_oid' => 1,
|
||||
'allowed_domains' => '',
|
||||
'allowed_groups' => array(), // id => display name.
|
||||
'denied_groups' => array(), // id => display name; members may never sign in.
|
||||
'remember_me' => 0,
|
||||
'upn_fallback' => 1,
|
||||
'bind_oid' => 1,
|
||||
'allowed_domains' => '',
|
||||
'allowed_groups' => array(), // id => display name.
|
||||
'remember_me' => 0,
|
||||
// Button-only mode.
|
||||
'button_only' => 0,
|
||||
'fallback_key' => '',
|
||||
'button_only' => 0,
|
||||
'fallback_key' => '',
|
||||
// Button appearance.
|
||||
'button_text' => __( 'Sign in with Microsoft', 'm365-login' ),
|
||||
'button_icon' => '', // Empty = bundled Microsoft logo.
|
||||
'button_show_icon' => 1,
|
||||
'button_bg' => '#2f2f2f',
|
||||
'button_bg_hover' => '#1a1a1a',
|
||||
'button_color' => '#ffffff',
|
||||
'button_border' => '#2f2f2f',
|
||||
'button_radius' => 4,
|
||||
'button_position' => 'below',
|
||||
'divider_text' => __( 'or', 'm365-login' ),
|
||||
'button_text' => __( 'Sign in with Microsoft', 'm365-login' ),
|
||||
'button_icon' => '', // Empty = bundled Microsoft logo.
|
||||
'button_show_icon' => 1,
|
||||
'button_bg' => '#2f2f2f',
|
||||
'button_bg_hover' => '#1a1a1a',
|
||||
'button_color' => '#ffffff',
|
||||
'button_border' => '#2f2f2f',
|
||||
'button_radius' => 4,
|
||||
'button_position' => 'below',
|
||||
'divider_text' => __( 'or', 'm365-login' ),
|
||||
// Custom login pages.
|
||||
'custom_login_url' => '',
|
||||
'inject_form' => 1, // Add the button to wp_login_form() output.
|
||||
// User sync.
|
||||
'sync_enabled' => 0, // Scheduled sync via WP-Cron.
|
||||
'sync_interval' => 'daily',
|
||||
'sync_guests' => 0,
|
||||
'sync_scope_groups' => array(), // id => display name; empty = whole tenant.
|
||||
'sync_default_role' => 'subscriber',
|
||||
'sync_role_map' => array(), // id => array( 'name' => .., 'role' => .. ), in priority order.
|
||||
'sync_role_mode' => 'add', // 'add' (extra roles) or 'replace' (first match replaces the default role).
|
||||
'sync_manage_existing' => 0, // Also manage roles of accounts that existed before the sync.
|
||||
'sync_attributes' => array( 'displayName', 'givenName', 'surname' ),
|
||||
'sync_disabled_action' => 'disable', // Account disabled in Microsoft 365: none|disable|delete.
|
||||
'sync_deleted_action' => 'disable', // Account deleted in Microsoft 365: none|disable|delete.
|
||||
'sync_scope_action' => 'none', // Removed from the sync groups: none|disable|delete.
|
||||
'sync_reassign' => 0, // User ID that receives content of deleted users.
|
||||
'custom_login_url' => '',
|
||||
'inject_form' => 1, // Add the button to wp_login_form() output.
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -95,13 +73,6 @@ class M365_Login_Settings {
|
|||
return $this->cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the cached settings (after the option was written).
|
||||
*/
|
||||
public function flush() {
|
||||
$this->cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a single setting.
|
||||
*
|
||||
|
|
@ -228,26 +199,11 @@ class M365_Login_Settings {
|
|||
$all = $this->all();
|
||||
$all['cert_private_key'] = $enc;
|
||||
$all['cert_certificate'] = $pair['certificate'];
|
||||
$this->write( $all );
|
||||
update_option( M365_LOGIN_OPTION, $all );
|
||||
$this->cache = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the stored certificate by a clean re-export (drops key bundles or chains saved by older versions).
|
||||
*/
|
||||
public function normalise_stored_certificate() {
|
||||
$pem = $this->certificate_pem();
|
||||
if ( '' === $pem ) {
|
||||
return;
|
||||
}
|
||||
$clean = M365_Login_Certificate::clean_pem( $pem );
|
||||
if ( '' !== $clean && $clean !== $pem ) {
|
||||
$all = $this->all();
|
||||
$all['cert_certificate'] = $clean;
|
||||
$this->write( $all );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the stored certificate and key.
|
||||
*/
|
||||
|
|
@ -255,22 +211,8 @@ class M365_Login_Settings {
|
|||
$all = $this->all();
|
||||
$all['cert_private_key'] = '';
|
||||
$all['cert_certificate'] = '';
|
||||
$this->write( $all );
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores already sanitised settings.
|
||||
*
|
||||
* The option is registered with sanitize() as callback, which expects raw form input
|
||||
* (it would, for example, encrypt the stored client secret a second time).
|
||||
*
|
||||
* @param array $all Complete settings.
|
||||
*/
|
||||
private function write( $all ) {
|
||||
$this->raw_write = true;
|
||||
update_option( M365_LOGIN_OPTION, $all, false ); // Holds encrypted secrets: never autoloaded.
|
||||
$this->raw_write = false;
|
||||
$this->cache = null;
|
||||
update_option( M365_LOGIN_OPTION, $all );
|
||||
$this->cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -347,56 +289,7 @@ class M365_Login_Settings {
|
|||
* @return array
|
||||
*/
|
||||
public function allowed_groups() {
|
||||
return self::guid_map( $this->get( 'allowed_groups', array() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Excluded Entra group IDs (lowercase GUIDs) mapped to display names.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function denied_groups() {
|
||||
return self::guid_map( $this->get( 'denied_groups', array() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups that limit the user sync (lowercase GUID => name); empty = whole tenant.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function sync_scope_groups() {
|
||||
return self::guid_map( $this->get( 'sync_scope_groups', array() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Group → role mapping in priority order.
|
||||
*
|
||||
* @return array lowercase GUID => array( 'name' => string, 'role' => string ).
|
||||
*/
|
||||
public function sync_role_map() {
|
||||
$raw = $this->get( 'sync_role_map', array() );
|
||||
$out = array();
|
||||
if ( is_array( $raw ) ) {
|
||||
foreach ( $raw as $id => $row ) {
|
||||
$id = strtolower( (string) $id );
|
||||
if ( self::is_guid( $id ) && is_array( $row ) && ! empty( $row['role'] ) ) {
|
||||
$out[ $id ] = array(
|
||||
'name' => isset( $row['name'] ) ? (string) $row['name'] : $id,
|
||||
'role' => (string) $row['role'],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps GUID keys (lowercased) of an id => name array.
|
||||
*
|
||||
* @param mixed $raw Stored value.
|
||||
* @return array
|
||||
*/
|
||||
private static function guid_map( $raw ) {
|
||||
$raw = $this->get( 'allowed_groups', array() );
|
||||
$out = array();
|
||||
if ( is_array( $raw ) ) {
|
||||
foreach ( $raw as $id => $name ) {
|
||||
|
|
@ -418,10 +311,7 @@ class M365_Login_Settings {
|
|||
if ( defined( 'M365_LOGIN_DISABLE_BUTTON_ONLY' ) && M365_LOGIN_DISABLE_BUTTON_ONLY ) {
|
||||
return false;
|
||||
}
|
||||
// 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();
|
||||
return $this->is_configured() && (bool) $this->get( 'button_only' ) && '' !== $this->fallback_key();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -489,10 +379,6 @@ class M365_Login_Settings {
|
|||
* @return array
|
||||
*/
|
||||
public function sanitize( $input ) {
|
||||
if ( $this->raw_write ) {
|
||||
return $input;
|
||||
}
|
||||
|
||||
$defaults = $this->defaults();
|
||||
$current = $this->all();
|
||||
$input = is_array( $input ) ? $input : array();
|
||||
|
|
@ -516,7 +402,7 @@ class M365_Login_Settings {
|
|||
$out['client_id'] = strtolower( $client_id );
|
||||
|
||||
// Client secret: only replaced when a new value was entered.
|
||||
$secret_input = self::scalar( $input, 'client_secret' );
|
||||
$secret_input = isset( $input['client_secret'] ) ? (string) wp_unslash( $input['client_secret'] ) : '';
|
||||
$secret_input = trim( $secret_input );
|
||||
if ( ! empty( $input['client_secret_clear'] ) ) {
|
||||
$out['client_secret'] = '';
|
||||
|
|
@ -539,8 +425,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 = trim( self::scalar( $input, 'cert_key_pem' ) );
|
||||
$pasted_cert = trim( self::scalar( $input, 'cert_cert_pem' ) );
|
||||
$pasted_key = isset( $input['cert_key_pem'] ) ? trim( (string) wp_unslash( $input['cert_key_pem'] ) ) : '';
|
||||
$pasted_cert = isset( $input['cert_cert_pem'] ) ? trim( (string) wp_unslash( $input['cert_cert_pem'] ) ) : '';
|
||||
if ( ! empty( $input['cert_remove'] ) ) {
|
||||
$out['cert_private_key'] = '';
|
||||
$out['cert_certificate'] = '';
|
||||
|
|
@ -575,13 +461,26 @@ class M365_Login_Settings {
|
|||
$out['bind_oid'] = empty( $input['bind_oid'] ) ? 0 : 1;
|
||||
$out['remember_me'] = empty( $input['remember_me'] ) ? 0 : 1;
|
||||
|
||||
$domains = isset( $input['allowed_domains'] ) ? sanitize_textarea_field( wp_unslash( $input['allowed_domains'] ) ) : '';
|
||||
$domains = preg_replace( '/[^a-z0-9.\-@,;\s]/i', '', $domains );
|
||||
$domains = isset( $input['allowed_domains'] ) ? sanitize_textarea_field( wp_unslash( $input['allowed_domains'] ) ) : '';
|
||||
$domains = preg_replace( '/[^a-z0-9.\-@,;\s]/i', '', $domains );
|
||||
$out['allowed_domains'] = trim( (string) $domains );
|
||||
|
||||
// Allowed groups: GUID => name.
|
||||
$out['allowed_groups'] = self::sanitize_group_list( isset( $input['allowed_groups'] ) ? $input['allowed_groups'] : array() );
|
||||
$out['denied_groups'] = self::sanitize_group_list( isset( $input['denied_groups'] ) ? $input['denied_groups'] : array() );
|
||||
$groups = array();
|
||||
if ( ! empty( $input['allowed_groups'] ) && is_array( $input['allowed_groups'] ) ) {
|
||||
foreach ( $input['allowed_groups'] as $id => $name ) {
|
||||
$id = strtolower( trim( sanitize_text_field( wp_unslash( (string) $id ) ) ) );
|
||||
if ( ! self::is_guid( $id ) ) {
|
||||
continue;
|
||||
}
|
||||
$name = sanitize_text_field( wp_unslash( (string) $name ) );
|
||||
$groups[ $id ] = '' === $name ? $id : mb_substr( $name, 0, 120 );
|
||||
if ( count( $groups ) >= 100 ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$out['allowed_groups'] = $groups;
|
||||
|
||||
// Button-only mode + fallback key.
|
||||
$out['button_only'] = empty( $input['button_only'] ) ? 0 : 1;
|
||||
|
|
@ -592,7 +491,7 @@ class M365_Login_Settings {
|
|||
$out['fallback_key'] = $key;
|
||||
|
||||
// Custom login page (must be on this site).
|
||||
$custom = esc_url_raw( trim( self::scalar( $input, 'custom_login_url' ) ) );
|
||||
$custom = isset( $input['custom_login_url'] ) ? esc_url_raw( trim( wp_unslash( $input['custom_login_url'] ) ) ) : '';
|
||||
if ( '' !== $custom ) {
|
||||
if ( 0 === strpos( $custom, '/' ) ) {
|
||||
$custom = home_url( $custom );
|
||||
|
|
@ -609,13 +508,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 = esc_url_raw( trim( self::scalar( $input, 'button_icon' ) ) );
|
||||
$icon = isset( $input['button_icon'] ) ? esc_url_raw( trim( wp_unslash( $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 = sanitize_hex_color( trim( self::scalar( $input, $color_key ) ) );
|
||||
$color = isset( $input[ $color_key ] ) ? sanitize_hex_color( trim( wp_unslash( $input[ $color_key ] ) ) ) : '';
|
||||
$out[ $color_key ] = $color ? $color : $defaults[ $color_key ];
|
||||
}
|
||||
|
||||
|
|
@ -628,128 +527,11 @@ class M365_Login_Settings {
|
|||
$divider = isset( $input['divider_text'] ) ? sanitize_text_field( wp_unslash( $input['divider_text'] ) ) : '';
|
||||
$out['divider_text'] = mb_substr( $divider, 0, 40 );
|
||||
|
||||
$out = $this->sanitize_sync( $input, $out );
|
||||
|
||||
$this->cache = null;
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitises the user sync settings.
|
||||
*
|
||||
* @param array $input Raw input.
|
||||
* @param array $out Settings sanitised so far.
|
||||
* @return array
|
||||
*/
|
||||
private function sanitize_sync( $input, $out ) {
|
||||
$defaults = $this->defaults();
|
||||
|
||||
$out['sync_enabled'] = empty( $input['sync_enabled'] ) ? 0 : 1;
|
||||
$out['sync_guests'] = empty( $input['sync_guests'] ) ? 0 : 1;
|
||||
$out['sync_manage_existing'] = empty( $input['sync_manage_existing'] ) ? 0 : 1;
|
||||
|
||||
$interval = isset( $input['sync_interval'] ) ? sanitize_key( $input['sync_interval'] ) : '';
|
||||
$out['sync_interval'] = in_array( $interval, array( 'hourly', 'twicedaily', 'daily' ), true ) ? $interval : $defaults['sync_interval'];
|
||||
|
||||
$mode = isset( $input['sync_role_mode'] ) ? sanitize_key( $input['sync_role_mode'] ) : '';
|
||||
$out['sync_role_mode'] = in_array( $mode, array( 'add', 'replace' ), true ) ? $mode : $defaults['sync_role_mode'];
|
||||
|
||||
$role = isset( $input['sync_default_role'] ) ? sanitize_key( $input['sync_default_role'] ) : '';
|
||||
$out['sync_default_role'] = '' !== $role && get_role( $role ) ? $role : $defaults['sync_default_role'];
|
||||
|
||||
$out['sync_scope_groups'] = self::sanitize_group_list( isset( $input['sync_scope_groups'] ) ? $input['sync_scope_groups'] : array() );
|
||||
|
||||
$map = array();
|
||||
if ( ! empty( $input['sync_role_map'] ) && is_array( $input['sync_role_map'] ) ) {
|
||||
foreach ( $input['sync_role_map'] as $id => $row ) {
|
||||
$id = strtolower( trim( sanitize_text_field( wp_unslash( (string) $id ) ) ) );
|
||||
if ( ! self::is_guid( $id ) || ! is_array( $row ) ) {
|
||||
continue;
|
||||
}
|
||||
$map_role = isset( $row['role'] ) ? sanitize_key( $row['role'] ) : '';
|
||||
if ( '' === $map_role || ! get_role( $map_role ) ) {
|
||||
continue;
|
||||
}
|
||||
$name = isset( $row['name'] ) && is_scalar( $row['name'] ) ? sanitize_text_field( wp_unslash( (string) $row['name'] ) ) : '';
|
||||
$map[ $id ] = array(
|
||||
'name' => '' === $name ? $id : mb_substr( $name, 0, 120 ),
|
||||
'role' => $map_role,
|
||||
);
|
||||
if ( count( $map ) >= 100 ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$out['sync_role_map'] = $map;
|
||||
|
||||
$attributes = array();
|
||||
if ( ! empty( $input['sync_attributes'] ) && is_array( $input['sync_attributes'] ) ) {
|
||||
$known = array_keys( M365_Login_Sync::attributes() );
|
||||
foreach ( $input['sync_attributes'] as $attribute ) {
|
||||
if ( ! is_scalar( $attribute ) ) {
|
||||
continue;
|
||||
}
|
||||
$attribute = sanitize_text_field( wp_unslash( (string) $attribute ) );
|
||||
if ( in_array( $attribute, $known, true ) ) {
|
||||
$attributes[] = $attribute;
|
||||
}
|
||||
}
|
||||
}
|
||||
$out['sync_attributes'] = array_values( array_unique( $attributes ) );
|
||||
|
||||
foreach ( array( 'sync_disabled_action', 'sync_deleted_action', 'sync_scope_action' ) as $key ) {
|
||||
$action = isset( $input[ $key ] ) ? sanitize_key( $input[ $key ] ) : '';
|
||||
$out[ $key ] = in_array( $action, array( 'none', 'disable', 'delete' ), true ) ? $action : $defaults[ $key ];
|
||||
}
|
||||
|
||||
$reassign = isset( $input['sync_reassign'] ) ? absint( $input['sync_reassign'] ) : 0;
|
||||
$out['sync_reassign'] = $reassign && get_userdata( $reassign ) ? $reassign : 0;
|
||||
|
||||
$deletes = in_array( 'delete', array( $out['sync_disabled_action'], $out['sync_deleted_action'], $out['sync_scope_action'] ), true );
|
||||
if ( $deletes && ! $out['sync_reassign'] ) {
|
||||
add_settings_error( M365_LOGIN_OPTION, 'sync_reassign', __( 'User sync: "Delete" needs a user who receives the posts of deleted accounts. Until one is selected, accounts are deactivated instead.', 'm365-login' ), 'warning' );
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* @param mixed $raw Raw input.
|
||||
* @return array
|
||||
*/
|
||||
private static function sanitize_group_list( $raw ) {
|
||||
$groups = array();
|
||||
if ( empty( $raw ) || ! is_array( $raw ) ) {
|
||||
return $groups;
|
||||
}
|
||||
foreach ( $raw as $id => $name ) {
|
||||
$id = strtolower( trim( sanitize_text_field( wp_unslash( (string) $id ) ) ) );
|
||||
if ( ! self::is_guid( $id ) ) {
|
||||
continue;
|
||||
}
|
||||
$name = is_scalar( $name ) ? sanitize_text_field( wp_unslash( (string) $name ) ) : '';
|
||||
$groups[ $id ] = '' === $name ? $id : mb_substr( $name, 0, 120 );
|
||||
if ( count( $groups ) >= 100 ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a GUID.
|
||||
*
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -40,13 +40,6 @@ final class M365_Login {
|
|||
*/
|
||||
public $graph;
|
||||
|
||||
/**
|
||||
* User sync component.
|
||||
*
|
||||
* @var M365_Login_Sync
|
||||
*/
|
||||
public $sync;
|
||||
|
||||
/**
|
||||
* Login button component.
|
||||
*
|
||||
|
|
@ -82,31 +75,13 @@ final class M365_Login {
|
|||
$this->settings = new M365_Login_Settings();
|
||||
$this->graph = new M365_Login_Graph( $this->settings );
|
||||
$this->auth = new M365_Login_Auth( $this->settings, $this->graph );
|
||||
$this->sync = new M365_Login_Sync( $this->settings, $this->graph );
|
||||
$this->button = new M365_Login_Button( $this->settings );
|
||||
|
||||
if ( is_admin() ) {
|
||||
$this->admin = new M365_Login_Admin( $this->settings, $this->auth, $this->graph, $this->sync );
|
||||
$this->admin = new M365_Login_Admin( $this->settings, $this->auth, $this->graph );
|
||||
}
|
||||
|
||||
add_filter( 'plugin_action_links_' . plugin_basename( M365_LOGIN_FILE ), array( $this, 'action_links' ) );
|
||||
add_action( 'init', array( $this, 'maybe_upgrade' ), 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time data migrations after an update.
|
||||
*/
|
||||
public function maybe_upgrade() {
|
||||
$stored = (string) get_option( 'm365_login_version', '1.0.0' );
|
||||
if ( version_compare( $stored, M365_LOGIN_VERSION, '>=' ) ) {
|
||||
return;
|
||||
}
|
||||
update_option( 'm365_login_version', M365_LOGIN_VERSION );
|
||||
|
||||
if ( version_compare( $stored, '1.1.0', '<' ) ) {
|
||||
M365_Login_Sync::harden_legacy_disabled();
|
||||
$this->settings->normalise_stored_certificate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
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
|
|
@ -2,8 +2,8 @@
|
|||
/**
|
||||
* Plugin Name: M365 Login
|
||||
* Plugin URI: https://github.com/friloo/wp-m365-login
|
||||
* Description: Adds a customisable "Sign in with Microsoft" button to the WordPress login page. Users are matched by e-mail address via Microsoft Entra ID (OpenID Connect, PKCE); an optional user sync imports Microsoft 365 users with roles and profile fields.
|
||||
* Version: 1.1.0
|
||||
* Description: Adds a customisable "Sign in with Microsoft" button to the WordPress login page. Existing users are matched by e-mail address via Microsoft Entra ID (OpenID Connect, PKCE).
|
||||
* Version: 1.0.0
|
||||
* Requires at least: 6.0
|
||||
* Requires PHP: 7.4
|
||||
* Author: friloo
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
define( 'M365_LOGIN_VERSION', '1.1.0' );
|
||||
define( 'M365_LOGIN_VERSION', '1.0.0' );
|
||||
define( 'M365_LOGIN_FILE', __FILE__ );
|
||||
define( 'M365_LOGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||
define( 'M365_LOGIN_URL', plugin_dir_url( __FILE__ ) );
|
||||
|
|
@ -28,13 +28,11 @@ require_once M365_LOGIN_DIR . 'includes/class-m365-login-jwt.php';
|
|||
require_once M365_LOGIN_DIR . 'includes/class-m365-login-certificate.php';
|
||||
require_once M365_LOGIN_DIR . 'includes/class-m365-login-graph.php';
|
||||
require_once M365_LOGIN_DIR . 'includes/class-m365-login-auth.php';
|
||||
require_once M365_LOGIN_DIR . 'includes/class-m365-login-sync.php';
|
||||
require_once M365_LOGIN_DIR . 'includes/class-m365-login-button.php';
|
||||
require_once M365_LOGIN_DIR . 'includes/class-m365-login-admin.php';
|
||||
require_once M365_LOGIN_DIR . 'includes/class-m365-login.php';
|
||||
require_once M365_LOGIN_DIR . 'includes/functions.php';
|
||||
|
||||
register_activation_hook( __FILE__, array( 'M365_Login', 'activate' ) );
|
||||
register_deactivation_hook( __FILE__, array( 'M365_Login_Sync', 'unschedule' ) );
|
||||
|
||||
add_action( 'plugins_loaded', array( 'M365_Login', 'instance' ) );
|
||||
|
|
|
|||
56
readme.txt
56
readme.txt
|
|
@ -4,7 +4,7 @@ Tags: microsoft, entra id, azure ad, sso, login
|
|||
Requires at least: 6.0
|
||||
Tested up to: 6.9
|
||||
Requires PHP: 7.4
|
||||
Stable tag: 1.1.0
|
||||
Stable tag: 1.0.0
|
||||
License: GPLv2 or later
|
||||
License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||
|
||||
|
|
@ -16,12 +16,10 @@ Adds a customisable "Sign in with Microsoft" button to the login page. Existing
|
|||
|
||||
The plugin is deliberately small and strict:
|
||||
|
||||
* **Sign-in never creates users.** A Microsoft sign-in succeeds only when a WordPress user with the same e-mail address already exists. Nobody gets an account just by having a Microsoft login.
|
||||
* **Optional user sync.** Import all Microsoft 365 users (or the members of selected groups) as WordPress accounts, assign a default role plus extra roles through a group → role mapping, copy selected profile attributes and the profile photo, and deactivate or delete WordPress accounts whose Microsoft 365 account was disabled or deleted. Runs on demand, on a WP-Cron schedule or with `wp m365-login sync`; a dry run shows every change first.
|
||||
* **No user provisioning.** A Microsoft sign-in succeeds only when a WordPress user with the same e-mail address already exists. Nobody gets an account just by having a Microsoft login.
|
||||
* **Password login stays available.** The button is an additional option; the normal form is untouched.
|
||||
* **Fully customisable button.** Change the text, replace the Microsoft logo with your own icon from the media library, pick background, hover, text and border colours, adjust the corner radius, and choose whether the button appears above or below the login form – with a live preview.
|
||||
* **Entra group restriction.** Search and pick the groups whose members may sign in, right in the settings screen. Membership is checked via the ID token's `groups` claim or Microsoft Graph (nested groups included).
|
||||
* **Excluded groups.** Members of the groups you exclude can never sign in with Microsoft, even if they are in an allowed group (checked with Microsoft Graph, fails closed).
|
||||
* **Button-only mode.** Hide the username/password form and refuse password sign-ins on the login page. A secret fallback link (and a `wp-config.php` constant) brings the form back when you need it.
|
||||
* **Clean settings screen** with a copy-and-paste redirect URI, a tenant connectivity test and a five-step setup guide.
|
||||
* **Custom login pages.** The button is added to every `wp_login_form()` form automatically; a shortcode and a template function cover page builders and theme templates. Point the plugin at your login page and error messages, the fallback link and the post-logout redirect go there instead of wp-login.php.
|
||||
|
|
@ -34,14 +32,11 @@ 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 – 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.
|
||||
* **Button-only mode** blocks password sign-in server-side, not just visually; the fallback key is rate limited and never stored in a cookie.
|
||||
* **Certificate authentication** (RFC 7523 client assertion) as an alternative to a client secret: generate a 3072-bit key pair in the settings, upload only the public certificate to Entra ID. Nothing secret is ever transmitted.
|
||||
* The **client secret / private key is encrypted at rest** (AES-256-GCM, key derived from your WordPress salts) and never displayed again.
|
||||
* In multi-tenant mode the unverified `email` claim is ignored; matching uses the user principal name (verified domain) only.
|
||||
* Login starts and fallback-key attempts are rate limited per client.
|
||||
* The user sync stops before deactivating anything when a Microsoft Graph request fails, treats an account as deleted only when Graph returns 404 for its object ID, refuses to deactivate or delete more than 20 % of the linked accounts in one run, and never touches administrators that existed before the sync or your own account.
|
||||
* Deactivated accounts lose every sign-in path (Microsoft, password, application passwords) and all sessions immediately.
|
||||
* Every setting is sanitised, every output escaped, every admin request nonce- and capability-checked.
|
||||
|
||||
= Developer hooks =
|
||||
|
|
@ -52,12 +47,10 @@ The plugin is deliberately small and strict:
|
|||
* `m365_login_allow_user` – filter, return `false` to block a matched user (e.g. group checks).
|
||||
* `m365_login_success` – action after a successful sign-in, receives the user and verified claims.
|
||||
* `m365_login_block_password_login` – filter, return `false` to exempt a password sign-in from button-only mode.
|
||||
* `m365_login_sync_attributes`, `m365_login_sync_roles`, `m365_login_sync_new_user_data`, `m365_login_sync_email`, `m365_login_sync_protect_user`, `m365_login_sync_deprovision_limit`, `m365_login_sync_photo_limit`, `m365_login_sync_photo_interval` – filters for the user sync.
|
||||
* `m365_login_sync_user_created`, `m365_login_sync_finished`, `m365_login_user_disabled`, `m365_login_user_enabled` – actions for the user sync.
|
||||
|
||||
== External services ==
|
||||
|
||||
This plugin connects to **Microsoft identity platform (Microsoft Entra ID)** to authenticate users. It is required for the plugin's main purpose – signing users in with their Microsoft account – and is only contacted when a user clicks the Microsoft button, when an administrator uses the "Test tenant" button, or when the optional user sync runs.
|
||||
This plugin connects to **Microsoft identity platform (Microsoft Entra ID)** to authenticate users. It is required for the plugin's only purpose – signing users in with their Microsoft account – and is only contacted when a user clicks the Microsoft button or when an administrator uses the "Test tenant" button.
|
||||
|
||||
Endpoints used (all under `https://login.microsoftonline.com/`):
|
||||
|
||||
|
|
@ -66,14 +59,12 @@ Endpoints used (all under `https://login.microsoftonline.com/`):
|
|||
* `/{tenant}/discovery/v2.0/keys` – the server downloads Microsoft's public signing keys to verify the ID token. No user data is sent.
|
||||
* `/{tenant}/v2.0/.well-known/openid-configuration` – fetched only when an administrator clicks "Test tenant". No user data is sent.
|
||||
|
||||
When the optional **group restriction** or the optional **user sync** is used, the plugin additionally connects to **Microsoft Graph** (`https://graph.microsoft.com/v1.0/`) using an application token obtained from `/{tenant}/oauth2/v2.0/token` (client credentials, client ID and secret or signed assertion are sent):
|
||||
When the optional **group restriction** is configured, the plugin additionally connects to **Microsoft Graph** (`https://graph.microsoft.com/v1.0/`) using an application token obtained from `/{tenant}/oauth2/v2.0/token` (client credentials, client ID and secret are sent):
|
||||
|
||||
* `/groups` – only when an administrator searches for groups in the settings screen. The typed search text is sent.
|
||||
* `/users/{id}/checkMemberGroups` – during sign-in when the ID token carries no usable `groups` claim, and on every sign-in when excluded groups are configured. The user's Microsoft object ID and the configured group IDs are sent; Microsoft returns which of those groups the user belongs to.
|
||||
* `/users`, `/groups/{id}/transitiveMembers`, `/users/{id}` – only while the user sync runs (manually, on the configured schedule or via WP-CLI). The configured group IDs and the object IDs of linked accounts are sent; Microsoft returns the users with their account status and the profile attributes selected in the settings.
|
||||
* `/$batch` with `/users/{id}/photo`, and `/users/{id}/photos/240x240`, `/users/{id}/photo` – only while the user sync runs and "Profile photo" is selected. Returns the version and, when it changed, the image of the user's profile photo.
|
||||
* `/users/{id}/checkMemberGroups` – during sign-in when the ID token carries no usable `groups` claim. The user's Microsoft object ID and the configured group IDs are sent; Microsoft returns which of those groups the user belongs to.
|
||||
|
||||
For sign-in the plugin receives the user's e-mail address / user principal name, display name and Microsoft object ID and uses them solely to find the matching WordPress account. The user sync stores the object ID, the account status and the attributes selected by the administrator (for example name, job title, department, phone numbers, profile photo) in the WordPress user profile; profile photos are saved in `wp-content/uploads/m365-login-avatars/` and are shown publicly wherever WordPress displays avatars.
|
||||
The plugin receives the user's e-mail address / user principal name, display name and Microsoft object ID from Microsoft and uses them solely to find the matching WordPress account. Nothing else is stored.
|
||||
|
||||
Microsoft terms and privacy: [Microsoft Services Agreement](https://www.microsoft.com/servicesagreement), [Microsoft Privacy Statement](https://privacy.microsoft.com/privacystatement), [Microsoft identity platform documentation](https://learn.microsoft.com/entra/identity-platform/).
|
||||
|
||||
|
|
@ -97,19 +88,7 @@ Both work. A certificate is recommended: the private key stays on your server (e
|
|||
|
||||
= Does the plugin create users? =
|
||||
|
||||
Signing in never creates users: they must already exist in WordPress, linked by e-mail address. If you want accounts for your Microsoft 365 users, enable the **User sync** tab: it imports all users (or the members of selected groups) ahead of time, assigns roles and keeps profiles up to date.
|
||||
|
||||
= What happens to people who leave the organisation? =
|
||||
|
||||
With the user sync, a WordPress account whose Microsoft 365 account is disabled or deleted can be deactivated (no sign-in of any kind, sessions ended) or deleted (content reassigned to a user you pick). If the Microsoft 365 account is enabled again, an account deactivated by the sync is reactivated automatically. Administrators that existed before the sync are never deactivated or deleted automatically.
|
||||
|
||||
= Our administrators' user principal name differs from their e-mail address. How do they sign in? =
|
||||
|
||||
Administrator accounts are never linked through the Microsoft "mail" attribute, because any user or Exchange administrator of the tenant can set it. Either the person signs in once with the password and clicks "Link Microsoft account" on the profile page, or an administrator enters the person's user principal name under "Assigned Microsoft account (UPN)" in the WordPress profile. After that the account is found through the Microsoft object ID.
|
||||
|
||||
= Which Microsoft Graph permissions does the user sync need? =
|
||||
|
||||
The application permission `User.Read.All` with admin consent, plus `GroupMember.Read.All` when you limit the sync to groups or map groups to roles. A tenant GUID must be configured on the Connection tab.
|
||||
No. Users must already exist in WordPress. The e-mail address is the only link between the Microsoft account and the WordPress account. This is intentional – it keeps the administrator in control of who can access the site.
|
||||
|
||||
= Which accounts can sign in? =
|
||||
|
||||
|
|
@ -137,11 +116,11 @@ Yes. Forms rendered with `wp_login_form()` get the button automatically. For pag
|
|||
|
||||
= Does it support multisite? =
|
||||
|
||||
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.
|
||||
Yes. Settings are per site; 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 stay without a role, with a random password and without application passwords.
|
||||
The settings, cached data and the per-user Microsoft object ID are removed.
|
||||
|
||||
== Screenshots ==
|
||||
|
||||
|
|
@ -153,25 +132,10 @@ The settings, cached data, the sync report and schedule, stored profile photos a
|
|||
|
||||
== Changelog ==
|
||||
|
||||
= 1.1.0 =
|
||||
* New: user sync – import Microsoft 365 users (whole tenant or selected groups) with a default role and group → role mapping, selectable profile attributes and profile photos as avatars.
|
||||
* New: profile fields and photos follow Microsoft 365 on every run – changed photos are replaced, deleted photos and cleared or deselected fields are removed.
|
||||
* New: deactivate or delete WordPress accounts whose Microsoft 365 account was disabled or deleted; automatic reactivation; dry run, safety stop and protected administrators.
|
||||
* New: "Microsoft 365" column, deactivate/reactivate row actions and a read-only Microsoft 365 section on the profile screen.
|
||||
* 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.
|
||||
* New: "Link Microsoft account" on the profile page and an administrator-assigned Microsoft account (UPN) per user; linked accounts are found by their Microsoft object ID.
|
||||
* 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 =
|
||||
* Initial release.
|
||||
|
||||
== Upgrade Notice ==
|
||||
|
||||
= 1.1.0 =
|
||||
Adds an optional Microsoft 365 user sync (import, roles, profile fields, deactivation). Nothing changes until you enable it on the new User sync tab.
|
||||
|
||||
= 1.0.0 =
|
||||
Initial release.
|
||||
|
|
|
|||
|
|
@ -17,30 +17,7 @@ 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_version' );
|
||||
delete_option( 'm365_login_sync_report' );
|
||||
wp_clear_scheduled_hook( 'm365_login_sync' );
|
||||
|
||||
// Synced profile photos (uploads/m365-login-avatars/).
|
||||
$uploads = wp_get_upload_dir();
|
||||
$dir = trailingslashit( $uploads['basedir'] ) . 'm365-login-avatars';
|
||||
if ( is_dir( $dir ) ) {
|
||||
foreach ( (array) glob( $dir . '/m365-*' ) as $file ) {
|
||||
wp_delete_file( $file );
|
||||
}
|
||||
@rmdir( $dir ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- best effort, may contain foreign files.
|
||||
}
|
||||
|
||||
// Transients: state records and JWKS cache.
|
||||
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
|
|
@ -63,7 +40,6 @@ if ( is_multisite() ) {
|
|||
m365_login_uninstall_site();
|
||||
}
|
||||
|
||||
// User meta is global. Imported accounts stay; copied profile fields (m365_*) are kept as ordinary user data.
|
||||
foreach ( array( '_m365_login_oid', '_m365_login_last_login', '_m365_login_synced', '_m365_login_disabled', '_m365_login_last_sync', '_m365_login_photo', '_m365_login_tid', '_m365_login_upn' ) as $m365_login_meta_key ) {
|
||||
delete_metadata( 'user', 0, $m365_login_meta_key, '', true );
|
||||
}
|
||||
// User meta is global.
|
||||
delete_metadata( 'user', 0, '_m365_login_oid', '', true );
|
||||
delete_metadata( 'user', 0, '_m365_login_last_login', '', true );
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue