From 9032a9ec457b765390f5e5487fa98af26c5a36a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxmili=C3=A1n=20Holomek?= Date: Tue, 4 Aug 2026 12:19:29 +0200 Subject: [PATCH] refactor(passkey): extract passkey logic into dedicated trait and interface - Extract passkey-related ORM fields and methods from IdentityTrait into new IdentityPasskeysTrait - Add HasPasskeys interface under Traits namespace for type-safe passkey contract - Remove passkey methods from Identity interface (getPasskeyUserHandle, setPasskeyUserHandle) - Update FancyAdminExtension imports to reference new IdentityPasskeysTrait and HasPasskeys --- README.md | 25 ++++++++-- src/DI/FancyAdminExtension.php | 3 ++ src/Model/Entities/Identity.php | 3 -- src/Model/Entities/IdentityPasskeysTrait.php | 49 +++++++++++++++++++ src/Model/Entities/IdentityTrait.php | 22 --------- src/Model/Entities/Traits/HasPasskeys.php | 15 ++++++ src/Model/Security/Passkey/PasskeyService.php | 20 ++++++-- 7 files changed, 105 insertions(+), 32 deletions(-) create mode 100644 src/Model/Entities/IdentityPasskeysTrait.php create mode 100644 src/Model/Entities/Traits/HasPasskeys.php diff --git a/README.md b/README.md index a05a536..cb23e05 100644 --- a/README.md +++ b/README.md @@ -1249,7 +1249,8 @@ alternativa k heslu (žádné passkey-only účty). Identity navázané na Keycl 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` +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. @@ -1283,7 +1284,23 @@ fancyadmin: Povinné je jen `passkeyEnabled` (pro zapnutí), `passkeyRpId` a `passkeyRpName` jsou volitelné. -### 19.3 Entita Passkey +### 19.3 Entity — Passkey + rozšíření Identity + +Entita Identity musí použít `IdentityPasskeysTrait` a implementovat `HasPasskeys` +(PasskeyService na ten interface spoléhá): + +```php +// 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; +} +``` ```php // app/Model/Entities/Passkey.php @@ -1319,9 +1336,9 @@ class Passkey extends BaseEntity implements \ADT\FancyAdmin\Model\Entities\Passk | `createdAt` | DATETIME | Vytvořeno | | `lastUsedAt` | DATETIME, nullable | Poslední přihlášení klíčem | -`IdentityTrait` navíc přidává do tabulky `identity` nullable sloupec `passkey_user_handle` +`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). +(autentikátoru se nikdy neposílá interní ID identity) — a inverzní vazbu `getPasskeys()`. ### 19.4 Query + factory diff --git a/src/DI/FancyAdminExtension.php b/src/DI/FancyAdminExtension.php index a21cf78..82046be 100644 --- a/src/DI/FancyAdminExtension.php +++ b/src/DI/FancyAdminExtension.php @@ -13,7 +13,9 @@ use ADT\FancyAdmin\Model\Entities\AclRole; use ADT\FancyAdmin\Model\Entities\AclRoleTrait; use ADT\FancyAdmin\Model\Entities\Identity; +use ADT\FancyAdmin\Model\Entities\IdentityPasskeysTrait; use ADT\FancyAdmin\Model\Entities\IdentityTrait; +use ADT\FancyAdmin\Model\Entities\Traits\HasPasskeys; use ADT\FancyAdmin\Model\Entities\Profile; use ADT\FancyAdmin\Model\Entities\ProfileTrait; use ADT\FancyAdmin\Model\FancyAdmin; @@ -196,6 +198,7 @@ private function validateTraitInterfaceCompliance(): void AclResourceTrait::class => AclResource::class, AclRoleTrait::class => AclRole::class, IdentityTrait::class => Identity::class, + IdentityPasskeysTrait::class => HasPasskeys::class, ProfileTrait::class => Profile::class, ]; diff --git a/src/Model/Entities/Identity.php b/src/Model/Entities/Identity.php index f3a6171..03011ff 100644 --- a/src/Model/Entities/Identity.php +++ b/src/Model/Entities/Identity.php @@ -57,9 +57,6 @@ public function setSelectedAccount(?Account $selectedAccount): static; public function getSso(): ?Sso; public function setSso(?Sso $sso): static; - public function getPasskeyUserHandle(): ?string; - public function setPasskeyUserHandle(?string $passkeyUserHandle): static; - public function getFullName(): string; public function getGravatar(): string; public function getAccounts(): array; diff --git a/src/Model/Entities/IdentityPasskeysTrait.php b/src/Model/Entities/IdentityPasskeysTrait.php new file mode 100644 index 0000000..81b85d3 --- /dev/null +++ b/src/Model/Entities/IdentityPasskeysTrait.php @@ -0,0 +1,49 @@ + true])] + protected mixed $passkeyUserHandle = null; + + /** + * @return Passkey[] + */ + public function getPasskeys(): array + { + // konstruktor s inicializací kolekcí žije v IdentityTrait — u nové entity + // je property neinicializovaná, ??= ji bezpečně doplní + $this->passkeys ??= new ArrayCollection(); + return $this->passkeys->toArray(); + } + + public function getPasskeyUserHandle(): ?string + { + if ($this->passkeyUserHandle === null) { + return null; + } + if (is_resource($this->passkeyUserHandle)) { + rewind($this->passkeyUserHandle); + return (string) stream_get_contents($this->passkeyUserHandle); + } + return (string) $this->passkeyUserHandle; + } + + public function setPasskeyUserHandle(?string $passkeyUserHandle): static + { + $this->passkeyUserHandle = $passkeyUserHandle; + return $this; + } +} diff --git a/src/Model/Entities/IdentityTrait.php b/src/Model/Entities/IdentityTrait.php index 760a280..e229e43 100644 --- a/src/Model/Entities/IdentityTrait.php +++ b/src/Model/Entities/IdentityTrait.php @@ -77,11 +77,6 @@ abstract public function getId(); #[LoggableProperty] protected Collection $roles; - // 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; - #[ORM\Column(nullable: true)] #[LoggableProperty] protected ?DateTimeImmutable $anonymizedAt = null; @@ -349,21 +344,4 @@ public function getIdentity(): Identity return $this; } - public function getPasskeyUserHandle(): ?string - { - if ($this->passkeyUserHandle === null) { - return null; - } - if (is_resource($this->passkeyUserHandle)) { - rewind($this->passkeyUserHandle); - return (string) stream_get_contents($this->passkeyUserHandle); - } - return (string) $this->passkeyUserHandle; - } - - public function setPasskeyUserHandle(?string $passkeyUserHandle): static - { - $this->passkeyUserHandle = $passkeyUserHandle; - return $this; - } } diff --git a/src/Model/Entities/Traits/HasPasskeys.php b/src/Model/Entities/Traits/HasPasskeys.php new file mode 100644 index 0000000..a8347e3 --- /dev/null +++ b/src/Model/Entities/Traits/HasPasskeys.php @@ -0,0 +1,15 @@ +assertEnabled(); $this->assertNotSso($identity); + $identity = $this->assertHasPasskeys($identity); // Lazy vygenerování opaque user handle — autentikátoru nikdy neposíláme interní ID identity if ($identity->getPasskeyUserHandle() === null) { @@ -64,8 +66,7 @@ public function getRegistrationArgs(Identity $identity): stdClass } $excludeCredentialIds = []; - /** @var Passkey $passkey */ - foreach ($this->getPasskeyQueryFactory()->create()->disableSecurityFilter()->disableAccountFilter()->byIdentity($identity)->fetch() as $passkey) { + foreach ($identity->getPasskeys() as $passkey) { $excludeCredentialIds[] = $passkey->getCredentialId(); } @@ -209,7 +210,7 @@ public function processLogin( throw new PasskeyException($this->translator->translate('fcadmin.passkeys.errors.unknownKey')); } - $identity = $passkey->getIdentity(); + $identity = $this->assertHasPasskeys($passkey->getIdentity()); if ($userHandle !== null && $userHandle !== '') { $storedHandle = $identity->getPasskeyUserHandle(); @@ -262,6 +263,19 @@ public function assertEnabled(): void } } + /** + * @throws RuntimeException pokud entita Identity nepodporuje passkeys — + * chyba konfigurace, ne uživatele + */ + protected function assertHasPasskeys(Identity $identity): Identity&HasPasskeys + { + if (!$identity instanceof HasPasskeys) { + throw new RuntimeException('Entita ' . $identity::class . ' neimplementuje ' . HasPasskeys::class . ' — přidejte `use IdentityPasskeysTrait` a `implements HasPasskeys` podle README (sekce 19).'); + } + + return $identity; + } + /** * @throws RuntimeException pokud projekt nemá zaregistrovanou passkey infrastrukturu — * chyba konfigurace, ne uživatele (FancyAdminExtension ji při passkeyEnabled hlídá už při kompilaci)