Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions src/DI/FancyAdminExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
];

Expand Down
3 changes: 0 additions & 3 deletions src/Model/Entities/Identity.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
49 changes: 49 additions & 0 deletions src/Model/Entities/IdentityPasskeysTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace ADT\FancyAdmin\Model\Entities;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

trait IdentityPasskeysTrait
{
#[ORM\OneToMany(targetEntity: 'Passkey', mappedBy: 'identity')]
protected Collection $passkeys;

// Náhodný opaque WebAuthn user handle, generovaný při registraci prvního klíče —
// autentikátoru se nikdy neposílá interní ID identity
#[ORM\Column(type: 'binary', length: 32, nullable: true, options: ['fixed' => 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;
}
}
22 changes: 0 additions & 22 deletions src/Model/Entities/IdentityTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
15 changes: 15 additions & 0 deletions src/Model/Entities/Traits/HasPasskeys.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace ADT\FancyAdmin\Model\Entities\Traits;

use ADT\FancyAdmin\Model\Entities\Passkey;

interface HasPasskeys
{
/**
* @return Passkey[]
*/
public function getPasskeys(): array;
public function getPasskeyUserHandle(): ?string;
public function setPasskeyUserHandle(?string $passkeyUserHandle): static;
}
20 changes: 17 additions & 3 deletions src/Model/Security/Passkey/PasskeyService.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use ADT\DoctrineComponents\EntityManager;
use ADT\FancyAdmin\Model\Entities\Identity;
use ADT\FancyAdmin\Model\Entities\Passkey;
use ADT\FancyAdmin\Model\Entities\Traits\HasPasskeys;
use ADT\FancyAdmin\Model\FancyAdmin;
use ADT\FancyAdmin\Model\Queries\Factories\PasskeyQueryFactory;
use DateTimeImmutable;
Expand Down Expand Up @@ -56,6 +57,7 @@ public function getRegistrationArgs(Identity $identity): stdClass
{
$this->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) {
Expand All @@ -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();
}

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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)
Expand Down