← Zpět · 11. června 2026 · 5 min čtení

Proč Fortify ověří captchu dvakrát v jednom requestu

Není to dvojí submit formuláře. Fortify se zapnutým 2FA volá authenticateUsing callback dvakrát v jednom requestu a jednorázový captcha token to neustojí.

Captcha hlásila při přihlášení pokaždé „token neplatný“. Přitom vím že funguje jak má, widget zezelenal, pole captchaapi_response v requestu sedělo. V logu serveru ale byla dvě volání na /verify se stejnou hodnotou - a to druhé vrátilo, že token je už neplatný protože už byl použitý.

První instinkt: formulář se odesílá dvakrát? Ale /login má jen jeden POST. Tahle past se jmenuje Fortify. Vysvětlení najdeš v jeho přihlašovací pipeline.

Navazuje na Jedna atestace, jeden submit, kde řeším, proč jednorázovost tokenu drží server captchaapi.eu a ne integrátor. Tady jde o to, jak ta jednorázovost narazí na Fortify.


Symptom

Přihlášení běží přes Fortify s vlastním ověřením přes Fortify::authenticateUsing(). Captcha se kontroluje uvnitř callbacku, ještě než se sáhne na heslo:

Fortify::authenticateUsing(function (Request $request) {
    if (! Captchaapi::isFake() && Captchaapi::enabled()) {
        $request->validate([
            'captchaapi_response' => ['required', 'string', 'captcha'],
        ]);
    }

    $user = User::query()->where('email', $request->string('email')->lower())->first();

    if ($user && Hash::check((string) $request->input('password'), $user->password)) {
        return $user;
    }

    return null;
});

Jeden POST /login, jeden token, a přesto dvě /verify. Token je jednorázový - server ho při prvním ověření zabere a druhý pokus o stejnou hodnotu odmítne. Tím padá přihlášení uživatele, který captchu vyřešil správně.


Není to dvojí submit, je to dvojí volání callbacku

Klíč je v tom, kolikrát Fortify ten authenticateUsing callback spustí. Se zapnutým 2FA? Dvakrát. Přihlašovací pipeline (AuthenticatedSessionController::loginPipeline) vypadá takhle:

return (new Pipeline(app()))->send($request)->through(array_filter([
    config('fortify.limiters.login') ? null : EnsureLoginIsNotThrottled::class,
    config('fortify.lowercase_usernames') ? CanonicalizeUsername::class : null,
    Features::enabled(Features::twoFactorAuthentication()) ? RedirectsIfTwoFactorAuthenticatable::class : null,
    AttemptToAuthenticate::class,
    PrepareAuthenticatedSession::class,
]));

Pipeline sáhne 2x na stejný callback - pokaždé z jiného důvodu.

1. RedirectIfTwoFactorAuthenticatable potřebuje uživatele, aby věděl, jestli ho poslat na 2FA challenge. Získá ho tím, že zavolá tvůj callback:

protected function validateCredentials($request)
{
    if (Fortify::$authenticateUsingCallback) {
        return tap(call_user_func(Fortify::$authenticateUsingCallback, $request), ...);
    }
    // …
}

2. AttemptToAuthenticate o pár řádků dál uživatele přihlašuje, takže callback zavolá znovu:

protected function handleUsingCustomCallback($request, $next)
{
    $user = call_user_func(Fortify::$authenticateUsingCallback, $request);
    // …
    $this->guard->login($user, $request->boolean('remember'));
    return $next($request);
}

Dvě nezávislá volání téhož callbacku v jednom requestu. Každé spustí $request->validate([...'captcha']), takže pravidlo ValidCaptcha posílá na /verify dvakrát. Druhé volání narazí na už zabraný token a failne.


Proč to nepřežije captcha ale heslo ano

Kontrola hesla je idempotentní. Hash::check() se stejnými vstupy vrátí podruhé to samé. Spustit ji dvakrát nikoho netrápí.

Ověření captchy idempotentní není. Token je jednorázový schválně - jedna vyřešená challenge smí pustit právě jeden submit. To je celá pointa replay ochrany. Jenže „jeden submit“ čte server jako „jedno /verify“, a Fortify pošle dvě. Druhé volání vypadá z pohledu serveru úplně stejně jako útočník, co recykluje cizí token.

Takže ironie: čím poctivěji server jednorázovost vynucuje, tím spolehlivěji ho Fortify shodí.


Oprava: uložení výsledku do request attributes

Druhé volání /verify se nesmí spustit proto se response z /verify uloží do request attributes:

$memoKey = $this->memoKey($value);

if ($this->isMemoized($memoKey)) {
    return; // už ověřeno v tomhle requestu
}

// … volání /verify …

$this->memoize($memoKey); // až po úspěchu

Memo žije v request()->attributes:

private function isMemoized(string $memoKey): bool
{
    if (! app()->bound('request')) {
        return false;
    }

    return (bool) request()->attributes->get($memoKey, false);
}

private function memoize(string $memoKey): void
{
    if (! app()->bound('request')) {
        return;
    }

    request()->attributes->set($memoKey, true);
}

První průchod zavolá /verify a zapamatuje si úspěch. Druhý ho najde v paměti requestu a vrátí se bez volání po síti. Token zůstává na serveru jednorázový tam, kde má být.


Proč ne perzistentní cache

Nabízí se varianta uložit verdikt do Redisu nebo databáze. Nedělej to! Dva nezávislé requesty útočníka se stejným tokenem by oba prošly z cache a replay ochrana, kterou server drží, by byla k ničemu. A přesně tohle má single-use token chytit.

Proto memo žije v request()->attributes a nikde jinde:

  • platí jen pro jeden HTTP request
  • zmizí na jeho konci
  • neovlivní ověření v dalším requestu

To je zásadní rozdíl oproti „dvěma submitům“. Kdyby šlo o dva HTTP requesty, memo by nepomohlo a musel bys řešit bezpečnostní problém. Jenže jde o dvě volání uvnitř jednoho requestu - a tam memo sedí přesně. Opraví dvojí volání, ale do dalšího pokusu nepřežije.


Kdy se to neprojeví

Ten první pipe je v pipeline podmíněný:

Features::enabled(Features::twoFactorAuthentication()) ? RedirectsIfTwoFactorAuthenticatable::class : null,

Bez 2FA pipe vypadne, callback se zavolá jen jednou a captcha se ověří jen jednou. Bug se ukáže až ve chvíli, kdy zapneš Features::twoFactorAuthentication() v config/fortify.php. Pak začne v pipeline figurovat RedirectIfTwoFactorAuthenticatable a druhé volání callbacku je na světě.

A poslední past: testy integrátorů to nechytí, pokud captchu v testech obcházejí přes fake bypass. Druhé /verify se reálně nepošle, token se nezabere a všechno svítí zeleně. Spadne to až na produkci u přihlášení.


TL;DR

  • Není to „dvojí submit“. Do /login přijde jeden POST.
  • Fortify se zapnutým 2FA zavolá authenticateUsing callback dvakrát v jednom requestu: jednou v RedirectIfTwoFactorAuthenticatable, jednou v AttemptToAuthenticate.
  • Heslo to přežije (idempotentní), captcha ne - single-use token padne na druhém /verify.
  • Oprava: memoizace v request()->attributes. První průchod ověří, druhý se vezme z paměti requestu.
  • Perzistentní cache ne - tím bys unikátnost tokenu obešel napříč requesty.
  • Bez 2FA se bug neprojeví a fake bypass v testech ho schová. Poznáš to až v produkci.

Stačí psát část slova · ⌘K otevře hledání odkudkoliv

No results found