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:
| 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 |
- 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í.
userId = 0je host.PermissionResolverpřevádí0i záporné id nanull, tedy na anonymní větev. Kdo čtegetUserId()mimo resolver, musí s nulou počítat sám.- Token bez
jtise bere jako neodvolaný. Odmítnout ho by při nasazení odhlásilo všechny živé relace s tokeny vydanými dřív. - Č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
- Heslo dej do
$meta['password'], ne dosetPassword(). - Zakládáš-li účet za někoho (import, administrace), přidej
$meta['skipNotifications'] = true. - 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:
- 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 loguauth. Stejnou odpověď dá i vyčerpaný rate limit akce.resetPassword()iactivateUser()mají pro neznámý e-mail a špatný, prošlý či použitý klíč jediný kód. - Žá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íč).
- 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í. - Úspěch klíč spotřebuje. Obnova zapíše heslo, vynuluje token a odvolá všechny refresh tokeny
účtu; aktivace nastaví
activateda klíč vymaže. Už aktivní účet dostaneUSER_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. - Neodeslaný e-mail vrátí předchozí klíč. Když se e-mail nepodaří zařadit,
UserManagerpodmí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. - 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:
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
- Vydává ho
CapabilityTokenIssuer::grantAdvertEdit()zEditAdvertFormTrait(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 bygetpro editaci vrátil zamaskovaný e-mail a formulář by neprošel validací), pak znovu po kontrole relace proti hashi v DB. - Ověřuje ho Api —
ownsByCapability()v ApiAdvertPresenteraAdvertScopeGuardTrait(záložky reakcí a statistik) přesJwtCapability::grants(): scope, id, expirace a otisk hesla entity načtené z DB, porovnáníhash_equals(). Prázdný hash nebo otisk neprojde nikdy. - Identita se nemění. Token převezme
userId,roleausernamez front relace; bez ní zůstaneuserId = 0, tedy host. Přihlášený uživatel v cizím inzerátu tak nepřijde o svéownergranty. - 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“.