From 5c9b19399ab852bfe9d1cb9fc149bd054634eecc Mon Sep 17 00:00:00 2001 From: Friederich Loheide Date: Wed, 23 Sep 2026 16:42:29 +0000 Subject: [PATCH 1/2] Restore the debug log helper of the auth component 701e85a removed M365_Login_Auth::log() while it is still called on every failure path of the callback (token exchange, token verification, object ID mismatch, group checks). Those sign-ins ended in a PHP fatal error instead of the error message on the login page. Co-Authored-By: Claude Opus 5.5 (1M context) --- includes/class-m365-login-auth.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/includes/class-m365-login-auth.php b/includes/class-m365-login-auth.php index 810e99a..9368cd7 100644 --- a/includes/class-m365-login-auth.php +++ b/includes/class-m365-login-auth.php @@ -796,6 +796,17 @@ class M365_Login_Auth { ); } + /** + * Writes a diagnostic line to the debug log (only with WP_DEBUG and WP_DEBUG_LOG; never tokens or secrets). + * + * @param string $message Message. + */ + private function log( $message ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { + error_log( '[M365 Login] ' . $message ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + } + } + /** * Aborts the flow and shows a generic error on the login screen. * From 791f43a80bb4fbc684cfeeafee2a1ee7d0e4cd93 Mon Sep 17 00:00:00 2001 From: Friederich Loheide Date: Wed, 23 Sep 2026 16:43:10 +0000 Subject: [PATCH 2/2] Add excluded Entra groups for the Microsoft sign-in New "Excluded Entra groups" card on the Security tab. Members of these groups (nested memberships count) can never sign in with Microsoft, even if they are in an allowed group. A hit in the ID token's groups claim refuses immediately. Otherwise the plugin always asks Microsoft Graph (checkMemberGroups), because a groups claim can be filtered in the app registration and cannot prove non-membership. Graph errors refuse the sign-in (fail closed). Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 3 + README.md | 15 ++- docs/security-audit.md | 9 ++ includes/class-m365-login-admin.php | 10 ++ includes/class-m365-login-auth.php | 77 ++++++++++++- includes/class-m365-login-settings.php | 11 ++ languages/m365-login-de_DE.mo | Bin 52380 -> 53745 bytes languages/m365-login-de_DE.po | 146 ++++++++++++++----------- languages/m365-login-de_DE_formal.mo | Bin 52448 -> 53812 bytes languages/m365-login-de_DE_formal.po | 146 ++++++++++++++----------- languages/m365-login.pot | 146 ++++++++++++++----------- readme.txt | 5 +- 12 files changed, 372 insertions(+), 196 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24864d8..c6863e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,10 +19,13 @@ All notable changes to this project are documented in this file. The format foll - Graph client: paging, retry on throttling (429/503/504), user, group member and photo endpoints. - Filters and actions for the sync (`m365_login_sync_*`, `m365_login_user_disabled`, `m365_login_user_enabled`). +- Excluded Entra groups (Security tab): members can never sign in with Microsoft, even when they are in an allowed group. Checked via the `groups` claim and always via Microsoft Graph `checkMemberGroups` (a filtered claim cannot prove non-membership); fails closed. + ### Changed - The group picker is reusable (security groups, sync groups, role mapping). ### Fixed +- Failed Microsoft sign-ins (token exchange, token verification, object ID mismatch, group checks) ended in a PHP fatal error because the auth component's log helper had been removed in 1.0.0 development. - "Generate certificate" and removing the certificate did not keep the change and encrypted a stored client secret a second time (internal settings writes ran through the form sanitiser). ## [1.0.0] – 2026-09-22 diff --git a/README.md b/README.md index ee0f185..9020155 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ | 📧 **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. Gruppen werden direkt im Backend gesucht und ausgewählt. | +| 👥 **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). | @@ -243,6 +243,16 @@ Prüfreihenfolge beim Login: Leere Liste = keine Beschränkung. +**Ausgeschlossene Entra-Gruppen** (gleicher Tab, darunter): Mitglieder dieser Gruppen können sich **nie** per Microsoft +anmelden – auch wenn sie in einer erlaubten Gruppe sind (Ausschluss hat Vorrang, verschachtelte Mitgliedschaften zählen). + +1. Steht eine ausgeschlossene Gruppe im `groups`-Claim → sofort abgelehnt. +2. Sonst wird **immer** Microsoft Graph gefragt (`checkMemberGroups`, Berechtigung `User.Read.All`), denn ein `groups`-Claim + kann in der App-Registrierung gefiltert sein und beweist nicht, dass jemand *kein* Mitglied ist. +3. Schlägt die Graph-Prüfung fehl → Anmeldung abgelehnt (fail closed). + +Die Passwort-Anmeldung betrifft das nicht; wer auch die sperren will, kombiniert es mit dem Nur-Button-Modus. + ### Nur-Button-Modus & Fallback Im Tab *Sicherheit* → **Button-only mode**: @@ -482,7 +492,8 @@ add_filter( 'm365_login_sync_deprovision_limit', fn( $limit, $linked ) => max( 1 | *No WordPress account exists for your Microsoft e-mail address.* | E-Mail in WordPress stimmt nicht mit Microsoft überein. | | *This WordPress account is linked to a different Microsoft account.* | Objekt-ID weicht ab. Wenn gewollt (neues Microsoft-Konto): User-Meta `_m365_login_oid` beim Benutzer löschen. | | *… not a member of a group that is allowed …* | Benutzer ist in keiner der ausgewählten Gruppen. | -| *Your group membership could not be verified.* | Graph nicht erreichbar oder Berechtigung fehlt (`User.Read.All`) – oder `groups`-Claim aktivieren. | +| *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 | diff --git a/docs/security-audit.md b/docs/security-audit.md index 53b4344..50eb2c3 100644 --- a/docs/security-audit.md +++ b/docs/security-audit.md @@ -137,6 +137,15 @@ Der Sync legt Konten an, ändert Rollen und deaktiviert bzw. löscht Konten. Gep - **Graph-Aufrufe:** nur `https://graph.microsoft.com/v1.0/`; Paging-Links werden auf diesen Präfix geprüft, IDs sind GUIDs. - **Ausgabe:** Protokoll und Profilfelder werden escaped ausgegeben; Benutzer-Zeilenaktionen mit Nonce und `edit_user`. +### N-8 · Ausgeschlossene Gruppen und fehlende Log-Methode (1.1.0) — **neu / behoben** + +- **Ausgeschlossene Entra-Gruppen:** Ausschluss vor Erlaubnis; Treffer im `groups`-Claim lehnt sofort ab, ohne Treffer wird + immer Graph `checkMemberGroups` (transitiv) gefragt, weil ein gefilterter Claim Nicht-Mitgliedschaft nicht beweist; + Graph-Fehler → Ablehnung (fail closed). +- **Behoben:** `M365_Login_Auth::log()` fehlte seit der Umstellung auf eigene Login-Seiten; alle Fehlerpfade des Callbacks + endeten in einem PHP-Fatal-Error (kein Sicherheitsleck – die Anmeldung scheiterte –, aber keine Fehlermeldung und ein + 500er). Durch Tests in einer echten Installation gefunden. + Hinweis für den Betrieb: Personenbezogene Daten (Telefon, Adresse, Foto) nur synchronisieren, wenn sie auf der Website gebraucht werden; die Auswahl ist bewusst Opt-in (Standard: nur Namen). diff --git a/includes/class-m365-login-admin.php b/includes/class-m365-login-admin.php index 6c6ef67..cbfff4d 100644 --- a/includes/class-m365-login-admin.php +++ b/includes/class-m365-login-admin.php @@ -1081,6 +1081,16 @@ class M365_Login_Admin { +
+

+

+ + group_picker( 'denied_groups', $this->settings->denied_groups(), 'chips', __( 'No groups excluded.', 'm365-login' ) ); ?> +
+

+
+
+

diff --git a/includes/class-m365-login-auth.php b/includes/class-m365-login-auth.php index 9368cd7..56824b9 100644 --- a/includes/class-m365-login-auth.php +++ b/includes/class-m365-login-auth.php @@ -642,6 +642,75 @@ class M365_Login_Auth { return $body; } + /** + * Applies the Entra group rules: members of an excluded group are refused, + * everybody else needs membership in one of the allowed groups (if any are set). + * + * @param array $claims Verified claims. + * @param string $oid User object ID. + * @return true|string True, or an error code for fail(). + */ + private function check_groups( $claims, $oid ) { + $denied = $this->check_denied_groups( $claims, $oid ); + if ( true !== $denied ) { + return $denied; + } + return $this->check_allowed_groups( $claims, $oid ); + } + + /** + * Group IDs from the "groups" claim, or null when the token has no complete list (claim missing or overage). + * + * @param array $claims Verified claims. + * @return string[]|null + */ + private function token_groups( $claims ) { + $overage = ! empty( $claims['_claim_names'] ) || ! empty( $claims['hasgroups'] ); + if ( $overage || ! isset( $claims['groups'] ) || ! is_array( $claims['groups'] ) ) { + return null; + } + return array_map( 'strtolower', array_filter( $claims['groups'], 'is_string' ) ); + } + + /** + * Refuses members of an excluded group (fails closed). + * + * A "groups" claim can be filtered in the app registration (e.g. only groups assigned to the + * application), so it can prove membership but never non-membership: without a match in the + * token the plugin always asks Microsoft Graph. + * + * @param array $claims Verified claims. + * @param string $oid User object ID. + * @return true|string True, or an error code for fail(). + */ + private function check_denied_groups( $claims, $oid ) { + $denied = array_keys( $this->settings->denied_groups() ); + if ( empty( $denied ) ) { + return true; + } + + $token_groups = $this->token_groups( $claims ); + if ( null !== $token_groups && array_intersect( $denied, $token_groups ) ) { + $this->log( 'User is a member of an excluded group (token claim).' ); + return 'in_denied_group'; + } + + if ( '' === $oid || ! M365_Login_Settings::is_guid( $oid ) ) { + return 'invalid_token'; + } + + $matches = $this->graph->check_member_groups( $oid, $denied ); + if ( is_wp_error( $matches ) ) { + $this->log( 'Excluded-group check via Microsoft Graph failed: ' . $matches->get_error_message() ); + return 'group_check_failed'; + } + if ( ! empty( $matches ) ) { + $this->log( 'User is a member of an excluded group (Graph).' ); + return 'in_denied_group'; + } + return true; + } + /** * Verifies membership in one of the allowed Entra groups. * @@ -652,15 +721,14 @@ class M365_Login_Auth { * @param string $oid User object ID. * @return true|string True, or an error code for fail(). */ - private function check_groups( $claims, $oid ) { + private function check_allowed_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' ) ); + $token_groups = $this->token_groups( $claims ); + if ( null !== $token_groups ) { if ( array_intersect( $allowed, $token_groups ) ) { return true; } @@ -869,6 +937,7 @@ class M365_Login_Auth { 'not_allowed' => __( 'You are not allowed to sign in with this account.', 'm365-login' ), 'not_in_group' => __( 'Your Microsoft account is not a member of a group that is allowed to sign in here.', 'm365-login' ), 'group_check_failed' => __( 'Your group membership could not be verified. Please contact an administrator.', 'm365-login' ), + 'in_denied_group' => __( 'Your Microsoft account is a member of a group that is not allowed to sign in here.', 'm365-login' ), 'fallback_invalid' => __( 'The fallback key is not valid.', 'm365-login' ), 'fallback_locked' => __( 'Too many attempts. Please wait 15 minutes.', 'm365-login' ), 'too_many_attempts' => __( 'Too many sign-in attempts from your connection. Please wait a few minutes and try again.', 'm365-login' ), diff --git a/includes/class-m365-login-settings.php b/includes/class-m365-login-settings.php index 5852f00..566450b 100644 --- a/includes/class-m365-login-settings.php +++ b/includes/class-m365-login-settings.php @@ -46,6 +46,7 @@ class M365_Login_Settings { 'bind_oid' => 1, 'allowed_domains' => '', 'allowed_groups' => array(), // id => display name. + 'denied_groups' => array(), // id => display name; members may never sign in. 'remember_me' => 0, // Button-only mode. 'button_only' => 0, @@ -333,6 +334,15 @@ class M365_Login_Settings { return self::guid_map( $this->get( 'allowed_groups', array() ) ); } + /** + * Excluded Entra group IDs (lowercase GUIDs) mapped to display names. + * + * @return array + */ + public function denied_groups() { + return self::guid_map( $this->get( 'denied_groups', array() ) ); + } + /** * Groups that limit the user sync (lowercase GUID => name); empty = whole tenant. * @@ -552,6 +562,7 @@ class M365_Login_Settings { // Allowed groups: GUID => name. $out['allowed_groups'] = self::sanitize_group_list( isset( $input['allowed_groups'] ) ? $input['allowed_groups'] : array() ); + $out['denied_groups'] = self::sanitize_group_list( isset( $input['denied_groups'] ) ? $input['denied_groups'] : array() ); // Button-only mode + fallback key. $out['button_only'] = empty( $input['button_only'] ) ? 0 : 1; diff --git a/languages/m365-login-de_DE.mo b/languages/m365-login-de_DE.mo index 982d3109dad5f74e15f577027391086ac44f50fd..efb30b89dc3de3aedfac521ce3612c89cb5669ba 100644 GIT binary patch delta 7074 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 delta 6139 zcmYM&3%HI|z5wtgV%u)zo?D`Ja*15Zc8yFC5{9Wv7q>wu$wb9`Ou5ZSQlz`mZB&}< zE|SuiqQo>A%v7SOoO33p)8%;PoF?Zqzklm{p7D76)_;BPyWX|_>%ZP_Z(pygeE&U_ z3kT~~S(W^mT`8qz+*>1}fBjk3D5bi>+c=hM*@nk8PN_ND@e~d!##0%eFJ(J!WCI@L z79M4PK6g?|r70ECQ4M3J?>U5xnxxc~6BrY^hh=<`llT$a@<&Zm>dhI{l{OT8o1Mg; zF|Kc5keQsvf&7Z~S#I_%JwIKf(M89#tj%?-%#G~97a0%ynkTbai@f4q)RnGaAI{}j ze1$RLFW8Uu-DIU{G-IJ-IkHkpH*$VK$L^N-f{WxaF1(bH@(G;F*?gBr88d&QRZ5-t zKC7~3>y#$49%BJFGWwS=cJp?g!262v(>zhUiG|e~J2ggf?5QcqT6&@Adz>u(f-#}d zr=?_<(iB$VEsWGI;SzqwTD;ZrM{y;q@jv)ee#Pn>dU{^yPfnNrxL}-)81QPw0M{~Z zn8#}TfVc4zUQo%NtFRwda7IeU>)*-9+#lJJA2L>cjIl)}_OB+(7+cVV${-DHEB|rh zWF1kZ*E6biDdUD0if&`6_+3VZK447j@3f8S|2d2;+#C%jHw&n${7hX_wIMr1e!wa~8TlqGta%s1`RVx@1 zdw{k1DkEdN*pGi^jB}Fo%wu2P8t4C2jT3bAKPROX9LaaVSKN_}qo0wX z0%PI#4l3lLd0a;e9orduceofI zbw)Zwjs3Zo&sUPYtMk_Fnwnqfg+m&#cSEM-clynY6s}=&ZekbiXJ?j9&*!-}cZo0I zP@blMVrAE}H49XKX&vM9%Gc)8Gn{c+uIDW-OHXRV1KQ8XKmT9FNcA&p#_jCHPdStI zuX8lHnBGaLna;R=23v9|yK_Bj@>9mdzG5F9V+Z!UJ|<-Ur)ad&@oUD+H*g^Lup^t! z$`csO!Q#1$0pDVrhP|9AwMY1{_=+3ycR`w+udBg&`Wx{!w&P*G%*LLd9-5O=yNOXG zM`*0{4I6RNjk&+Tc<>^gz?CfJx?;S6uZXvDEN@oPXYwP)X{dQ~{%+{QnE0D4M3EfO zsLXHp2>;F}dC%PZ2JAmC|7DiC3{P*A?Bun9x3+%tMUY`JQoq zr(fpG^VI22G1&s+B%NaNRH=|fuEy^#Nv)NC4G4J6@R%QFe`3H$EtWnAR zj1}Fx#1{`A<00ccpGLl_yE z%lLc+V*>ZF4L_whrkX4AmaJiY@qjz>3NK|%8LG81kK5$V{COM3@1Lo+MiVzoUqicVRT_p~#+iu+`279yf3rr%2l5J@;$!0d?7_JTU>RTH)g16p_E|O%H(8tC@tydkcm*SK^B*>8 zwtpo5Kfu3ny13W6Tb)A(QI~bJ>zuDd_3>{!;H-Q zjq!e{@kCC28=fxi#wf+^umGgA3G zx+lHEILFsp=4{@n2_dz4jmG=Dbl z;i+sY?#kzQF*}*@albVnyXcH97@=a%<7CGDPcs%&!6@2oEajhgA`kKwe#IWfDa@+K z$Kl!M^YM6tk+vD(Vg=*9G5^Kf|2M`2|BvOY z^3o4q(}mPWBT_n>?n$dyje|Gk35{ew@mMzCI=14=Jf8*~$tne(xgwJDl4&_-~ z#LnErt~|nKasFFw&M%tYj2Ta7Pwr%NkUubh{QCo?9ro#iaql8drGT+C*C zossg7_#}_;H@xf3{NO&@^A_%B4gCjMm7nuee$BmXxWoFIY8=^-tG3!(xtJQVjs7ym z@fpUL@im;rwd}!KZ|52q$Vm0=ypp@Pi0yagFP{p=M7FRx-(nN)*(txVqAzsB3`^h1 zp2$*hV@5HyW^1-%6%J!f{+Km5mT~=b#`SX;8Jf>1%H@pf*A{(-u@#%kHDU|iVO)5K zu@zqz`>Qxh^~H@Dnd-nOo(#LB;qE-~7Tf>Us+x}6b;=w