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
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

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

Expand Down
14 changes: 2 additions & 12 deletions src/Model/Entities/IdentityTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -98,7 +97,6 @@ public function __construct()
{
$this->profiles = new ArrayCollection();
$this->roles = new ArrayCollection();
$this->passkeys = new ArrayCollection();
}

public function getPassword(): ?string
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions src/Model/FancyAdmin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {}
Expand Down Expand Up @@ -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;
Expand Down
44 changes: 40 additions & 4 deletions src/Model/Security/Passkey/PasskeyService.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Nette\Http\Session;
use Nette\Http\SessionSection;
use Nette\Localization\Translator;
use RuntimeException;
use stdClass;
use Throwable;

Expand All @@ -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,
) {}

/**
Expand All @@ -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
Expand All @@ -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();
}

Expand Down Expand Up @@ -97,6 +102,7 @@ public function processRegistration(
?array $transports = null,
): Passkey
{
$this->assertEnabled();
$this->assertNotSso($identity);

$name = $this->normalizeName($name);
Expand All @@ -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'));
}

Expand Down Expand Up @@ -154,6 +160,8 @@ public function processRegistration(
*/
public function getLoginArgs(): stdClass
{
$this->assertEnabled();

$webAuthn = $this->createWebAuthn();
$args = $webAuthn->getGetArgs(
[],
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
*/
Expand Down
2 changes: 2 additions & 0 deletions src/UI/Components/Forms/SignIn/SignInForm.latte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
{/define}

{define section-passkey}
{if $isPasskeyEnabled}
<div
class="passkey-login mt-3"
data-adt-fancyadmin-passkey-login
Expand All @@ -19,4 +20,5 @@
</button>
<div class="alert alert-danger mt-2 d-none" data-passkey-error></div>
</div>
{/if}
{/define}
5 changes: 4 additions & 1 deletion src/UI/Components/Forms/SignIn/SignInFormTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
24 changes: 18 additions & 6 deletions src/UI/Presenters/Account/AccountPresenterTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,6 +23,7 @@
use ADT\FancyAdmin\UI\Presenters\PresenterTrait;
use Nette\Utils\Json;
use Nette\Utils\JsonException;
use RuntimeException;

trait AccountPresenterTrait
{
Expand All @@ -32,7 +33,6 @@ trait AccountPresenterTrait
use PersonalDataFormFactoryInject;
use ChangePasswordFormFactoryInject;
use FancyAdminInject;
use PasskeyFormFactoryInject;
use PasskeyServiceInject;
use TranslatorInject;

Expand All @@ -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');
}

Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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());
}
}
18 changes: 10 additions & 8 deletions src/UI/Presenters/Account/default.latte
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,19 @@
</div>
</div>

<div class="header mt-4">
<h2 class="header-title">
{_fcadmin.passkeys.account.title}
</h2>
{if $isPasskeyEnabled}
<div class="header mt-4">
<h2 class="header-title">
{_fcadmin.passkeys.account.title}
</h2>

<div class="header-actions">
<a n:href="addPasskey!" class="btn btn-primary ajax">{_fcadmin.passkeys.account.add}</a>
<div class="header-actions">
<a n:href="addPasskey!" class="btn btn-primary ajax">{_fcadmin.passkeys.account.add}</a>
</div>
</div>
</div>

{control passkeyGrid}
{control passkeyGrid}
{/if}

<div class="header mt-4">
<h2 class="header-title">
Expand Down