From dc9d1cba4170e51b5f3a038a1b30f22f45ae27da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxmili=C3=A1n=20Holomek?= Date: Tue, 4 Aug 2026 08:31:33 +0200 Subject: [PATCH] feat(passkey): add passkeyEnabled flag and conditional passkey UI - Replace implicit passkey availability with explicit `passkeyEnabled` boolean on FancyAdmin config object - Wrap passkey section in account view with `{if $isPasskeyEnabled}` so it only renders when the feature is configured - Inject PasskeyFormFactory directly via use statement instead of through a separate inject trait - Add PasskeyQueryFactory import to DI extension - Add RuntimeException import to AccountPresenterTrait --- README.md | 22 +++++++--- src/DI/FancyAdminExtension.php | 9 ++++ src/Model/Entities/Identity.php | 4 -- src/Model/Entities/IdentityTrait.php | 14 +----- src/Model/FancyAdmin.php | 6 +++ src/Model/Security/Passkey/PasskeyService.php | 44 +++++++++++++++++-- .../Components/Forms/SignIn/SignInForm.latte | 2 + .../Forms/SignIn/SignInFormTrait.php | 5 ++- .../Account/AccountPresenterTrait.php | 24 +++++++--- src/UI/Presenters/Account/default.latte | 18 ++++---- 10 files changed, 108 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index abf7f0d..a05a536 100644 --- a/README.md +++ b/README.md @@ -1240,10 +1240,18 @@ Pro použití vlastní třídy je potřeba rozšířit `KeycloakManager::createI ## 19. Passkeys (WebAuthn) Fancyadmin podporuje přihlašování přes passkeys (WebAuthn) postavené na knihovně -[lbuchs/webauthn](https://github.com/lbuchs/WebAuthn). Passkeys jsou **vždy zapnuté** — -žádný config flag; passkey je vždy jen alternativa k heslu (žádné passkey-only účty). -Identity navázané na Keycloak SSO se přes passkey přihlásit ani registrovat klíč nemohou -(autorita pro SSO účty je Keycloak). +[lbuchs/webauthn](https://github.com/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). Identity navázané na Keycloak SSO se přes +passkey přihlásit ani registrovat klíč nemohou (autorita pro SSO účty je Keycloak). + +Při `passkeyEnabled: false` (default) projekt **nemusí mít žádné passkey třídy** — +entitu, query, factory, form ani grid (sekce 19.3-19.5). 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: @@ -1260,17 +1268,21 @@ Co uživatel dostane: - **rpId = doména admin hostu** — klíče jsou svázané s doménou; změna domény znamená ztrátu registrovaných klíčů. Default se odvozuje z `adminHostPath`. -### 19.2 NEON konfigurace (volitelné) +### 19.2 NEON konfigurace ```neon 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 Entita Passkey ```php diff --git a/src/DI/FancyAdminExtension.php b/src/DI/FancyAdminExtension.php index e585d25..a21cf78 100644 --- a/src/DI/FancyAdminExtension.php +++ b/src/DI/FancyAdminExtension.php @@ -17,6 +17,7 @@ use ADT\FancyAdmin\Model\Entities\Profile; use ADT\FancyAdmin\Model\Entities\ProfileTrait; use ADT\FancyAdmin\Model\FancyAdmin; +use ADT\FancyAdmin\Model\Queries\Factories\PasskeyQueryFactory; use ADT\FancyAdmin\Model\Security\Authenticator; use ADT\FancyAdmin\Model\Security\Keycloak\KeycloakManager; use ADT\FancyAdmin\Model\Security\Passkey\PasskeyService; @@ -64,6 +65,7 @@ public function getConfigSchema(): Schema 'keycloakEnabled' => Expect::bool()->default(false), // Vypnutí validace TLS certifikátu Keycloak serveru — POUZE pro lokální vývoj (self-signed cert) 'keycloakVerifySsl' => Expect::bool()->default(true), + 'passkeyEnabled' => Expect::bool()->default(false), // WebAuthn Relying Party ID (doména) — když není nastaveno, odvodí se za běhu host z adminHostPath 'passkeyRpId' => Expect::string()->nullable()->default(null), // WebAuthn Relying Party name — když není nastaveno, použije se projectName @@ -126,6 +128,7 @@ public function loadConfiguration(): void 'context' => $this->config->context, 'colors' => (array) $this->config->colors, 'keycloakEnabled' => $this->config->keycloakEnabled, + 'passkeyEnabled' => $this->config->passkeyEnabled, 'passkeyRpId' => $this->config->passkeyRpId, 'passkeyRpName' => $this->config->passkeyRpName, ]); @@ -179,6 +182,12 @@ public function beforeCompile(): void $fancyAdminDef = $builder->getDefinition($this->prefix('administration')); $fancyAdminDef->addSetup('setKeycloakManager', [$this->prefix('@keycloakManager')]); } + + // passkeyEnabled vyžaduje passkey infrastrukturu v projektu — srozumitelná chyba + // při kompilaci kontejneru místo kryptické autowiring hlášky za běhu + if ($this->config->passkeyEnabled && $builder->getByType(PasskeyQueryFactory::class) === null) { + throw new RuntimeException('fancyadmin: passkeyEnabled je zapnuté, ale v projektu chybí implementace ' . PasskeyQueryFactory::class . '. Vytvořte entitu Passkey, PasskeyQuery, PasskeyQueryFactory, PasskeyForm a PasskeyGrid podle README (sekce 19), nebo passkeys vypněte.'); + } } private function validateTraitInterfaceCompliance(): void diff --git a/src/Model/Entities/Identity.php b/src/Model/Entities/Identity.php index e1dce39..f3a6171 100644 --- a/src/Model/Entities/Identity.php +++ b/src/Model/Entities/Identity.php @@ -57,10 +57,6 @@ public function setSelectedAccount(?Account $selectedAccount): static; public function getSso(): ?Sso; public function setSso(?Sso $sso): static; - /** - * @return Passkey[] - */ - public function getPasskeys(): array; public function getPasskeyUserHandle(): ?string; public function setPasskeyUserHandle(?string $passkeyUserHandle): static; diff --git a/src/Model/Entities/IdentityTrait.php b/src/Model/Entities/IdentityTrait.php index f74cc02..760a280 100644 --- a/src/Model/Entities/IdentityTrait.php +++ b/src/Model/Entities/IdentityTrait.php @@ -77,9 +77,8 @@ abstract public function getId(); #[LoggableProperty] protected Collection $roles; - #[ORM\OneToMany(targetEntity: 'Passkey', mappedBy: 'identity')] - protected Collection $passkeys; - + // Vazba na passkeys je jen jednosměrná (Passkey ManyToOne identity v PasskeyTrait) — + // entita Passkey je v projektu volitelná, Identity na ní nesmí záviset #[ORM\Column(type: 'binary', length: 32, nullable: true, options: ['fixed' => true])] protected mixed $passkeyUserHandle = null; @@ -98,7 +97,6 @@ public function __construct() { $this->profiles = new ArrayCollection(); $this->roles = new ArrayCollection(); - $this->passkeys = new ArrayCollection(); } public function getPassword(): ?string @@ -351,14 +349,6 @@ public function getIdentity(): Identity return $this; } - /** - * @return Passkey[] - */ - public function getPasskeys(): array - { - return $this->passkeys->toArray(); - } - public function getPasskeyUserHandle(): ?string { if ($this->passkeyUserHandle === null) { diff --git a/src/Model/FancyAdmin.php b/src/Model/FancyAdmin.php index 29941fe..5f2d36e 100644 --- a/src/Model/FancyAdmin.php +++ b/src/Model/FancyAdmin.php @@ -29,6 +29,7 @@ public function __construct( protected array $jsComponentsConfig = [], protected array $colors = [], protected bool $keycloakEnabled = false, + protected bool $passkeyEnabled = false, protected ?string $passkeyRpId = null, protected ?string $passkeyRpName = null, ) {} @@ -174,6 +175,11 @@ public function isKeycloakEnabled(): bool return $this->keycloakEnabled; } + public function isPasskeyEnabled(): bool + { + return $this->passkeyEnabled; + } + public function getPasskeyRpId(): ?string { return $this->passkeyRpId; diff --git a/src/Model/Security/Passkey/PasskeyService.php b/src/Model/Security/Passkey/PasskeyService.php index 83886f4..173bdfe 100644 --- a/src/Model/Security/Passkey/PasskeyService.php +++ b/src/Model/Security/Passkey/PasskeyService.php @@ -16,6 +16,7 @@ use Nette\Http\Session; use Nette\Http\SessionSection; use Nette\Localization\Translator; +use RuntimeException; use stdClass; use Throwable; @@ -39,8 +40,10 @@ public function __construct( protected EntityManager $em, protected Session $session, protected FancyAdmin $fancyAdmin, - protected PasskeyQueryFactory $passkeyQueryFactory, protected Translator $translator, + // nullable — passkey infrastruktura (entita, query, factory) je v projektu volitelná, + // služba se ale musí dát vytvořit vždy (injectuje se v traitech přes PasskeyServiceInject) + protected ?PasskeyQueryFactory $passkeyQueryFactory = null, ) {} /** @@ -51,6 +54,7 @@ public function __construct( */ public function getRegistrationArgs(Identity $identity): stdClass { + $this->assertEnabled(); $this->assertNotSso($identity); // Lazy vygenerování opaque user handle — autentikátoru nikdy neposíláme interní ID identity @@ -60,7 +64,8 @@ public function getRegistrationArgs(Identity $identity): stdClass } $excludeCredentialIds = []; - foreach ($identity->getPasskeys() as $passkey) { + /** @var Passkey $passkey */ + foreach ($this->getPasskeyQueryFactory()->create()->disableSecurityFilter()->disableAccountFilter()->byIdentity($identity)->fetch() as $passkey) { $excludeCredentialIds[] = $passkey->getCredentialId(); } @@ -97,6 +102,7 @@ public function processRegistration( ?array $transports = null, ): Passkey { + $this->assertEnabled(); $this->assertNotSso($identity); $name = $this->normalizeName($name); @@ -117,7 +123,7 @@ public function processRegistration( $credentialId = $data->credentialId; - if ($this->passkeyQueryFactory->create()->disableSecurityFilter()->disableAccountFilter()->byCredentialId($credentialId)->count() > 0) { + if ($this->getPasskeyQueryFactory()->create()->disableSecurityFilter()->disableAccountFilter()->byCredentialId($credentialId)->count() > 0) { throw new PasskeyException($this->translator->translate('fcadmin.passkeys.errors.alreadyRegistered')); } @@ -154,6 +160,8 @@ public function processRegistration( */ public function getLoginArgs(): stdClass { + $this->assertEnabled(); + $webAuthn = $this->createWebAuthn(); $args = $webAuthn->getGetArgs( [], @@ -186,10 +194,12 @@ public function processLogin( ?string $userHandle = null, ): Identity { + $this->assertEnabled(); + $challenge = $this->consumeChallenge(PasskeySessionSection::GET_CHALLENGE); /** @var Passkey|null $passkey */ - $passkey = $this->passkeyQueryFactory->create() + $passkey = $this->getPasskeyQueryFactory()->create() ->disableSecurityFilter() ->disableAccountFilter() ->byCredentialId($credentialId) @@ -239,6 +249,32 @@ public function processLogin( return $identity; } + /** + * Server-side vynucení opt-in configu (fancyadmin: passkeyEnabled) — + * musí fungovat i kdyby UI někde zůstalo viditelné. + * + * @throws PasskeyException pokud passkeys nejsou v configu zapnuté + */ + public function assertEnabled(): void + { + if (!$this->fancyAdmin->isPasskeyEnabled()) { + throw new PasskeyException($this->translator->translate('fcadmin.passkeys.errors.unavailable')); + } + } + + /** + * @throws RuntimeException pokud projekt nemá zaregistrovanou passkey infrastrukturu — + * chyba konfigurace, ne uživatele (FancyAdminExtension ji při passkeyEnabled hlídá už při kompilaci) + */ + protected function getPasskeyQueryFactory(): PasskeyQueryFactory + { + if ($this->passkeyQueryFactory === null) { + throw new RuntimeException('V projektu chybí implementace ' . PasskeyQueryFactory::class . ' — vytvořte entitu Passkey, query a factory podle README (sekce 19).'); + } + + return $this->passkeyQueryFactory; + } + /** * @throws PasskeyException pokud je identita navázaná na Keycloak SSO */ diff --git a/src/UI/Components/Forms/SignIn/SignInForm.latte b/src/UI/Components/Forms/SignIn/SignInForm.latte index fdc412d..37ccbe1 100644 --- a/src/UI/Components/Forms/SignIn/SignInForm.latte +++ b/src/UI/Components/Forms/SignIn/SignInForm.latte @@ -5,6 +5,7 @@ {/define} {define section-passkey} + {if $isPasskeyEnabled}
+ {/if} {/define} diff --git a/src/UI/Components/Forms/SignIn/SignInFormTrait.php b/src/UI/Components/Forms/SignIn/SignInFormTrait.php index 95e4d4e..3b4b827 100644 --- a/src/UI/Components/Forms/SignIn/SignInFormTrait.php +++ b/src/UI/Components/Forms/SignIn/SignInFormTrait.php @@ -55,9 +55,12 @@ public function initForm(Form $form): void $form->addSubmit('submit', 'fcadmin.forms.signIn.labels.logIn') ->getControlPrototype()->class[] = 'w-100'; - $form->addSection(name: 'passkey'); + if ($this->_fancyAdmin->isPasskeyEnabled()) { + $form->addSection(name: 'passkey'); + } $this->getTemplate()->isLostPasswordEnabled = $this->_fancyAdmin->isLostPasswordEnabled(); + $this->getTemplate()->isPasskeyEnabled = $this->_fancyAdmin->isPasskeyEnabled(); // Keycloak email check — přidá data atribut pro JS kontrolu if ($this->_fancyAdmin->isKeycloakEnabled()) { diff --git a/src/UI/Presenters/Account/AccountPresenterTrait.php b/src/UI/Presenters/Account/AccountPresenterTrait.php index dcad63f..e207c00 100644 --- a/src/UI/Presenters/Account/AccountPresenterTrait.php +++ b/src/UI/Presenters/Account/AccountPresenterTrait.php @@ -7,13 +7,13 @@ use ADT\FancyAdmin\DI\Injects\AuthenticatorInject; use ADT\FancyAdmin\DI\Injects\ChangePasswordFormFactoryInject; use ADT\FancyAdmin\DI\Injects\FancyAdminInject; -use ADT\FancyAdmin\DI\Injects\PasskeyFormFactoryInject; use ADT\FancyAdmin\DI\Injects\PasskeyServiceInject; use ADT\FancyAdmin\DI\Injects\PersonalDataFormFactoryInject; use ADT\FancyAdmin\DI\Injects\SecurityUserInject; use ADT\FancyAdmin\DI\Injects\TranslatorInject; use ADT\FancyAdmin\Model\Security\Passkey\PasskeyException; use ADT\FancyAdmin\Model\Security\Passkey\PasskeyService; +use ADT\FancyAdmin\UI\Components\Forms\Passkey\PasskeyFormFactory; use ADT\FancyAdmin\UI\Components\Controls\SidePanel\SidePanelControl; use ADT\FancyAdmin\UI\Components\Controls\SidePanel\SidePanelControlFactory; use ADT\FancyAdmin\UI\Components\Grids\Passkey\PasskeyGrid; @@ -23,6 +23,7 @@ use ADT\FancyAdmin\UI\Presenters\PresenterTrait; use Nette\Utils\Json; use Nette\Utils\JsonException; +use RuntimeException; trait AccountPresenterTrait { @@ -32,7 +33,6 @@ trait AccountPresenterTrait use PersonalDataFormFactoryInject; use ChangePasswordFormFactoryInject; use FancyAdminInject; - use PasskeyFormFactoryInject; use PasskeyServiceInject; use TranslatorInject; @@ -46,6 +46,7 @@ public function actionDefault(): void } $this->getTemplate()->identity = $this->_securityUser->getIdentity(); + $this->getTemplate()->isPasskeyEnabled = $this->_fancyAdmin->isPasskeyEnabled(); $this->getTemplate()->setFile(__DIR__ . '/default.latte'); } @@ -101,15 +102,22 @@ public function createComponentSessionGrid(SessionGridFactory $factory): Session return $factory->create(); } - public function createComponentPasskeyGrid(PasskeyGridFactory $factory): PasskeyGrid + // Passkey factories jsou nullable — projekt bez passkey tříd je nemá zaregistrované + // a kdyby/autowired validuje parametry všech createComponent* metod už při attachi presenteru + public function createComponentPasskeyGrid(?PasskeyGridFactory $factory = null): PasskeyGrid { + if ($factory === null) { + throw new RuntimeException('V projektu chybí implementace ' . PasskeyGridFactory::class . ' — vytvořte passkey třídy podle README (sekce 19).'); + } + return $factory->create(); } public function handleAddPasskey(): void { - // SSO uživatel klíč registrovat nesmí — panel se ani neotevře + // Vypnutá featura nebo SSO uživatel — panel se ani neotevře try { + $this->_passkeyService->assertEnabled(); $this->_passkeyService->assertNotSso($this->_securityUser->getIdentity()); } catch (PasskeyException $e) { $this->flashMessageError($e->getMessage()); @@ -171,9 +179,13 @@ public function handlePasskeyRegisterVerify(): void $this->getPresenter()->redirect('this'); } - public function createComponentAddPasskeySidePanel(SidePanelControlFactory $factory): SidePanelControl + public function createComponentAddPasskeySidePanel(SidePanelControlFactory $factory, ?PasskeyFormFactory $passkeyFormFactory = null): SidePanelControl { + if ($passkeyFormFactory === null) { + throw new RuntimeException('V projektu chybí implementace ' . PasskeyFormFactory::class . ' — vytvořte passkey třídy podle README (sekce 19).'); + } + return $factory->create() - ->setFormFactory(fn() => $this->_passkeyFormFactory->create()); + ->setFormFactory(fn() => $passkeyFormFactory->create()); } } diff --git a/src/UI/Presenters/Account/default.latte b/src/UI/Presenters/Account/default.latte index 0d1e760..746a43a 100644 --- a/src/UI/Presenters/Account/default.latte +++ b/src/UI/Presenters/Account/default.latte @@ -38,17 +38,19 @@ -
-

- {_fcadmin.passkeys.account.title} -

+{if $isPasskeyEnabled} +
+

+ {_fcadmin.passkeys.account.title} +

- -
-{control passkeyGrid} + {control passkeyGrid} +{/if}