{"schema":"threatline.record/2","record":{"id":"GHSA-8fvf-747v-qjr6","aliases":["GHSA-8fvf-747v-qjr6"],"summary":"Keycloak/Entra ID SSO login bypasses the two-factor token step","details":"### Summary\n\nWhen an account has TOTP two-factor authentication enabled, a login through the Keycloak or Microsoft Entra ID SSO callback grants a fully authenticated phpMyFAQ session without ever requesting the token step. Both callbacks call `setLoggedIn(true)` / `updateSessionId(true)` / `saveToSession()` directly and never read `twofactor_enabled`, so every SSO account effectively runs at single-factor strength — including administrators, for whom two-factor is the usual policy requirement.\n\n### Details\n\n`phpmyfaq/src/phpMyFAQ/Controller/Frontend/KeycloakAuthenticationController.php` (callback, after the subject is matched):\n\n```php\nif (!$this->isLinkedToKeycloakSubject($user, $claims)) {\n    // ... subject mismatch -> abort\n}\n\n$user->setLoggedIn(true);\n$user->setAuthSource(AuthenticationSourceType::AUTH_KEYCLOAK->value);\n$user->updateSessionId(true);\n$user->saveToSession();          // <- no twofactor_enabled check, no 2fa_pending_user_id\n$user->setTokenData([...]);\n$user->setSuccess(true);\n```\n\n`phpmyfaq/src/phpMyFAQ/Controller/Frontend/AzureAuthenticationController.php` has the same shape; it validates that the local account is usable (blocked accounts are refused) but never inspects the 2FA state:\n\n```php\n$userId = $auth->getAuthenticatedUserId();\nif ($userId <= 0 || !$user->getUserById($userId) || $user->getUserId() <= 0) {\n    return $this->loginFailed();      // account-status check exists, 2FA check does not\n}\n\n$user->setLoggedIn(true);\n$user->setAuthSource(AuthenticationSourceType::AUTH_AZURE->value);\n$user->updateSessionId(true);\n$user->saveToSession();\n```\n\nEvery other login-completing path consults the flag; the passkey path states the intended invariant explicitly:\n\n```php\n// If the account has TOTP two-factor enabled, the passkey counts as the first factor only:\n// defer to the token step instead of granting the session, mirroring the password\n// login flow, so a passkey cannot bypass two-factor authentication.\nif ((int) $currentUser->getUserData('twofactor_enabled') === 1) {\n    ...\n    $this->session->set('2fa_pending_user_id', $currentUser->getUserId());\n    $this->session->set('2fa_pending_remember_me', false);\n    return $this->json(['redirect' => $defaultUrl . 'token?user-id=' . $userId]);\n}\n```\n\nCount of `twofactor_enabled` reads per login path (`4.2.0-alpha.2` → `main`): `CurrentUser.php` 3 → 3, `UserAuthentication.php` (used by the API) 1 → 1, `WebAuthnController.php` 0 → 1, `KeycloakAuthenticationController.php` 0 → 0, `AzureAuthenticationController.php` 0 → 0.\n\n### Verification (live, not inferred)\n\nAn instance was installed from the published image `ghcr.io/thorsten/phpmyfaq:nightly-2026-09-26` (MariaDB 11, PHP 8.4.26) with a lab account `victim` that has `twofactor_enabled = 1`, a TOTP secret, and a Keycloak subject link. A local OpenID provider served only the endpoints the product itself calls (`/.well-known/openid-configuration`, `/protocol/openid-connect/auth|certs|token|userinfo`) with an RS256-signed `id_token`; no application code was modified.\n\n```\nCONTROL 1 - API password login, the 2FA account       -> {\"loggedin\":false}   (second factor enforced)\nCONTROL 2 - API password login, an account without 2FA-> {\"loggedin\":true}    (mechanism works)\nCASE      - Keycloak SSO login, the SAME 2FA account:\n  1) GET /auth/keycloak/authorize -> 302 to the provider (state, nonce, PKCE)\n  2) provider consent            -> 302 back with code + state\n  3) GET /auth/keycloak/callback -> 302 to the default page\n     home page with the returned session: \">Logout<\" + ./user/ucp ./user/bookmarks ./user/chat\n     faquser.last_login updated, auth_source=keycloak, access token stored\n     token step performed? 0 references to the token page\n```\n\nThe session the callback wrote contains `CURRENT_USER\";i:2` (the 2FA-protected account) and the accepted `id_token`, and contains no `2fa_pending_user_id` — the token step was never armed. A request with an unknown session id renders the guest navigation (`>Login<`), so the logged-in reading is not a false positive. The provider log for the case contains only discovery, authorize, token, certs and userinfo: nothing in the flow involves TOTP.\n\n### Impact\n\nAnyone holding a valid SSO identity for that account — a stolen Keycloak/Entra credential, a replayed IdP session, or a compromised IdP account — receives a full phpMyFAQ session, including administrator access when the account is an administrator. The product's own `twofactor_enabled` setting is never consulted on this path, so correct two-factor configuration by the administrator changes nothing for SSO users. This is the same class as two previously published advisories for this project: `GHSA-8gpw-xvpf-hvx5` (\"two-factor authentication login bypasses the password factor\") and `GHSA-hvj7-4fmg-53cr` (\"2FA Bypass via Remember-Me Cookie Issued Before Second Factor Verification\") — both about a login path that forgot the second factor. The fix commits for `GHSA-8gpw-xvpf-hvx5` (`410208b9`, `5097dff3`, `6a69f6e2`) touch `AuthenticationController.php`, `CurrentUser.php` and `UserAuthentication.php` only; the two SSO callbacks were not brought into the 2FA model.\n\n### Affected versions\n\n* `phpmyfaq/phpmyfaq` `>= 4.2.0-alpha` (checked at tags `4.2.0-alpha` and `4.2.0-alpha.2`, and at `main` `09969d672`)\n* the project's nightly images (the code is also present in the 4.1-line nightlies, e.g. `4.1-nightly-2026-04-20` onward)\n\nNo stable 4.1.x release contains these controllers (they are absent at tags 4.1.5–4.1.8). No fixed version is known at the time of writing.\n\n### Suggested fix\n\nMirror the password/passkey flow in both callbacks before the session is granted:\n\n```php\nif ((int) $user->getUserData('twofactor_enabled') === 1) {\n    if ($user->isTwoFactorLockedOut()) {\n        return $this->loginFailed();\n    }\n    $this->session->set('2fa_pending_user_id', $user->getUserId());\n    $this->session->set('2fa_pending_remember_me', false);\n    return new RedirectResponse($this->configuration->getDefaultUrl() . 'token?user-id=' . $user->getUserId());\n}\n```\n\nplus a regression test per controller asserting that a `twofactor_enabled = 1` subject leaves no `CURRENT_USER` in the session and redirects to the token step.\n\n### Notes on scope\n\nThe Keycloak path was reproduced live end to end. The Entra ID path is reported from code inspection: its login completion is the same three calls with no 2FA consultation, but no live run was performed against a Microsoft endpoint. Full transcripts, the provider stub and the driver script are available on request.\n","categories":["Unclassified"],"product":"phpmyfaq/phpmyfaq","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-8fvf-747v-qjr6","source_modified_at":"2026-10-03T11:26:54Z","fetched_at":"2026-10-24T14:26:37.433577Z","package":{"ecosystem":"composer","name":"phpmyfaq/phpmyfaq"},"range":">= 4.2.0-alpha"}],"fixed_versions":[],"cvss":8.8,"severity":"High","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-03T11:26:54Z","modified_at":"2026-10-03T11:26:54Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-8fvf-747v-qjr6","fetched_at":"2026-10-24T14:26:37.433577Z","source_modified_at":"2026-10-03T11:26:54Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-10-03T11:26:54Z","content_hash":"263d28c0be79a1991516677b6135d38365a21b98e972f49684dfd50987ecf96e"}}
