Let administrators link Microsoft accounts whose UPN differs from mail
Some checks are pending
CI / PHP lint (7.4) (pull_request) Waiting to run
CI / PHP lint (8.0) (pull_request) Waiting to run
CI / PHP lint (8.1) (pull_request) Waiting to run
CI / PHP lint (8.2) (pull_request) Waiting to run
CI / PHP lint (8.3) (pull_request) Waiting to run
CI / PHP lint (8.4) (pull_request) Waiting to run
CI / WordPress Coding Standards (pull_request) Waiting to run
CI / WordPress.org Plugin Check (pull_request) Waiting to run
Some checks are pending
CI / PHP lint (7.4) (pull_request) Waiting to run
CI / PHP lint (8.0) (pull_request) Waiting to run
CI / PHP lint (8.1) (pull_request) Waiting to run
CI / PHP lint (8.2) (pull_request) Waiting to run
CI / PHP lint (8.3) (pull_request) Waiting to run
CI / PHP lint (8.4) (pull_request) Waiting to run
CI / WordPress Coding Standards (pull_request) Waiting to run
CI / WordPress.org Plugin Check (pull_request) Waiting to run
Privileged accounts are never linked through the settable mail attribute. Two new ways make that workable when UPN and e-mail differ: - "Link Microsoft account" on the profile screen: the signed-in user (nonce, same browser via the state cookie, same user at the callback) signs in with Microsoft once and binds that identity. Existing links can only be removed by an administrator; an object ID bound elsewhere is refused. - "Assigned Microsoft account (UPN)" per user, editable by administrators, used by sign-in and user sync; with an option to remove a link. Sign-in now finds accounts by bound object ID first, then by assigned UPN, then by e-mail, so linked users sign in whatever their addresses. Also: third-audit report (docs/security-audit.md section 7), README section on linking administrator accounts, translations, tests. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9b893e42bc
commit
81b3a74ae5
12 changed files with 1583 additions and 995 deletions
|
|
@ -24,7 +24,12 @@ All notable changes to this project are documented in this file. The format foll
|
|||
### Changed
|
||||
- The group picker is reusable (security groups, sync groups, role mapping).
|
||||
|
||||
- "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.
|
||||
|
||||
### 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.
|
||||
|
|
|
|||
30
README.md
30
README.md
|
|
@ -32,6 +32,7 @@
|
|||
- [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)
|
||||
|
|
@ -304,12 +305,11 @@ Plugins, die `wp-login.php` umbenennen (z. B. WPS Hide Login), sind kompatibel,
|
|||
|
||||
### Benutzer-Sync
|
||||
|
||||
> **Administrator-Konten** (alle mit `manage_options`, `promote_users`, `edit_users` oder Super-Admin) werden nie über das
|
||||
> `mail`-Attribut verknüpft, sondern nur, wenn der **User Principal Name** eines Mitglieds (kein Gast) exakt ihrer
|
||||
> WordPress-E-Mail entspricht – das `mail`-Attribut kann jeder Benutzer- oder Exchange-Admin des Tenants frei setzen, der
|
||||
> UPN nur auf verifizierten Domains. Dieselbe Regel gilt für die erste Microsoft-Anmeldung eines Administrators. Ihre
|
||||
> E-Mail-Adresse ändert der Sync nie automatisch. Bei allen anderen Konten informiert WordPress die alte Adresse über eine
|
||||
> Änderung.
|
||||
> **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.
|
||||
|
|
@ -385,6 +385,22 @@ 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
|
||||
|
|
@ -403,7 +419,7 @@ wp m365-login sync # echter Lauf
|
|||
| Secret-Diebstahl aus der Datenbank | AES-256-GCM, Schlüssel per HKDF aus `AUTH_KEY`/`SECURE_AUTH_KEY`; ohne `wp-config.php` ist der Datensatz wertlos. Gilt für Client Secret und privaten Zertifikatsschlüssel. |
|
||||
| Secret-Abfluss im Transport | Zertifikatsmodus: es wird nie ein Geheimnis übertragen, nur eine 5 Minuten gültige, signierte Client Assertion (RFC 7523). |
|
||||
| Kontoübernahme im Multi-Tenant-Modus | `email`-Claim fremder Tenants wird ignoriert (nur UPN mit verifizierter Domain oder `xms_edov`). |
|
||||
| Flooding der State-Tabelle | Max. 30 Login-Starts pro IP und 10 Minuten; Proxy-Header per `M365_LOGIN_CLIENT_IP_HEADER`. |
|
||||
| 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). |
|
||||
| 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. |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Security-Audit: M365 Login 1.1.0
|
||||
|
||||
**Stand:** 23.09.2026 (Erst-Audit 22.09.2026, vollständiges Zweit-Audit 23.09.2026) · **Umfang:** gesamter Plugin-Code
|
||||
**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,
|
||||
RFC 7523 Client Assertions, OAuth 2.0 Security BCP), OWASP ASVS 4.0 (V2 Authentication, V3 Session, V5 Validation,
|
||||
|
|
@ -16,15 +16,16 @@ echte HTTP-Requests (PHP-Webserver + curl) gegen `wp-login.php`, `xmlrpc.php` un
|
|||
|
||||
## 1. Zusammenfassung
|
||||
|
||||
| Schweregrad | Erst-Audit | Zweit-Audit | Behoben | Akzeptiert / dokumentiert |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Kritisch | 0 | 1 (nur Multisite) | 1 | 0 |
|
||||
| Hoch | 1 | 4 | 5 | 0 |
|
||||
| Mittel | 3 | 9 | 12 | 0 |
|
||||
| Niedrig | 5 | 11 | 12 | 4 |
|
||||
| Hinweis | 6 | 11 | 4 | 13 |
|
||||
| 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 |
|
||||
|
||||
Nach beiden Audits sind **keine offenen kritischen, hohen oder mittleren Befunde** bekannt. Die schwersten Funde des
|
||||
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.
|
||||
|
|
@ -256,7 +257,46 @@ Status: ✅ behoben (mit Regressionstest) · 📄 akzeptiert/dokumentiert
|
|||
- **Deaktivierte Konten:** Passwort, XML-RPC (Passwort und Application Password), REST, bestehende Cookies, Microsoft-Callback, Super-Admin – alle abgewiesen; keine Selbst-Reaktivierung möglich.
|
||||
- **Graph-Client:** Paging-Links auf `https://graph.microsoft.com/v1.0/` festgelegt, 1000-Seiten-Limit, jeder Fehler bricht vor Änderungen ab.
|
||||
|
||||
## 7. Nicht im Umfang
|
||||
## 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
|
||||
|
||||
Sicherheit der Microsoft-Seite (Entra ID, Graph), WordPress-Core, Hosting-Umgebung, andere Plugins/Themes,
|
||||
Schwachstellen in PHP/OpenSSL.
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ class M365_Login_Auth {
|
|||
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;
|
||||
|
||||
|
|
@ -316,6 +318,22 @@ 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.
|
||||
*
|
||||
|
|
@ -354,6 +372,18 @@ 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 ) );
|
||||
|
|
@ -369,6 +399,7 @@ 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
|
||||
|
|
@ -496,16 +527,29 @@ class M365_Login_Auth {
|
|||
$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 );
|
||||
}
|
||||
|
||||
if ( ! $this->domain_allowed( $email ) ) {
|
||||
$email = $this->email_from_claims( $claims );
|
||||
if ( '' !== $email && ! $this->domain_allowed( $email ) ) {
|
||||
$this->fail( 'domain_not_allowed' );
|
||||
}
|
||||
|
||||
$user = get_user_by( 'email', $email );
|
||||
// 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.
|
||||
$user = $this->find_bound_user( $oid );
|
||||
if ( ! $user ) {
|
||||
$user = $this->find_assigned_user( $claims );
|
||||
}
|
||||
if ( ! $user ) {
|
||||
if ( '' === $email ) {
|
||||
$this->fail( 'no_email' );
|
||||
}
|
||||
$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.' ) );
|
||||
|
|
@ -520,8 +564,6 @@ class M365_Login_Auth {
|
|||
$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 );
|
||||
if ( true !== $group_check ) {
|
||||
|
|
@ -767,11 +809,155 @@ class M365_Login_Auth {
|
|||
* @return bool
|
||||
*/
|
||||
private function may_claim_privileged( $claims, $user ) {
|
||||
$upn = ! empty( $claims['preferred_username'] ) && is_string( $claims['preferred_username'] ) ? strtolower( trim( $claims['preferred_username'] ) ) : '';
|
||||
return '' !== $upn
|
||||
&& ! $this->settings->is_multi_tenant()
|
||||
&& ! $this->is_external_identity( $claims )
|
||||
&& hash_equals( strtolower( $user->user_email ), $upn );
|
||||
$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'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1081,7 +1267,9 @@ class M365_Login_Auth {
|
|||
'fallback_locked' => __( 'Too many attempts. Please wait 15 minutes.', 'm365-login' ),
|
||||
'too_many_attempts' => __( 'Too many sign-in attempts from your connection. Please wait a few minutes and try again.', 'm365-login' ),
|
||||
'account_disabled' => __( 'This account has been deactivated.', 'm365-login' ),
|
||||
'privileged_unlinked' => __( 'For security reasons this administrator account can only be linked to a Microsoft account whose user principal name equals the WordPress e-mail address. Please contact an administrator.', 'm365-login' ),
|
||||
'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' ),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,6 +108,8 @@ class M365_Login_Sync {
|
|||
add_action( 'admin_post_' . self::POST_STATE, array( $this, 'handle_user_state' ) );
|
||||
add_action( 'show_user_profile', array( $this, 'profile_section' ) );
|
||||
add_action( 'edit_user_profile', array( $this, 'profile_section' ) );
|
||||
add_action( 'personal_options_update', array( $this, 'save_profile' ) );
|
||||
add_action( 'edit_user_profile_update', array( $this, 'save_profile' ) );
|
||||
add_action( 'admin_notices', array( $this, 'user_state_notice' ) );
|
||||
}
|
||||
|
||||
|
|
@ -529,7 +531,8 @@ class M365_Login_Sync {
|
|||
}
|
||||
|
||||
// Large directories: keep memory flat and the run lock fresh.
|
||||
if ( 0 === ++$done % 250 ) {
|
||||
++$done;
|
||||
if ( 0 === $done % 250 ) {
|
||||
if ( function_exists( 'wp_cache_flush_runtime' ) ) {
|
||||
wp_cache_flush_runtime();
|
||||
}
|
||||
|
|
@ -739,9 +742,13 @@ class M365_Login_Sync {
|
|||
return null;
|
||||
}
|
||||
|
||||
// Not linked yet: match an existing account by e-mail address.
|
||||
// Not linked yet: the account an administrator assigned this user principal name to,
|
||||
// otherwise an existing account with the same e-mail address.
|
||||
if ( ! $user ) {
|
||||
$by_mail = get_user_by( 'email', $email );
|
||||
$by_mail = $this->assigned_user( $person );
|
||||
if ( ! $by_mail ) {
|
||||
$by_mail = get_user_by( 'email', $email );
|
||||
}
|
||||
if ( $by_mail instanceof WP_User ) {
|
||||
$stored = strtolower( (string) get_user_meta( $by_mail->ID, M365_Login_Auth::META_OID, true ) );
|
||||
if ( '' !== $stored && $stored !== $oid ) {
|
||||
|
|
@ -751,7 +758,7 @@ class M365_Login_Sync {
|
|||
}
|
||||
if ( ! $this->may_link( $by_mail, $person, $email ) ) {
|
||||
/* translators: %s: e-mail address */
|
||||
$this->skip( sprintf( __( '%s: privileged WordPress account – it is only linked when the Microsoft user principal name equals its e-mail address (member account, no guest). Skipped.', 'm365-login' ), $email ) );
|
||||
$this->skip( sprintf( __( '%s: privileged WordPress account – linked only when the Microsoft user principal name equals its e-mail address or the Microsoft account assigned in its profile, or when the person links it from the profile. Skipped.', 'm365-login' ), $email ) );
|
||||
return null;
|
||||
}
|
||||
$user = $by_mail;
|
||||
|
|
@ -1282,9 +1289,51 @@ class M365_Login_Sync {
|
|||
if ( ! self::is_privileged( $user ) ) {
|
||||
return true;
|
||||
}
|
||||
$upn = isset( $person['userPrincipalName'] ) ? strtolower( (string) $person['userPrincipalName'] ) : '';
|
||||
$upn = self::member_upn( $person );
|
||||
if ( '' === $upn ) {
|
||||
return false;
|
||||
}
|
||||
$assigned = strtolower( (string) get_user_meta( $user->ID, M365_Login_Auth::META_UPN, true ) );
|
||||
return ( '' !== $assigned && $assigned === $upn ) || ( strtolower( $user->user_email ) === $upn && $upn === $email );
|
||||
}
|
||||
|
||||
/**
|
||||
* User principal name of a member (not a guest), lowercase, or ''.
|
||||
*
|
||||
* @param array $person Graph user.
|
||||
* @return string
|
||||
*/
|
||||
private static function member_upn( $person ) {
|
||||
$upn = isset( $person['userPrincipalName'] ) ? strtolower( trim( (string) $person['userPrincipalName'] ) ) : '';
|
||||
$guest = isset( $person['userType'] ) && 'Guest' === $person['userType'];
|
||||
return ! $guest && '' !== $upn && false === strpos( $upn, '#ext#' ) && strtolower( $user->user_email ) === $upn && $upn === $email;
|
||||
return $guest || false !== strpos( $upn, '#ext#' ) ? '' : $upn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Account an administrator assigned this person's user principal name to.
|
||||
*
|
||||
* @param array $person Graph user.
|
||||
* @return WP_User|null
|
||||
*/
|
||||
private function assigned_user( $person ) {
|
||||
$upn = self::member_upn( $person );
|
||||
if ( '' === $upn ) {
|
||||
return null;
|
||||
}
|
||||
$ids = get_users(
|
||||
array(
|
||||
'meta_key' => M365_Login_Auth::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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1887,7 +1936,7 @@ class M365_Login_Sync {
|
|||
}
|
||||
$stored = get_user_meta( $user->ID, self::META_PHOTO, true );
|
||||
if ( is_array( $stored ) && ! empty( $stored['file'] ) && self::is_photo_file( $stored['file'] ) ) {
|
||||
$uploads = wp_get_upload_dir();
|
||||
$uploads = wp_get_upload_dir();
|
||||
$fields[ __( 'Profile photo', 'm365-login' ) ] = trailingslashit( $uploads['baseurl'] ) . $stored['file'];
|
||||
}
|
||||
$last = (int) get_user_meta( $user->ID, self::META_LAST_SYNC, true );
|
||||
|
|
@ -2188,6 +2237,16 @@ class M365_Login_Sync {
|
|||
* Confirmation after a row action.
|
||||
*/
|
||||
public function user_state_notice() {
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- display only.
|
||||
if ( isset( $_GET['m365_linked'] ) ) {
|
||||
printf( '<div class="notice notice-success is-dismissible"><p>%s</p></div>', esc_html__( 'Your Microsoft account is now linked. From now on you can sign in with the Microsoft button.', 'm365-login' ) );
|
||||
}
|
||||
$link_error = isset( $_GET['m365_link_error'] ) ? sanitize_key( wp_unslash( $_GET['m365_link_error'] ) ) : '';
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
||||
if ( '' !== $link_error ) {
|
||||
printf( '<div class="notice notice-error is-dismissible"><p>%s</p></div>', esc_html( M365_Login::instance()->auth->error_message( $link_error ) ) );
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only.
|
||||
$state = isset( $_GET['m365_user_state'] ) ? sanitize_key( wp_unslash( $_GET['m365_user_state'] ) ) : '';
|
||||
if ( '' === $state ) {
|
||||
|
|
@ -2205,8 +2264,11 @@ class M365_Login_Sync {
|
|||
* @param WP_User $user User being edited.
|
||||
*/
|
||||
public function profile_section( $user ) {
|
||||
$oid = (string) get_user_meta( $user->ID, M365_Login_Auth::META_OID, true );
|
||||
if ( '' === $oid && ! self::disabled_info( $user->ID ) ) {
|
||||
$oid = (string) get_user_meta( $user->ID, M365_Login_Auth::META_OID, true );
|
||||
$own = get_current_user_id() === $user->ID;
|
||||
$is_admin = current_user_can( M365_Login_Admin::capability() ) && current_user_can( 'edit_user', $user->ID );
|
||||
$can_link = $own && '' === $oid && $this->settings->is_configured() && ! self::disabled_info( $user->ID );
|
||||
if ( '' === $oid && ! self::disabled_info( $user->ID ) && ! $can_link && ! $is_admin ) {
|
||||
return;
|
||||
}
|
||||
$rows = array();
|
||||
|
|
@ -2242,9 +2304,36 @@ class M365_Login_Sync {
|
|||
$rows[ (string) $attribute['label'] ] = $value;
|
||||
}
|
||||
}
|
||||
$assigned = (string) get_user_meta( $user->ID, M365_Login_Auth::META_UPN, true );
|
||||
?>
|
||||
<h2><?php esc_html_e( 'Microsoft 365', 'm365-login' ); ?></h2>
|
||||
<table class="form-table" role="presentation">
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e( 'Microsoft account', 'm365-login' ); ?></th>
|
||||
<td>
|
||||
<?php if ( '' !== $oid ) : ?>
|
||||
<?php esc_html_e( 'Linked', 'm365-login' ); ?>
|
||||
<?php else : ?>
|
||||
<?php esc_html_e( 'Not linked', 'm365-login' ); ?>
|
||||
<?php endif; ?>
|
||||
<?php if ( $can_link ) : ?>
|
||||
<p><a class="button" href="<?php echo esc_url( M365_Login::instance()->auth->link_url() ); ?>"><?php esc_html_e( 'Link Microsoft account', 'm365-login' ); ?></a></p>
|
||||
<p class="description"><?php esc_html_e( 'You sign in with Microsoft once; afterwards this WordPress account is bound to that Microsoft account, even if its user principal name differs from your e-mail address.', 'm365-login' ); ?></p>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php if ( $is_admin ) : ?>
|
||||
<tr>
|
||||
<th scope="row"><label for="m365-login-upn"><?php esc_html_e( 'Assigned Microsoft account (UPN)', 'm365-login' ); ?></label></th>
|
||||
<td>
|
||||
<input type="text" class="regular-text code" id="m365-login-upn" name="m365_login_upn" value="<?php echo esc_attr( $assigned ); ?>" placeholder="name@contoso.onmicrosoft.com" autocomplete="off" spellcheck="false" />
|
||||
<p class="description"><?php esc_html_e( 'Optional. The user principal name of the Microsoft account that belongs to this user. Sign-in and user sync link exactly this account – needed for administrators whose user principal name differs from their WordPress e-mail address.', 'm365-login' ); ?></p>
|
||||
<?php if ( '' !== $oid ) : ?>
|
||||
<label><input type="checkbox" name="m365_login_unlink" value="1" /> <?php esc_html_e( 'Remove the link to the Microsoft account', 'm365-login' ); ?></label>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<?php foreach ( $rows as $label => $value ) : ?>
|
||||
<tr>
|
||||
<th scope="row"><?php echo esc_html( $label ); ?></th>
|
||||
|
|
@ -2252,7 +2341,36 @@ class M365_Login_Sync {
|
|||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<p class="description"><?php esc_html_e( 'These values are managed by the Microsoft 365 user sync and overwritten on the next run.', 'm365-login' ); ?></p>
|
||||
<?php if ( $rows ) : ?>
|
||||
<p class="description"><?php esc_html_e( 'These values are managed by the Microsoft 365 user sync and overwritten on the next run.', 'm365-login' ); ?></p>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the administrator fields of the Microsoft 365 profile section.
|
||||
*
|
||||
* Core has verified the "update-user_{$user_id}" nonce before these hooks run.
|
||||
*
|
||||
* @param int $user_id User being saved.
|
||||
*/
|
||||
public function save_profile( $user_id ) {
|
||||
if ( ! current_user_can( M365_Login_Admin::capability() ) || ! current_user_can( 'edit_user', $user_id ) ) {
|
||||
return;
|
||||
}
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing -- verified by core (update-user_{$user_id}).
|
||||
if ( isset( $_POST['m365_login_upn'] ) && is_string( $_POST['m365_login_upn'] ) ) {
|
||||
$upn = strtolower( trim( sanitize_text_field( wp_unslash( $_POST['m365_login_upn'] ) ) ) );
|
||||
if ( '' === $upn ) {
|
||||
delete_user_meta( $user_id, M365_Login_Auth::META_UPN );
|
||||
} elseif ( is_email( $upn ) ) {
|
||||
update_user_meta( $user_id, M365_Login_Auth::META_UPN, $upn );
|
||||
}
|
||||
}
|
||||
if ( ! empty( $_POST['m365_login_unlink'] ) ) {
|
||||
delete_user_meta( $user_id, M365_Login_Auth::META_OID );
|
||||
delete_user_meta( $user_id, M365_Login_Auth::META_TID );
|
||||
}
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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
|
|
@ -103,6 +103,10 @@ Signing in never creates users: they must already exist in WordPress, linked by
|
|||
|
||||
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.
|
||||
|
|
@ -156,6 +160,7 @@ The settings, cached data, the sync report and schedule, stored profile photos a
|
|||
* 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.
|
||||
|
|
|
|||
|
|
@ -64,6 +64,6 @@ if ( is_multisite() ) {
|
|||
}
|
||||
|
||||
// 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' ) as $m365_login_meta_key ) {
|
||||
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 );
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue