Přeskočit obsah
V
Pro vývojáře
Architektura, konvence, jádro systému a bezpečnost
Bezpečnost / Autentizace a autorizace

Autentizace#

Dvě nezávislé cesty ověření.

Cesta Kde Čím
Session web a administrace přihlašovací formulář
JWT API token v hlavičce; vnější volání navíc vždy s klíčem aplikace

Session#

Přihlašuje se přezdívkou nebo e-mailem. Účet dohledá App\Core\Security\LoginUser::findUserByIdentifier(): údaj ve tvaru e-mailu páruje nejdřív na email a teprve bez shody na username.

Pořadí hledání je bezpečnostní

Přezdívka smí obsahovat @, tedy i cizí e-mail. Kdyby se hledalo nejdřív podle přezdívky, útočník by si registrací přezdívky obet@example.cz přivlastnil pokusy oběti přihlásit se e-mailem. Heslo se ověřuje jen proti jednomu nalezenému účtu.

Nečinnost Odhlášení po
web bez „zapamatovat si mě“ 30 min
web se „zapamatovat si mě“ 31 dní
administrace vždy 30 min

Nečinnost se měří razítkem lastActivity v sekci auth relace (AuthSessionStorage::IDLE_TIMEOUT_SECONDS, REMEMBER_TIMEOUT_SECONDS). Po překročení se tokeny odvolají jako při odhlášení.

JWT pro API#

Token se získá přihlašovacím endpointem a posílá se v hlavičce:

Authorization: Bearer <token>
Věc Poznámka
Platnost omezená, obnovuje se refresh tokenem
Rotace refresh token se při použití mění
Detekce znovupoužití použití starého refresh tokenu zneplatní řetěz
Deny-list odhlášený token přestane platit okamžitě
Razítko odvolání uživatele po zablokování, deaktivaci, změně či obnově hesla a žádosti o zrušení účtu neplatí žádný dřív vydaný access token

Rotace s detekcí znovupoužití

Ukradený refresh token se pozná tím, že se použije dvakrát. Systém pak zneplatní celou větev — útočník i oprávněný uživatel se musí přihlásit znovu.

Razítko odvolání#

UserRefreshTokenManager::revokeAllForUser() kromě refresh tokenů zapíše do deny-listu razítko jwt:user-revoked:<id> = čas (TTL = jwt.ttl). AccessTokenValidator::isRevoked() pak odmítne každý token s iat starším než razítko — v Api guardu, ve checkAuthentication() webu i administrace a v is-auth-valid. Token vydaný v téže sekundě platí, takže relace, která si sama změnila heslo, dostane nový pár a zůstane přihlášená.

Náklad a selhání

Jeden GET do Redisu na požadavek s tokenem (stejně jako deny-list jti), bez Redisu jeden SELECT podle primárního klíče v cms_system_jwt_denylist. Fail-open jako celý deny-list: výpadek úložiště razítko nevynutí.

Co token nese a co z toho plyne#

Jestli je token použitelný, rozhoduje jediná služba AccessTokenValidator: podpis a expirace (JwtManager) plus deny-list jti a razítko odvolání. Ptá se jí Api guard, checkAuthentication() webu i administrace a LoginUser::isAuthenticated() (is-auth-valid).

Claim v data Význam
userId identita; 0 znamená bez identity (typ je int, ne ?int)
role jen informativní seznam rolí v okamžiku přihlášení
caps capability — důkaz přístupu k jednomu záznamu
  1. Práva se z tokenu nečtou. Resolver čte role volajícího z databáze při každém požadavku; odebrání role tak platí hned, i když refresh rodina žije až 60 dní.
  2. userId = 0 je host. PermissionResolver převádí 0 i záporné id na null, tedy na anonymní větev. Kdo čte getUserId() mimo resolver, musí s nulou počítat sám.
  3. Token bez jti se bere jako neodvolaný. Odmítnout ho by při nasazení odhlásilo všechny živé relace s tokeny vydanými dřív.
  4. Čtení je tolerantní. Chybějící klíč má výchozí hodnotu a vadná capability se zahodí; zbytek tokenu platí dál.

