All vulnerabilities
GHSA-8fvf-747v-qjr6

phpmyfaq/phpmyfaq: Keycloak/Entra ID SSO login bypasses the two-factor token step

phpmyfaq/phpmyfaq
CVSS
8.8High
ExploitationNo known exploitationNot in CISA KEV, which does not prove it is unexploitedCISA KEV
Exploit odds (EPSS)No scoreNo FIRST EPSS score in this snapshot yet
Fixed inNone listedNo source names a fixed version yet
Published3 Oct 2026Updated 3 Oct 2026GitHub

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 page

The 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 tags 4.2.0-alpha and 4.2.0-alpha.2, and at main 09969d672)
  • 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)

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