Přeskočit obsah
V
Pro vývojáře
Architektura, konvence, jádro systému a bezpečnost
Bezpečnost / Odkaz s klíčem bez přihlášení

Odkaz s klíčem bez přihlášení#

Vzor pro akci, kterou otevírá nepřihlášený člověk z odkazu v e-mailu a která smí udělat jedinou věc. Dnes ho používá tlačítko Označit jako přečtené v e-mailu o reakci na inzerát (markReadByKey). Stejně můžete postavit potvrzení, odhlášení nebo jednorázovou akci. Využívá podmíněnou roli receiver.

Z čeho se skládá#

Část Kde Úkol
Klíč sloupec read_key (VARCHAR 64) u reakce; Response::$readKey náhodný tajný řetězec, 24 náhodných bajtů = 48 hex znaků
Generování ResponseMarkReadTrait::generateReadKey() v beforeSave() Api manageru klíč vznikne při založení záznamu
Porovnání ResponseMarkReadTrait::readKeyMatches() hash_equals, prázdný uložený ani prázdný předaný klíč neprojde
Odkaz ResponseManager::buildMarkReadLink() absolutní adresa veřejné stránky; do e-mailu jde jako markReadLink
Veřejná stránka Front:Bazaar:Response:markRead, Front:Comcat:Response:markRead zavolá Api, ukáže hlášku
Api akce internalActionMarkReadByKey() v ResponseReceiverTrait interní, bez action*()
RBAC klíč :Api:{Bazaar,Comcat}:Response:markReadByKey grant jen receiver (+ admin), guest a registrated mají 0
Zápis markRead($id) přepne read = 1, nic jiného

Jak to teče#

  1. Odpověď na inzerát se uloží; beforeSave() jí dá read_key.
  2. sendEmail() složí odkaz //:Front:Bazaar:Response:markRead s parametry slug (pevné id stránky 2011, u Comcatu 2012), id a key a vloží ho do šablony jako {$markReadLink}.
  3. Příjemce klikne. Presenter nastaví hlavičky Referrer-Policy: no-referrer, Cache-Control: no-store a X-Robots-Tag: noindex, nofollow (adresa nese tajemství).

!!! warning "Referrer-Policy z .htaccess hodnotu stránky přepíše" Odpověď proto nese strict-origin-when-cross-origin, ne no-referrer: pravidlo Header always set ve www/.htaccess ji nahradí. Cizí web dostane jen adresu webu, ne cestu s klíčem, takže klíč neuniká — viz Hlavičky odpovědi. 4. Pozná-li CrawlerDetector robota, nic nezapíše a vrátí stav crawler. 5. id musí být malé kladné číslo a klíč neprázdný, jinak stav invalid. 6. Front klíč neověřuje — pošle id a key do Api (ResponseManager::markReadByKey() → bridge mark-read-by-key). 7. Api: requireApiPermission('markReadByKey', owned: …, ownedData: ['readKey' => $key]). Brána pustí grant receiver a zavolá isReceiver(), která porovná klíč (isResponseReceiver($response, $readKey), větev 4). Teprve pak markRead($id). 8. Stránka ukáže read, invalid nebo crawler.

Kontrakt#

Akce umí jedinou věc a o ničem nevypovídá

Přepne read = 1. Nevrací nic z reakce ani z inzerátu — jen obálku Result. Stránka ukazuje jen hlášku. Neplatný klíč, reakce bez klíče, smazaná i neexistující reakce vrací týž výsledek (INVALID_KEY) a HTTP 200, aby akce nebyla oracle na existenci záznamu.

  • Klíč se po použití nemaže. Platný klíč u už přečtené reakce je znovu úspěch, takže odkaz jde otevřít opakovaně.
  • Moduly se oddělují tabulkou. Každý manager hledá ve své tabulce reakcí; klíč z Bazaaru u reakce se stejným id v Comcatu neprojde.
  • Starší záznamy klíč nemají a nedoplňují se. buildMarkReadLink() pro ně vrátí null a šablona se blokem {if !empty($markReadLink)} obejde bez tlačítka.
  • Klíč nesmí být ve sloupci s právy zápisu zvenčí. #[ExternalReadonly] ho zvenčí nepřebírá; save ho tedy nepřepíše. Klíč se nikdy neukládá do session ani do JWT.
  • Zvenčí klíč nepomůže. Větev klíče existuje jen na interní cestě; na externím verifyReceiver() se klíč nepředává.

Postup: stejný vzor pro novou akci#

  1. Přidej sloupec s klíčem k záznamu (VARCHAR(64) NULL, bez backfillu, bez unikátního indexu, pokud se hledá podle primárního klíče). SQL idempotentní (ADD COLUMN IF NOT EXISTS), před nasazením kódu (entita sloupec čte).
  2. Entita: property s #[ExternalReadonly], getter a setter.
  3. Vygeneruj klíč v beforeSave() při INSERTu: bin2hex(random_bytes(24)).
  4. Porovnávej jen hash_equals a nikdy ==; prázdnou hodnotu odmítni.
  5. Api akce bez action*() s requireApiPermission(..., ownedData: ['…Key' => $key]) a kontrolou v isReceiver(). Grant receiver (+ admin) na klíč, guest a registrated vypni.
  6. Veřejná stránka v tabulce stránek s logged = 0, bez show_in_sitemap. Routa bez regulárního výrazu u id a key: špatný tvar odkazu má skončit stejnou hláškou, ne 404.
  7. Stránka musí nastavit Referrer-Policy, Cache-Control: no-store, X-Robots-Tag.
  8. Ošetři robota přes CrawlerDetector: robot nic nezapíše.
  9. Test z kontraktu: platný klíč ✓, špatný ✗, bez klíče ✗, cizí modul ✗, smazaná ✗, všechny neplatné případy vrací stejnou odpověď; vzor ResponseMarkReadKeyTest.

Past: skenery otevírají odkazy#

Poštovní klienti a bezpečnostní skenery někdy odkazy v e-mailu sami otevřou bez robotího User-Agentu. Záznam se pak označí bez zásahu člověka. Je to vědomě přijatý důsledek rozhodnutí „GET bez potvrzovacího tlačítka"; škoda je jen přepnutý příznak. Pro akci s následky přidej potvrzovací krok (POST).

Zápis při GET jinde ve frontě stojí na isSameSite()

Detail reakce na webu read nastaví jen při požadavku z vlastního webu. Odkaz otevřený z webmailu reakci ukáže, ale neoznačí — proto existuje tlačítko s klíčem. Viz CORS, rate limit a audit.

Migrace#

Pořadí, v jakém se pro tuhle funkci SQL aplikuje při nasazení:

  1. sql/2026-10-02_11_response_read_key_column.sql — sloupec read_key u obou tabulek reakcí (před nasazením kódu),
  2. …_08_response_mark_read_pages.sql — veřejné stránky 2011/2012 a routy,
  3. …_09_response_mark_read_privilege.sql — RBAC klíče markReadByKey,
  4. …_12_response_receiver_role.sql — role receiver a její granty (nahrazuje grant z _09),
  5. …_10_response_mark_read_email_button.sql — blok s tlačítkem v e-mailových šablonách.

Rollbacky mají prefixy _98_, _99_, _88_, _89_, _90_. Po aplikaci smaž temp/cache a route-table cache v Redisu (app:cache:route:*).

Varianta: odkaz ke správě inzerátu#

Stejný princip (tajemství jen v e-mailu, v DB otisk) používá odkaz Otevřít správu inzerátu ve vkládacím e-mailu záznamu bez účtu (Bazaar inzerát, Comcat firemní profil a příspěvek Diskuze). Dřív e-mail nesl čitelné heslo ke správě, dnes ho nenese vůbec. Záznam implementuje ManageKeyAwareInterface (heslo nebo u příspěvku hosta náhodné tajemství, otisk a platnost klíče) a vydání i ověření dělá sdílená ManageKeyTrait (app/Core/Traits/Api/Models/Managers/ManageKeyTrait.php).

