phpmyfaq/phpmyfaq: Keycloak/Entra ID SSO login bypasses the two-factor token step
What fixes it
No source in this snapshot names a fixed version.
Description
Summary
When 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.
Details
phpmyfaq/src/phpMyFAQ/Controller/Frontend/KeycloakAuthenticationController.php (callback, after the subject is matched):
if (!$this->isLinkedToKeycloakSubject($user, $claims)) {
// ... subject mismatch -> abort
}
$user->setLoggedIn(true);
$user->setAuthSource(AuthenticationSourceType::AUTH_KEYCLOAK->value);
$user->updateSessionId(true);
$user->saveToSession(); // <- no twofactor_enabled check, no 2fa_pending_user_id
$user->setTokenData([...]);
$user->setSuccess(true);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:
$userId = $auth->getAuthenticatedUserId();
if ($userId <= 0 || !$user->getUserById($userId) || $user->getUserId() <= 0) {
return $this->loginFailed(); // account-status check exists, 2FA check does not
}
$user->setLoggedIn(true);
$user->setAuthSource(AuthenticationSourceType::AUTH_AZURE->value);
$user->updateSessionId(true);
$user->saveToSession();Every other login-completing path consults the flag; the passkey path states the intended invariant explicitly:
// If the account has TOTP two-factor enabled, the passkey counts as the first factor only:
// defer to the token step instead of granting the session, mirroring the password
// login flow, so a passkey cannot bypass two-factor authentication.
if ((int) $currentUser->getUserData('twofactor_enabled') === 1) {
...
$this->session->set('2fa_pending_user_id', $currentUser->getUserId());
$this->session->set('2fa_pending_remember_me', false);
return $this->json(['redirect' => $defaultUrl . 'token?user-id=' . $userId]);
}Count 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.
Verification (live, not inferred)
An 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.
CONTROL 1 - API password login, the 2FA account -> {"loggedin":false} (second factor enforced)
CONTROL 2 - API password login, an account without 2FA-> {"loggedin":true} (mechanism works)
CASE - Keycloak SSO login, the SAME 2FA account:
1) GET /auth/keycloak/authorize -> 302 to the provider (state, nonce, PKCE)
2) provider consent -> 302 back with code + state
3) GET /auth/keycloak/callback -> 302 to the default page
home page with the returned session: ">Logout<" + ./user/ucp ./user/bookmarks ./user/chat
faquser.last_login updated, auth_source=keycloak, access token stored
token step performed? 0 references to the token pageThe 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.
Impact
Anyone 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.
Affected versions
phpmyfaq/phpmyfaq>= 4.2.0-alpha(checked at tags4.2.0-alphaand4.2.0-alpha.2, and atmain09969d672)- the project's nightly images (the code is also present in the 4.1-line nightlies, e.g.
4.1-nightly-2026-04-20onward)
No 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.
Suggested fix
Mirror the password/passkey flow in both callbacks before the session is granted:
if ((int) $user->getUserData('twofactor_enabled') === 1) {
if ($user->isTwoFactorLockedOut()) {
return $this->loginFailed();
}
$this->session->set('2fa_pending_user_id', $user->getUserId());
$this->session->set('2fa_pending_remember_me', false);
return new RedirectResponse($this->configuration->getDefaultUrl() . 'token?user-id=' . $user->getUserId());
}plus 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.
Notes on scope
The 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.
Text from GitHub Advisory Database
Affected versions 1
- phpmyfaq/phpmyfaq, composer>= 4.2.0-alphaGitHub