Kontrola autentizace jen přes isExpired() nechá odvolaný token přihlášený

JwtManager::isExpired() vidí jen expiraci. Odhlášený nebo razítkem odvolaný token, který ještě nevypršel, by tak dál držel relaci. Nové místo, které ověřuje token, volá AccessTokenValidator.

Úklid tabulek tokenů a limitů#

Tři tabulky rostou s přihlašováním, obnovou tokenů a odhlašováním a samy se nečistí. Úklid dělají denní crony:

Tabulka Cron Co maže Proč ne dřív
cms_system_user_refresh_tokens /cron/system/user-refresh-token/purge-old (?graceDays=N) tokeny po expiraci (plus N dní rezervy, výchozí 0) rotace starý token jen odvolá; do expirace musí zůstat kvůli detekci znovupoužití a řetězu parent_id
cms_system_jwt_denylist /cron/system/jwt-deny-list/cleanup-expired vypršelé záznamy —
cms_system_rate_limit /cron/system/rate-limit/purge-old (?age=N v sekundách) řádky starší než 2× nejdelší okno (přihlášení / obnova) prošlé okno se vynuluje dalším pokusem, ale klíč, na který už nikdo nesáhne, by zůstal navždy

Deny-list a limity používají tabulku jen při jwt.useRedis: false, resp. ratelimit.useRedis: false; s Redisem klíče vyprší samy a oba crony vrátí skipped: redis-backed.

Smazání refresh tokenu před expirací vypne detekci krádeže

Ruční „úklid“ zrotovaných tokenů (vyplněné revoked_at) dřív, než vyprší, znamená, že ukradený starý token už systém nepozná jako znovupoužitý — a celá větev se při jeho použití nezneplatní.

Hesla a tajné vstupy jen v těle#

Nette bere parametry signatury action*() jen z adresy — a adresa končí v access logu serveru, v proxy i v historii klienta. Akce s hesly, klíči a tokeny proto parametry v signatuře nemají a čtou je přes BasePresenter::readBodyParameter() (Api), které vezme formulářové tělo i JSON:

Akce Z těla Z adresy
:Api:System:User:updatePassword password, currentPassword nic
:Api:System:User:resetPassword email, key, password nic
:Api:System:Auth:isAuthValid token (přednost má hlavička Authorization) nic
:Api:System:User:activate email, activationKey přednostně ponecháno kvůli odkazu z e-mailu

Nový tajný vstup nepatří do signatury akce

Parametr v signatuře action*() znamená adresu. Čti ho readBodyParameter() a údaj v adrese ignoruj — ne jako záložní zdroj.

Pravidla hesel#

App\Core\Security\PasswordPolicy:

Heslo Minimální délka
uživatel (registrace, změna, obnova) 8 (USER_MIN_LENGTH)
uživatel zakládaný či měněný v administraci 10 (ADMIN_MIN_LENGTH)
heslo k editaci inzerátu 6 (ADVERT_MIN_LENGTH)

Změna vlastního hesla (UserManager::updateOwnPassword()) ověří současné heslo proti DB a neúspěchy počítá do limitu přihlášení na účet. Po změně se zruší rozběhnutá obnova hesla, odvolají se všechny refresh tokeny (důvod password_change) a zapíše se razítko odvolání. Totéž dělá změna hesla administrátorem. Zapomenuté heslo pošle jednorázový odkaz (PasswordResetToken, platnost 1 h, v DB jen SHA-256), heslo samo se nemění.

Minimální délku hlídá Api jen tam, kde si návštěvník heslo volí

USER_MIN_LENGTH vynucuje Api u změny vlastního hesla a u obnovy z odkazu v e-mailu. Systémový přepis UserManager::updatePasswordForUser() (dnes líné přehashování při přihlášení) délku nekontroluje: staré pětiznakové heslo musí fungovat dál, jinak by se majitel nepřihlásil, aby si ho změnil. Kontrolu tam nepřidávejte.

Heslo a e-maily při uložení účtu z administrace#

Heslo není pole entity, které by formulář zapsal. Admin formulář uživatele (UserFormTrait) ho pošle v $meta obyčejného save() a Api UserManager ho zpracuje v hácích:

Klíč $meta Kdy ho formulář pošle Co udělá Api
password jen vyplněné pole hesla beforeSave() heslo zahashuje do entity; u úpravy pak afterSave() odvolá relace a rozběhnutou obnovu hesla
skipNotifications při založení účtu afterSave() neodešle registrační a aktivační e-mail

Na založení je heslo povinné, na úpravě prázdné pole znamená „beze změny“. Obě větve hlídá PasswordPolicy::ADMIN_MIN_LENGTH.

Postup: ukládám uživatele z vlastního kódu

  1. Heslo dej do $meta['password'], ne do setPassword().
  2. Zakládáš-li účet za někoho (import, administrace), přidej $meta['skipNotifications'] = true.
  3. Ověř přihlášení novým heslem, ne jen isSuccess() uložení.

Heslo nastavené na entitě se tiše zahodí

Vlastnost password má FieldAccess s insert i update na Audience::None, takže ji payload uložení nikdy nenese. Uložení skončí úspěchem a účet zůstane se starým (u nového s prázdným) heslem.

Zapomenuté heslo a aktivace účtu#

Obě cesty otevírá nepřihlášený člověk z odkazu v e-mailu, takže stojí na jednorázovém tajemství v odkazu, ne na přihlášení. V DB je jen jeho SHA-256 s expirací; čitelný tvar existuje jen v e-mailu.

