From 56bf65019c5ec7839f345ae2b9f3a87ac7df7783 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxmili=C3=A1n=20Holomek?= Date: Mon, 3 Aug 2026 10:42:08 +0200 Subject: [PATCH] feat(auth): add WebAuthn passkey authentication support - Integrate lbuchs/webauthn library for passkey support - Add PasskeyService, PasskeyException, PasskeySessionSection - Add PasskeyForm and PasskeyGrid components with factories - Add Passkey entity and trait for user identity - Add PasskeyQuery with factory for data access - Wire passkey JS components in app.js for login and management - Add PasskeyFormFactoryInject, PasskeyServiceInject, PasskeyQueryFactoryInject - Extend AccountPresenterTrait with passkey registration/management actions --- ASVS/V06-Authentication.md | 12 +- README.md | 222 ++++++++++++ assets/js/app.js | 2 + composer.json | 3 +- src/DI/FancyAdminExtension.php | 10 + src/DI/Injects/PasskeyFormFactoryInject.php | 12 + src/DI/Injects/PasskeyQueryFactoryInject.php | 12 + src/DI/Injects/PasskeyServiceInject.php | 12 + src/Model/Entities/Identity.php | 7 + src/Model/Entities/IdentityTrait.php | 33 ++ src/Model/Entities/Passkey.php | 45 +++ src/Model/Entities/PasskeyTrait.php | 170 +++++++++ src/Model/FancyAdmin.php | 12 + .../Queries/Factories/PasskeyQueryFactory.php | 10 + src/Model/Queries/PasskeyQuery.php | 12 + src/Model/Queries/PasskeyQueryTrait.php | 25 ++ .../Security/Passkey/PasskeyException.php | 11 + src/Model/Security/Passkey/PasskeyService.php | 337 ++++++++++++++++++ .../Passkey/PasskeySessionSection.php | 30 ++ .../Components/Forms/Passkey/PasskeyForm.php | 7 + .../Forms/Passkey/PasskeyFormFactory.php | 8 + .../Forms/Passkey/PasskeyFormTrait.php | 49 +++ src/UI/Components/Forms/Passkey/index.js | 212 +++++++++++ .../Components/Forms/SignIn/SignInForm.latte | 19 +- .../Forms/SignIn/SignInFormTrait.php | 106 ++++++ src/UI/Components/Forms/SignIn/index.js | 176 +++++++++ .../Grids/Passkey/PasskeyGrid.latte | 1 + .../Components/Grids/Passkey/PasskeyGrid.php | 7 + .../Grids/Passkey/PasskeyGridFactory.php | 8 + .../Grids/Passkey/PasskeyGridTrait.php | 81 +++++ .../Account/AccountPresenterTrait.php | 88 +++++ src/UI/Presenters/Account/default.latte | 12 + src/lang/fcadmin.cs.yml | 33 ++ src/lang/fcadmin.sk.yml | 33 ++ 34 files changed, 1809 insertions(+), 8 deletions(-) create mode 100644 src/DI/Injects/PasskeyFormFactoryInject.php create mode 100644 src/DI/Injects/PasskeyQueryFactoryInject.php create mode 100644 src/DI/Injects/PasskeyServiceInject.php create mode 100644 src/Model/Entities/Passkey.php create mode 100644 src/Model/Entities/PasskeyTrait.php create mode 100644 src/Model/Queries/Factories/PasskeyQueryFactory.php create mode 100644 src/Model/Queries/PasskeyQuery.php create mode 100644 src/Model/Queries/PasskeyQueryTrait.php create mode 100644 src/Model/Security/Passkey/PasskeyException.php create mode 100644 src/Model/Security/Passkey/PasskeyService.php create mode 100644 src/Model/Security/Passkey/PasskeySessionSection.php create mode 100644 src/UI/Components/Forms/Passkey/PasskeyForm.php create mode 100644 src/UI/Components/Forms/Passkey/PasskeyFormFactory.php create mode 100644 src/UI/Components/Forms/Passkey/PasskeyFormTrait.php create mode 100644 src/UI/Components/Forms/Passkey/index.js create mode 100644 src/UI/Components/Forms/SignIn/index.js create mode 100644 src/UI/Components/Grids/Passkey/PasskeyGrid.latte create mode 100644 src/UI/Components/Grids/Passkey/PasskeyGrid.php create mode 100644 src/UI/Components/Grids/Passkey/PasskeyGridFactory.php create mode 100644 src/UI/Components/Grids/Passkey/PasskeyGridTrait.php diff --git a/ASVS/V06-Authentication.md b/ASVS/V06-Authentication.md index 85f9e33..e4ca23d 100644 --- a/ASVS/V06-Authentication.md +++ b/ASVS/V06-Authentication.md @@ -10,7 +10,7 @@ This section contains requirements detailing the authentication documentation th |---|-------|-------------|--------|---------------|------------| | 6.1.1 | 1 | Verify that application documentation defines how controls such as rate limiting, anti‑automation, and adaptive response, are used to defend against attacks such as credential stuffing and password brute force. The documentation must make clear how these controls are configured and prevent malicious account lockout. | Partial | Rate limiting configured via setLoginAttemptProtection(). Documentation of controls not yet formalized in ASVS format. | Per-project: document `setLoginAttemptProtection($maxAttempts, $timeout)` values and rationale in project security docs. | | 6.1.2 | 2 | Verify that a list of context‑specific words is documented in order to prevent their use in passwords. The list could include permutations of organization names, product names, system identifiers, project codenames, department or role names, and similar. | Partial | Context-specific word list not yet documented or implemented. | Per-project: create context-specific word list (org name, product name, domain) for password deny list. | -| 6.1.3 | 2 | Verify that, if the application includes multiple authentication pathways, these are all documented together with the security controls and authentication strength which must be consistently enforced across them. | Compliant | Single authentication pathway (email/username + password). No multiple pathways. | — | +| 6.1.3 | 2 | Verify that, if the application includes multiple authentication pathways, these are all documented together with the security controls and authentication strength which must be consistently enforced across them. | Compliant | Authentication pathways documented in README: email + password, passkeys (WebAuthn, user verification required), optional Keycloak SSO. Same ACL check (customer/backoffice resource) enforced on all pathways; SSO-bound identities cannot use password or passkey login. | — | ## V6.2 Password Security @@ -39,8 +39,8 @@ This section contains general requirements for the security of authentication me |---|-------|-------------|--------|---------------|------------| | 6.3.1 | 1 | Verify that controls to prevent attacks such as credential stuffing and password brute force are implemented according to the application's security documentation. | Compliant | IP-based rate limiting via DoctrineAuthenticator. Configurable maxLoginAttempts and loginAttemptTimeout. | — | | 6.3.2 | 1 | Verify that default user accounts (e.g., "root", "admin", or "sa") are not present in the application or are disabled. | Compliant | No default user accounts. All accounts created through application flows. | — | -| 6.3.3 | 2 | Verify that either a multi‑factor authentication mechanism or a combination of single‑factor authentication mechanisms, must be used in order to access the application. For L3, one of the factors must be a hardware‑based authentication mechanism which provides compromise and impersonation resistance against phishing attacks while verifying the intent to authenticate by requiring a user‑initiated action (such as a button press on a FIDO hardware key or a mobile phone). Relaxing any of the considerations in this requirement requires a fully documented rationale and a comprehensive set of mitigating controls. | Partial | MFA not yet implemented. Single-factor authentication (password) only. MFA is required for L2 compliance. | Pending fancyadmin: implement TOTP MFA. `IdentityTrait` already uses `DoctrineAuthenticator\OTP\IdentityTrait` as foundation. | -| 6.3.4 | 2 | Verify that, if the application includes multiple authentication pathways, there are no undocumented pathways and that security controls and authentication strength are enforced consistently. | Compliant | Single authentication pathway (email + password). No undocumented pathways. | — | +| 6.3.3 | 2 | Verify that either a multi‑factor authentication mechanism or a combination of single‑factor authentication mechanisms, must be used in order to access the application. For L3, one of the factors must be a hardware‑based authentication mechanism which provides compromise and impersonation resistance against phishing attacks while verifying the intent to authenticate by requiring a user‑initiated action (such as a button press on a FIDO hardware key or a mobile phone). Relaxing any of the considerations in this requirement requires a fully documented rationale and a comprehensive set of mitigating controls. | Partial | Passkeys (WebAuthn) implemented — phishing-resistant, hardware-backed, multi-factor in itself (possession + `userVerification: required`, i.e. biometrics/PIN), with user-initiated action. Password remains an alternative pathway, so MFA is not yet *enforced* for every login. | Per-project: for strict L2/L3 compliance, enforce passkey-only or passkey+password policy (passkey infrastructure is now available in fancyadmin). | +| 6.3.4 | 2 | Verify that, if the application includes multiple authentication pathways, there are no undocumented pathways and that security controls and authentication strength are enforced consistently. | Compliant | All pathways documented (password, passkey, optional SSO). Consistent controls: same ACL login check, inactive identities rejected everywhere, SSO identities rejected on password and passkey pathways, passkey requires user verification. | — | | 6.3.5 | 3 | Verify that users are notified of suspicious authentication attempts (successful or unsuccessful). This may include authentication attempts from an unusual location or client, partially successful authentication (only one of multiple factors), an authentication attempt after a long period of inactivity or a successful authentication after several unsuccessful attempts. | | | | | 6.3.6 | 3 | Verify that email is not used as either a single‑factor or multi‑factor authentication mechanism. | | | | | 6.3.7 | 3 | Verify that users are notified after updates to authentication details, such as credential resets or modification of the username or email address. | | | | @@ -70,7 +70,7 @@ This section provides general guidance that will be relevant to various differen | 6.5.3 | 2 | Verify that lookup secrets, out‑of‑band authentication code, and time‑based one‑time password seeds, are generated using a Cryptographically Secure Pseudorandom Number Generator (CSPRNG) to avoid predictable values. | Out of scope | MFA not yet implemented. These requirements apply when MFA is added. | — | | 6.5.4 | 2 | Verify that lookup secrets and out‑of‑band authentication codes have a minimum of 20 bits of entropy (typically 4 random alphanumeric characters or 6 random digits is sufficient). | Out of scope | MFA not yet implemented. These requirements apply when MFA is added. | — | | 6.5.5 | 2 | Verify that out‑of‑band authentication requests, codes, or tokens, as well as time‑based one‑time passwords (TOTPs) have a defined lifetime. Out of band requests must have a maximum lifetime of 10 minutes and for TOTP a maximum lifetime of 30 seconds. | Out of scope | MFA not yet implemented. These requirements apply when MFA is added. | — | -| 6.5.6 | 3 | Verify that any authentication factor (including physical devices) can be revoked in case of theft or other loss. | | | | +| 6.5.6 | 3 | Verify that any authentication factor (including physical devices) can be revoked in case of theft or other loss. | Compliant | Passkeys can be deleted by the user on the Account page (only own keys); password can be reset. Admin can deactivate the identity, which blocks all pathways including passkey login. | — | | 6.5.7 | 3 | Verify that biometric authentication mechanisms are only used as secondary factors together with either something you have or something you know. | | | | | 6.5.8 | 3 | Verify that time‑based one‑time passwords (TOTPs) are checked based on a time source from a trusted service and not from an untrusted or client provided time. | | | | @@ -91,8 +91,8 @@ Cryptographic authentication mechanisms include smart cards or FIDO keys, where | # | Level | Requirement | Status | How We Comply | What to Do | |---|-------|-------------|--------|---------------|------------| -| 6.7.1 | 3 | Verify that the certificates used to verify cryptographic authentication assertions are stored in a way protects them from modification. | | | | -| 6.7.2 | 3 | Verify that the challenge nonce is at least 64 bits in length, and statistically unique or unique over the lifetime of the cryptographic device. | | | | +| 6.7.1 | 3 | Verify that the certificates used to verify cryptographic authentication assertions are stored in a way protects them from modification. | Compliant | Passkey public keys (PEM) are stored server-side in the `passkey` table and are never modifiable by the user — only the key name can be renamed; verification data is written exclusively by `PasskeyService::processRegistration()`. | — | +| 6.7.2 | 3 | Verify that the challenge nonce is at least 64 bits in length, and statistically unique or unique over the lifetime of the cryptographic device. | Compliant | WebAuthn challenge is 32 bytes (256 bits) from a CSPRNG (`ByteBuffer::randomBuffer(32)`), single-use (removed from session on first read) and expires after 5 minutes. | — | ## V6.8 Authentication with an Identity Provider diff --git a/README.md b/README.md index 94b7850..abf7f0d 100644 --- a/README.md +++ b/README.md @@ -1237,6 +1237,227 @@ 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). + +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; změna domény znamená + ztrátu registrovaných klíčů. Default se odvozuje z `adminHostPath`. + +### 19.2 NEON konfigurace (volitelné) + +```neon +fancyadmin: + # ... ostatní konfigurace ... + # 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 +``` + +### 19.3 Entita Passkey + +```php +// app/Model/Entities/Passkey.php + + */ +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 {} +} +``` + +```php +// app/Model/Queries/Factories/PasskeyQueryFactory.php +initGridTrait($grid); + } +} +``` + +```php +// app/UI/Portal/Components/Grids/Passkey/PasskeyGridFactory.php + 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), + // 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 + 'passkeyRpName' => Expect::string()->nullable()->default(null), 'colors' => Expect::structure([ 'backgroundColor' => Expect::string()->required(), 'dashboardAccentColor' => Expect::string()->required(), @@ -121,11 +126,16 @@ public function loadConfiguration(): void 'context' => $this->config->context, 'colors' => (array) $this->config->colors, 'keycloakEnabled' => $this->config->keycloakEnabled, + 'passkeyRpId' => $this->config->passkeyRpId, + 'passkeyRpName' => $this->config->passkeyRpName, ]); $builder->addDefinition($this->prefix('jsComponents')) ->setFactory(JsComponents::class); + $builder->addDefinition($this->prefix('passkeyService')) + ->setFactory(PasskeyService::class); + // Keycloak — registrace KeycloakManager (instance se vytváří lazy z DB) if ($this->config->keycloakEnabled) { $builder->addDefinition($this->prefix('keycloakManager')) diff --git a/src/DI/Injects/PasskeyFormFactoryInject.php b/src/DI/Injects/PasskeyFormFactoryInject.php new file mode 100644 index 0000000..30e72f8 --- /dev/null +++ b/src/DI/Injects/PasskeyFormFactoryInject.php @@ -0,0 +1,12 @@ + true])] + protected mixed $passkeyUserHandle = null; + #[ORM\Column(nullable: true)] #[LoggableProperty] protected ?DateTimeImmutable $anonymizedAt = null; @@ -92,6 +98,7 @@ public function __construct() { $this->profiles = new ArrayCollection(); $this->roles = new ArrayCollection(); + $this->passkeys = new ArrayCollection(); } public function getPassword(): ?string @@ -343,4 +350,30 @@ public function getIdentity(): Identity { return $this; } + + /** + * @return Passkey[] + */ + public function getPasskeys(): array + { + 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/Passkey.php b/src/Model/Entities/Passkey.php new file mode 100644 index 0000000..4854adb --- /dev/null +++ b/src/Model/Entities/Passkey.php @@ -0,0 +1,45 @@ + true, 'default' => 0])] + protected int $signCount = 0; + + /** Raw binary AAGUID autentikátoru (BINARY(16)) */ + #[ORM\Column(type: 'binary', length: 16, nullable: true, options: ['fixed' => true])] + protected mixed $aaguid = null; + + #[ORM\Column(type: 'json', nullable: true)] + protected ?array $transports = null; + + #[ORM\Column(nullable: true)] + protected ?bool $backupEligible = null; + + #[ORM\Column(nullable: true)] + protected ?bool $backupState = null; + + #[ORM\Column(nullable: true)] + protected ?DateTimeImmutable $lastUsedAt = null; + + public function getIdentity(): Identity + { + return $this->identity; + } + + public function setIdentity(Identity $identity): static + { + $this->identity = $identity; + return $this; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + return $this; + } + + public function getCredentialId(): string + { + return self::binaryColumnToString($this->credentialId); + } + + public function setCredentialId(string $credentialId): static + { + $this->credentialId = $credentialId; + return $this; + } + + public function getPublicKey(): string + { + return $this->publicKey; + } + + public function setPublicKey(string $publicKey): static + { + $this->publicKey = $publicKey; + return $this; + } + + public function getSignCount(): int + { + return $this->signCount; + } + + public function setSignCount(int $signCount): static + { + $this->signCount = $signCount; + return $this; + } + + public function getAaguid(): ?string + { + return $this->aaguid === null ? null : self::binaryColumnToString($this->aaguid); + } + + public function setAaguid(?string $aaguid): static + { + $this->aaguid = $aaguid; + return $this; + } + + public function getTransports(): ?array + { + return $this->transports; + } + + public function setTransports(?array $transports): static + { + $this->transports = $transports; + return $this; + } + + public function getBackupEligible(): ?bool + { + return $this->backupEligible; + } + + public function setBackupEligible(?bool $backupEligible): static + { + $this->backupEligible = $backupEligible; + return $this; + } + + public function getBackupState(): ?bool + { + return $this->backupState; + } + + public function setBackupState(?bool $backupState): static + { + $this->backupState = $backupState; + return $this; + } + + public function getLastUsedAt(): ?DateTimeImmutable + { + return $this->lastUsedAt; + } + + public function setLastUsedAt(?DateTimeImmutable $lastUsedAt): static + { + $this->lastUsedAt = $lastUsedAt; + return $this; + } + + /** DBAL typ binary hydratuje podle verze DBAL buď string, nebo stream */ + private static function binaryColumnToString(mixed $value): string + { + if (is_resource($value)) { + rewind($value); + return (string) stream_get_contents($value); + } + return (string) $value; + } +} diff --git a/src/Model/FancyAdmin.php b/src/Model/FancyAdmin.php index 973f071..29941fe 100644 --- a/src/Model/FancyAdmin.php +++ b/src/Model/FancyAdmin.php @@ -29,6 +29,8 @@ public function __construct( protected array $jsComponentsConfig = [], protected array $colors = [], protected bool $keycloakEnabled = false, + protected ?string $passkeyRpId = null, + protected ?string $passkeyRpName = null, ) {} public function getProject(): string @@ -171,4 +173,14 @@ public function isKeycloakEnabled(): bool { return $this->keycloakEnabled; } + + public function getPasskeyRpId(): ?string + { + return $this->passkeyRpId; + } + + public function getPasskeyRpName(): string + { + return $this->passkeyRpName ?? $this->projectName; + } } \ No newline at end of file diff --git a/src/Model/Queries/Factories/PasskeyQueryFactory.php b/src/Model/Queries/Factories/PasskeyQueryFactory.php new file mode 100644 index 0000000..6b0aaa4 --- /dev/null +++ b/src/Model/Queries/Factories/PasskeyQueryFactory.php @@ -0,0 +1,10 @@ +by('credentialId', $credentialId); + } + + public function byIdentity(Identity $identity): static + { + return $this->by('identity', $identity); + } + + protected function setDefaultOrder(): void + { + $this->orderBy('createdAt', 'ASC'); + } +} diff --git a/src/Model/Security/Passkey/PasskeyException.php b/src/Model/Security/Passkey/PasskeyException.php new file mode 100644 index 0000000..ca9f04a --- /dev/null +++ b/src/Model/Security/Passkey/PasskeyException.php @@ -0,0 +1,11 @@ +assertNotSso($identity); + + // Lazy vygenerování opaque user handle — autentikátoru nikdy neposíláme interní ID identity + if ($identity->getPasskeyUserHandle() === null) { + $identity->setPasskeyUserHandle(random_bytes(32)); + $this->em->flush(); + } + + $excludeCredentialIds = []; + foreach ($identity->getPasskeys() as $passkey) { + $excludeCredentialIds[] = $passkey->getCredentialId(); + } + + $webAuthn = $this->createWebAuthn(); + $args = $webAuthn->getCreateArgs( + $identity->getPasskeyUserHandle(), + (string) $identity->getEmail(), + $identity->getFullName() !== '' ? $identity->getFullName() : (string) $identity->getEmail(), + self::TIMEOUT_SECONDS, + requireResidentKey: true, + requireUserVerification: 'required', + excludeCredentialIds: $excludeCredentialIds, + ); + + $this->storeChallenge(PasskeySessionSection::CREATE_CHALLENGE, $webAuthn->getChallenge()->getBinaryString()); + + return $args; + } + + /** + * Ověří odpověď autentikátoru na create ceremony a persistuje nový klíč. + * + * @param string $clientDataJSON raw binary (dekódované z base64url) + * @param string $attestationObject raw binary (dekódované z base64url) + * @param string $name uživatelský název klíče (povinný) + * @param string[]|null $transports transports z browseru (credential.response.transports) + * @throws PasskeyException + */ + public function processRegistration( + Identity $identity, + string $clientDataJSON, + string $attestationObject, + string $name, + ?array $transports = null, + ): Passkey + { + $this->assertNotSso($identity); + + $name = $this->normalizeName($name); + + $challenge = $this->consumeChallenge(PasskeySessionSection::CREATE_CHALLENGE); + + $webAuthn = $this->createWebAuthn(); + try { + $data = $webAuthn->processCreate( + $clientDataJSON, + $attestationObject, + new ByteBuffer($challenge), + requireUserVerification: true, + ); + } catch (WebAuthnException) { + throw new PasskeyException($this->translator->translate('fcadmin.passkeys.errors.invalidKey')); + } + + $credentialId = $data->credentialId; + + if ($this->passkeyQueryFactory->create()->disableSecurityFilter()->disableAccountFilter()->byCredentialId($credentialId)->count() > 0) { + throw new PasskeyException($this->translator->translate('fcadmin.passkeys.errors.alreadyRegistered')); + } + + $aaguid = $data->AAGUID instanceof ByteBuffer ? $data->AAGUID->getBinaryString() : ($data->AAGUID ?: null); + if ($aaguid !== null && trim($aaguid, "\0") === '') { + $aaguid = null; + } + + $transports = $transports === null ? null : array_values(array_filter(array_map('strval', $transports))); + + $passkeyClass = $this->em->findEntityClassByInterface(Passkey::class); + /** @var Passkey $passkey */ + $passkey = new $passkeyClass(); + $passkey + ->setIdentity($identity) + ->setName($name) + ->setCredentialId($credentialId) + ->setPublicKey($data->credentialPublicKey) + ->setSignCount($webAuthn->getSignatureCounter() ?? 0) + ->setAaguid($aaguid) + ->setTransports($transports) + ->setBackupEligible($data->isBackupEligible ?? null) + ->setBackupState($data->isBackedUp ?? null); + + $this->em->persist($passkey); + $this->em->flush(); + + return $passkey; + } + + /** + * Vygeneruje PublicKeyCredentialRequestOptions pro usernameless login + * (prázdné allowCredentials — prohlížeč nabídne discoverable credentials). + */ + public function getLoginArgs(): stdClass + { + $webAuthn = $this->createWebAuthn(); + $args = $webAuthn->getGetArgs( + [], + self::TIMEOUT_SECONDS, + requireUserVerification: 'required', + ); + + $this->storeChallenge(PasskeySessionSection::GET_CHALLENGE, $webAuthn->getChallenge()->getBinaryString()); + + return $args; + } + + /** + * Ověří assertion z get ceremony a vrátí identitu klíče. + * Credential-first lookup podle credentialId, kontrola userHandle přes hash_equals, + * odmítá SSO identity a neaktivní identity. Po úspěchu bumpne signCount a lastUsedAt. + * + * @param string $credentialId raw binary + * @param string $clientDataJSON raw binary + * @param string $authenticatorData raw binary + * @param string $signature raw binary + * @param string|null $userHandle raw binary (pokud ho autentikátor poslal) + * @throws PasskeyException + */ + public function processLogin( + string $credentialId, + string $clientDataJSON, + string $authenticatorData, + string $signature, + ?string $userHandle = null, + ): Identity + { + $challenge = $this->consumeChallenge(PasskeySessionSection::GET_CHALLENGE); + + /** @var Passkey|null $passkey */ + $passkey = $this->passkeyQueryFactory->create() + ->disableSecurityFilter() + ->disableAccountFilter() + ->byCredentialId($credentialId) + ->fetchOneOrNull(); + + if ($passkey === null) { + throw new PasskeyException($this->translator->translate('fcadmin.passkeys.errors.unknownKey')); + } + + $identity = $passkey->getIdentity(); + + if ($userHandle !== null && $userHandle !== '') { + $storedHandle = $identity->getPasskeyUserHandle(); + if ($storedHandle === null || !hash_equals($storedHandle, $userHandle)) { + throw new PasskeyException($this->translator->translate('fcadmin.passkeys.errors.unknownKey')); + } + } + + $this->assertNotSso($identity); + + if (!$identity->getIsActive()) { + throw new PasskeyException($this->translator->translate('fcadmin.appGeneral.exceptions.inactiveUser')); + } + + $webAuthn = $this->createWebAuthn(); + try { + $webAuthn->processGet( + $clientDataJSON, + $authenticatorData, + $signature, + $passkey->getPublicKey(), + new ByteBuffer($challenge), + $passkey->getSignCount(), + requireUserVerification: true, + ); + } catch (WebAuthnException) { + throw new PasskeyException($this->translator->translate('fcadmin.passkeys.errors.unknownKey')); + } + + $newSignCount = $webAuthn->getSignatureCounter(); + if ($newSignCount !== null) { + $passkey->setSignCount($newSignCount); + } + $passkey->setLastUsedAt(new DateTimeImmutable()); + $this->em->flush(); + + return $identity; + } + + /** + * @throws PasskeyException pokud je identita navázaná na Keycloak SSO + */ + public function assertNotSso(Identity $identity): void + { + if ($identity->getSso() !== null) { + throw new PasskeyException($this->translator->translate('fcadmin.passkeys.errors.ssoAccount')); + } + } + + protected function createWebAuthn(): WebAuthn + { + try { + // 4. parametr: base64url pro všechny binárky v JSON args (ByteBuffer::$useBase64UrlEncoding) + return new WebAuthn($this->getRpName(), $this->getRpId(), ['none'], true); + } catch (Throwable) { + throw new PasskeyException($this->translator->translate('fcadmin.passkeys.errors.unavailable')); + } + } + + /** + * rpId = doména admin hostu. Explicitně z configu (passkeyRpId), + * jinak odvozeno z adminHostPath (bez schématu, cesty a portu). + */ + public function getRpId(): string + { + if ($rpId = $this->fancyAdmin->getPasskeyRpId()) { + return $rpId; + } + + $host = (string) preg_replace('~^https?://~', '', $this->fancyAdmin->getAdminHostPath()); + $host = explode('/', $host)[0]; + return explode(':', $host)[0]; + } + + public function getRpName(): string + { + return $this->fancyAdmin->getPasskeyRpName(); + } + + protected function storeChallenge(string $key, string $challenge): void + { + $this->getSessionSection()->set($key, $challenge, PasskeySessionSection::CHALLENGE_EXPIRATION); + } + + /** + * One-shot vyzvednutí challenge — po přečtení se ze session maže. + * + * @throws PasskeyException pokud challenge chybí nebo expirovala + */ + protected function consumeChallenge(string $key): string + { + $section = $this->getSessionSection(); + $challenge = $section->get($key); + $section->remove($key); + + if (!is_string($challenge) || $challenge === '') { + throw new PasskeyException($this->translator->translate('fcadmin.passkeys.errors.expiredChallenge')); + } + + return $challenge; + } + + private function getSessionSection(): SessionSection + { + return $this->session->getSection(PasskeySessionSection::SECTION_NAME); + } + + /** + * Ověří a normalizuje název klíče — název je povinný, zkrátí se na délku sloupce. + * + * @throws PasskeyException pro prázdný název + */ + protected function normalizeName(string $name): string + { + $name = trim($name); + if ($name === '') { + throw new PasskeyException($this->translator->translate('fcadmin.passkeys.form.errors.nameRequired')); + } + + return mb_substr($name, 0, self::NAME_MAX_LENGTH); + } + + /** + * Dekódování base64url (WebAuthn JSON serializace) na raw binary. + * Vrací null pro nevalidní vstup — volající odpoví přeloženou chybou. + */ + public static function base64UrlDecode(?string $data): ?string + { + if ($data === null || $data === '') { + return null; + } + $decoded = base64_decode(strtr($data, '-_', '+/'), true); + return $decoded === false ? null : $decoded; + } +} diff --git a/src/Model/Security/Passkey/PasskeySessionSection.php b/src/Model/Security/Passkey/PasskeySessionSection.php new file mode 100644 index 0000000..e538a16 --- /dev/null +++ b/src/Model/Security/Passkey/PasskeySessionSection.php @@ -0,0 +1,30 @@ +addText('name', 'fcadmin.passkeys.form.name') + ->setHtmlAttribute('maxlength', 64) + ->setHtmlAttribute('placeholder', 'fcadmin.passkeys.form.namePlaceholder') + ->setRequired('fcadmin.passkeys.form.errors.nameRequired') + ->addRule($form::MaxLength, null, 64); + + $presenter = $this->getPresenter(); + $form->getElementPrototype()->setAttribute('data-adt-fancyadmin-passkey-form', true); + $form->getElementPrototype()->setAttribute('data-passkey-register-args-url', $presenter->link('passkeyRegisterArgs!')); + $form->getElementPrototype()->setAttribute('data-passkey-register-verify-url', $presenter->link('passkeyRegisterVerify!')); + $form->getElementPrototype()->setAttribute('data-passkey-error-unsupported', $this->getTranslator()->translate('fcadmin.passkeys.errors.unsupportedBrowser')); + $form->getElementPrototype()->setAttribute('data-passkey-error-failed', $this->getTranslator()->translate('fcadmin.passkeys.errors.registrationFailed')); + $form->getElementPrototype()->setAttribute('data-passkey-error-name-required', $this->getTranslator()->translate('fcadmin.passkeys.form.errors.nameRequired')); + + $form->addSubmit('register', 'fcadmin.passkeys.form.register') + ->setHtmlAttribute('data-passkey-register-button'); + } + + public function validateForm(array $values, Form $form): void + { + // Formulář se odesílá jen přes JS signály; přímý submit = prohlížeč bez WebAuthn/JS + $form->addError('fcadmin.passkeys.errors.unsupportedBrowser'); + } + + protected function getEntityClass(): ?string + { + // Entita nevzniká z formuláře — vytváří ji PasskeyService::processRegistration() + return null; + } +} diff --git a/src/UI/Components/Forms/Passkey/index.js b/src/UI/Components/Forms/Passkey/index.js new file mode 100644 index 0000000..04ba21c --- /dev/null +++ b/src/UI/Components/Forms/Passkey/index.js @@ -0,0 +1,212 @@ +/** + * PasskeyForm — registrace nového passkey (WebAuthn create ceremony). + * + * Formulář v side panelu (add mód) má data-adt-fancyadmin-passkey-form a URL + * signálů passkeyRegisterArgs / passkeyRegisterVerify v data atributech. + * Klik na tlačítko spustí ceremony: fetch args → navigator.credentials.create() + * → POST {name, credential} na verify signál → redirect (reload s flash zprávou). + * + * Binárky konvertuje nativní PublicKeyCredential JSON API s base64url fallbackem. + * Listener je delegovaný na document — funguje i pro side panel vložený AJAXem. + */ + +const FORM_SELECTOR = '[data-adt-fancyadmin-passkey-form]'; +const BUTTON_SELECTOR = '[data-passkey-register-button]'; + +let ceremonyRunning = false; + +const base64UrlToBuffer = (base64Url) => { + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const binary = window.atob(base64); + const buffer = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + buffer[i] = binary.charCodeAt(i); + } + return buffer; +}; + +const bufferToBase64Url = (buffer) => { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return window.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +}; + +// Fallback pro prohlížeče bez PublicKeyCredential.parseCreationOptionsFromJSON() +const parseCreationOptions = (publicKey) => { + if (typeof PublicKeyCredential.parseCreationOptionsFromJSON === 'function') { + return PublicKeyCredential.parseCreationOptionsFromJSON(publicKey); + } + + const options = { ...publicKey }; + options.challenge = base64UrlToBuffer(publicKey.challenge); + options.user = { ...publicKey.user, id: base64UrlToBuffer(publicKey.user.id) }; + if (publicKey.excludeCredentials) { + options.excludeCredentials = publicKey.excludeCredentials.map((cred) => ({ + ...cred, + id: base64UrlToBuffer(cred.id), + })); + } + return options; +}; + +// Fallback pro prohlížeče bez PublicKeyCredential.prototype.toJSON() +const credentialToJson = (credential) => { + if (typeof credential.toJSON === 'function') { + return credential.toJSON(); + } + + return { + id: credential.id, + rawId: bufferToBase64Url(credential.rawId), + type: credential.type, + response: { + clientDataJSON: bufferToBase64Url(credential.response.clientDataJSON), + attestationObject: bufferToBase64Url(credential.response.attestationObject), + transports: typeof credential.response.getTransports === 'function' + ? credential.response.getTransports() + : [], + }, + }; +}; + +const showError = (form, message) => { + let errorEl = form.querySelector('[data-passkey-error]'); + if (!errorEl) { + errorEl = document.createElement('div'); + errorEl.className = 'alert alert-danger mt-2'; + errorEl.setAttribute('data-passkey-error', ''); + form.appendChild(errorEl); + } + errorEl.textContent = message; + errorEl.classList.remove('d-none'); +}; + +const hideError = (form) => { + const errorEl = form.querySelector('[data-passkey-error]'); + if (errorEl) { + errorEl.classList.add('d-none'); + } +}; + +const register = async (form, button) => { + const argsUrl = form.getAttribute('data-passkey-register-args-url'); + const verifyUrl = form.getAttribute('data-passkey-register-verify-url'); + const nameInput = form.querySelector('input[name="name"]'); + const name = nameInput ? nameInput.value.trim() : ''; + + // Název je povinný — kontrola PŘED ceremony, aby při prázdném poli nezůstal + // v autentikátoru osiřelý klíč, který by server vzápětí odmítl + if (name === '') { + showError(form, form.getAttribute('data-passkey-error-name-required')); + if (nameInput) { + nameInput.focus(); + } + return; + } + + const argsResponse = await fetch(argsUrl, { + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + }); + const args = await argsResponse.json(); + if (args.error || !args.publicKey) { + showError(form, args.error || form.getAttribute('data-passkey-error-failed')); + return; + } + + const credential = await navigator.credentials.create({ + publicKey: parseCreationOptions(args.publicKey), + }); + if (!credential) { + showError(form, form.getAttribute('data-passkey-error-failed')); + return; + } + + const verifyResponse = await fetch(verifyUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ + name, + credential: credentialToJson(credential), + }), + }); + const data = await verifyResponse.json(); + + if (data.error) { + showError(form, data.error); + return; + } + + if (data.redirect) { + window.location.href = data.redirect; + } else { + window.location.reload(); + } +}; + +const onButtonClick = async (event) => { + const button = event.target.closest(BUTTON_SELECTOR); + if (!button) { + return; + } + + const form = button.closest(FORM_SELECTOR); + if (!form) { + return; + } + + // Ceremony řídí JS — klik nesmí odeslat formulář ani doputovat k nette.ajax + // handlerům (delegovaný click.nette na :submit na formuláři), proto capture + // fáze + stopPropagation (viz run()). + event.preventDefault(); + event.stopPropagation(); + + if (ceremonyRunning) { + return; + } + + hideError(form); + + if (!window.PublicKeyCredential || !navigator.credentials) { + showError(form, form.getAttribute('data-passkey-error-unsupported')); + return; + } + + ceremonyRunning = true; + button.disabled = true; + try { + await register(form, button); + } catch (e) { + // NotAllowedError = uživatel dialog zavřel — bez chybové hlášky. + // InvalidStateError = klíč pro tento účet už v autentikátoru existuje (excludeCredentials). + if (!(e instanceof DOMException && e.name === 'NotAllowedError')) { + showError(form, form.getAttribute('data-passkey-error-failed')); + } + } finally { + ceremonyRunning = false; + button.disabled = false; + } +}; + +// Pojistka: add-mód formulář se nikdy nesmí odeslat standardně (Enter v named poli) +const onSubmit = (event) => { + const form = event.target.closest ? event.target.closest(FORM_SELECTOR) : null; + if (form && form.querySelector(BUTTON_SELECTOR)) { + event.preventDefault(); + event.stopImmediatePropagation(); + } +}; + +const run = () => { + // Capture fáze — musí běžet dřív než delegované click.nette handlery + // nette.ajax na formuláři, jinak klik na submit odešle formulář AJAXem + document.addEventListener('click', onButtonClick, true); + document.addEventListener('submit', onSubmit, true); +}; + +export default { run }; diff --git a/src/UI/Components/Forms/SignIn/SignInForm.latte b/src/UI/Components/Forms/SignIn/SignInForm.latte index f159392..fdc412d 100644 --- a/src/UI/Components/Forms/SignIn/SignInForm.latte +++ b/src/UI/Components/Forms/SignIn/SignInForm.latte @@ -2,4 +2,21 @@ {_fcadmin.forms.signIn.links.lostPassword} -{/define} \ No newline at end of file +{/define} + +{define section-passkey} +
+ +
+
+{/define} diff --git a/src/UI/Components/Forms/SignIn/SignInFormTrait.php b/src/UI/Components/Forms/SignIn/SignInFormTrait.php index 6a6d3cf..95e4d4e 100644 --- a/src/UI/Components/Forms/SignIn/SignInFormTrait.php +++ b/src/UI/Components/Forms/SignIn/SignInFormTrait.php @@ -7,12 +7,18 @@ use ADT\FancyAdmin\DI\Injects\AuthenticatorInject; use ADT\FancyAdmin\DI\Injects\FancyAdminInject; use ADT\FancyAdmin\DI\Injects\IdentityQueryFactoryInject; +use ADT\FancyAdmin\DI\Injects\PasskeyServiceInject; use ADT\FancyAdmin\DI\Injects\SecurityUserInject; +use ADT\FancyAdmin\DI\Injects\TranslatorInject; use ADT\FancyAdmin\Model\Entities\Identity; +use ADT\FancyAdmin\Model\Security\Passkey\PasskeyException; +use ADT\FancyAdmin\Model\Security\Passkey\PasskeyService; use ADT\FancyAdmin\UI\Components\ControlTrait; use ADT\FancyAdmin\UI\RedirectAfterLoginTrait; use ADT\Forms\Form; use Nette\Security\AuthenticationException; +use Nette\Utils\Json; +use Nette\Utils\JsonException; trait SignInFormTrait { @@ -22,6 +28,8 @@ trait SignInFormTrait use AuthenticatorInject; use SecurityUserInject; use IdentityQueryFactoryInject; + use PasskeyServiceInject; + use TranslatorInject; private Identity $_identity; @@ -33,6 +41,7 @@ public function initForm(Form $form): void $form->addEmail('email') ->setHtmlAttribute('id', 'login-form-input-email') ->setHtmlAttribute('placeholder', 'fcadmin.forms.signIn.labels.email') + ->setHtmlAttribute('autocomplete', 'username') ->setRequired('fcadmin.forms.signIn.errors.emailRequired'); $form->addPassword('password') @@ -46,6 +55,8 @@ public function initForm(Form $form): void $form->addSubmit('submit', 'fcadmin.forms.signIn.labels.logIn') ->getControlPrototype()->class[] = 'w-100'; + $form->addSection(name: 'passkey'); + $this->getTemplate()->isLostPasswordEnabled = $this->_fancyAdmin->isLostPasswordEnabled(); // Keycloak email check — přidá data atribut pro JS kontrolu @@ -69,6 +80,101 @@ public function handleCheckKeycloak(string $email): void $this->getPresenter()->sendJson(['loginUrl' => $this->getKeycloakLoginUrl($email)]); } + /** + * AJAX signal — vrátí PublicKeyCredentialRequestOptions pro usernameless + * passkey login (binárky base64url). Challenge se drží one-shot v session. + */ + public function handlePasskeyLoginArgs(): void + { + try { + $args = $this->_passkeyService->getLoginArgs(); + } catch (PasskeyException $e) { + $this->getPresenter()->sendJson(['error' => $e->getMessage()]); + } + + $this->getPresenter()->sendJson($args); + } + + /** + * AJAX signal — ověří WebAuthn assertion (JSON tělo requestu ve formátu + * PublicKeyCredential.toJSON()), přihlásí identitu a vrátí JSON s redirect URL + * (přes redirectAfterLogin(), který pod AJAXem pošle payload {redirect: ...}). + */ + public function handlePasskeyLoginVerify(): void + { + $credential = $this->parsePasskeyCredential(); + + try { + if ($credential === null) { + throw new PasskeyException($this->_translator->translate('fcadmin.passkeys.errors.invalidKey')); + } + + $identity = $this->_passkeyService->processLogin( + $credential['credentialId'], + $credential['clientDataJSON'], + $credential['authenticatorData'], + $credential['signature'], + $credential['userHandle'], + ); + + // Stejný ACL check jako AuthenticatorTrait::validateIdentity() + if ( + !$identity->isAllowed($this->_fancyAdmin->getCustomerAclResource()) + && + !$identity->isAllowed($this->_fancyAdmin->getBackofficeAclResource()) + ) { + throw new PasskeyException($this->_translator->translate('fcadmin.appGeneral.exceptions.noPermission')); + } + + $this->_securityUser->login($identity, context: $this->_fancyAdmin->getContext()); + } catch (PasskeyException $e) { + $this->getPresenter()->sendJson(['error' => $e->getMessage()]); + } + + $this->redirectAfterLogin(); + } + + /** + * Načte a dekóduje WebAuthn assertion z JSON těla requestu + * (výstup PublicKeyCredential.toJSON(), binárky base64url). + * + * @return array{credentialId: string, clientDataJSON: string, authenticatorData: string, signature: string, userHandle: ?string}|null + */ + private function parsePasskeyCredential(): ?array + { + try { + $data = Json::decode((string) $this->getPresenter()->getHttpRequest()->getRawBody(), true); + } catch (JsonException) { + return null; + } + + if (!is_array($data)) { + return null; + } + + $response = $data['response'] ?? null; + if (!is_array($response)) { + return null; + } + + $credentialId = PasskeyService::base64UrlDecode($data['rawId'] ?? $data['id'] ?? null); + $clientDataJSON = PasskeyService::base64UrlDecode($response['clientDataJSON'] ?? null); + $authenticatorData = PasskeyService::base64UrlDecode($response['authenticatorData'] ?? null); + $signature = PasskeyService::base64UrlDecode($response['signature'] ?? null); + + if ($credentialId === null || $clientDataJSON === null || $authenticatorData === null || $signature === null) { + return null; + } + + return [ + 'credentialId' => $credentialId, + 'clientDataJSON' => $clientDataJSON, + 'authenticatorData' => $authenticatorData, + 'signature' => $signature, + 'userHandle' => PasskeyService::base64UrlDecode($response['userHandle'] ?? null), + ]; + } + /** * Vrátí Keycloak login URL, pokud se má uživatel s daným emailem přihlašovat přes SSO. * Jinak vrátí null (uživatel neexistuje, nemá SSO instanci nebo Keycloak není zapnutý). diff --git a/src/UI/Components/Forms/SignIn/index.js b/src/UI/Components/Forms/SignIn/index.js new file mode 100644 index 0000000..79bb0be --- /dev/null +++ b/src/UI/Components/Forms/SignIn/index.js @@ -0,0 +1,176 @@ +/** + * SignInForm — přihlášení passkey (WebAuthn). + * + * Ceremony se spouští VÝHRADNĚ kliknutím na tlačítko "Přihlásit se přihlašovacím + * klíčem" (usernameless get, prázdné allowCredentials). Žádná automatika: + * - passkey se nenabízí sám v autofillu email pole (conditional mediation) + * - dokud uživatel neklikne, nejde na server žádný request, takže anonymní + * návštěvník login stránky nedostane ani session cookie + * + * Binárky konvertuje nativní PublicKeyCredential JSON API s base64url fallbackem + * pro starší prohlížeče. + * + * Listener je delegovaný na document, takže funguje i pro formulář vložený přes + * AJAX snippet. Aktivace přes data-adt-fancyadmin-passkey-login. + */ + +const ROOT_SELECTOR = '[data-adt-fancyadmin-passkey-login]'; +const BUTTON_SELECTOR = '[data-passkey-login-button]'; + +let ceremonyRunning = false; + +const base64UrlToBuffer = (base64Url) => { + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const binary = window.atob(base64); + const buffer = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + buffer[i] = binary.charCodeAt(i); + } + return buffer; +}; + +const bufferToBase64Url = (buffer) => { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return window.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +}; + +// Fallback pro prohlížeče bez PublicKeyCredential.parseRequestOptionsFromJSON() +const parseRequestOptions = (publicKey) => { + if (typeof PublicKeyCredential.parseRequestOptionsFromJSON === 'function') { + return PublicKeyCredential.parseRequestOptionsFromJSON(publicKey); + } + + const options = { ...publicKey }; + options.challenge = base64UrlToBuffer(publicKey.challenge); + if (publicKey.allowCredentials) { + options.allowCredentials = publicKey.allowCredentials.map((cred) => ({ + ...cred, + id: base64UrlToBuffer(cred.id), + })); + } + return options; +}; + +// Fallback pro prohlížeče bez PublicKeyCredential.prototype.toJSON() +const credentialToJson = (credential) => { + if (typeof credential.toJSON === 'function') { + return credential.toJSON(); + } + + return { + id: credential.id, + rawId: bufferToBase64Url(credential.rawId), + type: credential.type, + response: { + clientDataJSON: bufferToBase64Url(credential.response.clientDataJSON), + authenticatorData: bufferToBase64Url(credential.response.authenticatorData), + signature: bufferToBase64Url(credential.response.signature), + userHandle: credential.response.userHandle ? bufferToBase64Url(credential.response.userHandle) : null, + }, + }; +}; + +const showError = (root, message) => { + const errorEl = root.querySelector('[data-passkey-error]'); + if (errorEl && message) { + errorEl.textContent = message; + errorEl.classList.remove('d-none'); + } +}; + +const hideError = (root) => { + const errorEl = root.querySelector('[data-passkey-error]'); + if (errorEl) { + errorEl.classList.add('d-none'); + } +}; + +const authenticate = async (root) => { + const argsUrl = root.getAttribute('data-passkey-args-url'); + const verifyUrl = root.getAttribute('data-passkey-verify-url'); + + const argsResponse = await fetch(argsUrl, { + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + }); + const args = await argsResponse.json(); + if (args.error || !args.publicKey) { + throw new Error(args.error || 'invalid args'); + } + + const credential = await navigator.credentials.get({ + publicKey: parseRequestOptions(args.publicKey), + }); + if (!credential) { + throw new Error('no credential'); + } + + const verifyResponse = await fetch(verifyUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify(credentialToJson(credential)), + }); + + let data = null; + try { + data = await verifyResponse.json(); + } catch (e) { + // non-JSON odpověď (např. ForwardResponse po restoreRequest) — uživatel už je přihlášen + } + + if (data && data.error) { + showError(root, data.error); + return; + } + + if (data && data.redirect) { + window.location.href = data.redirect; + } else { + window.location.reload(); + } +}; + +const onButtonClick = async (event) => { + const button = event.target.closest(BUTTON_SELECTOR); + if (!button) { + return; + } + + const root = button.closest(ROOT_SELECTOR); + if (!root || ceremonyRunning) { + return; + } + + hideError(root); + + if (!window.PublicKeyCredential || !navigator.credentials) { + showError(root, root.getAttribute('data-passkey-error-unsupported')); + return; + } + + ceremonyRunning = true; + button.disabled = true; + try { + await authenticate(root); + } catch (e) { + // NotAllowedError = uživatel dialog zavřel — bez chybové hlášky + if (!(e instanceof DOMException && e.name === 'NotAllowedError')) { + showError(root, root.getAttribute('data-passkey-error-failed')); + } + } finally { + ceremonyRunning = false; + button.disabled = false; + } +}; + +const run = () => { + document.addEventListener('click', onButtonClick); +}; + +export default { run }; diff --git a/src/UI/Components/Grids/Passkey/PasskeyGrid.latte b/src/UI/Components/Grids/Passkey/PasskeyGrid.latte new file mode 100644 index 0000000..a62e5a0 --- /dev/null +++ b/src/UI/Components/Grids/Passkey/PasskeyGrid.latte @@ -0,0 +1 @@ +{varType ADT\FancyAdmin\Model\Entities\Passkey $item} diff --git a/src/UI/Components/Grids/Passkey/PasskeyGrid.php b/src/UI/Components/Grids/Passkey/PasskeyGrid.php new file mode 100644 index 0000000..586fa65 --- /dev/null +++ b/src/UI/Components/Grids/Passkey/PasskeyGrid.php @@ -0,0 +1,7 @@ +withoutIsActiveColumn = true; + $grid->setPagination(false); + + $grid->addColumnText('name', 'fcadmin.passkeys.grid.name'); + + $grid->addColumnText('createdAt', 'fcadmin.passkeys.grid.createdAt') + ->setRenderer(fn(Passkey $passkey) => $passkey->getCreatedAt()->format('d.m.Y H:i')); + + $grid->addColumnText('lastUsedAt', 'fcadmin.passkeys.grid.lastUsedAt') + ->setRenderer(fn(Passkey $passkey) => $passkey->getLastUsedAt()?->format('d.m.Y H:i') ?? ''); + + $grid->addColumnText('backupState', '') + ->setRenderer(fn(Passkey $passkey) => $passkey->getBackupState() + ? Html::el('span') + ->class('badge bg-success') + ->setText($this->getTranslator()->translate('fcadmin.passkeys.grid.synced')) + : ''); + + $grid->addAction('deletePasskey', 'fcadmin.passkeys.grid.delete', 'deletePasskey!') + ->setIcon('trash') + ->setClass('btn btn-danger btn-sm ajax datagrid-delete') + ->setConfirmation(new StringConfirmation('fcadmin.passkeys.confirms.delete')); + } + + public function handleDeletePasskey(int $id): void + { + // Mazat lze jen klíče patřící přihlášené identitě + /** @var Passkey|null $passkey */ + $passkey = $this->_passkeyQueryFactory->create() + ->disableSecurityFilter() + ->disableAccountFilter() + ->byIdentity($this->getSecurityUser()->getIdentity()) + ->byId($id) + ->fetchOneOrNull(); + + if ($passkey === null) { + $this->getPresenter()->error(); + } + + $this->getEntityManager()->remove($passkey); + $this->getEntityManager()->flush(); + + $this->getPresenter()->flashMessageSuccess('fcadmin.passkeys.messages.deleted'); + $this->getPresenter()->redirect('this'); + } + + protected function initQueryObject($queryObject): void + { + $queryObject + ->disableSecurityFilter() + ->disableAccountFilter() + ->byIdentity($this->getSecurityUser()->getIdentity()); + } + + protected function getQueryObjectFactoryClass(): string + { + return PasskeyQueryFactory::class; + } +} diff --git a/src/UI/Presenters/Account/AccountPresenterTrait.php b/src/UI/Presenters/Account/AccountPresenterTrait.php index 928ddbe..dcad63f 100644 --- a/src/UI/Presenters/Account/AccountPresenterTrait.php +++ b/src/UI/Presenters/Account/AccountPresenterTrait.php @@ -7,13 +7,22 @@ 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\Controls\SidePanel\SidePanelControl; use ADT\FancyAdmin\UI\Components\Controls\SidePanel\SidePanelControlFactory; +use ADT\FancyAdmin\UI\Components\Grids\Passkey\PasskeyGrid; +use ADT\FancyAdmin\UI\Components\Grids\Passkey\PasskeyGridFactory; use ADT\FancyAdmin\UI\Components\Grids\Session\SessionGrid; use ADT\FancyAdmin\UI\Components\Grids\Session\SessionGridFactory; use ADT\FancyAdmin\UI\Presenters\PresenterTrait; +use Nette\Utils\Json; +use Nette\Utils\JsonException; trait AccountPresenterTrait { @@ -23,6 +32,9 @@ trait AccountPresenterTrait use PersonalDataFormFactoryInject; use ChangePasswordFormFactoryInject; use FancyAdminInject; + use PasskeyFormFactoryInject; + use PasskeyServiceInject; + use TranslatorInject; public function actionDefault(): void { @@ -88,4 +100,80 @@ public function createComponentSessionGrid(SessionGridFactory $factory): Session { return $factory->create(); } + + public function createComponentPasskeyGrid(PasskeyGridFactory $factory): PasskeyGrid + { + return $factory->create(); + } + + public function handleAddPasskey(): void + { + // SSO uživatel klíč registrovat nesmí — panel se ani neotevře + try { + $this->_passkeyService->assertNotSso($this->_securityUser->getIdentity()); + } catch (PasskeyException $e) { + $this->flashMessageError($e->getMessage()); + $this->getPresenter()->redirect('this'); + } + + $this->redrawSidePanel('addPasskey'); + } + + /** + * AJAX signal — vrátí PublicKeyCredentialCreationOptions pro registraci nového + * klíče přihlášené identity (binárky base64url, challenge one-shot v session). + */ + public function handlePasskeyRegisterArgs(): void + { + try { + $args = $this->_passkeyService->getRegistrationArgs($this->_securityUser->getIdentity()); + } catch (PasskeyException $e) { + $this->getPresenter()->sendJson(['error' => $e->getMessage()]); + } + + $this->getPresenter()->sendJson($args); + } + + /** + * AJAX signal — ověří odpověď autentikátoru (JSON tělo: {name, credential}) + * a uloží nový klíč. Vrací {redirect} pro reload stránky, nebo {error}. + */ + public function handlePasskeyRegisterVerify(): void + { + try { + $data = Json::decode((string) $this->getPresenter()->getHttpRequest()->getRawBody(), true); + } catch (JsonException) { + $data = null; + } + + $response = is_array($data) ? ($data['credential']['response'] ?? null) : null; + $clientDataJSON = is_array($response) ? PasskeyService::base64UrlDecode($response['clientDataJSON'] ?? null) : null; + $attestationObject = is_array($response) ? PasskeyService::base64UrlDecode($response['attestationObject'] ?? null) : null; + $transports = is_array($response) && is_array($response['transports'] ?? null) ? $response['transports'] : null; + + try { + if ($clientDataJSON === null || $attestationObject === null) { + throw new PasskeyException($this->_translator->translate('fcadmin.passkeys.errors.invalidKey')); + } + + $this->_passkeyService->processRegistration( + $this->_securityUser->getIdentity(), + $clientDataJSON, + $attestationObject, + is_string($data['name'] ?? null) ? $data['name'] : '', + $transports, + ); + } catch (PasskeyException $e) { + $this->getPresenter()->sendJson(['error' => $e->getMessage()]); + } + + $this->flashMessageSuccess('fcadmin.passkeys.messages.added'); + $this->getPresenter()->redirect('this'); + } + + public function createComponentAddPasskeySidePanel(SidePanelControlFactory $factory): SidePanelControl + { + return $factory->create() + ->setFormFactory(fn() => $this->_passkeyFormFactory->create()); + } } diff --git a/src/UI/Presenters/Account/default.latte b/src/UI/Presenters/Account/default.latte index 0a259db..0d1e760 100644 --- a/src/UI/Presenters/Account/default.latte +++ b/src/UI/Presenters/Account/default.latte @@ -38,6 +38,18 @@ +
+

+ {_fcadmin.passkeys.account.title} +

+ + +
+ +{control passkeyGrid} +

{_fcadmin.presenters.account.sessions} diff --git a/src/lang/fcadmin.cs.yml b/src/lang/fcadmin.cs.yml index 9ed4196..5a622d3 100644 --- a/src/lang/fcadmin.cs.yml +++ b/src/lang/fcadmin.cs.yml @@ -346,6 +346,39 @@ keycloak: noEmail: Účet SSO nemá přiřazený e-mail. loopDetected: Bylo detekováno opakované přesměrování. Zkuste to prosím znovu. +passkeys: + loginButton: Přihlásit se přihlašovacím klíčem + account: + title: Přihlašovací klíče + add: Přidat klíč + form: + name: Název klíče + namePlaceholder: Např. Pracovní notebook + register: Vytvořit přihlašovací klíč + errors: + nameRequired: Název klíče je povinný + grid: + name: Název + createdAt: Vytvořeno + lastUsedAt: Naposledy použito + synced: Synchronizovaný + delete: Smazat + confirms: + delete: Opravdu chcete smazat tento přihlašovací klíč? + messages: + added: Přihlašovací klíč byl přidán. + deleted: Přihlašovací klíč byl smazán. + errors: + unsupportedBrowser: Váš prohlížeč nepodporuje přihlašovací klíče (passkeys). + unknownKey: Neplatný nebo neznámý přihlašovací klíč. + invalidKey: Přihlašovací klíč se nepodařilo ověřit. + alreadyRegistered: Tento přihlašovací klíč je již zaregistrován. + expiredChallenge: Platnost výzvy vypršela. Zkuste to prosím znovu. + ssoAccount: Účet přihlašovaný přes SSO nemůže používat přihlašovací klíče. + unavailable: Přihlašovací klíče nejsou momentálně dostupné. + loginFailed: Přihlášení přihlašovacím klíčem se nezdařilo. Zkuste to prosím znovu. + registrationFailed: Přihlašovací klíč se nepodařilo vytvořit. Zkuste to prosím znovu. + modules: web: navbar: diff --git a/src/lang/fcadmin.sk.yml b/src/lang/fcadmin.sk.yml index e6b4350..9c0553e 100644 --- a/src/lang/fcadmin.sk.yml +++ b/src/lang/fcadmin.sk.yml @@ -344,6 +344,39 @@ keycloak: noEmail: Účet SSO nemá priradený e-mail. loopDetected: Bolo detegované opakované presmerovanie. Skúste to prosím znovu. +passkeys: + loginButton: Prihlásiť sa prihlasovacím kľúčom + account: + title: Prihlasovacie kľúče + add: Pridať kľúč + form: + name: Názov kľúča + namePlaceholder: Napr. Pracovný notebook + register: Vytvoriť prihlasovací kľúč + errors: + nameRequired: Názov kľúča je povinný + grid: + name: Názov + createdAt: Vytvorené + lastUsedAt: Naposledy použité + synced: Synchronizovaný + delete: Zmazať + confirms: + delete: Naozaj chcete zmazať tento prihlasovací kľúč? + messages: + added: Prihlasovací kľúč bol pridaný. + deleted: Prihlasovací kľúč bol zmazaný. + errors: + unsupportedBrowser: Váš prehliadač nepodporuje prihlasovacie kľúče (passkeys). + unknownKey: Neplatný alebo neznámy prihlasovací kľúč. + invalidKey: Prihlasovací kľúč sa nepodarilo overiť. + alreadyRegistered: Tento prihlasovací kľúč je už zaregistrovaný. + expiredChallenge: Platnosť výzvy vypršala. Skúste to prosím znovu. + ssoAccount: Účet prihlasovaný cez SSO nemôže používať prihlasovacie kľúče. + unavailable: Prihlasovacie kľúče nie sú momentálne dostupné. + loginFailed: Prihlásenie prihlasovacím kľúčom sa nepodarilo. Skúste to prosím znovu. + registrationFailed: Prihlasovací kľúč sa nepodarilo vytvoriť. Skúste to prosím znovu. + modules: web: navbar: