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

Jedna atestace, jeden submit: návrat ke kořenům

Lokální jti replay přes Cache::add fungoval. Server-side verify ale drží jednorázovost na autoritě, ne u integrátora. Proč jsem ten model opustil.

Cíl se nezměnil: jeden vyřešený challenge smí projít právě jedním odesláním formuláře. Co se změnilo, je kde tu jednorázovost vynucuju. Dřív lokálně v Laravelu přes jti a Cache::add. Teď to drží server captchaapi.eu a integrátor jen položí jeden dotaz na /verify.

Tenhle článek je o tom, proč jsem ten model přepsal a co tím odpadlo.


Jak to fungovalo dřív

PoW captcha vydala podepsanou atestaci (request na /challenge → spočítání PoW → request na /verify → podepsaná atestace) a Laravel ji ověřil lokálně jako HMAC kontrolu, bez HTTP volání. Rychlé, jenže ta atestace je bearer token - kdo ji drží, může ji poslat znovu, dokud nevyprší exp. To je jti replay.

Řešil jsem to jednorázovým tokenem: každá atestace nesla claim jti, server si použité jti pamatoval a druhý pokus odmítl. Atomicky přes:

Cache::add($key, true, $ttl);
// Redis: SET key 1 NX EX ttl

První request projde, druhý vrátí false a je to replay. Fungovalo to. Tak proč to měnit?


Proč jsem to přesunul na server

Tři důvody, všechny o bezpečnosti.

1. Replay ochrana závisela na konfiguraci integrátora. Cache::add je atomický jen na sdíleném driveru (Redis, Memcached, databáze). Když měl integrátor array nebo file driver, tak ochrana vůbec nefungovala.

2. Single-use má vlastnit autorita, ne klient. Server vidí všechny requesty napříč všemi integrátory. Jeden SETNX u něj a jednorázovost je daná pro každého, bez ohledu na jeho stack. Lokální ověření tuhle jistotu nikdy nedá.

3. Tohle není zodpovědnost integrátora. Žádná lokální replay tabulka, žádný cache_prefix, žádné parsování jti, žádný úklid. Integrátor pošle hodnotu a dostane ano/ne.


Jak to vypadá teď

Widget přidá do formuláře skryté pole captchaapi_response. Backend ho pošle na /verify s projektovým secretem jako Bearer tokenem:

$response = Http::asJson()
    ->withToken($secret)
    ->post('captchaapi.eu/api/v1/verify', ['response' => $value]);

if ($response->json('success') === true) {
    // projde
}

Jednorázové a unikátní ověření teď má server: při prvním /verify se vytvoří token pomocí (SETNX), druhý pokus o stejný token vrátí neúspěch. Integrátor nemusí o replayi vědět vůbec nic.

Více jistoty za stejnou cenu: místo lokální HMAC kontroly je při každém odeslání jedno server-to-server volání. Ale to není žádná změna oproti předchozí implementaci kde volání /verify následovalo ihned po vystavení PoW challenge.


Co zůstalo: Fortify validuje dvakrát

Tahle past přežila migraci. Fortify spustí validační pravidla dvakrát v jednom requestu. Token je ale jednorázový na serveru - druhý průchod pošle /verify se stejnou hodnotou, narazí na už zabraný token a odmítne uživatele, který captchu vyřešil správně.

Řešení je stejné jako dřív: memoizace v rámci requestu, oddělená od ověření.

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

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

// … volání /verify …

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

Memo:

  • žije v request()->attributes
  • zmizí na konci requestu
  • neovlivní ověření v dalším requestu

První průchod zavolá /verify, druhý se vezme z cache. Mezi requesty zůstává token jednorázový tam, kde má být - na serveru.

Pozn.: tohle byl reálný bug. Když jsem balík přepisoval na server-side verify, memoizaci jsem omylem vypustil. Testy integrátorů ji nechytily, protože používají fake bypass. Spadlo to až na produkci u přihlášení přes Fortify. Návrat memoizace byl ta oprava.


Když je server nedostupný

Lokální HMAC nikdy „nespadlo“, počítalo se offline. Server-to-server volání spadnout může, takže k němu patří fail policy:

  • ConnectionException nebo 5xx → rozhoduje config fail_open. true pustí formulář dál (dostupnost > pustím bota), false odmítne.
  • „neplatné“ od serveru → vždy odmítne.

TL;DR

  • Cíl je pořád „jedna atestace, jeden submit“. Změnilo se jenom, kdo to implementuje.
  • Lokální jti + Cache::add fungovalo, ale jednorázovost záležela na cache driveru integrátora.
  • Server-side verify přesouvá single-use na autoritu: jeden /verify, token se zabere, replay řeší server.
  • Cena: HTTP volání při každém odeslání. Stejná cena jako předtím. Takže cena je stejná
  • Double-validation (Fortify) se řeší pomocí memoizace v request()->attributes.
  • Při výpadku serveru rozhoduje fail_open (pozor, nemá vliv na zamítnutí ze strany serveru to stále blokuje)

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

No results found