Akce (#[PublicAction]) Metoda UserManager Odpověď
:Api:System:User:sendNewPassword (username = jméno nebo e-mail) sendNewPassword() vždy success s kódem OK
:Api:System:User:sendActivationEmail i :Api:System:Auth:sendActivationEmail (username) sendActivationEmail() vždy success s kódem OK
:Api:System:User:resetPassword (email, key, password z těla) resetPassword() OK; jinak USER_PASSWORD_RESET_KEY_INVALID nebo INVALID_LENGTH
:Api:System:User:activate (email, activationKey) activateUser() OK, USER_ALREADY_ACTIVE nebo USER_ACTIVATION_KEY_INVALID
:Api:System:User:generateNewPassword (jen admin, ne veřejná) generateNewPassword() nové heslo v data.password, žádný e-mail
Odkaz Platnost (TTL_SECONDS) Cíl
obnova hesla (PasswordResetToken) 1 h stránka „Zapomenuté heslo“ (Page:form, komponenta lostPasswordForm) s email a key
aktivace (ActivationKey) 72 h :Front:System:Registration:registration s email a activationKey

Pravidla, která drží obě cesty:

  1. Odpověď neprozradí, jestli účet existuje. sendNewPassword() vrátí bajtově stejný Result::success(OK) pro neznámý účet, existující účet i vnitřní selhání (DB, e-mail); selhání jde jen do logu auth. Stejnou odpověď dá i vyčerpaný rate limit akce. resetPassword() i activateUser() mají pro neznámý e-mail a špatný, prošlý či použitý klíč jediný kód.
  2. Žádost o obnovu heslo nemění. Vznikne jen čekající token, staré heslo platí, dokud majitel odkaz nepoužije. Cizí člověk tak účet nezamkne. Nová žádost token přepíše, platí jen poslední odeslaný odkaz; totéž u aktivace (každé odeslání vydá nový klíč).
  3. Krátké heslo token nespotřebuje. INVALID_LENGTH (PasswordPolicy::USER_MIN_LENGTH) přijde až po ověření tokenu, aby délka nebyla oracle, a odkaz dál platí.
  4. Úspěch klíč spotřebuje. Obnova zapíše heslo, vynuluje token a odvolá všechny refresh tokeny účtu; aktivace nastaví activated a klíč vymaže. Už aktivní účet dostane USER_ALREADY_ACTIVE, ne chybu klíče. Kód ale přijde před ověřením klíče, proto ho front nerozlišuje: stránka aktivace (RegistrationPresenter::activateUser()) ukáže při úspěchu flash a přesměruje, při čemkoli jiném jednu obecnou chybu přímo ve stránce.
  5. Neodeslaný e-mail vrátí předchozí klíč. Když se e-mail nepodaří zařadit, UserManager podmíněně vrátí původní otisk a expiraci (jen dokud řádek nese otisk vydaný teď), takže odkaz, který už uživatel ve schránce má, platí dál.
  6. Stav klíče se čte z DB mimo query cache (find($id, [], false)). Ze staré kopie by šel použitý klíč uplatnit podruhé.

Front nesmí hlásit „účet neexistuje“

Odpověď sendNewPassword je konstantní schválně. Hláška messages.noUser nebo jiná větev podle výsledku by z formuláře udělala nástroj na zjišťování registrovaných e-mailů.

Rozlišení „už aktivní“ na frontu je oracle

Hláška „účet je už aktivní“ by na adrese aktivace s cizím e-mailem a libovolným klíčem prozradila, že účet existuje a je aktivní.

deliverPasswordResetLink() nezveřejňujte

Metoda je privátní, aby jediný vstup byl sendNewPassword() s konstantní odpovědí. Veřejné volání by vrátilo rozdíl úspěch × chyba, tedy oracle na účty. Token navíc nesmí do data ani message žádné odpovědi — kdo ho dostane, převezme účet.

generateNewPassword není zapomenuté heslo

Je to přegenerování správcem: vrátí čitelné heslo volajícímu, aby ho předal jiným kanálem, a odvolá všechny relace účtu i rozběhnutou obnovu hesla. Klíč musí zůstat jen roli admin; grant hostovi by komukoli dovolil přepsat heslo libovolného účtu a přečíst si ho v odpovědi.

Klíč aplikace#

Každé vnější volání Api posílá hlavičku X-API-Key. Klíč říká jen jedno: tahle aplikace smí Api volat. Nenahrazuje přihlášení a neurčuje, jaká data aplikace dostane — jde spolu s volitelným tokenem uživatele:

Požadavek Kdo je volající
klíč, bez tokenu host — role guest, totéž co anonym na webu
klíč + platný token uživatel z tokenu se svými rolemi
klíč + neplatný, prošlý nebo odvolaný token 401 USER_NOT_LOGGED_IN (103) — na hosta se potichu nespadne
bez klíče 401 API_UNAUTHORIZED (501), dál se nic nevyhodnocuje

Klient smí práva uživatele jen omezit — povolenými rolemi, viz RBAC → Strop rolí aplikace. Rozsahy (scopes) a atribut #[RequiresScope] jsou pryč; žádnou akci kvůli klíči značit nemusíte.

Kód: app/Core/Traits/Api/Presenters/ApiClientKeyTrait.php, app/Core/Security/ApiClient/.

Tvar a uložení#

Věc Hodnota
Tvar coolcms_ + 64 hex znaků (32 náhodných bajtů), ApiClientKey::generate()
V DB cms_system_api_client_keys — aplikace má víc klíčů (nejvýš 5 platných): key_hash = SHA-256 celého klíče, key_prefix = prvních 16 znaků pro rozlišení v administraci, volitelný label, expires_at (NULL = bez expirace), revoked_at, last_used_at klíče
Platnost klíč platí, když není zrušený a nemá expiraci, nebo ta ještě nenastala; navíc musí být aplikace zapnutá a nezrušená. Expirace se porovnává s časem požadavku, ne s časem záznamu v cache. Pořadí: neznámý → klíč zrušený → vypršel → aplikace zrušená → vypnutá
Čitelný klíč jen v odpovědi na založení aplikace a vydání dalšího klíče (data.key), pak už nikde
Povolené role vazební tabulka cms_system_api_client_roles
Cache záznamu Nette cache, klíč = otisk, api.clientKey.cacheTtl (nejvýš 60 s); každá změna aplikace i klíče přes ApiClientManager maže záznamy všech klíčů aplikace, takže vypnutí, zrušení i nová expirace platí hned. Neznámý klíč se necachuje

Klienty spravuje administrátor v Nastavení → Aplikace s přístupem k Api (:Admin:System:ApiClient), přes Api endpoint api/system/api-client — všechny jeho akce jen pro admin.

Jednorázové zobrazení klíče v administraci#

Administrace ukládá podle PRG, takže čitelný klíč z odpovědi na založení aplikace nebo vydání klíče musí přežít přesměrování. Nesmí přitom do adresy (historie, Referer, access log) ani do flash zprávy (ta se překládá jako klíč a chybějící překlad se loguje). Předává ho ApiClientKeyHandover přes session:

Krok Co se stane
POST (add, addKey) store() uloží klíč s důvodem created / issued a id aplikace; přesměruje se vždy na stránku, která ho ukáže (výpis aplikací / klíče té aplikace), nikdy na Referer
GET cílové stránky take() vrátí jen záznam se svým důvodem a aplikací a hned ho smaže; cizí záznam nechá ležet
neodebraný záznam vyprší po EXPIRATION (10 minut)
výjimka během požadavku ApiClientPresenter::startup() přidá SESSION_KEY a key do Tracy keysToHide, takže klíč nebude v log/exception-*.html

Klíč z odpovědi create / issue-key nikde nelogujte

ApiClientRepository výsledek nezapisuje ani při chybě. Řádek logu nebo dump s data.key znamená, že klíč může použít kdokoli s přístupem k logům, a jedinou nápravou je ho zrušit.

update aplikace nahrazuje všechny čtyři limity

Neposlaný limit se vrátí na výchozí hodnotu. ApiClientFormPayload proto posílá vždy všechny čtyři a vždy roleIds; vynechané roleIds by naopak role nezměnily.

Odmítnutí klíče#

Chybějící, neznámý, vypnutý, zrušený i vypršelý klíč dostane jednu a tutéž odpověď, aby volající nepoznal, který klíč existuje:

{"success":false,"message":"A valid application key (X-API-Key) is required.","data":null,"code":501}

s HTTP 401. Důvod jde jen do logu:

Kam Co
log/auth.log vždy řádek API_CLIENT_KEY reason=… client=… key=… presenter=… action=… ip=…; reason je missing, unknown, disabled, revoked nebo expired, client id klienta a key id klíče (ne klíč), jinak -
cms_system_auth_log událost api_key_failure s meta.reason (a meta.keyId) — jen u neznámého, vypnutého, zrušeného a vypršelého klíče

Chybějící klíč do tabulky nejde: posílá ho každý robot, který na /api/ narazí, a auth log by se z něj stal access logem. Klíč ani jeho část se nezapisuje nikam.

Limity aplikace#

Limity patří aplikaci (klíčují se id klienta), ne jednotlivému klíči — všechny klíče aplikace je sdílejí.

ApiClientRateLimiter, pevná okna v Redisu (ratelimit:apiclient:<id>:<okno>:<číslo okna>, INCR + EXPIRE, všechna okna klienta v jednom pipeline). Bez Redisu (ratelimit.useRedis: false) jdou okna přes DbRateLimit.

Okno Identita Výchozí (api.clientKey.limits) Kdy se počítá
client_second celá aplikace perSecond: 20 checkClientKey(), hned po ověření klíče
client_minute celá aplikace perMinute: 600 tamtéž
user_minute userId z JWT payloadu userPerMinute: 120 checkClientCallerLimit(), po JWT
ip_minute SHA-256 adresy ipPerMinute: 120 tamtéž, když payload userId nemá

Klient má vlastní hodnoty ve sloupcích limit_per_second, limit_per_minute, user_limit_per_minute, ip_limit_per_minute; NULL = výchozí z konfigurace.

U #[PublicAction] se počítá podle adresy i s tokenem

checkJwtAuth() veřejnou akci přeskočí, payload zůstane null a limit volajícího padne na ip_minute.

Překročení vrací HTTP 429, hlavičku Retry-After (sekundy do konce okna) a:

{"success":false,"message":"Too many requests.","data":{"retryAfter":3},"code":404}

Do cms_system_auth_log jde jen první odmítnutí v okně: událost rate_limited, meta.reason = api_client_limit, meta.window = název okna, meta.limit. Výpadek Redisu je fail-open (řádek RATELIMIT_ERROR api_client redis … v auth.log).

Limit přihlášení a obnovy tokenu je jiná brána

AuthPresenter počítá své limity v operaci (internalActionLogin(), internalActionRefresh()), aby je sdílela vnější i interní cesta. Vnější akce pak pošle 429 + Retry-After s tělem Too many requests, retry in <N>s a data.retryAfter; interní volající dostane jen Result s RATE_LIMIT_EXCEEDED a čte data.retryAfter. Událost v auth logu je také rate_limited, s meta.type = login nebo refresh.

Výjimky bez klíče#

Akce, kterou volá třetí strana neschopná poslat náš klíč, nese #[NoClientKey] (app/Core/Attributes/NoClientKey.php). Dnes jsou čtyři a všechny jsou zároveň #[PublicAction]:

Akce Kdo volá
:Api:System:Payment:execute platební brána (notifyUrl) i návrat zákazníka
:Api:System:Payment:delivery notifikace brány, doručenka SMS agregátoru
:Api:System:Payment:expressExecute SMS agregátor — příchozí expresní platba
:Api:System:Auth:oauthDeauthorizeCallback OAuth poskytovatel

Výjimka ruší jen klíč, ne autorizaci

Akce dál prochází JWT guardem i RBAC (obvykle jako host). Bezpečnost takové akce stojí na jejím vlastním ověření — podpisu brány nebo kontrole platby u poskytovatele. Kdo #[NoClientKey] přidá akci bez vlastního ověření, otevře ji komukoli.

Seznam drží test

tests/Unit/Security/ApiClientKeyExemptionTest.php porovná atributy v kódu se seznamem. Nová výjimka = zápis do testu s důvodem. Atribut se čte z metody action*(); neznámá akce výjimkou není (fail-closed).

Vnitřní cesta#

Web a administrace volají Api přes ApiBridge::internalCall(), který startup() nespustí. Klíč, limity ani strop rolí se jich netýkají; v řádku API_RBAC … path=internal mají client=web.

Přepínač api.clientKey.required#

config/Shared/api.neon, výchozí true. Při false projde volání bez klíče jako dřív (bez tokenu 401 na neveřejné akci); volání s klíčem se dál ověří, omezí limity i stropem rolí — jinak by špatný klíč „prošel“ jako žádný. Compile-time parametr, po změně smazat temp/cache.

Klíč nepatří do adresy

Adresa se loguje a zůstane v historii. Klíč patří do hlavičky, jen přes HTTPS.

Přihlášení přes cizí službu#

app/UI/Front/System/Components/LoginExternalComponent/ a poskytovatelé v administraci. Identitu ověří front komponenta přes Hybridauth u poskytovatele a výsledek předá interní operaci AuthPresenter::internalActionLoginExternal() — HTTP akci nemá. Holé „poskytovatel, id a e-mail“ zvenčí by stačilo k převzetí cizího účtu.

Situace Výsledek
e-mail existujícího účtu, poskytovatel ho nepotvrdil nic se nezapíše, USER_EXTERNAL_EMAIL_NOT_VERIFIED (113)
nový účet s nepotvrzeným e-mailem vznikne neaktivní + aktivační e-mail, USER_ACTIVATION_REQUIRED (114)
potvrzený e-mail účtu registrovaného heslem a bez uložené identity nepřihlásí; na e-mail účtu odejde odkaz na potvrzení propojení, USER_EXTERNAL_LINK_CONFIRMATION_SENT (115)
odkaz neplatný, použitý, prošlý nebo identita mezitím patří jinému účtu USER_EXTERNAL_LINK_KEY_INVALID (116)
účet bez hesla (vznikl přes cizí službu) nebo s už uloženou identitou spáruje a přihlásí rovnou

Potvrzení propojení řeší App\Core\Security\ExternalLinkConfirmation: token v DB jen jako SHA-256, platnost 24 h, jedna žádost na účet, nejvýš jeden e-mail za 15 min (cms_system_user_external_link_requests, šablona SY_EXTERNAL_LINK_CONFIRM). Ověření e-mailu poskytovatelem vyhodnocuje ExternalEmailVerification.

Tajemství poskytovatele se v administraci nezobrazuje

Pole je typu heslo, prázdné znamená „ponechat“. Nezobrazujte je ani do logu.

Klíče poskytovatele (clientId, clientSecret, scope) bere front komponenta z interní akce :Api:System:LoginProvider:oauthConfig (LoginProviderManager::oauthConfig($key)), ne z entity: pole mají #[FieldAccess(read: Audience::Privileged)] a front jede jako host, z entity by tedy dostal prázdné řetězce. Akce nemá action*() a payload skládá ručně; neznámý nebo neaktivní poskytovatel vrátí [] a komponenta skončí BadRequestException.

Heslo k inzerátu: capability v tokenu#

Inzerát vložený bez účtu (Bazaar, Comcat) upravuje návštěvník, který zadal jeho heslo. Do Api vrstvy jinak žádná identita neteče — guard čte jen JWT — proto front ten fakt předá jako capability v interním tokenu: JwtCapability = důkaz přístupu k jednomu záznamu, ne role a ne identita.

Pole (caps[]) K čemu
scope doména: bazaar.advert.edit × comcat.advert.edit; bez ní by inzerát 5 odemkl firmu 5
id jediný záznam, nikdy plošně
exp konec platnosti, kopie expirace relace editace (advertSessionExpiration, 7200 s)
fp sha256 hashe hesla z DB v okamžiku vydání
request → grantAdvertEditCapabilityFromSession()  → token s caps[] z relace → ApiBridge
        → checkAutorization()  → relace [id, hash, exp] × hash z DB → znovu vydá token
        → internalAction*()     → isOwner() porovná caps[].fp s otiskem hesla z DB
  1. Vydává ho CapabilityTokenIssuer::grantAdvertEdit() z EditAdvertFormTrait (front Bazaar i Comcat), na každém požadavku znovu: nejdřív z relace ještě před načtením inzerátu (jinak by get pro editaci vrátil zamaskovaný e-mail a formulář by neprošel validací), pak znovu po kontrole relace proti hashi v DB.
  2. Ověřuje ho Api — ownsByCapability() v Api AdvertPresenter a AdvertScopeGuardTrait (záložky reakcí a statistik) přes JwtCapability::grants(): scope, id, expirace a otisk hesla entity načtené z DB, porovnání hash_equals(). Prázdný hash nebo otisk neprojde nikdy.
  3. Identita se nemění. Token převezme userId, role a username z front relace; bez ní zůstane userId = 0, tedy host. Přihlášený uživatel v cizím inzerátu tak nepřijde o své owner granty.
  4. Držitel capability vidí osobní údaje jen řádků toho jednoho inzerátu (exemptPersonalDataForCapabilityHolder(), viz RBAC).

Do capability nikdy nedosazujte vlastníka

Dosadit do tokenu insertUserId majitele inzerátu by anonymnímu editorovi dalo všechna práva toho účtu, ne jeden inzerát.

Capability se nikam neukládá

Token žije jen v paměti bridge po dobu jednoho požadavku — ne v session, ne v cookie, ne v prohlížeči. Změnu hesla majitelem drží dvě nezávislé kontroly proti DB (relace frontu a isOwner() v Api); uložená capability by přežila změnu hesla až do expirace.

Zrušení účtu a brány přihlášení#

Účet si uživatel ruší sám; po ochranné lhůtě (account.deletion.graceDays, výchozí 7 dní) ho cron anonymizuje. Řádek v cms_system_users zůstává kvůli dokladům a příspěvkům.

Stav účtu Heslem Cizí službou Obnovou tokenu User:save
čeká na zrušení (isDeletionPending()) USER_DELETION_PENDING (117) + scheduledAt a krátkodobý ticket pro storno odmítnuto USER_NOT_FOUND —
anonymizovaný (isDeleted()) neznámý účet (110) k účtu už nevede — identita je uvolněná refresh tokeny jsou smazané USER_DELETED (120)

Poslední brána je až po vydání tokenů. AuthPresenter::refuseTokensOfChangedAccount() (volá se z přihlášení heslem, cizí službou i obnovy tokenu) přečte stav účtu znovu přímo z DB (UserManager::isSignInAllowedNow(), jeden skalární dotaz mimo query cache). Když se účet mezitím zablokoval, deaktivoval nebo požádal o zrušení, odvolá právě vydaný refresh token, dá jti access tokenu na deny-list a nevydá nic. Proč až po vydání: žádost o zrušení nejdřív zapíše čekající stav a teprve pak hromadně odvolá relace. Refresh token vložený před tím zápisem tedy chytí hromadné odvolání, vložený po něm uvidí tahle kontrola.

Nová cesta k tokenu musí projít refuseTokensOfChangedAccount()

Kontrola stavu jen před vydáním nestačí: žádost o zrušení nebo ban, který proběhne mezi kontrolou a zápisem refresh tokenu, by nechal platnou relaci zrušenému účtu.

ticket je HMAC nad id, časem žádosti, náhodnou verzí žádosti (deletion_nonce) a expirací 30 min (AccountDeletionTicket); web ho drží v session a do HTML se nedostane. Storno, nová žádost, převzetí anonymizace cronem i anonymizace sama verzi vymění nebo smažou, takže každý starší ticket přestane sedět.

Čas žádosti verzí není

deletion_requested_at má přesnost na sekundy. Storno a nová žádost v téže sekundě by daly stejnou hodnotu a starý ticket by zrušil novou žádost. Ticket k jedné žádosti váže až deletion_nonce. Token storna z e-mailu se tu nepoužívá schválně: jeho uložený otisk by se novým vydáním přepsal a odkaz v e-mailu by přestal fungovat.

Žádost odvolá všechny tokeny účtu. Tok žádost, potvrzení, storno a anonymizace řídí AccountDeletionManager; jak se do anonymizace zapojí modul, popisuje Moduly. Každá změna stavu se zapisuje do kanálu accountAudit (jen id a způsob, nikdy e-mail).

Co API vyžaduje#

Ověření běží jako první věc v každém Api presenteru, v tomhle pořadí:

Krok Otázka Co dělá při nesplnění
applyCors() smí ten prohlížeč? předletový dotaz se odbaví hned (204)
checkClientKey() smí tahle aplikace Api volat? 401 (kód 501); limit aplikace 429
checkJwtAuth() kdo jsi? neplatný token 401 (kód 103); bez tokenu s klíčem = host
checkClientCallerLimit() nevyčerpal jeden volající aplikaci? 429
checkApiPermission() co smíš? externě 403; na interní cestě (bridge) prázdný výsledek

Pořadí je v app/Core/Base/Api/BasePresenter.php::startup() a je závazné. Interní cesta (ApiBridge) startup() nespouští, takže z tabulky prochází jen oprávnění — vlastním guardem.

Výjimky se značí přímo v kódu atributem #[PublicAction] — přihlášení, obnova tokenu, aktivační e-mail, zapomenuté heslo a návratové adresy platebních bran.

Chybějící oprávnění na interní cestě není chyba — je to TICHÉ PRÁZDNO

Tohle je dnes ta past, na kterou se naráží nejčastěji. Volání přes ApiBridge bez záznamu v privilegiích nevrátí do stránky 403 ani chybu; vrátí prázdný seznam. Když něco „nejde a nikde není chyba“, hledejte nejdřív privilegium, ne kód.

Veřejná akce potřebuje vlastní brzdu

Co je za #[PublicAction], nechrání token. Přihlášení, obnova tokenu i zapomenuté heslo proto mají rate limit; nová veřejná akce ho musí dostat taky, jinak ji lze volat neomezeně.

Audit#

Pokusy o přihlášení se zapisují do log/auth.log a do tabulky. Navenek mají neznámý účet i špatné heslo jeden kód (110); rozdíl je jen v logu (meta.reason). Neznámý účet se ověřuje proti fiktivnímu hashi, aby odpověď nebyla měřitelně rychlejší.

Auditní log se nepřepisuje

Ani při úklidu testovacích dat. Je to záznam o tom, co se skutečně stalo; jeho přepsání by z něj udělalo padělek.

Přepínače#

config/Shared/security.neon.

Vypnutá ochrana se nikde nepřipomíná

Vypnete-li kvůli ladění kontrolu, na produkci se to neprojeví hláškou. Vraťte ji hned, ne „potom“.

Nastavení cookies