Skip to content

[Security] high: OAuth2 password grant bypasses account lockout AND mandatory 2FA enforcement (parallel auth path missing controls) #472

Description

@Kravalg

Summary

The service exposes two independent password-authentication paths against the SAME user store, but the security controls live only in one of them.

Path A — POST /api/signin -> SignInCommandHandler -> UserCredentialValidator.validate(): enforces the atomic Redis account lockout (assertNotLocked / recordFailure / clearFailures, SIGNIN_LOCKOUT_MAX_ATTEMPTS=20, 900s lock), a per-email limiter (5/min) and a per-IP limiter (10/min), publishes failure/lockout events, uses a dummy-hash timing defense, AND crucially enforces 2FA: if isTwoFactorEnabled() the handler returns only a pending_session_id and issues NO tokens until a valid TOTP is supplied.

Path B — POST /api/oauth/token with grant_type=password (enable_password_grant: true) -> league/oauth2-server-bundle -> the ONLY registered user_resolve listener, UserResolveListener::onUserResolve (config/services.yaml:260-264). resolveUser() does nothing but findUserByEmail + PasswordHasher->verify and returns the user. It performs: NO lockout check, NO recordFailure, NO failure event, and NO isTwoFactorEnabled() check. On a correct password league immediately issues a full access_token + refresh_token. I grepped every league event listener (only UserResolveListener and ClientManager hook league events) to confirm no other listener re-adds lockout/2FA.

Consequences: (1) Complete 2FA bypass — a user who enabled 2FA is fully authenticated by password alone through /api/oauth/token, defeating the entire second-factor feature that /api/signin enforces. (2) Account-lockout bypass — the brute-force lockout that would lock an account after ~20 failures on /api/signin is never consulted or updated here; failures are never recorded, so the account is never locked and no lockout/alert event is emitted. The only throttle on this path is the 'oauth_token' limiter (10/min), and its key is the REQUEST-SUPPLIED client_id (ApiRateLimitClientIdentityResolver::resolveClientId reads client_id from the body/Basic-auth with no validation), i.e. it is not account-scoped and provides no lockout semantics.

Severity: HIGH • Category: A07:2021 Identification & Authentication Failures / auth-bypass • OWASP: A07:2021 Identification and Authentication Failures; API2:2023 Broken Authentication; CWE-287 Improper Authentication; CWE-307 Improper Restriction of Excessive Authentication Attempts; CWE-308 Use of Single-factor Authentication
Found by an authorized automated adversarial pentest loop and confirmed by 2 independent skeptic verifiers (unanimous).

Affected code / location

config/packages/league_oauth2_server.yaml:33 (enable_password_grant: true); src/User/Application/EventListener/UserResolveListener.php:29-49 (onUserResolve/resolveUser); contrast src/User/Application/Validator/UserCredentialValidator.php:44-72 and src/User/Application/CommandHandler/SignInCommandHandler.php:50-54

Exploit scenario

Precondition: attacker holds valid credentials for a first-party client that has the password grant enabled (every SPA/mobile app that uses this token endpoint does; a leaked/embedded client secret suffices).

2FA bypass: Target victim has 2FA enabled and the attacker knows/phished/reused their password. Instead of POST /api/signin (which returns only {"2fa_enabled":true,"pending_session_id":...} and demands a TOTP code), send:
POST /api/oauth/token
grant_type=password&client_id=&client_secret=&username=victim@example.com&password=&scope=email
league resolves the user via UserResolveListener with zero 2FA awareness and returns a full access_token + refresh_token. The second factor was never requested.

Lockout bypass / brute force: Loop the same request varying only password. Because UserResolveListener never calls recordFailure and never consults isLocked, the account never locks (unlike /api/signin, which locks after 20 attempts) and no failure telemetry is emitted. Distinguish outcomes by response: HTTP 200 with tokens = correct password, HTTP 400 invalid_grant = wrong password — a clean credential oracle. Even the account already hard-locked on /api/signin remains attackable here.

Suggested remediation

Route the league password grant through the same UserCredentialValidator used by /api/signin (or replicate its guarantees inside UserResolveListener): call the account-lockout provider (isLocked -> reject, recordFailure on mismatch, clearFailures on success) AND reject resolution when $user->isTwoFactorEnabled() so 2FA users cannot obtain tokens via the password grant. Preferably disable enable_password_grant entirely (the ROPC grant is deprecated in OAuth 2.1) and force interactive flows. Also key the oauth_token limiter on the target username, not the client-supplied client_id.

