diff --git a/.github/assets/button-preview.svg b/.github/assets/button-preview.svg new file mode 100644 index 0000000..c898145 --- /dev/null +++ b/.github/assets/button-preview.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + Benutzername oder E-Mail-Adresse + + Passwort + + + + + oder + + + + + + + + + + Login mit Microsoft + + + + + PRESETS + + Microsoft dark + + + Microsoft light + + + Azure blue + + + WordPress blue + + + + + + SICHERHEIT + PKCE (S256) + State + Nonce + JWKS-Signatur + Tenant-Pinning + Secret verschlüsselt + Gruppen-Check + + diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cae79b..ad01554 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,5 +13,7 @@ All notable changes to this project are documented in this file. The format foll - ID token verification against Microsoft's JWKS (RS256, issuer, audience, tenant, expiry, nonce). - Encrypted client secret storage (AES-256-GCM). - Account binding to the Microsoft object ID, e-mail domain allow-list. +- Entra group restriction with a Graph-powered group picker; membership verified via the `groups` claim or Microsoft Graph `checkMemberGroups`. +- Button-only mode that hides the password form and blocks password sign-in on `wp-login.php`, with a secret, rate-limited fallback link and a `wp-config.php` emergency constant. - `[m365_login_button]` shortcode and developer hooks. - German translation. diff --git a/README.md b/README.md index e42a70d..4628664 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,394 @@ -# M365 Login für WordPress +
-Ein schlankes, sicherheitsorientiertes WordPress-Plugin, das einen **„Login mit Microsoft“-Button** auf die -Anmeldeseite (`wp-login.php`) setzt. Bestehende WordPress-Benutzer melden sich mit ihrem Microsoft 365 / -Entra-ID-Konto an. Der gemeinsame Schlüssel ist die **E-Mail-Adresse** – es werden keine Benutzer angelegt. + -> Plugin-Slug / Text Domain: `m365-login` · Lizenz: GPL-2.0-or-later · PHP ≥ 7.4 · WordPress ≥ 6.0 +# M365 Login -## Funktionen +**Anmeldung an WordPress mit dem Microsoft 365 / Entra ID-Konto – sicher, schlank, gestaltbar.** -- **Button auf der Login-Seite** – Text, Icon (Microsoft-Logo oder eigenes Bild aus der Mediathek), Hintergrund-, - Hover-, Text- und Rahmenfarbe, Eckenradius und Position (über/unter dem Formular) sind im Backend einstellbar, - mit Live-Vorschau und Farb-Presets. -- **Aufgeräumte Einstellungsseite** unter *Einstellungen → M365 Login* mit Redirect-URI zum Kopieren, - Tenant-Verbindungstest und 5-Schritte-Anleitung. -- **Kein Provisioning**: Anmeldung nur, wenn ein WordPress-Benutzer mit derselben E-Mail-Adresse existiert. -- **Shortcode** `[m365_login_button redirect="/mein-konto/"]` für eigene Login-Seiten. -- Vollständig übersetzbar, deutsche Übersetzung enthalten. +[![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-777bb4?logo=php&logoColor=white)](https://www.php.net/) +[![License](https://img.shields.io/badge/Lizenz-GPL--2.0--or--later-blue.svg)](LICENSE) +[![CI](https://github.com/friloo/wp-m365-login/actions/workflows/ci.yml/badge.svg)](https://github.com/friloo/wp-m365-login/actions/workflows/ci.yml) +[![Plugin Check](https://img.shields.io/badge/WordPress.org-Plugin%20Check%20ready-46b450)](docs/wordpress-org-einreichung.md) -## Sicherheit +Login-Seite mit Microsoft-Button, Farb-Presets und Sicherheitsmerkmalen -| Maßnahme | Umsetzung | +
+ +--- + +## Inhalt + +- [Auf einen Blick](#auf-einen-blick) +- [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) +- [Sicherheitskonzept](#sicherheitskonzept) +- [Shortcode & Hooks](#shortcode--hooks) +- [Fehlerbehebung](#fehlerbehebung) +- [Entwicklung](#entwicklung) +- [Einreichung bei WordPress.org](#einreichung-bei-wordpressorg) +- [FAQ](#faq) +- [Lizenz](#lizenz) + +--- + +## Auf einen Blick + +| | | | --- | --- | -| Authorization Code Flow **mit PKCE (S256)** | Tokens laufen ausschließlich Server-zu-Server, nie durch den Browser. | -| **State & Nonce** | Einmalig, 10 Minuten gültig, per HttpOnly/SameSite-Cookie an den Browser gebunden (CSRF-/Replay-Schutz, verhindert Login-CSRF). | -| **ID-Token-Prüfung** | Signatur gegen Microsofts JWKS (RS256, Schlüssel-Rollover wird abgefangen), Issuer, Audience, Tenant, `exp`/`nbf`/`iat`, Nonce. `alg=none`/HMAC werden abgelehnt. | -| **Tenant-Pinning** | Bei konfigurierter Tenant-GUID werden Tokens anderer Tenants abgewiesen. | -| **Konto-Bindung** | Beim ersten Login wird die unveränderliche Microsoft-Objekt-ID am Benutzer gespeichert; spätere Logins mit gleicher E-Mail, aber anderer Identität werden abgelehnt. | -| **Domain-Allowlist** | Optional nur bestimmte E-Mail-Domains zulassen. | -| **Client Secret verschlüsselt** | AES-256-GCM, Schlüssel aus den WordPress-Salts abgeleitet; wird nie wieder angezeigt. | -| **WordPress-Standards** | Capability-Checks, Nonces, Sanitizing aller Eingaben, Escaping aller Ausgaben, `wp_safe_redirect`, keine externen Assets. | +| 🔑 **Login per Microsoft** | Ein Klick auf der Anmeldeseite, Anmeldung bei Microsoft, zurück in WordPress – fertig. | +| 📧 **Zuordnung über die E-Mail-Adresse** | Es werden **keine Benutzer angelegt**. Nur wer schon ein WordPress-Konto mit derselben E-Mail hat, kommt rein. | +| 🎨 **Gestaltbarer Button** | Text, Icon (Microsoft-Logo oder eigenes Bild), Farben, Hover-Farbe, Rahmen, Eckenradius, Position – mit Live-Vorschau und Presets. | +| 👥 **Entra-Gruppen** | Optional nur Mitglieder ausgewählter Gruppen zulassen. Gruppen werden direkt im Backend gesucht und ausgewählt. | +| 🚪 **Nur-Button-Modus** | Passwortfelder ausblenden und Passwort-Logins sperren – mit geheimem Fallback-Link als Notausgang. | +| 🛡️ **Sicher by default** | OpenID Connect + PKCE, Signaturprüfung, Tenant-Pinning, Konto-Bindung, verschlüsseltes Secret. | +| 🌍 **Übersetzbar** | Englische Basis, deutsche Übersetzung (du & Sie) enthalten. | +| 📦 **WordPress.org-ready** | readme.txt, Lizenz, Uninstall, Plugin Check in CI, Build-Script. | -## Installation & Einrichtung +--- -1. Ordner in `wp-content/plugins/` legen (oder ZIP aus `bin/build-zip.sh` hochladen) und aktivieren. -2. *Einstellungen → M365 Login* öffnen und die **Redirect-URI** aus der Seitenleiste kopieren - (`https://deine-seite.tld/m365-login/callback`). -3. Im [Microsoft Entra Admin Center](https://entra.microsoft.com/) → **App-Registrierungen → Neue Registrierung**: - - Name frei wählbar, z. B. „WordPress Login“. - - Kontotypen: *Nur Konten in diesem Organisationsverzeichnis* (Single Tenant). - - Plattform **Web**, Redirect-URI einfügen. -4. Auf der Übersichtsseite **Anwendungs-ID (Client)** und **Verzeichnis-ID (Mandant)** kopieren und im Plugin eintragen. -5. **Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel** – den *Wert* (nicht die ID) ins Plugin eintragen. - Ablaufdatum notieren; abgelaufene Secrets müssen erneuert werden. -6. **Tokenkonfiguration → Optionalen Anspruch hinzufügen → ID → `email`** (empfohlen). Die delegierten - Berechtigungen `openid`, `profile`, `email` sind standardmäßig vorhanden. -7. Speichern. Der Button erscheint auf `wp-login.php`; Gestaltung im Tab **Button**. +## So funktioniert es -Stelle sicher, dass die E-Mail-Adressen der WordPress-Benutzer mit denen in Microsoft 365 übereinstimmen. +```mermaid +sequenceDiagram + autonumber + participant B as Browser + participant WP as WordPress
(M365 Login) + participant MS as Microsoft Entra ID + participant G as Microsoft Graph
(optional) -## Entwickler-Hooks + B->>WP: Klick auf „Login mit Microsoft“ + WP->>WP: state, nonce, PKCE-Verifier erzeugen
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 + +
+Schritt für Schritt (ca. 5 Minuten) + +1. **Redirect-URI kopieren.** In WordPress *Einstellungen → 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. **Client Secret erstellen.** *Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel* → den **Wert** (nicht die Geheimnis-ID) in WordPress eintragen. + Das Ablaufdatum notieren – abgelaufene Secrets müssen erneuert werden. +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. + +
+ +
+Zusätzlich für die Gruppen-Beschränkung + +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. + +
+ +Wichtig: Jeder Benutzer, der sich per Microsoft anmelden soll, braucht in WordPress **dieselbe E-Mail-Adresse** wie in Microsoft 365. + +--- + +## Einstellungen im Backend + +*Einstellungen → M365 Login* – drei Tabs, ein Formular, ein Speichern-Button. + +### Verbindung + +| Feld | Beschreibung | +| --- | --- | +| Verzeichnis-ID (Tenant) | GUID des Tenants (empfohlen, aktiviert Tenant-Pinning) oder `organizations` / `common` / `consumers`. | +| Anwendungs-ID (Client) | GUID der App-Registrierung. | +| Client Secret | Wird verschlüsselt gespeichert und nie wieder angezeigt. Leer lassen = behalten. | +| 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 | 0–50 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. + +### Nur-Button-Modus & Fallback + +Im Tab *Sicherheit* → **Button-only mode**: + +- Blendet Benutzername/Passwort-Felder und den „Passwort vergessen?“-Link aus. +- **Sperrt Passwort-Logins** über `wp-login.php` serverseitig – nicht nur per CSS. +- Application Passwords, REST API und XML-RPC sind nicht betroffen. +- Wird erst aktiv, wenn die Verbindung vollständig konfiguriert ist. + +**Fallback (Notausgang):** Beim Speichern erzeugt das Plugin einen geheimen Schlüssel und zeigt den Fallback-Link an: + +``` +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 -// Button z. B. nur für eine bestimmte Domain anzeigen -add_filter( 'm365_login_show_button', fn( $show ) => $show && 'intranet.example.com' === $_SERVER['HTTP_HOST'] ); +define( 'M365_LOGIN_DISABLE_BUTTON_ONLY', true ); +``` -// domain_hint an Microsoft senden +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. + +--- + +## 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 | Kein Provisioning, optionale Domain-Allowlist, optionale Gruppen-Beschränkung (fail closed). | +| Secret-Diebstahl aus der Datenbank | AES-256-GCM, Schlüssel per HKDF aus `AUTH_KEY`/`SECURE_AUTH_KEY`; ohne `wp-config.php` ist der Datensatz wertlos. | +| 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 und Verschlüsselung haben isolierte Tests (manipulierte Signaturen, abgelaufene Tokens, falsche Audience/Tenant/Issuer, `alg=none`, fremde Schlüssel). + +--- + +## 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; } ); -// Login zusätzlich anhand der Claims verbieten (z. B. Gruppenmitgliedschaft) +// 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 && ! empty( $claims['groups'] ); + 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 ) { - // z. B. Anzeigenamen synchronisieren + if ( ! empty( $claims['name'] ) ) { + wp_update_user( array( 'ID' => $user->ID, 'display_name' => $claims['name'] ) ); + } }, 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' ); ``` -Weitere: `m365_login_match_email` (E-Mail vor dem Lookup anpassen). +--- + +## 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. | + +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 -```bash -composer install # PHPCS + WordPress Coding Standards -composer lint # php -l über alle Dateien -composer phpcs # Coding-Standards-Prüfung -bash bin/build-zip.sh # build/m365-login.zip für Upload/Einreichung -python3 bin/compile-mo.py # languages/*.po → *.mo +``` +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, Gruppensuche, checkMemberGroups + class-m365-login-button.php Ausgabe auf wp-login.php, Shortcode + class-m365-login-admin.php Einstellungsseite, AJAX +assets/ CSS/JS für Login-Seite und Backend (unminifiziert) +languages/ .pot, de_DE, de_DE_formal +bin/ build-zip.sh, make-pot.py, compile-mo.py +docs/ Einreichungs-Checkliste ``` -Die GitHub-Actions-Pipeline (`.github/workflows/ci.yml`) führt Syntax-Check (PHP 7.4–8.4), PHPCS und den -offiziellen **WordPress Plugin Check** aus. +```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.4–8.4, führt PHPCS aus und lässt den offiziellen +**WordPress Plugin Check** über das Build-Verzeichnis laufen. + +--- ## Einreichung bei WordPress.org -Siehe [docs/wordpress-org-einreichung.md](docs/wordpress-org-einreichung.md) für die vollständige Checkliste. +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 + +
+Kann ich Benutzer automatisch anlegen lassen? +Nein, bewusst nicht. Der Admin entscheidet, wer ein Konto hat. Wer Auto-Provisioning braucht, kann es über den Hook m365_login_allow_user nicht nachrüsten – das wäre ein anderes Sicherheitsmodell. +
+ +
+Funktioniert es mit privaten Microsoft-Konten (outlook.com)? +Ja, Tenant auf consumers oder common stellen. Microsoft erlaubt dann keine Query-Strings in Redirect-URIs, deshalb müssen sprechende Permalinks aktiv sein (Callback ohne ?). +
+ +
+Multisite? +Ja. Einstellungen gelten pro Site; der Benutzer muss Mitglied der Site (oder Super-Admin) sein. +
+ +
+Was passiert beim Deinstallieren? +Einstellungen, Caches (Transients) und die pro Benutzer gespeicherte Objekt-ID werden entfernt – auch in Multisite. +
+ +
+Ich habe mich ausgesperrt. +Fallback-Link öffnen. Kein Link zur Hand? define( 'M365_LOGIN_DISABLE_BUTTON_ONLY', true ); in die wp-config.php oder den Plugin-Ordner per FTP umbenennen. +
+ +--- ## Lizenz -GPL-2.0-or-later – siehe [LICENSE](LICENSE). +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. diff --git a/assets/css/admin.css b/assets/css/admin.css index e7eb953..40756cc 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -576,3 +576,145 @@ .m365-card code { font-size: 12px; } + +/* Group picker */ +.m365-group-results { + margin-top: 10px; + border: 1px solid var(--m365-border); + border-radius: 8px; + max-height: 320px; + overflow-y: auto; + background: #fff; +} + +.m365-group-results.is-error { + border-color: #f0b8bd; + background: #fcf0f1; +} + +.m365-group-results__status { + margin: 0; + padding: 12px 14px; + color: var(--m365-muted); +} + +.m365-group-result { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid #f0f0f1; +} + +.m365-group-result:last-child { + border-bottom: 0; +} + +.m365-group-result__meta { + min-width: 0; + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 10px; +} + +.m365-group-result__meta code { + font-size: 11px; + color: var(--m365-muted); + background: transparent; + padding: 0; +} + +.m365-group-result__meta em { + display: block; + width: 100%; + font-style: normal; + color: var(--m365-muted); + font-size: 12px; +} + +.m365-group-result__type { + font-size: 11px; + padding: 1px 8px; + border-radius: 999px; + background: #f0f6fc; + color: #0a4b78; +} + +.m365-group-list { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.m365-group-list.is-empty::before { + content: attr(data-empty); + color: var(--m365-muted); + font-size: 13px; +} + +.m365-group-chip { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 0; + padding: 6px 6px 6px 12px; + background: #f0f6fc; + border: 1px solid #c5d9ed; + border-radius: 999px; + font-size: 13px; + max-width: 100%; +} + +.m365-group-chip__name { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.m365-group-chip__id { + font-size: 11px; + color: var(--m365-muted); + background: transparent; + padding: 0; +} + +.m365-group-chip__remove { + appearance: none; + border: 0; + background: #fff; + color: #b32d2e; + width: 22px; + height: 22px; + border-radius: 50%; + line-height: 1; + font-size: 16px; + cursor: pointer; +} + +.m365-group-chip__remove:hover { + background: #b32d2e; + color: #fff; +} + +/* Fallback link */ +.m365-fallback { + margin-top: 8px; + padding: 16px; + border: 1px dashed #c3c4c7; + border-radius: 8px; + background: #fafafa; +} + +.m365-fallback .description { + margin: 4px 0 8px; +} + +.m365-fallback .m365-copy { + margin-bottom: 8px; +} diff --git a/assets/css/login.css b/assets/css/login.css index 8392390..9fba99d 100644 --- a/assets/css/login.css +++ b/assets/css/login.css @@ -120,3 +120,17 @@ body.m365-login-attached #loginform { background: transparent; max-width: 360px; } + +/* Button-only mode: the password form is hidden until the fallback key is used. */ +body.m365-button-only #loginform, +body.m365-button-only #nav { + display: none; +} + +body.m365-button-only .m365-login--below, +body.m365-button-only .m365-login--above { + margin-top: 20px; + padding: 26px 24px; + border: 1px solid #c3c4c7; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} diff --git a/assets/js/admin.js b/assets/js/admin.js index a6b2373..ccba121 100644 --- a/assets/js/admin.js +++ b/assets/js/admin.js @@ -159,6 +159,110 @@ } } ); + /* ---------------- Entra group picker ---------------- */ + var $groupList = $( '#m365-group-list' ); + var $groupResults = $( '#m365-group-results' ); + var optionName = ( $( '#m365-tenant' ).attr( 'name' ) || '' ).replace( /\[tenant_id\]$/, '' ); + + function escapeHtml( str ) { + return $( '
' ).text( str || '' ).html(); + } + + function refreshGroupList() { + $groupList.toggleClass( 'is-empty', 0 === $groupList.children( 'li' ).length ); + } + + function addGroup( id, name ) { + id = ( id || '' ).toLowerCase(); + if ( ! /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test( id ) ) { + return; + } + if ( $groupList.find( 'li[data-id="' + id + '"]' ).length ) { + return; + } + var $li = $( '
  • ' ).attr( 'data-id', id ); + $li.append( $( '' ).text( name || id ) ); + $li.append( $( '' ).text( id ) ); + $li.append( $( '' ).attr( 'name', optionName + '[allowed_groups][' + id + ']' ).val( name || id ) ); + $li.append( $( '' ) ); + $groupList.append( $li ); + refreshGroupList(); + } + + $groupList.on( 'click', '.m365-group-chip__remove', function () { + $( this ).closest( 'li' ).remove(); + refreshGroupList(); + } ); + refreshGroupList(); + + function searchGroups() { + var query = $.trim( $( '#m365-group-search' ).val() ); + $groupResults.prop( 'hidden', false ).removeClass( 'is-error' ).html( '

    ' + escapeHtml( i18n.searching ) + '

    ' ); + + $.post( cfg.ajaxUrl, { + action: cfg.groupAction, + nonce: cfg.nonce, + query: query + } ).done( function ( res ) { + if ( ! res || ! res.success ) { + var msg = ( res && res.data && res.data.message ) || i18n.testFailed; + $groupResults.addClass( 'is-error' ).html( '

    ' + escapeHtml( msg ) + '

    ' ); + // Allow adding a pasted GUID even when Graph is unavailable. + if ( /^[0-9a-f-]{36}$/i.test( query ) ) { + $groupResults.append( buildResult( { id: query, name: query, type: '', description: '' } ) ); + } + return; + } + var groups = res.data.groups || []; + if ( ! groups.length ) { + $groupResults.html( '

    ' + escapeHtml( i18n.noGroups ) + '

    ' ); + return; + } + $groupResults.empty(); + $.each( groups, function ( i, g ) { + $groupResults.append( buildResult( g ) ); + } ); + } ).fail( function () { + $groupResults.addClass( 'is-error' ).html( '

    ' + escapeHtml( i18n.testFailed ) + '

    ' ); + } ); + } + + function buildResult( g ) { + var $row = $( '
    ' ); + var $meta = $( '
    ' ); + $meta.append( $( '' ).text( g.name ) ); + if ( g.type ) { + $meta.append( $( '' ).text( g.type ) ); + } + $meta.append( $( '' ).text( g.id ) ); + if ( g.description ) { + $meta.append( $( '' ).text( g.description ) ); + } + var $btn = $( '
    +
    +

    +

    + +
    + +
    + /> + +
    + +

    + +

    + + +
    + +
    + +
      + settings->allowed_groups() as $gid => $gname ) : ?> +
    • + + + + +
    • + +
    +

    +
    +
    + +
    +

    +

    + + + +
    + +

    + settings->fallback_url() ) : ?> +
    + settings->fallback_url() ); ?> + +
    + + +

    + +

    + define( \'M365_LOGIN_DISABLE_BUTTON_ONLY\', true );' + ); + ?> +

    +
    +
    +

      @@ -444,6 +562,7 @@ class M365_Login_Admin {
    • +

    diff --git a/includes/class-m365-login-auth.php b/includes/class-m365-login-auth.php index f3d40fe..2efa8c6 100644 --- a/includes/class-m365-login-auth.php +++ b/includes/class-m365-login-auth.php @@ -15,6 +15,8 @@ class M365_Login_Auth { const ACTION_START = 'm365_login'; const CALLBACK_PATH = 'm365-login/callback'; const STATE_COOKIE = 'm365_login_state'; + const FALLBACK_COOKIE = 'm365_login_fallback'; + const FALLBACK_TTL = 30 * MINUTE_IN_SECONDS; const STATE_TTL = 600; // 10 minutes. const META_OID = '_m365_login_oid'; const META_LAST_LOGIN = '_m365_login_last_login'; @@ -28,17 +30,123 @@ class M365_Login_Auth { */ private $settings; + /** + * Graph client. + * + * @var M365_Login_Graph + */ + private $graph; + /** * Constructor. * * @param M365_Login_Settings $settings Settings. + * @param M365_Login_Graph $graph Graph client. */ - public function __construct( M365_Login_Settings $settings ) { + public function __construct( M365_Login_Settings $settings, M365_Login_Graph $graph ) { $this->settings = $settings; + $this->graph = $graph; add_action( 'login_form_' . self::ACTION_START, array( $this, 'handle_start' ) ); add_action( 'init', array( $this, 'maybe_handle_callback' ), 5 ); add_filter( 'wp_login_errors', array( $this, 'login_errors' ), 10, 1 ); + + // Button-only mode. + add_action( 'login_init', array( $this, 'maybe_accept_fallback_key' ) ); + // Runs after core's username/password handlers (priority 20), which would otherwise overwrite an early WP_Error. + add_filter( 'authenticate', array( $this, 'block_password_login' ), 99, 3 ); + } + + /* ------------------------------------------------------------------ */ + /* Button-only mode */ + /* ------------------------------------------------------------------ */ + + /** + * Whether the current browser presented the fallback key (cookie set for 30 minutes). + * + * @return bool + */ + public function fallback_active() { + if ( ! $this->settings->button_only() ) { + return true; // Nothing is hidden, the form is always available. + } + $cookie = isset( $_COOKIE[ self::FALLBACK_COOKIE ] ) ? sanitize_text_field( wp_unslash( $_COOKIE[ self::FALLBACK_COOKIE ] ) ) : ''; + return '' !== $cookie && hash_equals( $this->fallback_cookie_value(), $cookie ); + } + + /** + * Expected fallback cookie value (HMAC of the key, so the key itself never sits in the cookie). + * + * @return string + */ + private function fallback_cookie_value() { + return hash_hmac( 'sha256', 'fallback|' . $this->settings->fallback_key(), wp_salt( 'auth' ) ); + } + + /** + * wp-login.php?m365_fallback=KEY → sets the fallback cookie and reloads without the key in the URL. + */ + public function maybe_accept_fallback_key() { + if ( ! $this->settings->button_only() ) { + return; + } + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the key itself is the secret. + $given = isset( $_GET['m365_fallback'] ) ? sanitize_text_field( wp_unslash( $_GET['m365_fallback'] ) ) : ''; + if ( '' === $given ) { + return; + } + + // Slow down brute force attempts on the key. + $ip_key = 'm365_login_fb_' . md5( $this->client_ip() ); + $attempts = (int) get_transient( $ip_key ); + if ( $attempts >= 10 ) { + $this->fail( 'fallback_locked' ); + } + + if ( ! hash_equals( $this->settings->fallback_key(), $given ) ) { + set_transient( $ip_key, $attempts + 1, 15 * MINUTE_IN_SECONDS ); + $this->fail( 'fallback_invalid' ); + } + + delete_transient( $ip_key ); + $this->send_cookie( self::FALLBACK_COOKIE, $this->fallback_cookie_value(), time() + self::FALLBACK_TTL ); + nocache_headers(); + wp_safe_redirect( add_query_arg( 'm365_fallback', 'on', wp_login_url() ) ); + exit; + } + + /** + * Refuses username/password sign-in on wp-login.php while button-only mode is active. + * + * @param null|WP_User|WP_Error $user Result so far. + * @param string $username Username. + * @param string $password Password. + * @return null|WP_User|WP_Error + */ + public function block_password_login( $user, $username, $password ) { + if ( ! $this->settings->button_only() || $this->fallback_active() ) { + return $user; + } + if ( '' === (string) $username && '' === (string) $password ) { + return $user; // Initial form render or cookie auth, no password attempt. + } + // Only the interactive login form is affected: XML-RPC, REST and application passwords keep working. + if ( ! isset( $GLOBALS['pagenow'] ) || 'wp-login.php' !== $GLOBALS['pagenow'] ) { + return $user; + } + if ( ! $user instanceof WP_User ) { + return $user; // Already failed for another reason; keep core's message. + } + return new WP_Error( 'm365_login_button_only', __( 'Password sign-in is disabled on this site. Please use the Microsoft button.', 'm365-login' ) ); + } + + /** + * Best-effort client IP for rate limiting. + * + * @return string + */ + private function client_ip() { + return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '0.0.0.0'; } /* ------------------------------------------------------------------ */ @@ -276,8 +384,15 @@ class M365_Login_Auth { $this->fail( 'no_user' ); } - // Bind the account to the immutable Microsoft object ID after first login. $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 ) { + $this->fail( $group_check ); + } + + // Bind the account to the immutable Microsoft object ID after first login. if ( $this->settings->get( 'bind_oid' ) ) { if ( '' === $oid || ! M365_Login_Settings::is_guid( $oid ) ) { $this->fail( 'invalid_token' ); @@ -459,6 +574,49 @@ class M365_Login_Auth { return $body; } + /** + * Verifies membership in one of the allowed Entra groups. + * + * Uses the "groups" claim when the token carries one (and is not in overage), + * otherwise asks Microsoft Graph (transitive check, needs application permissions). + * + * @param array $claims Verified claims. + * @param string $oid User object ID. + * @return true|string True, or an error code for fail(). + */ + private function check_groups( $claims, $oid ) { + $allowed = array_keys( $this->settings->allowed_groups() ); + if ( empty( $allowed ) ) { + return true; + } + + $overage = ! empty( $claims['_claim_names'] ) || ! empty( $claims['hasgroups'] ); + if ( ! $overage && isset( $claims['groups'] ) && is_array( $claims['groups'] ) ) { + $token_groups = array_map( 'strtolower', array_filter( $claims['groups'], 'is_string' ) ); + if ( array_intersect( $allowed, $token_groups ) ) { + return true; + } + // The claim is authoritative when present: no need to ask Graph. + $this->log( 'User is not a member of an allowed group (token claim).' ); + return 'not_in_group'; + } + + if ( '' === $oid || ! M365_Login_Settings::is_guid( $oid ) ) { + return 'invalid_token'; + } + + $matches = $this->graph->check_member_groups( $oid, $allowed ); + if ( is_wp_error( $matches ) ) { + $this->log( 'Group check via Microsoft Graph failed: ' . $matches->get_error_message() ); + return 'group_check_failed'; + } + if ( empty( $matches ) ) { + $this->log( 'User is not a member of an allowed group (Graph).' ); + return 'not_in_group'; + } + return true; + } + /** * Extracts the e-mail address used for matching. * @@ -520,28 +678,29 @@ class M365_Login_Auth { * @param string $token Cookie value. */ private function set_state_cookie( $token ) { - $this->send_cookie( $token, time() + self::STATE_TTL ); + $this->send_cookie( self::STATE_COOKIE, $token, time() + self::STATE_TTL ); } /** * Removes the state cookie. */ private function clear_state_cookie() { - $this->send_cookie( '', time() - YEAR_IN_SECONDS ); + $this->send_cookie( self::STATE_COOKIE, '', time() - YEAR_IN_SECONDS ); } /** * Cookie helper: HttpOnly, SameSite=Lax (needed for the top-level redirect back), Secure on HTTPS. * + * @param string $name Cookie name. * @param string $value Value. * @param int $expires Expiry timestamp. */ - private function send_cookie( $value, $expires ) { + private function send_cookie( $name, $value, $expires ) { $path = wp_parse_url( home_url( '/' ), PHP_URL_PATH ); $path = is_string( $path ) && '' !== $path ? $path : '/'; setcookie( - self::STATE_COOKIE, + $name, $value, array( 'expires' => $expires, @@ -583,8 +742,18 @@ class M365_Login_Auth { * @return WP_Error */ public function login_errors( $errors ) { - // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display of a whitelisted error code. - $code = isset( $_GET['m365_error'] ) ? sanitize_key( wp_unslash( $_GET['m365_error'] ) ) : ''; + if ( ! $errors instanceof WP_Error ) { + $errors = new WP_Error(); + } + + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only display of whitelisted flags. + $code = isset( $_GET['m365_error'] ) ? sanitize_key( wp_unslash( $_GET['m365_error'] ) ) : ''; + $fallback_on = isset( $_GET['m365_fallback'] ) && 'on' === $_GET['m365_fallback']; + // phpcs:enable WordPress.Security.NonceVerification.Recommended + + if ( $fallback_on && $this->settings->button_only() && $this->fallback_active() ) { + $errors->add( 'm365_login_fallback_on', __( 'Password sign-in is temporarily enabled for this browser (30 minutes).', 'm365-login' ), 'message' ); + } if ( '' === $code ) { return $errors; } @@ -601,11 +770,12 @@ class M365_Login_Auth { 'no_user' => __( 'No WordPress account exists for your Microsoft e-mail address.', 'm365-login' ), 'oid_mismatch' => __( 'This WordPress account is linked to a different Microsoft account. Please contact an administrator.', 'm365-login' ), 'not_allowed' => __( 'You are not allowed to sign in with this account.', 'm365-login' ), + 'not_in_group' => __( 'Your Microsoft account is not a member of a group that is allowed to sign in here.', 'm365-login' ), + 'group_check_failed' => __( 'Your group membership could not be verified. Please contact an administrator.', 'm365-login' ), + 'fallback_invalid' => __( 'The fallback key is not valid.', 'm365-login' ), + 'fallback_locked' => __( 'Too many attempts. Please wait 15 minutes.', 'm365-login' ), ); - if ( ! $errors instanceof WP_Error ) { - $errors = new WP_Error(); - } $errors->add( 'm365_login_' . $code, isset( $messages[ $code ] ) ? $messages[ $code ] : $messages['provider_error'], diff --git a/includes/class-m365-login-button.php b/includes/class-m365-login-button.php index e378ae6..5ff81f8 100644 --- a/includes/class-m365-login-button.php +++ b/includes/class-m365-login-button.php @@ -28,6 +28,7 @@ class M365_Login_Button { $this->settings = $settings; add_action( 'login_enqueue_scripts', array( $this, 'enqueue' ) ); + add_filter( 'login_body_class', array( $this, 'body_class' ) ); add_filter( 'login_message', array( $this, 'render_above' ), 20 ); add_action( 'login_footer', array( $this, 'render_below' ) ); add_shortcode( 'm365_login_button', array( $this, 'shortcode' ) ); @@ -57,6 +58,28 @@ class M365_Login_Button { return (bool) apply_filters( 'm365_login_show_button', true ); } + /** + * Whether the password form is hidden for this request. + * + * @return bool + */ + private function form_hidden() { + return $this->should_render() && $this->settings->button_only() && ! M365_Login::instance()->auth->fallback_active(); + } + + /** + * Adds a body class while the password form is hidden. + * + * @param string[] $classes Body classes. + * @return string[] + */ + public function body_class( $classes ) { + if ( $this->form_hidden() ) { + $classes[] = 'm365-button-only'; + } + return $classes; + } + /** * Enqueues login styles and the small positioning script. */ @@ -140,7 +163,7 @@ class M365_Login_Button { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- passed through to the flow, validated there. $redirect_to = isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : ''; - $divider = (string) $this->settings->get( 'divider_text' ); + $divider = $this->form_hidden() ? '' : (string) $this->settings->get( 'divider_text' ); $divider = '' === trim( $divider ) ? '' : ''; $html = '