Část Kde Úkol
Pravidla klíče App\Core\Security\AdvertManageKey čisté funkce: 32 náhodných bajtů jako hex, otisk sha256(klíč \| otisk hesla inzerátu), platnost TTL_SECONDS = 7 dní
Uložení sloupce manage_key_hash, manage_key_expires u inzerátu (FieldAccess všude None) čitelný klíč existuje jen v e-mailu
Vydání ManageKeyTrait::issueManageKey() po commitu vložení; volá ho AdvertEmailTrait jen u e-mailu BZ_ADVERT_INSERT (Comcat CC_ADVERT_INSERT), v diskuzi MessageConfirmationTrait klíč jde do odkazu na editaci jako parametr manageKey
Uplatnění front edit?id=…&manageKey=…, redeemManageKeyFromLink() → interní Api akce redeemManageKey (ManageKeyTrait::redeemManageKey()) při shodě založí relaci editace stejně jako zadání hesla, flash a přesměrování bez klíče

Klíč jde použít opakovaně po celou dobu platnosti (skener odkazů v poště ho tak nespálí dřív než majitel). Je svázaný s otiskem hesla, takže jakmile se heslo inzerátu změní (reset, editace, správce), odkaz přestane platit, aniž by se sloupec mazal. Po expiraci platí jen heslo zvolené při vkládání nebo „Zapomenuté heslo". Heslo ve frontě odchozí pošty je zamaskované a kdo prokázal přístup heslem inzerátu, vidí nemaskované osobní údaje jen řádků toho inzerátu (PersonalDataPolicy::apply($payload, $capabilityRows)).

Varianta: potvrzení záznamu autorem#

Inzerát, firemní profil a příspěvek diskuze, u kterých modul vyžaduje potvrzení autorem (viz Inzerce), dostanou při vložení klíč a autorovi odejde e-mail s odkazem ?do=confirm&key=…. Na odkaz kliká i nepřihlášený autor, bezpečnost proto drží klíč, ne přihlášení.

Část Kde Úkol
Pravidla klíče App\Core\Security\AdvertConfirmationKey 32 náhodných bajtů (64 hex), v DB jen sha256; bez expirace
Uložení sloupec activation_key čeká (hash(klíč)) → spotřebováno (hash('consumed:' . hash(klíč)))
Vydání InitialConfirmationTrait::applyInitialConfirmation() v beforeSave() při insertu čitelný klíč jde jen přes $meta do e-mailu po commitu
Uplatnění ConfirmByKeyTrait::confirmByKey($id, $key, $ip); inzertní moduly přes AdvertConfirmByKeyTrait interní Api akce confirmByKey bez action*(), klíč RBAC má guest = 1
Limit ratelimit.advertConfirm (config/Shared/ratelimit.neon) neúspěchy per záznam a per IP, sdílené všemi moduly, klíče oddělené modulem
Vstup Odpověď
neznámý, smazaný, zabanovaný záznam; prázdný nebo špatný klíč; vyčerpaný limit jedna chyba (ADVERT_CONFIRMATION_KEY_INVALID, u diskuze MESSAGE_CONFIRMATION_KEY_INVALID)
správný klíč nepotvrzeného záznamu confirmed_at = teď, klíč se spotřebuje, následky (afterRecordConfirmed(): index, počty v kategoriích)
správný klíč už potvrzeného záznamu úspěch beze změny (opakovaný klik z pošty není chyba)

Na rozdíl od odkazu na přečtení se klíč spotřebuje: spotřebovaný otisk už nic nepotvrdí, ani když správce confirmed_at později vymaže. Správný klíč kvótu nečerpá, po vyčerpání se ale odmítne i on, dokud okno nevyprší. Po potvrzení neodchází žádný e-mail.

Záznam čti čerstvě z databáze

findRecordForConfirmation() musí jít mimo query cache. Ze staré kopie by šel spotřebovaný klíč použít ještě jednou.

Klíč nezapisuj přes updateColumn() z frontu

Api updateColumns nepřihlášenému odmítne (PERMISSION_DENIED), takže by se host, který vložil inzerát bez účtu, nikdy nepotvrdil. Zápis dělá confirmByKey() přes interní akci.

Frontový handler potvrzení potřebuje #[CrossOrigin]

Nette každému handle*() vnutí kontrolu stejného původu. Klik z poštovního klienta je cross-site, takže bez #[CrossOrigin] skončí detekovaným CSRF: přesměrování bez do a key a handler se vůbec nespustí. handleConfirm() v Bazaaru, Comcatu i Diskuzi proto atribut má a čte jen id z adresy, protože nepotvrzený záznam je pro nepřihlášeného skrytý a actionDetail() ho nenačte.