Verification notes

  • Verifier 1 (CONFIRMED, exploitable=true, duplicate=false): Traced the full path in current code. league_oauth2_server.yaml:33 has enable_password_grant: true (base/prod). security.yaml:10 exposes POST /api/oauth/token as PUBLIC_ACCESS. Grep confirms UserResolveListener is the ONLY listener on league.oauth2_server.event.user_resolve (ClientManager only handles PreSaveClientEvent during client persistence, not token issuance). UserResolveListener::resolveUser (lines 37-50) performs only findUserBySubmittedEmail + passwordMatches($hasher->verify) and returns the user; league then mints full access+refresh tokens. It never calls the AccountLockoutProvider (no isLocked/recordFailure), emits no failure/lockout telemetry, and never checks isTwoFactorEnabled(). This is in direct contrast to the /api/signin path: SignInCommandHandler.php:51-53 returns only a pending_session_id (no tokens) when isTwoFactorEnabled(), and UserCredentialValidator.php enforces assertNotLocked (51), recordFailure (102) and lockout events. ApiRateLimitAuthTargetResolver maps only /api/signin, /api/signin/2fa, graphql and /api/2fa/* to the signin/2FA/lockout limiters — /api/oauth/token is absent, so no account-scoped throttle or lockout applies there (only the generic per-client_id oauth_token limiter). Consequences are concretely reachable: (1) a 2FA-enrolled user is fully authenticated by password alone via /api/oauth/token, a complete second-factor bypass; (2) brute force via /api/oauth/token never records failures or trips the 20-attempt lockout, and returns a clean 200-vs-400 credential oracle, even for accounts already locked on /api/signin. Not a duplicate: [Security] high: Two-factor (2FA/TOTP) verification: no server-side brute-force counter and codes are replayable #314 (TOTP-endpoint brute-force), [Security] low: Account-lockout failure counter uses non-atomic read-modify-write (race allows extra guesses) #324 (lockout atomicity), [Security] critical: OAuth2/JWT access tokens silently escalate to ROLE_SERVICE; issuer/audience validation skipped; session-revocation bypassed #312 (token role escalation), [Security] high: GraphQL endpoint bypasses all per-endpoint rate limiters (sign-in, refresh, password-reset, 2FA) #315 (GraphQL rate limiting) all concern different mechanisms; this is a distinct parallel-auth-path control gap. Minor caveat: the title's 'mandatory 2FA' overstates — 2FA is per-user opt-in — but the bypass is real and high-severity for any enrolled user, and the lockout bypass affects all accounts.
  • Verifier 2 (CONFIRMED, exploitable=true, duplicate=false): Verified against code. league_oauth2_server.yaml:33 enables the password grant. config/services.yaml:260-266 registers UserResolveListener::onUserResolve as the ONLY listener on league.oauth2_server.event.user_resolve (grep for user_resolve returns only this one registration). UserResolveListener::resolveUser (lines 37-50) performs solely findUserByEmail + PasswordHasher->verify and returns the user; it never calls isLocked/assertNotLocked, never recordFailure, emits no failure/lockout telemetry, and never checks isTwoFactorEnabled(). On a correct password league issues full access+refresh tokens immediately.

Contrast the /api/signin path: SignInCommandHandler::__invoke calls UserCredentialValidator::validate (atomic Redis lockout: assertNotLocked line 51, recordFailure line 102, clearFailures line 70, dummy-hash timing defense line 87, failure/lockout events) and then at line 51 enforces isTwoFactorEnabled(), returning only pending_session_id with NO tokens (handleTwoFactorPath). Thus two password-auth paths hit the same user store with controls on only one. I also confirmed 2FA is enforced on the SOCIAL OAuth callback path (HandleOAuthCallbackCommandHandler:46, OAuthCallbackController:83) but not on the password-grant token path, reinforcing that the guard is path-specific and absent on the token endpoint.

Consequences confirmed: (1) complete 2FA bypass for any user who enabled 2FA — password alone yields tokens via /api/oauth/token; (2) lockout bypass / unthrottled credential oracle — failures never recorded, account never locks, HTTP 200+tokens vs 400 invalid_grant distinguishes correct password.

Not a duplicate of the KNOWN fixed list: #312 concerns ROLE_SERVICE token escalation / non-first-party token acceptance (privilege, not lockout/2FA); #314 is a missing server-side TOTP brute-force counter that presupposes you are already on the 2FA challenge; #324 is lockout-counter non-atomicity. None address the password-grant path skipping lockout and 2FA entirely.

Exploitable in present code: password grant requires valid client credentials (embedded in every first-party SPA/mobile; a leaked/embedded secret suffices) plus a known/phished/reused password, which is a realistic precondition and fully defeats the second factor and brute-force lockout. Severity high.

Related

Part of the enterprise security-hardening effort — umbrella tracker #348; security NFR issues #426 (securability), #441 (vulnerability), #362 (confidentiality), #389 (integrity). Prior security wave: #312#324. Not a duplicate of the already-fixed items in that range.


Acceptance = the exploit path is closed AND a regression test (unit/Behat/Schemathesis) proves it stays closed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions