wp-m365-login/README.md
Friederich Loheide 7749ebff9b
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
Remove the login box frame and hide "Lost your password?" reliably
- No border or shadow around the login box on wp-login.php (form and
  Microsoft block, also in button-only mode); the white area stays.
- Button-only mode: the "Lost your password?" link is removed through
  lost_password_html_link instead of CSS only, the lostpassword,
  retrievepassword, rp and resetpass screens redirect to the login page
  and allow_password_reset refuses resets – all unless the fallback link
  is active.
- The login stylesheet is also loaded when only the form is hidden
  (e.g. broken connection); before, the link and form showed there.

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

626 lines
37 KiB
Markdown
Raw 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)
- [Administrator-Konten verknüpfen](#administrator-konten-verknüpfen)
- [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 | 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. |
| 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 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.
- **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
> **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
| 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. 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. |
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.