adt / fancyadmin
Requires
- php: >=8.4
- adt/ajax-select: ^4.0
- adt/datagrid-components: ^1.0
- adt/doctrine-authenticator: ^2.5
- adt/doctrine-components: ^3.0
- adt/doctrine-forms: ^2.0
- adt/doctrine-loggable: ^3.0
- adt/log-sanitizer: ^1.0
- adt/nette-forms-components: ^2.5
- adt/nette-forms-phone-number: ^1.6
- adt/route-port: ^3.0
- adt/single-recipient-mailer: ^5.1
- adt/utils: ^2.0
- brick/phonenumber: ^0.7
- contributte/translation: ^2.0
- kdyby/autowired: ^3.1
- lbuchs/webauthn: ^2.2
- nette/application: ^3.2
- nette/mail: ^4.0
- nettrine/extensions-atlantic18: ^0.7.1
- nettrine/orm: ^0.10
- symfony/yaml: ^7.3
- tijsverkoyen/css-to-inline-styles: ^2.3
Requires (Dev)
None
Suggests
- firebase/php-jwt: Required for Keycloak SSO integration (backchannel logout token validation)
- guzzlehttp/guzzle: Required for Keycloak SSO integration
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v1.3.0
- v1.2.33
- v1.2.32
- v1.2.31
- v1.2.30
- v1.2.29
- v1.2.28
- v1.2.27
- v1.2.26
- v1.2.25
- v1.2.24
- v1.2.23
- v1.2.22
- v1.2.21
- v1.2.20
- v1.2.19
- v1.2.18
- v1.2.17
- v1.2.16
- v1.2.15
- v1.2.14
- v1.2.13
- v1.2.12
- v1.2.11
- v1.2.10
- v1.2.9
- v1.2.8
- v1.2.7
- v1.2.6
- v1.2.5
- v1.2.4
- v1.2.3
- v1.2.2
- v1.2.1
- v1.2
- v1.1.6
- v1.1.5
- v1.1.4
- v1.1.3
- v1.1.2
- v1.1.1
- v1.1.0
- v1.0.151
- v1.0.150
- v1.0.149
- v1.0.148
- v1.0.147
- v1.0.146
- v1.0.145
- v1.0.144
- v1.0.143
- v1.0.142
- v1.0.141
- v1.0.140
- v1.0.139
- v1.0.138
- v1.0.137
- v1.0.136
- v1.0.135
- v1.0.134
- v1.0.133
- v1.0.132
- v1.0.131
- v1.0.130
- v1.0.129
- v1.0.128
- v1.0.127
- v1.0.126
- v1.0.125
- v1.0.124
- v1.0.123
- v1.0.122
- v1.0.121
- v1.0.120
- v1.0.119
- v1.0.118
- v1.0.117
- v1.0.116
- v1.0.115
- v1.0.114
- v1.0.113
- v1.0.112
- v1.0.111
- v1.0.110
- v1.0.109
- v1.0.108
- v1.0.107
- v1.0.106
- v1.0.105
- v1.0.104
- v1.0.103
- v1.0.102
- v1.0.101
- v1.0.100
- v1.0.99
- v1.0.98
- v1.0.97
- v1.0.96
- v1.0.95
- v1.0.94
- v1.0.93
- v1.0.92
- v1.0.91
- v1.0.90
- v1.0.89
- v1.0.88
- v1.0.87
- v1.0.86
- v1.0.85
- v1.0.84
- v1.0.83
- v1.0.82
- v1.0.81
- v1.0.80
- v1.0.79
- v1.0.78
- v1.0.77
- v1.0.76
- v1.0.75
- v1.0.74
- v1.0.73
- v1.0.72
- v1.0.71
- v1.0.70
- v1.0.69
- v1.0.68
- v1.0.67
- v1.0.66
- v1.0.65
- v1.0.64
- v1.0.63
- v1.0.62
- v1.0.61
- v1.0.60
- v1.0.59
- v1.0.58
- v1.0.57
- v1.0.56
- v1.0.55
- v1.0.54
- v1.0.53
- v1.0.52
- v1.0.51
- v1.0.50
- v1.0.49
- v1.0.48
- v1.0.47
- v1.0.46
- v1.0.45
- v1.0.44
- v1.0.43
- v1.0.42
- v1.0.41
- v1.0.40
- v1.0.39
- v1.0.38
- v1.0.37
- v1.0.36
- v1.0.35
- v1.0.34
- v1.0.33
- v1.0.32
- v1.0.31
- v1.0.30
- v1.0.29
- v1.0.28
- v1.0.27
- v1.0.26
- v1.0.25
- v1.0.24
- v1.0.23
- v1.0.22
- v1.0.21
- v1.0.20
- v1.0.19
- v1.0.18
- v1.0.17
- v1.0.16
- v1.0.15
- v1.0.14
- v1.0.13
- v1.0.12
- v1.0.11
- v1.0.10
- v1.0.9
- v1.0.8
- v1.0.7
- v1.0.6
- v1.0.5
- v1.0.4
- v1.0.3
- v1.0.2
- v1.0.1
- v1.0
- dev-feat/sso-enabled-flag
- dev-fix/request-log-json-depth
- dev-fix/security-immutable-nanoid
- dev-feat/return-path-cookie
- dev-f-2fa-auth
- dev-fix-keycloak-silent-check-sso-fallback
- dev-f-tree-loggable-parent
- dev-f-5058-tree-view
- dev-f-password-reveal-signin
- dev-f-password-reveal-login
- dev-f-security-signal-acl
- dev-f-api-keys
- dev-f-9510-001x-typeerror-nette-mail-messageaddto-argument-1-email-must-be-of-type-string-null-given-called-in-var-www-html-releases-123-ve
- dev-f-configurable-theme-colors
- dev-webauthn
- dev-fix/curl-close-deprecation
- dev-f-firebase-token-sync
- dev-f-keycloak
- dev-ajax-summernote-cleaner
- dev-sass-module-system
- dev-fix/sidepanel-dirty-close-confirm
- dev-vite
- dev-f-fancyadmin-keep-scroll-after-form-submit
- dev-summernote-code-view
- dev-f-grid-search-field-customization
- dev-f-fix-select-account-subaccount
- dev-f-5058-umožnit-pridat-podrazenou-kategorii
- dev-f-fix-sign-in-form-backoffice-permission
- dev-f-add-fullData-acl-resource
- dev-f-fix-sidemenu
- dev-change-logy
- dev-f-add-modal-snippet
- dev-anonymize
- dev-f-side-panel-ajax-snippet-redraw
- dev-codemirror
- dev-f-fix-account-navbar
- dev-f-mobile-nav
- dev-f-change-lang-variable
- dev-f-fix-item-detail-hover
- dev-f-fix-toggle-item-detail
- dev-f-fix-datagrid-filter-entity-and-add-remove-firebase
- dev-f-change-js-components
- dev-feat/firebase-support
- dev-hot-fix-mobile-menu-link
- dev-f-menuitem-conditions
- dev-f-confirm-on-close-after-form-change-in-side-panel
- dev-f-fix-login-page
- dev-fixy-summernote
- dev-f-add-grid-filter-account
- dev-f-add-firebase-user-menu-item-option
- dev-role-fix
- dev-fixy
- dev-f-bug-fixes
- dev-f-add-favicons
- dev-f-add-parameters
- dev-feat/add-created-by-attribute
- dev-f-fix-favicon
- dev-f-fix-checkbox
- dev-f-fix-dashboard-icon-position
- dev-fix-configuration
- dev-change/account-text
- dev-f-fix-menu
- dev-f-fix-colors
- dev-fixes-2026
This package is auto-updated.
Last update: 2026-09-08 08:40:40 UTC
README
Tento dokument popisuje krok za krokem, jak integrovat balíček adt/fancyadmin do nového Nette 3.x projektu.
Předpoklady
Projekt musí mít nainstalováno:
- PHP >= 8.4
- Nette 3.1+
- Nettrine ORM (
nettrine/orm ^0.10,nettrine/dbal ^0.10) - Nettrine Migrations (
nettrine/migrations ^0.10) kdyby/autowired ^3.1contributte/console ^0.10- MySQL 8.0
1. Composer require
composer require adt/fancyadmin:^1.0
Fancyadmin automaticky stáhne tyto závislosti:
adt/doctrine-authenticator— autentizace přes Doctrineadt/doctrine-components— BaseEntity, QueryObject, EntityManageradt/doctrine-forms— formuláře napojené na Doctrine entityadt/nette-forms-components— rozšířené formulářové prvkyadt/datagrid-components— datagridyadt/files— správa souborůadt/doctrine-loggable— audit logcontributte/translation— překladynette/forms,nette/security,nette/mailublaboo/datagrid
Doplňkově doporučeno:
composer require adt/doctrine-components:^3.2 adt/query-object-data-source:^3.0
2. BaseEntity
Vytvořte abstraktní BaseEntity, od které budou dědit všechny entity:
// app/Model/Entities/Abstract/BaseEntity.php <?php declare(strict_types=1); namespace App\Model\Entities\Abstract; use ADT\DoctrineComponents\Entities\Entity; use ADT\DoctrineComponents\Entities\Traits\Identifier; use Doctrine\ORM\Mapping\MappedSuperclass; #[MappedSuperclass] abstract class BaseEntity implements Entity { use Identifier; }
Trait Identifier poskytuje:
$id(int, auto-increment PK)getId(): ?intisNew(): bool
3. Entity
Fancyadmin vyžaduje 9 entit. Každá:
- dědí z
BaseEntity - implementuje interface z
ADT\FancyAdmin\Model\Entities - používá odpovídající trait, který poskytuje sloupce, vztahy a metody
3.1 Identity (hlavní uživatelská entita)
// app/Model/Entities/Identity.php <?php declare(strict_types=1); namespace App\Model\Entities; use ADT\FancyAdmin\Model\Entities\IdentityTrait; use App\Model\Entities\Abstract\BaseEntity; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] class Identity extends BaseEntity implements \ADT\FancyAdmin\Model\Entities\Identity, \ADT\DoctrineAuthenticator\OTP\Identity { use IdentityTrait; }
IdentityTrait poskytuje:
- Sloupce:
firstName,lastName,email,username,password,phoneNumber,context,isActive - Timestamps:
createdAt,updatedAt,createdBy,updatedBy - Vztahy:
profiles(1:N),roles(M:N s AclRole),selectedAccount(N:1) - Metody:
getFullName(),getRoles(),isAllowed(),isAdmin(),getGravatar() - Auth metody:
getAuthObjectId(),getAuthToken(),setAuthToken(),setPassword()(automaticky hashuje)
3.2 Account
// app/Model/Entities/Account.php <?php declare(strict_types=1); namespace App\Model\Entities; use ADT\FancyAdmin\Model\Entities\AccountTrait; use App\Model\Entities\Abstract\BaseEntity; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] class Account extends BaseEntity implements \ADT\FancyAdmin\Model\Entities\Account { use AccountTrait; public function __construct() { $this->accounts = new ArrayCollection(); } }
AccountTrait poskytuje: name, parent (self-ref), accounts (sub-accounts), timestamps
3.3 Profile
// app/Model/Entities/Profile.php <?php declare(strict_types=1); namespace App\Model\Entities; use ADT\FancyAdmin\Model\Entities\ProfileTrait; use App\Model\Entities\Abstract\BaseEntity; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] class Profile extends BaseEntity implements \ADT\FancyAdmin\Model\Entities\Profile { use ProfileTrait; }
ProfileTrait poskytuje: identity (N:1), account (N:1), roles (M:N s AclRole), isActive, timestamps
3.4 AclRole
// app/Model/Entities/AclRole.php <?php declare(strict_types=1); namespace App\Model\Entities; use ADT\FancyAdmin\Model\Entities\AclRoleTrait; use ADT\FancyAdmin\Model\Entities\Traits\CreatedByNullableInterface; use ADT\FancyAdmin\Model\Entities\Traits\UpdatedByInterface; use App\Model\Entities\Abstract\BaseEntity; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] class AclRole extends BaseEntity implements \ADT\FancyAdmin\Model\Entities\AclRole, CreatedByNullableInterface, UpdatedByInterface { use AclRoleTrait; }
AclRoleTrait poskytuje: name, type (AclRoleTypeEnum), context, isAdmin, acls (1:N), metody isAllowed(), getResources(), getRoleId()
3.5 AclResource
// app/Model/Entities/AclResource.php <?php declare(strict_types=1); namespace App\Model\Entities; use ADT\FancyAdmin\Model\Entities\AclResourceTrait; use App\Model\Entities\Abstract\BaseEntity; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] class AclResource extends BaseEntity implements \ADT\FancyAdmin\Model\Entities\AclResource { use AclResourceTrait; }
AclResourceTrait poskytuje: name (unique), title
3.6 Acl (vazba role-resource)
// app/Model/Entities/Acl.php <?php declare(strict_types=1); namespace App\Model\Entities; use ADT\FancyAdmin\Model\Entities\AclTrait; use ADT\FancyAdmin\Model\Entities\Traits\CreatedByInterface; use ADT\FancyAdmin\Model\Entities\Traits\UpdatedByInterface; use App\Model\Entities\Abstract\BaseEntity; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] class Acl extends BaseEntity implements \ADT\FancyAdmin\Model\Entities\Acl, CreatedByInterface, UpdatedByInterface { use AclTrait; }
AclTrait poskytuje: role (N:1 AclRole), resource (N:1 AclResource), isActive, timestamps
3.7 Configuration
// app/Model/Entities/Configuration.php <?php declare(strict_types=1); namespace App\Model\Entities; use ADT\FancyAdmin\Model\Entities\ConfigurationTrait; use App\Model\Entities\Abstract\BaseEntity; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] class Configuration extends BaseEntity implements \ADT\FancyAdmin\Model\Entities\Configuration { use ConfigurationTrait; }
3.8 File
// app/Model/Entities/File.php <?php declare(strict_types=1); namespace App\Model\Entities; use ADT\Files\Entities\FileTrait; use App\Model\Entities\Abstract\BaseEntity; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] class File extends BaseEntity implements \ADT\FancyAdmin\Model\Entities\File { use FileTrait; }
3.9 GridFilter
// app/Model/Entities/GridFilter.php <?php declare(strict_types=1); namespace App\Model\Entities; use App\Model\Entities\Abstract\BaseEntity; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] class GridFilter extends BaseEntity { use \ADT\FancyAdmin\Model\Entities\GridFilter; }
4. ACL Resource Enum
Definujte enum s ACL resources. Fancyadmin vyžaduje minimálně 3:
- customer resource (přístup do zákaznické části)
- backoffice resource (přístup do administrace)
- full data resource (plný přístup k datům)
// app/Model/Entities/Enums/AclResourceNameEnum.php <?php declare(strict_types=1); namespace App\Model\Entities\Enums; use Nette\Security\Resource; enum AclResourceNameEnum: string implements Resource { case CUSTOMER_HOME = 'portalCustomer.home'; case BACKOFFICE_HOME = 'portalBackoffice.home'; case FULL_DATA = 'portal.fullData'; public function getResourceId(): string { return $this->value; } }
5. Query třídy
Fancyadmin vyžaduje QueryObject pattern z adt/doctrine-components. Každý query objekt:
- dědí z BaseQuery (rozšiřuje
ADT\DoctrineComponents\QueryObject\QueryObject) - implementuje interface z fancyadmin
- používá odpovídající trait z fancyadmin
5.1 BaseQuery
// app/Model/Queries/Abstract/BaseQuery.php <?php declare(strict_types=1); namespace App\Model\Queries\Abstract; use ADT\Components\AjaxSelect\Interfaces\OrByIdFilterInterface; use ADT\DoctrineComponents\QueryObject\QueryObject; use ADT\FancyAdmin\Model\Queries\Abstract\BaseQueryTrait; /** * @extends QueryObject<TEntity> * @template TEntity of object */ abstract class BaseQuery extends QueryObject implements OrByIdFilterInterface, \ADT\FancyAdmin\Model\Queries\Abstract\BaseQuery { use BaseQueryTrait; }
5.2 Konkrétní Query třídy
Vzor je pro všechny stejný — implementovat interface, použít trait, přidat stub metody:
// app/Model/Queries/IdentityQuery.php <?php declare(strict_types=1); namespace App\Model\Queries; use ADT\FancyAdmin\Model\Entities\Account; use ADT\FancyAdmin\Model\Queries\IdentityQueryTrait; use App\Model\Entities\Identity; use Doctrine\ORM\QueryBuilder; /** * @extends Abstract\BaseQuery<Identity> */ class IdentityQuery extends Abstract\BaseQuery implements \ADT\FancyAdmin\Model\Queries\IdentityQuery, \ADT\DoctrineAuthenticator\OTP\IdentityQuery { use IdentityQueryTrait; protected function applySecurityFilter(): void {} protected function applyAccountFilter(QueryBuilder $qb, Account $account): void {} protected function setDefaultOrder(): void {} }
Stejný vzor pro:
- AccountQuery —
use AccountQueryTrait; implements \ADT\FancyAdmin\Model\Queries\AccountQuery - ProfileQuery —
use ProfileQueryTrait; implements \ADT\FancyAdmin\Model\Queries\ProfileQuery - AclRoleQuery —
use AclRoleQueryTrait; implements \ADT\FancyAdmin\Model\Queries\AclRoleQuery(+applyAccountFilter) - ConfigurationQuery —
use ConfigurationQueryTrait; implements \ADT\FancyAdmin\Model\Queries\ConfigurationQuery - GridFilterQuery —
use \ADT\Datagrid\Model\Queries\GridFilterQueryTrait; implements \ADT\Datagrid\Model\Queries\GridFilterQuery
5.3 DefaultFilters trait
// app/Model/Queries/Filters/DefaultFilters.php <?php namespace App\Model\Queries\Filters; trait DefaultFilters { use \ADT\FancyAdmin\Model\Queries\Filters\DefaultFilters; }
6. Query Factory interfaces
Každá Query třída potřebuje factory interface pro DI autowiring. Factory interface rozšiřuje fancyadmin factory a upřesňuje return type:
// app/Model/Queries/Factories/IdentityQueryFactory.php <?php namespace App\Model\Queries\Factories; use App\Model\Queries\IdentityQuery; interface IdentityQueryFactory extends \ADT\FancyAdmin\Model\Queries\Factories\IdentityQueryFactory { public function create(): IdentityQuery; }
Vytvořte factory pro každou query: AccountQueryFactory, AclRoleQueryFactory, ConfigurationQueryFactory, ProfileQueryFactory, GridFilterQueryFactory.
Registrace v config: Query factories se registrují automaticky přes search v neon:
search: queries: in: %appDir%/Model/Queries files: - *Factory.php
7. Security — Authenticator
// app/Model/Security/Authenticator.php <?php namespace App\Model\Security; use ADT\DoctrineAuthenticator\OTP\OnetimeTokenAuthenticator; use ADT\FancyAdmin\Model\Security\AuthenticatorTrait; class Authenticator extends OnetimeTokenAuthenticator implements \ADT\FancyAdmin\Model\Security\Authenticator { use AuthenticatorTrait; }
OnetimeTokenAuthenticator rozšiřuje DoctrineAuthenticator a přidává OTP podporu. AuthenticatorTrait přidává validateIdentity() kontrolu ACL.
Poznámka: Pokud nepotřebujete OTP, můžete rozšiřovat přímo DoctrineAuthenticator a implementovat verifyCredentials().
8. Security — SecurityUser
// app/Model/Security/SecurityUser.php <?php namespace App\Model\Security; use ADT\FancyAdmin\Model\Security\SecurityUserTrait; use App\Model\Entities\Identity; /** * @method Identity getIdentity() */ class SecurityUser extends \ADT\DoctrineAuthenticator\SecurityUser implements \ADT\FancyAdmin\Model\Security\SecurityUser { use SecurityUserTrait; }
Důležité: Rozšiřuje ADT\DoctrineAuthenticator\SecurityUser (ne Nette\Security\User přímo), protože ten má kompatibilní (ne-final) getAuthorizator().
9. Security — Permission
// app/Model/Security/Permission.php <?php namespace App\Model\Security; class Permission extends \ADT\FancyAdmin\Model\Security\Permission { }
10. Doctrine — EntityManager
// app/Model/Doctrine/EntityManager.php <?php declare(strict_types=1); namespace App\Model\Doctrine; class EntityManager extends \ADT\DoctrineComponents\EntityManager { }
11. Listeners
Fancyadmin potřebuje 3 event listenery pro automatické nastavování createdBy, account a selectedAccount:
// app/Model/Listeners/Abstract/BaseListener.php <?php declare(strict_types=1); namespace App\Model\Listeners\Abstract; abstract class BaseListener extends \ADT\DoctrineComponents\BaseListener { }
// app/Model/Listeners/CreatedByEntityBaseListener.php <?php declare(strict_types=1); namespace App\Model\Listeners; use ADT\FancyAdmin\Model\Listeners\CreatedByListenerTrait; use App\Model\Listeners\Abstract\BaseListener; class CreatedByEntityBaseListener extends BaseListener { use CreatedByListenerTrait; }
// app/Model/Listeners/AccountFieldBaseListener.php <?php declare(strict_types=1); namespace App\Model\Listeners; use ADT\FancyAdmin\Model\Listeners\AccountFieldListenerTrait; use App\Model\Listeners\Abstract\BaseListener; class AccountFieldBaseListener extends BaseListener { use AccountFieldListenerTrait; }
// app/Model/Listeners/SelectAccountListener.php <?php declare(strict_types=1); namespace App\Model\Listeners; use ADT\FancyAdmin\Model\Listeners\SelectAccountListenerTrait; use App\Model\Listeners\Abstract\BaseListener; class SelectAccountListener extends BaseListener { use SelectAccountListenerTrait; }
Registrace v config:
search: listeners: in: %appDir%/Model/Listeners files: - *Listener.php
12. Translator
// app/Model/Translator.php <?php declare(strict_types=1); namespace App\Model; class Translator extends \Contributte\Translation\Translator { }
13. Router
FancyAdminRouter se integruje do RouterFactory:
// app/Core/RouterFactory.php <?php declare(strict_types=1); namespace App\Core; use ADT\FancyAdmin\Core\FancyAdminRouter; use ADT\Routing\RouteList; class RouterFactory { public static function create(FancyAdminRouter $fancyAdminRouter): RouteList { $router = new RouteList(); // Fancyadmin routes (Sign:in, Sign:out, portal routes) $router[] = $fancyAdminRouter->getRouteList(); // Web module routes $webModule = new RouteList('Web'); $webModule->addRoute('<presenter>/<action>[/<id>]', [ 'presenter' => 'Home', 'action' => 'default', ]); $router[] = $webModule; return $router; } }
14. Portal Presentery
Fancyadmin poskytuje presenter traity pro portálovou část (admin):
BasePresenter
// app/UI/Portal/Presenters/BasePresenter.php <?php namespace App\UI\Portal\Presenters; use ADT\FancyAdmin\UI\Presenters\BasePresenterTrait; use Kdyby\Autowired\AutowireComponentFactories; use Kdyby\Autowired\AutowireProperties; use Nette\Application\UI\Presenter; class BasePresenter extends Presenter { use AutowireComponentFactories; use AutowireProperties; use BasePresenterTrait { BasePresenterTrait::beforeRender as traitBeforeRender; } }
AuthPresenter (abstraktní — base pro všechny presentery vyžadující přihlášení)
// app/UI/Portal/Presenters/AuthPresenter.php <?php namespace App\UI\Portal\Presenters; use ADT\FancyAdmin\UI\Presenters\AuthPresenterTrait; use App\Model\Security\SecurityUser; /** * @method SecurityUser getUser() */ abstract class AuthPresenter extends BasePresenter implements \ADT\FancyAdmin\UI\Presenters\AuthPresenter { use AuthPresenterTrait; }
Kam se uživatel vrátí po přihlášení
Nepřihlášený požadavek na AuthPresenter skončí na přihlašovací stránce a cíl se zapamatuje
do cookie returnPath (host-only, HttpOnly, SameSite=Lax, 10 minut) — bez ohledu na
HTTP metodu.
Platí tedy invariant, že nepřihlášený požadavek nikdy nesáhne na session. Nette
backlink (Presenter::storeRequest()) by ji naopak založil, takže by šlo session_storage
nafouknout requesty zvenčí: nejde ani o POST, na obejití by stačil GET s hlavičkou
X-Requested-With: XMLHttpRequest.
Cenou je, že se POST po přihlášení nezopakuje — uživatel skončí na cílové stránce
a formulář odešle znovu. Zopakování POSTu ale stejně z velké části nefungovalo: CSRF token
je token ^ session ID, takže když je uživatel nepřihlášený kvůli vypršelé session, po
loginu dostane jiné session ID a replay na CSRF spadne.
RedirectAfterLoginTrait::redirectAfterLogin() po přihlášení zkusí nejdřív session
backlink, pak cookie, a nakonec spadne na výchozí route. Backlink fancyadmin sám nezakládá,
ale zpracovat ho umí — aplikace si storeRequest() může zavolat sama tam, kde opravdu
potřebuje zopakovat POST, a vzít si za to tu expozici na sebe.
Presenter, který z AuthPresenteru nedědí (typicky výdej souboru z odkazu v e-mailu), si cíl uloží sám:
use ADT\FancyAdmin\DI\Injects\ReturnPathInject; class DownloadPresenter extends BasePresenter { use ReturnPathInject; protected function startup(): void { parent::startup(); if (!$this->getUser()->isLoggedIn()) { $this->_returnPath->store($this->getHttpRequest()->getUrl()); $this->redirect(':Portal:Sign:in'); } } }
Cíl v cookie není svázaný s identitou (na rozdíl od session backlinku), takže se na něm nesmí stavět autorizace — cílová akce si musí právo přihlášeného uživatele ověřit sama.
15. NEON konfigurace
common.neon — extensions
extensions: autowired: Kdyby\Autowired\DI\AutowiredExtension translation: Contributte\Translation\DI\TranslationExtension nettrine.dbal: ADT\DoctrineComponents\DI\DbalExtension nettrine.orm: Nettrine\ORM\DI\OrmExtension nettrine.extensions.atlantic18: Nettrine\Extensions\Atlantic18\DI\Atlantic18BehaviorExtension queryObjectDataSource: ADT\QueryObjectDataSource\DI\QueryObjectDataSourceExtension fancyadmin: ADT\FancyAdmin\DI\FancyAdminExtension datagridComponents: ADT\Datagrid\DI\DataGridComponentsExtension
Poznámka: DBAL extension je ADT\DoctrineComponents\DI\DbalExtension (ne Nettrine\DBAL\DI\DbalExtension). Tato extension rozšiřuje Nettrine DBAL o další funkce.
common.neon — search (auto-registrace services)
search: listeners: in: %appDir%/Model/Listeners files: - *Listener.php queries: in: %appDir%/Model/Queries files: - *Factory.php
common.neon — fancyadmin
fancyadmin: project: muj-projekt projectName: Můj Projekt logoPublicPath: logo.svg logoBitmapPublicPath: /images/logo.png logoMenuPath: /images/logo.png loginPageLogoPath: logo.svg context: project lostPasswordEnabled: true adminHostPath: %env.PORTAL_URL% hmr: %hmr% customerAclResource: App\Model\Entities\Enums\AclResourceNameEnum::CUSTOMER_HOME backofficeAclResource: App\Model\Entities\Enums\AclResourceNameEnum::BACKOFFICE_HOME fullDataAclResource: App\Model\Entities\Enums\AclResourceNameEnum::FULL_DATA locksDir: %locksDir% emailBackgroundColor: '#fff' colors: backgroundColor: '#f1f7f7' dashboardAccentColor: '#9ad0f5' primaryColor: '#42b6a4' primaryColorDark: '#3fad9c' primaryColorDark20: '#3ba494' secondaryColor: '#f1f7f7' secondaryColorDark: '#e1eeee' secondaryColorDarker: '#d2e5e5' ternaryColor: '#101D40' ternaryTextColor: '#ffffff' loginBackground: 'rgb(90, 97, 120)' loginBackgroundInput: 'rgb(255, 255, 255, 0.3)' loginBackgroundInputFocus: 'rgb(255, 255, 255, 0.4)' loginInputTextColor: '#1a1a1a' inputBorder: '1px solid #c8c8c8' inputFocusBorder: '0' inputFocusBackground: '#f0f0f0'
common.neon — ORM mapping
nettrine.orm: managers: default: connection: default entityManagerDecoratorClass: App\Model\Doctrine\EntityManager lazyNativeObjects: true mapping: entities: namespace: App\Model\Entities directories: - %appDir%/Model/Entities doctrineAuthenticator: namespace: ADT\DoctrineAuthenticator directories: - %appDir%/../vendor/adt/doctrine-authenticator/src
Důležité: Mapování doctrineAuthenticator je potřeba, protože ADT\DoctrineAuthenticator obsahuje entity (StorageEntity, LoginAttempt, OnetimeToken) s Doctrine atributy.
common.neon — services
services: router: App\Core\RouterFactory::create jsComponents: ADT\Utils\JsComponents - App\Model\Security\Permission security.user: App\Model\Security\SecurityUser security.userStorage: Nette\Bridges\SecurityHttp\CookieStorage security.authenticator: factory: App\Model\Security\Authenticator(expiration: '14 days') setup: - setFraudDetection(true) - setExpirationCallback(Closure::fromCallable(@ADT\FancyAdmin\Model\Security\SessionExpirationCallback)) - ADT\DoctrineAuthenticator\OTP\OnetimeTokenService - ADT\FancyAdmin\Model\Security\SessionExpirationCallback
common.neon — datagrid
datagridComponents: locksDir: %locksDir% downloadLink: Portal:Download:gridExport
common.neon — translation
translation: locales: default: cs whitelist: [cs, en] fallback: [cs] dirs: - %appDir%/lang localeResolvers: [] loaders: yml: Symfony\Component\Translation\Loader\YamlFileLoader translatorFactory: App\Model\Translator
common.neon — atlantic18 (Gedmo)
nettrine.extensions.atlantic18: timestampable: true softDeleteable: true
common.neon — decorator
decorator: App\Model\Queries\Abstract\BaseQuery: setup: - setSecurityUser(@App\Model\Security\SecurityUser)
16. Migrace
Po nastavení vygenerujte migraci:
php bin/console migrations:diff php bin/console migrations:migrate
Toto vytvoří tabulky: identity, account, profile, acl_role, acl_resource, acl, configuration, file, grid_filter, storage_entity (auth sessions), login_attempt, ext_log_entries (audit log).
Po migraci vytvořte první identitu:
php bin/console adt:fancyadmin:create-identity
17. Struktura souborů
app/
├── Core/
│ └── RouterFactory.php
├── Model/
│ ├── Doctrine/
│ │ └── EntityManager.php
│ ├── Entities/
│ │ ├── Abstract/
│ │ │ └── BaseEntity.php
│ │ ├── Enums/
│ │ │ └── AclResourceNameEnum.php
│ │ ├── Acl.php
│ │ ├── AclResource.php
│ │ ├── AclRole.php
│ │ ├── Account.php
│ │ ├── Configuration.php
│ │ ├── File.php
│ │ ├── GridFilter.php
│ │ ├── Identity.php
│ │ └── Profile.php
│ ├── Listeners/
│ │ ├── Abstract/
│ │ │ └── BaseListener.php
│ │ ├── AccountFieldBaseListener.php
│ │ ├── CreatedByEntityBaseListener.php
│ │ └── SelectAccountListener.php
│ ├── Queries/
│ │ ├── Abstract/
│ │ │ └── BaseQuery.php
│ │ ├── Factories/
│ │ │ ├── AccountQueryFactory.php
│ │ │ ├── AclRoleQueryFactory.php
│ │ │ ├── ConfigurationQueryFactory.php
│ │ │ ├── GridFilterQueryFactory.php
│ │ │ ├── IdentityQueryFactory.php
│ │ │ └── ProfileQueryFactory.php
│ │ ├── Filters/
│ │ │ └── DefaultFilters.php
│ │ ├── AccountQuery.php
│ │ ├── AclRoleQuery.php
│ │ ├── ConfigurationQuery.php
│ │ ├── GridFilterQuery.php
│ │ ├── IdentityQuery.php
│ │ └── ProfileQuery.php
│ ├── Security/
│ │ ├── Authenticator.php
│ │ ├── Permission.php
│ │ └── SecurityUser.php
│ └── Translator.php
└── UI/
├── Portal/
│ └── Presenters/
│ ├── AuthPresenter.php
│ └── BasePresenter.php
└── Web/
└── Presenters/
└── BasePresenter.php
18. Keycloak SSO integrace (volitelné)
Fancyadmin podporuje napojení na jeden nebo více Keycloak serverů/realmů pro SSO autentizaci. Integrace je ve výchozím stavu vypnutá a aktivuje se přidáním keycloak sekce do konfigurace.
Technický popis integrace (použité OAuth2/OIDC flows, volané endpointy, bezpečnostní mechanismy) — vhodný pro security review nebo externí partnery provozující vlastní Keycloak — je v docs/keycloak.md.
18.1 Předpoklady
- Keycloak server s nakonfigurovaným realmem
- Klient v Keycloaku s:
- Client authentication: zapnuto (confidential client)
- Service accounts roles: zapnuto (pro Admin API — vyhledávání a správa uživatelů)
- Valid redirect URIs — pouze exact URIs, žádné wildcardy (OAuth 2.1);
nazev-ssonahraďte názvem SSO instance (sloupecnamev Sso entitě):https://admin.muj-projekt.cz/keycloak-auth/callback?instance=nazev-ssohttps://admin.muj-projekt.cz/keycloak-auth/silent-check?instance=nazev-sso
- Valid post logout redirect URIs:
https://admin.muj-projekt.cz/keycloak-auth/post-log-out - Web origins:
https://admin.muj-projekt.cz - Require PKCE: zapnuto, PKCE Method:
S256(Settings → Capability config; server pak request bezcode_challengeodmítne).
- Service account musí mít roli
manage-userszrealm-managementclienta - Druhý (public) klient pro frontend keycloak-js adapter — s Client authentication: vypnuto, Require PKCE + PKCE Method
S256a Valid redirect URIs:https://admin.muj-projekt.cz/keycloak-auth/silent-check-sso - Doporučeno: v realmu aktivovat client policies s vestavěnými profily
oauth-2-1-for-confidential-clientaoauth-2-1-for-public-client— Keycloak pak požadavky OAuth 2.1 (PKCE, exact URIs, zakázané granty) vynucuje sám guzzlehttp/guzzleafirebase/php-jwt(validace backchannel logout tokenů) nainstalované v projektu:composer require guzzlehttp/guzzle:^7.0 firebase/php-jwt:^6.0
18.2 Sso entita
Fancyadmin vyžaduje entitu Sso, která uchovává kompletní konfiguraci Keycloak instancí:
// app/Model/Entities/Sso.php <?php declare(strict_types=1); namespace App\Model\Entities; use ADT\FancyAdmin\Model\Entities\SsoTrait; use App\Model\Entities\Abstract\BaseEntity; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] class Sso extends BaseEntity implements \ADT\FancyAdmin\Model\Entities\Sso { use SsoTrait; }
Po vytvoření entity spusťte migraci.
SsoTrait poskytuje:
| Sloupec | Typ | Popis |
|---|---|---|
name |
string (unique) | Identifikátor instance |
realm |
string | Název Keycloak realmu |
baseUrl |
string | Interní URL pro API volání (např. http://keycloak:8080) |
hostUrl |
string | Veřejná URL pro redirect uživatele (např. https://auth.muj-projekt.cz) |
clientId |
string | Confidential client ID |
clientSecret |
string | Client secret |
frontendClientId |
string | Public client ID pro keycloak-js adapter |
defaultRole |
AclRole (nullable) | Role, která se přiřadí novému uživateli při SSO registraci — relace na entitu AclRole (v DB sloupec default_role_id) |
isActive |
bool | Zapojuje se instance do přihlašování? Default true (v DB sloupec is_active) |
baseUrl i hostUrl musí být absolutní http(s) URL — formulář odmítne hodnotu bez schématu.
Kam smí mířit se needituje, to je věc administrátora.
Proč isActive
Silent SSO na přihlašovací stránce projde všechny aktivní instance a na každou udělá
jeden prompt=none check. Vadná konfigurace tedy ovlivní login celé platformy — deaktivace
je způsob, jak takovou instanci odstavit, aniž by se musela smazat: konfigurace i identity
na ni navázané zůstanou zachované. Smazat ji ostatně nejde, dokud na ní visí identity.
Co deaktivace ovlivní:
- Silent SSO instanci vynechá,
prompt=nonecheck se na ni neposílá. - SSO uživatelé navázaní na instanci (identita se
sso_ida alespoň jednou rolí sneeds_sso) se chovají jako běžní uživatelé s heslem: přihlásí se lokálním heslem, lost password i reset hesla z gridu uživatelů pošlou lokální recovery mail a v Můj účet si mění lokální heslo.KeycloakManager::getInstanceForIdentity()pro ně vrátínulla všechny tyhle cesty propadnou na stejnou větev, kterou používají uživatelé bez SSO. - Callback rozpracovaného requestu, odhlášení a backchannel logout fungují dál. Odhlášení
si instanci bere přes
getInstanceForIdentity($identity, activeOnly: false), takže uživatel přihlášený před deaktivací se odhlásí i z Keycloaku a deaktivace ho neuvězní.
Fallback na heslo je záměr: deaktivace je zároveň nouzový režim, kdy se SSO uživatelé dostanou do aplikace i při výpadku nebo špatné konfiguraci Keycloaku. Kdo lokální heslo nemá, nastaví si ho přes lost password. Po reaktivaci instance se přihlašování zase řídí Keycloakem a lokální heslo se ignoruje.
Vyzkoušení instance před aktivací
Akce Vyzkoušet v SSO gridu ověří konfiguraci, aniž by se instance musela aktivovat. Běží ve dvou vrstvách, protože každá chytá jinou třídu chyb:
- Serverová sonda:
client_credentialsgrant nabaseUrl. Ověří interní URL, realm, Client ID i Client Secret. Nepotřebuje prohlížeč, takže když selže, končí se hned a do gridu se rovnou zapíše chyba. - Zkušební průchod: admin projde reálným silent checkem (
prompt=none) nahostUrlpřes existujícísilent-checkredirect URI. Tenhle krok ověří jen ty případy, kdy Keycloak přesměruje zpět do aplikace:codev odpovědi (admin má v Keycloaku session) ierror=login_required(resp.interaction_required) jsou úspěch: Keycloak request přijal a zpracoval, jen zrovna neběží žádná SSO session. U admina, který v Keycloaku přihlášený není, je to očekávaný výsledek.- Jiná OAuth chyba (např.
unauthorized_client,invalid_scope) se zobrazí jako chybová hláška s kódem chyby. Kód se před zobrazením sanitizuje na[a-z0-9_.-], max 64 znaků, aby se do hlášky nedostalo nic, co do parametru vloží Keycloak nebo někdo za něj.
Co zkušební průchod neověří hláškou: když Keycloak client_id nezná, redirect_uri
nemá registrované, nebo hostUrl není z prohlížeče dosažitelná, Keycloak zpět nepřesměruje.
Admin skončí na chybové stránce Keycloaku (typicky Invalid parameter: redirect_uri,
Client not found), resp. na síťové chybě prohlížeče, a do aplikace se nevrátí. Právě to,
že se nevrátil do gridu, je v těchto případech výsledek testu.
Výsledek se do gridu dostane přes návratovou URL: actionSilentCheck k backRedirect ze
session přidá query parametr ssoTest (konstanty Keycloak::SSO_TEST_PARAM,
Keycloak::SSO_TEST_OK, hodnota ok nebo kód chyby) a SsoPresenterTrait::actionDefault
z něj udělá flash zprávu a URL redirectem vyčistí, aby se hláška při refreshi neopakovala.
Flash zpráva se nepoužívá přímo, protože redirect na absolutní URL by ji do cílové stránky
nepřenesl.
Průchod nikoho nepřihlásí: příznak isTest se drží v session u jednorázového state
(ne v URL, aby nešel podvrhnout) a actionSilentCheck podle něj místo autentizace jen
ohlásí výsledek. Přijatý code se za token nevymění. Bez toho by se admin mohl přihlásit
cizí identitou, případně by se přes defaultRole provisionovala nová.
Používá se existující silent-check redirect URI, takže se v Keycloaku nic nepřidává.
18.3 NEON konfigurace
V neonu se Keycloak pouze zapíná/vypíná. Veškerá konfigurace instancí je v tabulce sso:
fancyadmin: # ... ostatní konfigurace ... keycloakEnabled: true
Pokud je keycloakEnabled nastaveno na false (výchozí), vše Keycloak-related je vypnuté a projekt funguje jako dříve.
Pro lokální vývoj se self-signed certifikátem lze vypnout validaci TLS certifikátu Keycloak serveru volbou keycloakVerifySsl: false. Na produkci musí zůstat výchozí true — přes tento kanál jde výměna authorization code za tokeny včetně client_secret.
18.4 Nastavení SSO v databázi
-
Vytvořte záznamy v tabulce
ssos kompletní konfigurací Keycloak instance:id name realm baseUrl hostUrl clientId clientSecret frontendClientId default_role_id is_active 1 hlavni muj-realm http://keycloak:8080 https://auth.example.cz app-client secret123 app-public 5 1 Kde
default_role_idje cizí klíč naacl_role.id— ID role, která se automaticky přiřadí novému uživateli při prvním SSO přihlášení. Pokud nechcete automatické přiřazení role, nechteNULL.is_active(TINYINT(1) NOT NULL DEFAULT 1) říká, zda se instance zapojuje do přihlašování (viz 18.2, „PročisActive"). Novou instanci lze založit s0, vyzkoušet ji akcí Vyzkoušet a aktivovat až potom.Migrace: knihovna migraci pro tabulku
ssonepřináší (vsrc/Migrations/je jen nesouvisející migrace), sloupce vznikají zSsoTraitv entitě projektu. Sloupecis_activesi proto projekt musí do existující tabulky přidat sám, buď vygenerováním migrace z entity, nebo ručně:ALTER TABLE sso ADD is_active TINYINT(1) DEFAULT 1 NOT NULL;
-
Označte role, které vyžadují SSO: v tabulce
acl_rolenastavteneeds_sso = 1u rolí, jejichž uživatelé se mají přihlašovat výhradně přes Keycloak. Sloupecacl_role.sso_idneexistuje, role sama na konkrétní instanci navázaná není. -
Navažte identity na instanci: v tabulce
identitynastavtesso_idnasso.id. Identita se přihlašuje přes SSO, když máidentity.sso_ida zároveň alespoň jednu roli sneeds_sso = 1Samotná vazbasso_idbez takové role SSO nevynutí a samotná role bezsso_idneříká, přes kterou instanci se přihlašovat.
Při zadání emailu na login stránce fancyadmin zjistí SSO instanci z identity (identity.sso_id + role s needs_sso) a přesměruje na odpovídající Keycloak. Pokud identita takovou vazbu nemá, zobrazí se standardní přihlášení heslem. Pokud vazbu má, ale instance je deaktivovaná, heslem se nepřihlásí a dostane hlášku o dočasné nedostupnosti SSO (viz 18.2).
18.5 Presentery
Vytvořte dva presentery pro Keycloak OAuth2 flow:
// app/UI/Portal/Presenters/KeycloakAuth/KeycloakAuthPresenter.php <?php declare(strict_types=1); namespace App\UI\Portal\Presenters\KeycloakAuth; use ADT\FancyAdmin\UI\Presenters\Keycloak\KeycloakAuthPresenterTrait; use App\UI\Portal\Presenters\BasePresenter; class KeycloakAuthPresenter extends BasePresenter { use KeycloakAuthPresenterTrait; }
// app/UI/Portal/Presenters/KeycloakLog/KeycloakLogPresenter.php <?php declare(strict_types=1); namespace App\UI\Portal\Presenters\KeycloakLog; use ADT\FancyAdmin\UI\Presenters\Keycloak\KeycloakLogPresenterTrait; use App\UI\Portal\Presenters\BasePresenter; class KeycloakLogPresenter extends BasePresenter { use KeycloakLogPresenterTrait; }
18.6 JavaScript
V app.js projektu přidejte import keycloak adaptéru pro silent SSO check:
import { keycloakLoginSync } from '../path/to/vendor/adt/fancyadmin/assets/js/keycloak'; keycloakLoginSync();
Pro keycloak email check na login formuláři importujte modul eagerly v app.js:
import '../path/to/vendor/adt/fancyadmin/assets/js/signInKeycloak';
Důležité: import musí být eager (ne přes
AdtJsComponents.init, který modul načítá lazy až když je formulář na stránce). Modul si při importu naváže delegovanýchangelistener nadocument, takže funguje i pro login formulář vložený přes AJAX (např. po odhlášení), aniž by se musel reinicializovat. Při lazy načtení by se po AJAX přepnutí na/sign/inlistener nenavázal.
Závislost: Projekt musí mít nainstalovaný npm balíček keycloak-js:
yarn add keycloak-js
18.7 Co se děje automaticky
Po zapnutí Keycloak konfigurace fancyadmin automaticky:
- Registruje routy
keycloak-auth/<action>akeycloak-log/<action>v Portal modulu - Login formulář — přidá
data-keycloak-check-urlatribut na email input; po zadání emailu JS zjistí SSO instanci z identity/role a přesměruje na odpovídající Keycloak - Logout —
Sign:outautomaticky odhlásí i z Keycloaku (pokud se uživatel přihlásil přes SSO) - Frontend — do layoutu injektuje
window.__keycloakSettingspro keycloak-js adapter (silent SSO check, token refresh) - Registrace při SSO — pokud se přes Keycloak přihlásí uživatel, který v aplikaci neexistuje, automaticky se mu vytvoří identita s vazbou na SSO instanci a
defaultRole(pokud je nakonfigurovaná). Toto chování zajišťujeautoRegister: truev interním voláníloginUser()— lze přepsat rozšířením třídyKeycloak(viz 18.11)
18.8 Keycloak služba — správa uživatelů
Keycloak instance jsou dostupné přes KeycloakManager:
$manager = $this->_fancyAdmin->getKeycloakManager(); // null pokud je Keycloak vypnutý // Získat konkrétní instanci podle názvu $keycloak = $manager->getInstance('hlavni'); // Získat instanci podle identity (identity.sso_id + role s needs_sso; deaktivovaná instance vrátí null) $keycloak = $manager->getInstanceForIdentity($identity); // Získat instanci, přes kterou je přihlášen aktuální uživatel (ze session) $keycloak = $manager->getInstanceFromSession();
Každá instance poskytuje metody pro správu uživatelů přes Admin API:
// Registrace uživatele v Keycloaku (vrací existujícího pokud už existuje) $keycloakUser = $keycloak->registerUser($identity, 'heslo', temporaryPassword: false); // Aktualizace údajů (email, jméno, příjmení) $keycloakUser = $keycloak->updateUser($identity); // Deaktivace / aktivace $keycloak->disableUser($identity); $keycloak->enableUser($identity); // Nastavení hesla $keycloak->setUserPassword($identity, 'noveHeslo', temporary: true); // Vyhledání uživatele podle emailu $keycloakUser = $keycloak->findUser('user@example.com'); // Odeslání emailu pro reset hesla přes Keycloak (execute-actions-email) // Keycloak pošle svůj email s odkazem na formulář; po nastavení hesla KC přesměruje na $redirectUri $keycloak->sendPasswordResetEmail($identity, redirectUri: 'https://admin.muj-projekt.cz/sign/in');
Pro přesměrování přihlášeného uživatele na změnu hesla přímo v Keycloaku (Application-Initiated Action) použijte getUpdatePasswordUrl() — Keycloak si sám vyžádá re-autentizaci současným heslem, ohlídá password policy i 2FA a po nastavení nového hesla vrátí uživatele zpět do aplikace:
// URL pro přesměrování na změnu hesla v Keycloaku $url = $keycloak->getUpdatePasswordUrl( backRedirect: 'https://admin.muj-projekt.cz/profil', loginHint: $identity->getEmail(), ); $this->redirectUrl($url);
18.9 Backchannel logout
Keycloak podporuje backchannel logout — při ukončení session v Keycloaku (odhlášení, expirace, deaktivace uživatele) Keycloak pošle POST request na aplikaci, která invaliduje lokální session uživatele.
Nastavení v Keycloaku
V Keycloak admin panelu → Clients → váš confidential client → Settings:
-
Backchannel logout URL:
https://admin.muj-projekt.cz/keycloak-auth/backchannel-logout?instance=nazev-ssoKde
nazev-ssoodpovídá hodnotěnamev tabulcesso. -
Backchannel logout session required: On
Opakujte pro každou SSO instanci s odpovídajícím ?instance= parametrem.
Co se děje
- Keycloak pošle POST s
logout_token(JWT) na backchannel URL - Aplikace token zvaliduje podle OIDC Back-Channel Logout spec (podpis proti JWKS realmu, iss, aud, events, replay ochrana) — vyžaduje
firebase/php-jwt; nevalidní token dostane400 - Z tokenu získá
sub(Keycloak user ID) - Přes Admin API zjistí email uživatele
- Najde lokální identitu podle emailu
- Invaliduje všechny její sessions (
Authenticator::clearIdentity)
Tím je zajištěno, že:
- Uživatel odhlášený z Keycloaku je automaticky odhlášen i z aplikace
- Uživatel deaktivovaný v Keycloaku ztrácí přístup okamžitě (session je ukončena a nové SSO přihlášení selže)
18.10 Přidání nové Keycloak instance
Postup pro přidání další SSO instance do existujícího projektu:
- DB — vytvořte nový záznam v tabulce
ssos kompletní konfigurací (realm, URL, credentials) - DB — u identit nastavte
identity.sso_idna novou instanci a u jejich rolíacl_role.needs_sso = 1 - Keycloak — v novém clientu nastavte backchannel logout URL (viz 18.9)
Žádná změna PHP kódu, .env ani neon konfigurace není potřeba. Instance se vytváří dynamicky z databáze.
18.11 Rozšíření chování
Keycloak službu lze rozšířit v projektu — např. pro úpravu logiky vytváření identity při SSO loginu:
class MyKeycloak extends \ADT\FancyAdmin\Model\Security\Keycloak\Keycloak { protected function createIdentity(array $userInfo): Identity { $identity = parent::createIdentity($userInfo); // vlastní logika — přiřazení kontextu, notifikace, atd. return $identity; } }
Pro použití vlastní třídy je potřeba rozšířit KeycloakManager::createInstanceFromSso() v projektu.
19. Passkeys (WebAuthn)
Fancyadmin podporuje přihlašování přes passkeys (WebAuthn) postavené na knihovně
lbuchs/webauthn. Passkeys jsou opt-in — zapínají
se configem passkeyEnabled: true (default false, viz 19.2). Při vypnuté featuře se
nevykresluje tlačítko na login stránce ani karta v Můj účet a všechny passkey operace
jsou zablokované i server-side (PasskeyService::assertEnabled()). Existující klíče
v DB při vypnutí zůstávají — po opětovném zapnutí zase fungují. Passkey je vždy jen
alternativa k heslu (žádné passkey-only účty). Klíč si může zaregistrovat i identita
navázaná na Keycloak SSO, aby měla 2FA připravené na dobu, kdy jí SSO bude zrušeno.
Uživatel s povinným Keycloak loginem (SSO instance + role s needsSso) se ale přes
passkey nepřihlásí: místo přihlášení ho login formulář natvrdo přesměruje na Keycloak.
Při passkeyEnabled: false (default) projekt nemusí mít žádné passkey třídy —
entitu, query, factory, form, grid ani passkey trait v Identity (sekce 19.3-19.5);
v tabulce identity pak není žádný passkey sloupec. Při passkeyEnabled: true
jsou povinné; extension to zvaliduje při kompilaci DI kontejneru a chybějící
infrastrukturu ohlásí srozumitelnou chybou.
Co uživatel dostane:
- Login stránka — tlačítko „Přihlásit se přihlašovacím klíčem" (usernameless login, prohlížeč nabídne uložené discoverable credentials). Tlačítko je jediná cesta — passkey se nenabízí automaticky v autofillu email pole (conditional mediation není zapnutá)
- Můj účet — karta „Přihlašovací klíče": přidání klíče (side panel s povinným názvem), smazání, badge pro synchronizované klíče (zálohované u správce passkeys)
19.1 Požadavky
- HTTPS — WebAuthn funguje jen v secure kontextu (výjimka:
localhost) - rpId = doména admin hostu — klíče jsou svázané s doménou. Default se odvozuje
z
adminHostPath, ale doporučujeme nastavitpasskeyRpIdexplicitně: jeho pozdější změna zneplatní všechny už registrované klíče, takže je to hodnota, kterou chcete mít vědomě v konfiguraci, ne odvozenou. Nastavte ji na přesnou doménu adminu, ne na nadřazenou domain (širší rpId znamená, že klíč jde použít i na ostatních subdomén). Když rpId není známé (passkeyRpIdprázdné a zadminHostPathse nedá odvodit), kontejner se nezkompiluje a řekne proč. - Pokud se doména mění napříč prostředími (staging, migrace), řešte to stabilním DNS názvem, ne širším rpId.
19.2 NEON konfigurace
fancyadmin: # ... ostatní konfigurace ... # Zapnutí passkeys — bez tohoto flagu je celá featura vypnutá (default: false) passkeyEnabled: true # Relying Party ID — doména; když není nastaveno, odvodí se host z adminHostPath passkeyRpId: admin.muj-projekt.cz # Relying Party name — zobrazuje se v dialogu autentikátoru; default = projectName passkeyRpName: Můj projekt
Povinné je jen passkeyEnabled (pro zapnutí), passkeyRpId a passkeyRpName jsou volitelné.
19.3 Entity — Passkey + rozšíření Identity
Entita Identity musí použít IdentityPasskeysTrait a implementovat HasPasskeys
(PasskeyService na ten interface spoléhá):
// app/Model/Entities/Identity.php — přidat k existující entitě use ADT\FancyAdmin\Model\Entities\IdentityPasskeysTrait; use ADT\FancyAdmin\Model\Entities\Traits\HasPasskeys; #[ORM\Entity] class Identity extends BaseEntity implements \ADT\FancyAdmin\Model\Entities\Identity, HasPasskeys /* , ... */ { use IdentityTrait; use IdentityPasskeysTrait; }
// app/Model/Entities/Passkey.php <?php declare(strict_types=1); namespace App\Model\Entities; use ADT\FancyAdmin\Model\Entities\PasskeyTrait; use App\Model\Entities\Abstract\BaseEntity; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] class Passkey extends BaseEntity implements \ADT\FancyAdmin\Model\Entities\Passkey { use PasskeyTrait; }
PasskeyTrait poskytuje:
| Sloupec | Typ | Popis |
|---|---|---|
identity |
Identity (FK, ON DELETE CASCADE) | Vlastník klíče |
name |
VARCHAR(64) | Uživatelský název klíče |
credentialId |
VARBINARY(255), unique | Raw binary credential ID |
publicKey |
TEXT | Veřejný klíč (PEM) |
signCount |
INT UNSIGNED | Signature counter (detekce klonu) |
aaguid |
BINARY(16), nullable | AAGUID autentikátoru |
transports |
JSON, nullable | Transports z prohlížeče |
backupEligible / backupState |
BOOL, nullable | Backup flags (synchronizovaný klíč) |
createdAt |
DATETIME | Vytvořeno |
lastUsedAt |
DATETIME, nullable | Poslední přihlášení klíčem |
IdentityPasskeysTrait přidává do tabulky identity nullable sloupec passkey_user_handle
(BINARY(32)) — náhodný opaque WebAuthn user handle, generovaný při registraci prvního klíče
(autentikátoru se nikdy neposílá interní ID identity) — a inverzní vazbu getPasskeys().
19.4 Query + factory
// app/Model/Queries/PasskeyQuery.php <?php declare(strict_types=1); namespace App\Model\Queries; use ADT\FancyAdmin\Model\Entities\Account; use ADT\FancyAdmin\Model\Queries\PasskeyQueryTrait; use App\Model\Entities\Passkey; use Doctrine\ORM\QueryBuilder; /** * @extends Base\BaseQuery<Passkey> */ class PasskeyQuery extends Base\BaseQuery implements \ADT\FancyAdmin\Model\Queries\PasskeyQuery { use PasskeyQueryTrait; protected function applySecurityFilter(): void {} protected function applyAccountFilter(QueryBuilder $qb, Account $account): void {} }
// app/Model/Queries/Factories/PasskeyQueryFactory.php <?php namespace App\Model\Queries\Factories; use App\Model\Queries\PasskeyQuery; interface PasskeyQueryFactory extends \ADT\FancyAdmin\Model\Queries\Factories\PasskeyQueryFactory { public function create(): PasskeyQuery; }
19.5 Form + grid (Account stránka)
// app/UI/Portal/Components/Forms/Passkey/PasskeyForm.php <?php declare(strict_types=1); namespace App\UI\Portal\Components\Forms\Passkey; use ADT\FancyAdmin\UI\Components\Forms\Passkey\PasskeyFormTrait; use App\UI\Portal\Components\Forms\Base\BaseForm; class PasskeyForm extends BaseForm implements \ADT\FancyAdmin\UI\Components\Forms\Passkey\PasskeyForm { use PasskeyFormTrait; }
// app/UI/Portal/Components/Forms/Passkey/PasskeyFormFactory.php <?php declare(strict_types=1); namespace App\UI\Portal\Components\Forms\Passkey; interface PasskeyFormFactory extends \ADT\FancyAdmin\UI\Components\Forms\Passkey\PasskeyFormFactory { public function create(): PasskeyForm; }
// app/UI/Portal/Components/Grids/Passkey/PasskeyGrid.php <?php declare(strict_types=1); namespace App\UI\Portal\Components\Grids\Passkey; use ADT\Datagrid\Component\DataGrid; use ADT\FancyAdmin\UI\Components\Grids\Passkey\PasskeyGridTrait; use App\UI\Portal\Components\Grids\Base\BaseGrid; class PasskeyGrid extends BaseGrid implements \ADT\FancyAdmin\UI\Components\Grids\Passkey\PasskeyGrid { use PasskeyGridTrait { initGrid as initGridTrait; } public function initGrid(DataGrid $grid): void { parent::initGrid($grid); $this->initGridTrait($grid); } }
// app/UI/Portal/Components/Grids/Passkey/PasskeyGridFactory.php <?php declare(strict_types=1); namespace App\UI\Portal\Components\Grids\Passkey; interface PasskeyGridFactory extends \ADT\FancyAdmin\UI\Components\Grids\Passkey\PasskeyGridFactory { public function create(): PasskeyGrid; }
Factory interfaces se registrují automaticky přes stávající search sekce v neonu
(*Factory.php v Model/Queries a UI/Portal/Components).
19.6 Migrace
Knihovna žádnou migraci nedodává — schéma vlastní projekt:
php bin/console migrations:diff php bin/console migrations:migrate
Vytvoří tabulku passkey a přidá sloupec identity.passkey_user_handle.
19.7 Jak to funguje (bezpečnostní poznámky)
- Attestation format
none(standard pro passkeys),residentKey: required(discoverable credentials),userVerification: required - Login je usernameless — prázdné
allowCredentials, klíč se hledá podle credential ID z assertion (credential-first lookup);userHandlese ověřuje protiidentity.passkey_user_handlepřeshash_equals() - Challenge se drží v Nette session, one-shot (po přečtení se maže), expirace 5 minut, oddělené klíče pro registraci a login
- Všechny binárky v JSON jsou base64url (
PublicKeyCredential.toJSON()formát) - Signature counter se ověřuje (
lbuchs/webauthnvyhodí chybu při poklesu — možný klon klíče) - Neaktivní identita a SSO identita se klíčem nepřihlásí; po loginu platí stejný ACL check jako u hesla (customer/backoffice resource)
- Ceremony se spouští jen kliknutím na tlačítko — na server nejde žádný request, dokud uživatel neklikne, takže anonymní návštěvník login stránky nedostane session cookie (challenge se do session zapisuje až v okamžiku ceremony)
20. API klíče (volitelné)
Správa API klíčů pro server-to-server přístup do aplikace. Featura je opt-in — pokud
projekt glue třídy nevytvoří, nic se nikde nezobrazuje a v databázi žádná tabulka nevzniká;
fancyadmin sám na ApiKey nikde nespoléhá.
Co uživatel dostane: stránku s gridem klíčů (název, otisk klíče, účet) a side panelem pro
vytvoření a editaci. Klíč se generuje při vytvoření záznamu (32 alfanumerických znaků) a
zobrazí se jednou ve flash zprávě — v databázi je uložený jen jeho SHA-256 otisk
(sloupec key), takže z databáze klíč zpětně nezískáte. Editací názvu se klíč nemění,
kompromitovaný klíč se řeší smazáním a vytvořením nového.
20.1 Entita
// app/Model/Entities/ApiKey.php <?php declare(strict_types=1); namespace App\Model\Entities; use ADT\FancyAdmin\Model\Entities\ApiKeyTrait; use App\Model\Entities\Abstract\BaseEntity; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] class ApiKey extends BaseEntity implements \ADT\FancyAdmin\Model\Entities\ApiKey { use ApiKeyTrait; }
ApiKeyTrait poskytuje:
| Sloupec | Typ | Popis |
|---|---|---|
name |
VARCHAR(255) | Název klíče |
key |
VARCHAR(255), unique, nullable | SHA-256 otisk klíče |
account |
Account (FK, nullable) | Účet, kterému klíč patří (null = globální klíč) |
Projekt si může přidat vlastní sloupce a traity (IsActive, CreatedAt, CreatedBy, …)
— trait mapuje jen ta tři pole. Díky poli account platí obvyklá pravidla fancyadminu:
AccountFieldListener doplní při persistu vybraný účet a applySecurityFilter /
applyAccountFilter omezí grid na účty přihlášené identity.
20.2 Query + factory
// app/Model/Queries/ApiKeyQuery.php <?php declare(strict_types=1); namespace App\Model\Queries; use ADT\FancyAdmin\Model\Queries\ApiKeyQueryTrait; use App\Model\Entities\ApiKey; use App\Model\Queries\Filters\DefaultFilters; /** * @extends Abstract\BaseQuery<ApiKey> */ class ApiKeyQuery extends Abstract\BaseQuery implements \ADT\FancyAdmin\Model\Queries\ApiKeyQuery { use DefaultFilters; use ApiKeyQueryTrait; protected function getPrimaryEntityAlias(): ?string { return 'e'; } }
// app/Model/Queries/Factories/ApiKeyQueryFactory.php <?php namespace App\Model\Queries\Factories; use App\Model\Queries\ApiKeyQuery; interface ApiKeyQueryFactory extends \ADT\FancyAdmin\Model\Queries\Factories\ApiKeyQueryFactory { public function create(): ApiKeyQuery; }
20.3 Form + grid
// app/UI/Portal/Components/Forms/ApiKey/ApiKeyForm.php <?php declare(strict_types=1); namespace App\UI\Portal\Components\Forms\ApiKey; use ADT\FancyAdmin\UI\Components\Forms\ApiKey\ApiKeyFormTrait; use App\UI\Portal\Components\Forms\Base\BaseForm; class ApiKeyForm extends BaseForm implements \ADT\FancyAdmin\UI\Components\Forms\ApiKey\ApiKeyForm { use ApiKeyFormTrait; }
// app/UI/Portal/Components/Forms/ApiKey/ApiKeyFormFactory.php <?php declare(strict_types=1); namespace App\UI\Portal\Components\Forms\ApiKey; interface ApiKeyFormFactory extends \ADT\FancyAdmin\UI\Components\Forms\ApiKey\ApiKeyFormFactory { public function create(): ApiKeyForm; }
// app/UI/Portal/Components/Grids/ApiKey/ApiKeyGrid.php <?php declare(strict_types=1); namespace App\UI\Portal\Components\Grids\ApiKey; use ADT\FancyAdmin\UI\Components\Grids\ApiKey\ApiKeyGridTrait; use App\UI\Portal\Components\Grids\Base\BaseGrid; class ApiKeyGrid extends BaseGrid implements \ADT\FancyAdmin\UI\Components\Grids\ApiKey\ApiKeyGrid { use ApiKeyGridTrait; }
// app/UI/Portal/Components/Grids/ApiKey/ApiKeyGridFactory.php <?php declare(strict_types=1); namespace App\UI\Portal\Components\Grids\ApiKey; interface ApiKeyGridFactory extends \ADT\FancyAdmin\UI\Components\Grids\ApiKey\ApiKeyGridFactory { public function create(): ApiKeyGrid; }
Sloupec s účtem se v gridu zobrazuje jen identitám s právem na fullData resource,
ostatní vidí jen klíče svého účtu.
Když má projekt na entitě vlastní sloupce, přepíše initForm() a zavolá
addApiKeyFields() (pole klíče bez submitu), aby submit zůstal poslední:
public function initForm(Form $form): void { $this->addApiKeyFields($form); $form->addCheckbox('isAdmin', 'app.forms.apiKey.labels.isAdmin'); $form->addSubmit('submit', 'app.forms.apiKey.labels.submit'); }
Grid se rozšiřuje obvyklým aliasem traitu (ApiKeyGridTrait::initGrid as traitInitGrid).
20.4 Presenter
// app/UI/Portal/Backoffice/Presenters/ApiKeys/ApiKeysPresenter.php <?php declare(strict_types=1); namespace App\UI\Portal\Backoffice\Presenters\ApiKeys; use ADT\FancyAdmin\UI\Presenters\ApiKeys\ApiKeysPresenterTrait; use App\UI\Portal\Presenters\AuthPresenter; class ApiKeysPresenter extends AuthPresenter { use ApiKeysPresenterTrait; }
Stejný presenter lze vytvořit i v zákaznické části (Customer), pak si každý účet spravuje
vlastní klíče. Nezapomeňte na ACL resource (portalBackoffice.apiKeys, resp.
portalCustomer.apiKeys) a položku v NavbarMenuFactory.
20.5 Migrace
Knihovna žádnou migraci nedodává — schéma vlastní projekt:
php bin/console migrations:diff php bin/console migrations:migrate
Vytvoří tabulku api_key.
20.6 Ověření klíče
Klíč přijatý v požadavku se ověřuje přes query object, hashování řeší ApiKeyQueryTrait:
$apiKey = $this->apiKeyQueryFactory->create() ->disableSecurityFilter() ->disableAccountFilter() ->byRawKey($rawKeyZHlavicky) ->fetchOneOrNull();
Hash se dá spočítat i přímo — ADT\FancyAdmin\Model\Security\ApiKeyHasher::hash($rawKey),
generování nového klíče ApiKeyHasher::generateRawKey().
Pokud projekt migruje ze starších klíčů uložených jiným způsobem (např. password_hash
ve vlastním sloupci hash), může si sloupec hash v entitě nechat a při prvním úspěšném
ověření dopsat do key hodnotu ApiKeyHasher::hash($rawKey) — od té chvíle stačí
byRawKey() a starý sloupec lze časem zrušit.
Shrnutí
| Krok | Co | Proč |
|---|---|---|
| BaseEntity | Abstraktní třída s Identifier trait | Sdílený základ pro všechny entity |
| 9 entit | Identity, Account, Profile, AclRole, AclResource, Acl, Configuration, File, GridFilter | Fancyadmin vyžaduje všechny pro funkční ACL, auth, grid filtry, konfiguraci |
| AclResourceNameEnum | Enum implementující Nette\Security\Resource | Definice ACL resources pro fancyadmin config |
| BaseQuery + 6 Query tříd | QueryObject pattern s fancyadmin traits | Fancyadmin interně používá query factories pro přístup k datům |
| 6 QueryFactory interfaces | Rozšiřují fancyadmin factory interfaces | DI autowiring pro query třídy |
| Authenticator | Rozšiřuje OnetimeTokenAuthenticator | Autentizace přes Doctrine (email + heslo, OTP) |
| SecurityUser | Rozšiřuje ADT\DoctrineAuthenticator\SecurityUser | Session management, isAllowed(), isAdmin() |
| Permission | Rozšiřuje fancyadmin Permission | ACL authorizátor |
| EntityManager | Rozšiřuje ADT\DoctrineComponents\EntityManager | Rozšířený EntityManager s helper metodami |
| 3 Listeners | CreatedBy, AccountField, SelectAccount | Automatické nastavování created_by, account polí při persistu |
| Translator | Rozšiřuje Contributte\Translation\Translator | Překlady |
| RouterFactory | Integruje FancyAdminRouter | Sign routes, portal routes |
| Portal presentery | BasePresenter + AuthPresenter s fancyadmin traits | Admin layout, auth check, side panel |
| Passkey glue třídy | Entita Passkey, PasskeyQuery + factory, PasskeyForm + factory, PasskeyGrid + factory | Přihlašování přes passkeys (WebAuthn) — viz sekce 19 |
| ApiKey glue třídy | Entita ApiKey, ApiKeyQuery + factory, ApiKeyForm + factory, ApiKeyGrid + factory, ApiKeysPresenter | Správa API klíčů pro server-to-server přístup — viz sekce 20 |