Varianta: zapomenuté heslo k inzerátu#

Inzerát vložený bez účtu (Bazaar inzerát, Comcat firemní profil) má vlastní „Zapomenuté heslo“. E-mail nenese nové heslo, ale jednorázový odkaz na editaci, kde si majitel heslo zvolí. Dosavadní heslo platí, dokud se nové neuloží.

Část Kde Úkol
Žádost modál ForgotPasswordForm → :Api:{Bazaar,Comcat}:Advert:sendPasswordReset (advertId, email) e-mail se porovná s e-mailem inzerátu (bez ohledu na velikost písmen), při shodě odejde odkaz
Klíč PasswordResetToken: 32 náhodných bajtů, v DB jen sha256 ve sloupci password_reset_hash, platnost 1 h v password_reset_expires čitelný klíč jde jen do odkazu edit?id=…&resetKey=…
E-mail BZ_ADVERT_NEW_PASSWORD, CC_ADVERT_NEW_PASSWORD (proměnné advertName, advertDetailLink, link)
Nové heslo actionEdit() přepne na pohled passwordReset, když návštěvník editovat nesmí a adresa nese resetKey; odeslání → :Api:…:Advert:resetPassword ověří klíč, zapíše hash hesla (min. PasswordPolicy::ADVERT_MIN_LENGTH = 6 znaků) a klíč smaže
Logika AdvertPasswordResetTrait, jedna implementace pro oba moduly modul dodá e-mail, presenter a id stránek detailu a editace

Po úspěšném uložení dostane návštěvník stejnou relaci editace jako po zadání hesla ([id, hash, expirace], hash načtený z Api znovu) a otevře se mu editace bez resetKey v adrese.

Vstup sendPasswordReset Odpověď
neznámý inzerát, nesedící e-mail, vyčerpaný limit, selhané doručení vždy OK
shoda OK, odejde odkaz
Vstup resetPassword Odpověď
neznámý inzerát; špatný, prošlý nebo použitý klíč; vyčerpaný limit USER_PASSWORD_RESET_KEY_INVALID
krátké heslo INVALID_LENGTH, klíč se nespotřebuje
správný klíč a heslo OK, klíč smazán

Limity drží Api s hodnotami ratelimit.sendNewPassword: žádost per inzerát (3 za hodinu) i per IP (10 za hodinu), uložení hesla per IP. Klíče limitů jsou oddělené modulem.

Odpověď žádosti nesmí prozradit, jestli e-mail sedí

E-mail inzerátu bývá vidět na detailu. Kdyby akce na nesedící e-mail odpověděla jinak, šlo by jí ověřovat odhady. Proto je odpověď vždy OK a selhání jdou jen do logu email a auth.

Selhané doručení vrací předchozí klíč

Když se e-mail neodešle, vrátí se sloupce klíče na hodnoty před požadavkem, ale jen pokud řádek pořád nese právě vydaný otisk. Souběžná úspěšná žádost tak vyhraje a dřív odeslaný odkaz dál platí.

Limit v relaci není limit

Limit pokusů patří na server. Limit v session obejde každý, kdo zahodí cookie.

Kam sáhnout#

Chci Kde
logiku klíče app/Core/Traits/Api/Models/Managers/ResponseMarkReadTrait.php
rozhodnutí příjemce app/Core/Traits/Api/Presenters/ResponseReceiverTrait.php
veřejnou stránku app/UI/Front/{Bazaar,Comcat}/Presenters/Traits/Details/ResponseMarkReadTrait.php
šablonu stránky app/UI/Front/{Bazaar,Comcat}/Templates/Response/markRead.latte
potvrzení záznamu klíčem app/Core/Traits/Api/Models/Managers/ConfirmByKeyTrait.php, app/Core/Security/AdvertConfirmationKey.php
zapomenuté heslo k inzerátu app/Core/Traits/Api/Models/Managers/AdvertPasswordResetTrait.php, app/Core/Security/PasswordResetToken.php, front app/UI/Front/{Bazaar,Comcat}/Presenters/Traits/Forms/AdvertPasswordResetFormTrait.php

Nastavení cookies