wp-m365-login/README.md
Friederich Loheide 850f0dcd54
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
Fix the findings of a full second security audit
Four-part audit (OIDC/JWT/crypto, user sync, admin UI, login bypasses)
with dynamic PoCs against a real WordPress install; every fix is covered
by a regression test. Report: docs/security-audit.md, section 6.

Critical/High
- Multisite: settings, AJAX actions and certificate download require
  manage_network_options (site admins could sign in as super admin).
- Privileged accounts are only linked (sync and first sign-in) via a
  matching UPN of a member account, never via the settable mail
  attribute; the sync never changes their e-mail address; e-mail change
  notifications stay on.
- Button-only mode exempts by credential (application passwords, WP-CLI)
  instead of request context, closing bypasses through xmlrpc.php and
  REST login handlers; API requests never receive login cookies.
- Multi-tenant mode refuses guest/external identities.

Medium/Low
- Same message for right and wrong passwords; button-only no longer
  switches off when the connection breaks; server-side fallback cookie
  expiry; correct fallback key beats IP lockouts; right-most proxy hop;
  higher start limit; one object ID per account.
- Deactivation sets a random password, revokes application passwords and
  removes the role (restored on reactivation); disabled people are
  deactivated even when their mail vanished; duplicate bindings handled.
- Sync: abort on empty directory answer, no deprovisioning right after a
  tenant change, atomic run lock, strict photo path validation.
- Certificates: key bundles refused, clean re-exported certificate.
- Array-safe sanitising, encoded redirect_to, per-action nonces, escaped
  role lists, no Graph sleeps during sign-in, warnings for public groups,
  multi-tenant group rules and missing salts, uninstall clears the token.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 17:10:30 +00:00

