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:
ConnectionExceptionnebo 5xx → rozhoduje configfail_open.truepustí formulář dál (dostupnost > pustím bota),falseodmí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::addfungovalo, 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)