608 lines
35 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<div align="center">
<img src=".wordpress-org/icon.svg" width="96" height="96" alt="">
# M365 Login
**Anmeldung an WordPress mit dem Microsoft 365 / Entra ID-Konto sicher, schlank, gestaltbar.**
[![WordPress](https://img.shields.io/badge/WordPress-6.0%2B-21759b?logo=wordpress&logoColor=white)](https://wordpress.org/)
[![PHP](https://img.shields.io/badge/PHP-7.4%2B-4f5b93?logo=php&logoColor=white)](https://www.php.net/)
[![License](https://img.shields.io/badge/Lizenz-GPL--2.0--or--later-blue.svg)](LICENSE)
[![Plugin Check](https://img.shields.io/badge/WordPress.org-Plugin%20Check%20ready-2e7d32)](docs/wordpress-org-einreichung.md)
<img src=".github/assets/button-preview.svg" width="720" alt="Login-Seite mit Microsoft-Button, Farb-Presets und Sicherheitsmerkmalen">
</div>
---
## Inhalt
- [Auf einen Blick](#auf-einen-blick)
- [Screenshots](#screenshots)
- [So funktioniert es](#so-funktioniert-es)
- [Installation](#installation)
- [Einrichtung in Microsoft Entra ID](#einrichtung-in-microsoft-entra-id)
- [Einstellungen im Backend](#einstellungen-im-backend)
- [Verbindung](#verbindung)
- [Button](#button)
- [Sicherheit](#sicherheit)
- [Gruppen-Beschränkung](#gruppen-beschränkung)
- [Nur-Button-Modus & Fallback](#nur-button-modus--fallback)
- [Eigene Login-Seite](#eigene-login-seite)
- [Benutzer-Sync](#benutzer-sync)
- [Sicherheitskonzept](#sicherheitskonzept)
- [Shortcode & Hooks](#shortcode--hooks)
- [Fehlerbehebung](#fehlerbehebung)
- [Entwicklung](#entwicklung)
- [Einreichung bei WordPress.org](#einreichung-bei-wordpressorg)
- [FAQ](#faq)
- [Lizenz](#lizenz)
---
## Auf einen Blick
| | |
| --- | --- |
| 🔑 **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. |
| 🎨 **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. |
| 🚪 **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). |
| 🌍 **Übersetzbar** | Englische Basis, deutsche Übersetzung (du & Sie) enthalten. |
| 📦 **WordPress.org-ready** | readme.txt, Lizenz, Uninstall, Plugin Check in CI, Build-Script. |
---
## Screenshots
> Vorschauen, gerendert aus dem echten Plugin-Markup und -CSS in einer nachgebauten WordPress-Oberfläche.
| Login-Seite | Nur-Button-Modus |
| --- | --- |
| ![Login-Seite mit Microsoft-Button](.github/assets/screenshots/login.png) | ![Login-Seite im Nur-Button-Modus](.github/assets/screenshots/login-button-only.png) |
| M365 Login Verbindung | M365 Login Button |
| --- | --- |
| ![Tab Verbindung](.github/assets/screenshots/settings-connection.png) | ![Tab Button mit Live-Vorschau](.github/assets/screenshots/settings-button.png) |
<details>
<summary><strong>M365 Login Sicherheit</strong> (Gruppen-Auswahl, Nur-Button-Modus, Fallback-Link)</summary>
![Tab Sicherheit](.github/assets/screenshots/settings-security.png)
</details>
---
## So funktioniert es
```mermaid
sequenceDiagram
autonumber
participant B as Browser
participant WP as WordPress<br/>(M365 Login)
participant MS as Microsoft Entra ID
participant G as Microsoft Graph<br/>(optional)
B->>WP: Klick auf „Login mit Microsoft“
WP->>WP: state, nonce, PKCE-Verifier erzeugen<br/>State-Cookie setzen (HttpOnly)
WP-->>B: Redirect zu Microsoft (code_challenge, state, nonce)
B->>MS: Anmeldung beim Microsoft-Konto
MS-->>B: Redirect zurück mit code + state
B->>WP: /m365-login/callback?code=…&state=…
WP->>WP: State einmalig einlösen, Cookie prüfen
WP->>MS: Code + code_verifier + Client Secret (Server-zu-Server)
MS-->>WP: ID-Token
WP->>MS: Signaturschlüssel (JWKS, gecacht)
WP->>WP: Signatur, Issuer, Audience, Tenant, exp, Nonce prüfen
opt Gruppen-Beschränkung aktiv
WP->>G: checkMemberGroups(oid, erlaubte Gruppen)
G-->>WP: Treffer / kein Treffer
end
WP->>WP: Benutzer per E-Mail suchen, Objekt-ID abgleichen
WP-->>B: WordPress-Session, Redirect ins Dashboard
```
Tokens laufen ausschließlich zwischen deinem Server und Microsoft. Der Browser sieht nur einen Autorisierungscode, der ohne den serverseitigen PKCE-Verifier und das Client Secret wertlos ist.
---
## Installation
**Variante A manuell (empfohlen, solange das Plugin nicht im Verzeichnis ist)**
```bash
git clone https://github.com/friloo/wp-m365-login.git
cd wp-m365-login
bash bin/build-zip.sh # erzeugt build/m365-login.zip
```
Dann in WordPress unter *Plugins → Installieren → Plugin hochladen* das ZIP hochladen und aktivieren.
Alternativ den Repo-Inhalt als Ordner `m365-login` nach `wp-content/plugins/` kopieren.
**Variante B WordPress.org** (nach der Freigabe): *Plugins → Installieren → „M365 Login“*.
> **Voraussetzungen:** WordPress ≥ 6.0, PHP ≥ 7.4 mit OpenSSL-Erweiterung, HTTPS auf der Website (Microsoft akzeptiert `http://` nur für `localhost`).
---
## Einrichtung in Microsoft Entra ID
<details open>
<summary><strong>Schritt für Schritt (ca. 5 Minuten)</strong></summary>
1. **Redirect-URI kopieren.** In WordPress den Menüpunkt *M365 Login* öffnen; die URI steht in der Seitenleiste
(`https://deine-seite.tld/m365-login/callback`, bei einfachen Permalinks `https://deine-seite.tld/?m365-login=callback`).
2. **App registrieren.** [Microsoft Entra Admin Center](https://entra.microsoft.com/) → *App-Registrierungen → Neue Registrierung*
- Name: z. B. „WordPress Login“
- Unterstützte Kontotypen: **Nur Konten in diesem Organisationsverzeichnis** (Single Tenant)
- Umleitungs-URI: Plattform **Web**, URI aus Schritt 1
3. **IDs übernehmen.** Auf der Übersichtsseite **Anwendungs-ID (Client)** und **Verzeichnis-ID (Mandant)** kopieren → in WordPress eintragen.
4. **Authentifizierung wählen.**
- *Zertifikat (empfohlen):* In WordPress *Zertifikat erzeugen**.cer herunterladen* → in Entra ID *Zertifikate & Geheimnisse → Zertifikate → Zertifikat hochladen*. Thumbprint vergleichen.
- *Client Secret:* *Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel* → den **Wert** (nicht die Geheimnis-ID) in WordPress eintragen. Ablaufdatum notieren.
5. **E-Mail-Claim aktivieren** (empfohlen). *Tokenkonfiguration → Optionalen Anspruch hinzufügen → ID → `email`*.
6. **Speichern** und mit *Tenant testen* prüfen, ob Microsoft erreichbar ist.
</details>
<details>
<summary><strong>Zusätzlich für die Gruppen-Beschränkung</strong></summary>
Damit das Backend Gruppen suchen und beim Login die Mitgliedschaft prüfen kann, braucht die App-Registrierung
**Anwendungsberechtigungen** (nicht delegiert) für Microsoft Graph, jeweils mit **Administratorzustimmung**:
| Berechtigung | Wofür |
| --- | --- |
| `GroupMember.Read.All` | Gruppen im Backend suchen |
| `User.Read.All` | Mitgliedschaft beim Login prüfen (`checkMemberGroups`, inkl. verschachtelter Gruppen) |
`Directory.Read.All` deckt beides ab, ist aber weiter gefasst.
**Ohne Graph-Berechtigungen** geht es auch: Unter *Tokenkonfiguration → Gruppenanspruch hinzufügen* den `groups`-Claim
für ID-Tokens aktivieren (am besten *Der Anwendung zugewiesene Gruppen* oder *Sicherheitsgruppen*). Dann prüft das Plugin die
Mitgliedschaft direkt im Token. Gruppen-IDs lassen sich im Backend auch von Hand einfügen. Bei mehr als 200 Gruppen pro
Benutzer liefert Microsoft keinen `groups`-Claim mehr („Overage“); dann greift das Plugin automatisch auf Graph zurück.
</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.
---
## Einstellungen im Backend
Menüpunkt *M365 Login* drei Tabs (auch als Untermenüs erreichbar), ein Formular, ein Speichern-Button.
### Verbindung
| Feld | Beschreibung |
| --- | --- |
| Verzeichnis-ID (Tenant) | GUID des Tenants (empfohlen, aktiviert Tenant-Pinning) oder `organizations` / `common` / `consumers`. Im Multi-Tenant-Modus wird nur der UPN zur Zuordnung verwendet (siehe Audit H-1). |
| Anwendungs-ID (Client) | GUID der App-Registrierung. |
| Authentifizierung | **Client Secret** oder **Zertifikat** (empfohlen). Für beide Wege gibt es im Backend eine Schritt-für-Schritt-Anleitung. |
| Client Secret | Wird verschlüsselt gespeichert und nie wieder angezeigt. Leer lassen = behalten. |
| Zertifikat | *Zertifikat erzeugen* legt ein 3072-Bit-RSA-Schlüsselpaar mit selbstsigniertem Zertifikat (2 Jahre) an. Der private Schlüssel bleibt verschlüsselt auf dem Server; die `.cer`-Datei wird in Entra ID unter *Zertifikate & Geheimnisse → Zertifikate* hochgeladen. Alternativ eigenes PEM-Paar einfügen. |
| Kontoauswahl | `select_account` (Standard), `none` (bestehende Microsoft-Sitzung nutzen) oder `login` (immer Anmeldedaten verlangen). |
| Tenant testen | Lädt die OpenID-Konfiguration des Tenants prüft ID und ausgehende Verbindung. |
### Button
| Option | Beschreibung |
| --- | --- |
| Button-Text | Standard „Sign in with Microsoft“ / „Login mit Microsoft“ (max. 80 Zeichen). |
| Trennlinien-Text | Standard „or“ / „oder“; leer = keine Trennlinie. |
| Icon | Microsoft-Logo (eingebettet) oder eigenes Bild aus der Mediathek (PNG, SVG, JPG, WebP). Ein-/ausblendbar. |
| Farben | Hintergrund, Hintergrund (Hover), Text, Rahmen mit Farbwähler. |
| Eckenradius | 050 px. |
| Position | Unter dem Login-Formular (Standard) oder darüber. |
| Presets | Microsoft dunkel, Microsoft hell, Azure-Blau, WordPress-Blau. |
Alles wird live in der Vorschau angezeigt, bevor du speicherst.
### Sicherheit
| 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 | 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`. |
### Gruppen-Beschränkung
Im Tab *Sicherheit***Erlaubte Entra-Gruppen**:
1. Gruppenname eintippen (oder Objekt-ID einfügen) → *Suchen*.
2. Treffer mit *Hinzufügen* übernehmen sie erscheinen als Chips mit Name und ID.
3. Speichern. Ab jetzt darf sich nur anmelden, wer in **mindestens einer** dieser Gruppen ist (verschachtelte Mitgliedschaften zählen).
Prüfreihenfolge beim Login:
1. Enthält das ID-Token einen `groups`-Claim → Abgleich direkt im Token.
2. Sonst (oder bei Overage) → Microsoft Graph `checkMemberGroups`.
3. Schlägt beides fehl → **Anmeldung abgelehnt** (fail closed), Meldung „Gruppenmitgliedschaft konnte nicht geprüft werden“.
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 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.
**Fallback (Notausgang):** Beim Speichern erzeugt das Plugin einen geheimen Schlüssel und zeigt den Fallback-Link an:
```
https://deine-seite.tld/wp-login.php?m365_fallback=AbC…xYz
```
Wer den Link öffnet, sieht für **30 Minuten in diesem Browser** wieder das normale Formular und kann sich mit Passwort anmelden.
Der Schlüssel landet nicht im Cookie (nur ein HMAC davon), Fehlversuche werden pro IP gedrosselt (10 Versuche / 15 Minuten),
und über die Checkbox *Neuen Schlüssel beim Speichern erzeugen* lässt er sich jederzeit rotieren.
**Notschalter ohne Backend-Zugang:** In `wp-config.php`
```php
define( 'M365_LOGIN_DISABLE_BUTTON_ONLY', true );
```
schaltet den Modus komplett ab. Alternativ das Plugin-Verzeichnis per FTP umbenennen.
> ⚠️ Vor dem Aktivieren sicherstellen, dass dein eigenes Admin-Konto per Microsoft funktioniert, und den Fallback-Link sicher ablegen.
### Eigene Login-Seite
Das Plugin funktioniert auch, wenn die Anmeldung nicht über `wp-login.php` läuft.
| Baustein | Was passiert |
| --- | --- |
| **`wp_login_form()`** (Themes, viele Login-Plugins) | Der Button wird automatisch unter dem Formular eingefügt, inklusive Fehlermeldungen darüber. Im Nur-Button-Modus werden die Passwortfelder ausgeblendet. Abschaltbar im Tab *Button*. |
| **Shortcode** | `[m365_login_button redirect="/dashboard/" divider="yes" messages="yes"]` für Block-Editor und Page Builder (Elementor, Divi, …). |
| **Template-Funktion** | `m365_login_button( array( 'redirect' => '/dashboard/' ) );` in Theme-Dateien, `m365_login_messages();` für die Meldungen an anderer Stelle. |
| **URL der Login-Seite** (Tab *Button**Eigene Login-Seite*) | Fehlermeldungen nach einem gescheiterten Microsoft-Login, der Fallback-Link und die Weiterleitung nach dem Abmelden zeigen auf diese Seite statt auf `wp-login.php`. |
| **Nur-Button-Modus** | Greift serverseitig für alle Passwort-Logins, also auch für Formulare von Page Buildern, die das Plugin nicht ausblenden kann. |
Plugins, die `wp-login.php` umbenennen (z. B. WPS Hide Login), sind kompatibel, weil das Plugin durchgehend `wp_login_url()` verwendet.
### Benutzer-Sync
> **Administrator-Konten** (alle mit `manage_options`, `promote_users`, `edit_users` oder Super-Admin) werden nie über das
> `mail`-Attribut verknüpft, sondern nur, wenn der **User Principal Name** eines Mitglieds (kein Gast) exakt ihrer
> WordPress-E-Mail entspricht das `mail`-Attribut kann jeder Benutzer- oder Exchange-Admin des Tenants frei setzen, der
> UPN nur auf verifizierten Domains. Dieselbe Regel gilt für die erste Microsoft-Anmeldung eines Administrators. Ihre
> E-Mail-Adresse ändert der Sync nie automatisch. Bei allen anderen Konten informiert WordPress die alte Adresse über eine
> Änderung.
Tab *Benutzer-Sync*. Legt WordPress-Konten für Microsoft-365-Benutzer an und hält sie aktuell manuell per Knopfdruck,
automatisch per WP-Cron (stündlich, zweimal täglich, täglich) oder per WP-CLI.
**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 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
```
---
## Sicherheitskonzept
| Bedrohung | Gegenmaßnahme |
| --- | --- |
| Abfangen von Tokens im Browser | Authorization Code Flow **mit PKCE (S256)**; ID-Token wird serverseitig geholt, `response_mode=query` ohne Token. |
| CSRF / Login-CSRF | `state` ist zufällig (256 Bit), **einmalig** verwendbar, 10 Min. gültig und per HttpOnly-/SameSite-Cookie an den startenden Browser gebunden. |
| Token-Replay | `nonce` wird im ID-Token geprüft und mit dem State-Datensatz verworfen. |
| 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. |
| Secret-Diebstahl aus der Datenbank | AES-256-GCM, Schlüssel per HKDF aus `AUTH_KEY`/`SECURE_AUTH_KEY`; ohne `wp-config.php` ist der Datensatz wertlos. Gilt für Client Secret und privaten Zertifikatsschlüssel. |
| Secret-Abfluss im Transport | Zertifikatsmodus: es wird nie ein Geheimnis übertragen, nur eine 5 Minuten gültige, signierte Client Assertion (RFC 7523). |
| Kontoübernahme im Multi-Tenant-Modus | `email`-Claim fremder Tenants wird ignoriert (nur UPN mit verifizierter Domain oder `xms_edov`). |
| Flooding der State-Tabelle | Max. 30 Login-Starts pro IP und 10 Minuten; Proxy-Header per `M365_LOGIN_CLIENT_IP_HEADER`. |
| Offene Redirects | `redirect_to` läuft durch `wp_validate_redirect`, alle Redirects über `wp_safe_redirect`. |
| Fehler-Reflektion | Fehlermeldungen sind Codes → feste, übersetzte Texte; Details nur ins Log (`WP_DEBUG_LOG`). |
| Rate Limiting Fallback-Key | 10 Fehlversuche pro IP / 15 Min. |
Die Klassen für JWT-Prüfung, Verschlüsselung, Zertifikate und die Login-Sperre haben isolierte Tests (manipulierte Signaturen, abgelaufene Tokens, falsche Audience/Tenant/Issuer, `alg=none`, fremde Schlüssel, gefälschte Fallback-Cookies, schwache RSA-Schlüssel).
Der vollständige Bericht mit Bedrohungsmodell, Befunden und Betriebsempfehlungen: **[docs/security-audit.md](docs/security-audit.md)**.
---
## Shortcode & Hooks
**Shortcode** für eigene Login-Seiten:
```
[m365_login_button redirect="/mein-konto/"]
```
**Filter & Actions**
```php
// Button z. B. nur im Intranet zeigen
add_filter( 'm365_login_show_button', function ( $show ) {
return $show && 'intranet.example.com' === $_SERVER['HTTP_HOST'];
} );
// domain_hint mitschicken, damit Microsoft direkt die Firmenanmeldung zeigt
add_filter( 'm365_login_authorize_params', function ( $params ) {
$params['domain_hint'] = 'contoso.com';
return $params;
} );
// E-Mail vor dem Lookup umschreiben (z. B. Alias-Domain)
add_filter( 'm365_login_match_email', function ( $email, $claims ) {
return str_replace( '@alt.contoso.com', '@contoso.com', $email );
}, 10, 2 );
// Eigene Zusatzprüfung nach allen Plugin-Checks
add_filter( 'm365_login_allow_user', function ( $allowed, WP_User $user, array $claims ) {
return $allowed && ! in_array( 'subscriber', $user->roles, true );
}, 10, 3 );
// Nach erfolgreichem Login, z. B. Anzeigenamen synchronisieren
add_action( 'm365_login_success', function ( WP_User $user, array $claims ) {
if ( ! empty( $claims['name'] ) ) {
wp_update_user( array( 'ID' => $user->ID, 'display_name' => $claims['name'] ) );
}
}, 10, 2 );
// Ein vertrauenswürdiges Plugin darf trotz Nur-Button-Modus per Passwort anmelden
add_filter( 'm365_login_block_password_login', function ( $block, WP_User $user ) {
return doing_action( 'my_membership_plugin_login' ) ? false : $block;
}, 10, 2 );
// Redirect-URI anpassen (z. B. hinter einem Reverse Proxy)
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
| Meldung auf der Login-Seite | Ursache & Lösung |
| --- | --- |
| *Microsoft login is not configured yet.* | Tenant-ID, Client-ID oder Secret fehlt. |
| *The login request expired or was invalid.* | State abgelaufen (> 10 Min.), Cookie blockiert oder Seite doppelt geladen. Erneut versuchen; Cookies für die Domain erlauben. |
| *Could not complete the sign-in with Microsoft.* | Token-Tausch fehlgeschlagen meist falsches/abgelaufenes Client Secret oder Redirect-URI stimmt nicht exakt mit Entra überein. Details im Log. |
| *The Microsoft sign-in could not be verified.* | ID-Token abgelehnt (Tenant, Audience, Signatur). Tenant-ID prüfen; Serverzeit prüfen (NTP). |
| *Your Microsoft account did not provide an e-mail address.* | `email`-Claim fehlt und UPN-Fallback ist aus oder UPN ist keine E-Mail. Claim in der Tokenkonfiguration hinzufügen. |
| *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. |
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.
---
## Entwicklung
```
m365-login.php Plugin-Header & Bootstrap
includes/
class-m365-login.php Verdrahtung der Komponenten
class-m365-login-settings.php Defaults, Sanitizing, Redirect-URI, Fallback-Key
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-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)
languages/ .pot, de_DE, de_DE_formal
bin/ build-zip.sh, make-pot.py, compile-mo.py
docs/ Einreichungs-Checkliste
```
```bash
composer install # PHPCS + WordPress Coding Standards + PHPCompatibility
composer lint # php -l für alle Dateien
composer phpcs # Coding-Standards-Prüfung (phpcs.xml.dist)
python3 bin/make-pot.py # Strings extrahieren (oder: wp i18n make-pot . languages/m365-login.pot)
python3 bin/compile-mo.py # .po → .mo
bash bin/build-zip.sh # build/m365-login.zip
```
Die CI (`.github/workflows/ci.yml`) prüft Syntax unter PHP 7.48.4, führt PHPCS aus und lässt den offiziellen
**WordPress Plugin Check** über das Build-Verzeichnis laufen.
---
## Einreichung bei WordPress.org
Das Plugin bringt alles mit, was das Review-Team verlangt: `readme.txt` mit *External services*-Abschnitt, GPL-Lizenz,
`uninstall.php`, eindeutige Präfixe, keine externen Assets, Übersetzungen, Verzeichnis-Icon. Die komplette Checkliste
(inkl. Slug-/Marken-Hinweisen und SVN-Schritten nach der Freigabe) steht in
**[docs/wordpress-org-einreichung.md](docs/wordpress-org-einreichung.md)**.
---
## FAQ
<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.
</details>
<details>
<summary><strong>Funktioniert es mit privaten Microsoft-Konten (outlook.com)?</strong></summary>
Ja, Tenant auf <code>consumers</code> oder <code>common</code> stellen. Microsoft erlaubt dann keine Query-Strings in Redirect-URIs, deshalb müssen sprechende Permalinks aktiv sein (Callback ohne <code>?</code>).
</details>
<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.
</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.
</details>
<details>
<summary><strong>Ich habe mich ausgesperrt.</strong></summary>
Fallback-Link öffnen. Kein Link zur Hand? <code>define( 'M365_LOGIN_DISABLE_BUTTON_ONLY', true );</code> in die <code>wp-config.php</code> oder den Plugin-Ordner per FTP umbenennen.
</details>
---
## Lizenz
GPL-2.0-or-later siehe [LICENSE](LICENSE). „Microsoft“, „Microsoft 365“ und das Microsoft-Logo sind Marken der Microsoft Corporation;
das Plugin ist ein unabhängiges Community-Projekt und steht in keiner Verbindung zu Microsoft.