From 464113c7692e81bd655eb330086f856ea30d78b8 Mon Sep 17 00:00:00 2001 From: Tobias Gruber Date: Tue, 19 May 2026 22:49:01 +0200 Subject: [PATCH 1/9] Add WebAuthn (Yubikey) as a second factor - Claude Code assisted --- Classes/Controller/BackendController.php | 59 +++- Classes/Controller/LoginController.php | 259 ++++++++++++++--- Classes/Domain/Model/SecondFactor.php | 29 ++ .../Repository/SecondFactorRepository.php | 34 ++- .../SecondFactorMethodInterface.php | 41 +++ .../SecondFactorMethodRegistry.php | 65 +++++ .../Domain/SecondFactorMethod/TotpMethod.php | 37 +++ .../SecondFactorMethod/WebAuthnMethod.php | 37 +++ ...icKeyCredentialSourceRepositoryAdapter.php | 77 +++++ .../SecondFactorSessionStorageService.php | 47 +++- Classes/Service/WebAuthnService.php | 263 ++++++++++++++++++ Configuration/Routes.yaml | 56 ++++ Configuration/Settings.yaml | 15 + Migrations/Mysql/Version20260519120000.php | 39 +++ README.md | 34 ++- .../Integration/Controller/Backend/New.fusion | 145 ++-------- .../Controller/Backend/NewTotp.fusion | 134 +++++++++ .../Controller/Backend/NewWebAuthn.fusion | 30 ++ .../Login/AskForSecondFactor.fusion | 24 ++ .../Controller/Login/SetupSecondFactor.fusion | 25 +- .../Controller/Login/SetupTotp.fusion | 13 + .../Controller/Login/SetupWebAuthn.fusion | 12 + .../Components/LoginWebAuthnStep.fusion | 47 ++++ .../Components/MethodPicker.fusion | 22 ++ .../Components/SetupTotpStep.fusion | 91 ++++++ .../Components/SetupWebAuthnStep.fusion | 36 +++ .../Pages/LoginSecondFactorPage.fusion | 25 +- .../Pages/SetupSecondFactorPage.fusion | 92 +----- Resources/Private/Translations/de/Backend.xlf | 12 + Resources/Private/Translations/de/Main.xlf | 56 ++++ Resources/Private/Translations/en/Backend.xlf | 9 + Resources/Private/Translations/en/Main.xlf | 42 +++ Resources/Public/JavaScript/webauthn.js | 171 ++++++++++++ Resources/Public/Styles/Login.css | 73 +++++ composer.json | 3 +- 35 files changed, 1883 insertions(+), 271 deletions(-) create mode 100644 Classes/Domain/SecondFactorMethod/SecondFactorMethodInterface.php create mode 100644 Classes/Domain/SecondFactorMethod/SecondFactorMethodRegistry.php create mode 100644 Classes/Domain/SecondFactorMethod/TotpMethod.php create mode 100644 Classes/Domain/SecondFactorMethod/WebAuthnMethod.php create mode 100644 Classes/Service/PublicKeyCredentialSourceRepositoryAdapter.php create mode 100644 Classes/Service/WebAuthnService.php create mode 100644 Migrations/Mysql/Version20260519120000.php create mode 100644 Resources/Private/Fusion/Integration/Controller/Backend/NewTotp.fusion create mode 100644 Resources/Private/Fusion/Integration/Controller/Backend/NewWebAuthn.fusion create mode 100644 Resources/Private/Fusion/Integration/Controller/Login/SetupTotp.fusion create mode 100644 Resources/Private/Fusion/Integration/Controller/Login/SetupWebAuthn.fusion create mode 100644 Resources/Private/Fusion/Presentation/Components/LoginWebAuthnStep.fusion create mode 100644 Resources/Private/Fusion/Presentation/Components/MethodPicker.fusion create mode 100644 Resources/Private/Fusion/Presentation/Components/SetupTotpStep.fusion create mode 100644 Resources/Private/Fusion/Presentation/Components/SetupWebAuthnStep.fusion create mode 100644 Resources/Public/JavaScript/webauthn.js diff --git a/Classes/Controller/BackendController.php b/Classes/Controller/BackendController.php index 15b017f..18ca948 100644 --- a/Classes/Controller/BackendController.php +++ b/Classes/Controller/BackendController.php @@ -21,6 +21,7 @@ use Sandstorm\NeosTwoFactorAuthentication\Domain\Model\Dto\SecondFactorDto; use Sandstorm\NeosTwoFactorAuthentication\Domain\Model\SecondFactor; use Sandstorm\NeosTwoFactorAuthentication\Domain\Repository\SecondFactorRepository; +use Sandstorm\NeosTwoFactorAuthentication\Domain\SecondFactorMethod\SecondFactorMethodRegistry; use Sandstorm\NeosTwoFactorAuthentication\Service\SecondFactorService; use Sandstorm\NeosTwoFactorAuthentication\Service\SecondFactorSessionStorageService; use Sandstorm\NeosTwoFactorAuthentication\Service\TOTPService; @@ -80,6 +81,12 @@ class BackendController extends AbstractModuleController */ protected $secondFactorService; + /** + * @Flow\Inject + * @var SecondFactorMethodRegistry + */ + protected $methodRegistry; + /** * @Flow\Inject * @var PersistenceManagerInterface @@ -118,9 +125,29 @@ public function indexAction() } /** - * show the form to register a new second factor + * Method picker shown when the user clicks "Add second factor" inside the backend module. */ public function newAction(): void + { + $account = $this->securityContext->getAccount(); + $currentUser = $this->partyService->getAssignedPartyOfAccount($account); + + $this->view->assignMultiple([ + 'styles' => array_filter($this->getNeosSettings()['userInterface']['backendLoginForm']['stylesheets']), + 'scripts' => array_filter($this->getNeosSettings()['userInterface']['backendLoginForm']['scripts']), + 'currentUser' => $currentUser instanceof User ? $currentUser : null, + 'accountIdentifier' => $account->getAccountIdentifier(), + 'methods' => $this->methodRegistry->getAll(), + 'flashMessages' => $this->flashMessageService + ->getFlashMessageContainerForRequest($this->request) + ->getMessagesAndFlush(), + ]); + } + + /** + * TOTP wizard (extracted from the previous newAction). + */ + public function newTotpAction(): void { $otp = TOTPService::generateNewTotp(); $secret = $otp->getSecret(); @@ -143,7 +170,27 @@ public function newAction(): void } /** - * save the registered second factor + * WebAuthn setup wizard. The JS on the page talks to LoginController's + * webAuthnRegister(Options|Verify)Action XHR endpoints. + */ + public function newWebAuthnAction(): void + { + $account = $this->securityContext->getAccount(); + $currentUser = $this->partyService->getAssignedPartyOfAccount($account); + + $this->view->assignMultiple([ + 'styles' => array_filter($this->getNeosSettings()['userInterface']['backendLoginForm']['stylesheets']), + 'scripts' => array_filter($this->getNeosSettings()['userInterface']['backendLoginForm']['scripts']), + 'currentUser' => $currentUser instanceof User ? $currentUser : null, + 'accountIdentifier' => $account->getAccountIdentifier(), + 'flashMessages' => $this->flashMessageService + ->getFlashMessageContainerForRequest($this->request) + ->getMessagesAndFlush(), + ]); + } + + /** + * save the registered second factor (TOTP) * * @throws SessionNotStartedException * @throws IllegalObjectTypeException @@ -166,10 +213,14 @@ public function createAction(string $secret, string $secondFactorFromApp): void '', Message::SEVERITY_WARNING ); - $this->redirect('new'); + $this->redirect('newTotp'); } - $this->secondFactorRepository->createSecondFactorForAccount($secret, $this->securityContext->getAccount()); + $this->secondFactorRepository->createSecondFactorForAccount( + $secret, + $this->securityContext->getAccount(), + SecondFactor::TYPE_TOTP + ); $this->secondFactorSessionStorageService->setAuthenticationStatus(AuthenticationStatus::AUTHENTICATED); diff --git a/Classes/Controller/LoginController.php b/Classes/Controller/LoginController.php index 33c7ebc..abd66ef 100644 --- a/Classes/Controller/LoginController.php +++ b/Classes/Controller/LoginController.php @@ -24,8 +24,12 @@ use Sandstorm\NeosTwoFactorAuthentication\Domain\AuthenticationStatus; use Sandstorm\NeosTwoFactorAuthentication\Domain\Model\SecondFactor; use Sandstorm\NeosTwoFactorAuthentication\Domain\Repository\SecondFactorRepository; +use Sandstorm\NeosTwoFactorAuthentication\Domain\SecondFactorMethod\SecondFactorMethodRegistry; use Sandstorm\NeosTwoFactorAuthentication\Service\SecondFactorSessionStorageService; use Sandstorm\NeosTwoFactorAuthentication\Service\TOTPService; +use Sandstorm\NeosTwoFactorAuthentication\Service\WebAuthnService; +use Webauthn\PublicKeyCredentialCreationOptions; +use Webauthn\PublicKeyCredentialRequestOptions; class LoginController extends ActionController { @@ -34,6 +38,13 @@ class LoginController extends ActionController */ protected $defaultViewObjectName = FusionView::class; + protected $supportedMediaTypes = ['text/html', 'application/json']; + + protected $viewFormatToObjectNameMap = [ + 'json' => \Neos\Flow\Mvc\View\JsonView::class, + 'html' => FusionView::class, + ]; + /** * @var SecurityContext * @Flow\Inject @@ -76,6 +87,18 @@ class LoginController extends ActionController */ protected $tOTPService; + /** + * @Flow\Inject + * @var WebAuthnService + */ + protected $webAuthnService; + + /** + * @Flow\Inject + * @var SecondFactorMethodRegistry + */ + protected $methodRegistry; + /** * @Flow\Inject * @var Translator @@ -83,21 +106,29 @@ class LoginController extends ActionController protected $translator; /** - * This action decides which tokens are already authenticated - * and decides which is next to authenticate - * - * ATTENTION: this code is copied from the Neos.Neos:LoginController + * Adaptive 2FA challenge screen — shows whichever methods the account has registered. */ public function askForSecondFactorAction(?string $username = null): void { + $account = $this->securityContext->getAccount(); $currentDomain = $this->domainRepository->findOneByActiveRequest(); $currentSite = $currentDomain !== null ? $currentDomain->getSite() : $this->siteRepository->findDefault(); + $availableMethodTypes = []; + if ($account !== null) { + foreach ($this->secondFactorRepository->findByAccount($account) as $factor) { + /** @var SecondFactor $factor */ + $availableMethodTypes[$factor->getType()] = true; + } + } + $this->view->assignMultiple([ 'styles' => array_filter($this->getNeosSettings()['userInterface']['backendLoginForm']['stylesheets']), 'scripts' => array_filter($this->getNeosSettings()['userInterface']['backendLoginForm']['scripts']), 'username' => $username, 'site' => $currentSite, + 'hasTotp' => isset($availableMethodTypes[SecondFactor::TYPE_TOTP]), + 'hasWebAuthn' => isset($availableMethodTypes[SecondFactor::TYPE_PUBLIC_KEY]), 'flashMessages' => $this->flashMessageService ->getFlashMessageContainerForRequest($this->request) ->getMessagesAndFlush(), @@ -112,7 +143,7 @@ public function checkSecondFactorAction(string $otp): void { $account = $this->securityContext->getAccount(); - $isValidOtp = $this->enteredTokenMatchesAnySecondFactor($otp, $account); + $isValidOtp = $this->enteredTotpMatchesAnyTotpFactor($otp, $account); if ($isValidOtp) { $this->secondFactorSessionStorageService->setAuthenticationStatus(AuthenticationStatus::AUTHENTICATED); @@ -138,21 +169,34 @@ public function checkSecondFactorAction(string $otp): void ); } - $originalRequest = $this->securityContext->getInterceptedRequest(); - if ($originalRequest !== null) { - $this->redirectToRequest($originalRequest); - } - - $this->redirect('index', 'Backend\Backend', 'Neos.Neos'); + $this->redirectToInterceptedRequestOrBackend(); } /** - * This action decides which tokens are already authenticated - * and decides which is next to authenticate - * - * ATTENTION: this code is copied from the Neos.Neos:LoginController + * Method-picker page shown when an account that doesn't have a 2FA yet is forced + * to set one up before continuing. */ public function setupSecondFactorAction(?string $username = null): void + { + $currentDomain = $this->domainRepository->findOneByActiveRequest(); + $currentSite = $currentDomain !== null ? $currentDomain->getSite() : $this->siteRepository->findDefault(); + + $this->view->assignMultiple([ + 'styles' => array_filter($this->getNeosSettings()['userInterface']['backendLoginForm']['stylesheets']), + 'scripts' => array_filter($this->getNeosSettings()['userInterface']['backendLoginForm']['scripts']), + 'username' => $username, + 'site' => $currentSite, + 'methods' => $this->methodRegistry->getAll(), + 'flashMessages' => $this->flashMessageService + ->getFlashMessageContainerForRequest($this->request) + ->getMessagesAndFlush(), + ]); + } + + /** + * TOTP-specific setup wizard (QR code + manual secret + form). + */ + public function setupTotpAction(?string $username = null): void { $otp = TOTPService::generateNewTotp(); $secret = $otp->getSecret(); @@ -174,6 +218,26 @@ public function setupSecondFactorAction(?string $username = null): void ]); } + /** + * WebAuthn-specific setup wizard. The page loads JS which calls the + * register-options and register-verify XHR endpoints. + */ + public function setupWebAuthnAction(?string $username = null): void + { + $currentDomain = $this->domainRepository->findOneByActiveRequest(); + $currentSite = $currentDomain !== null ? $currentDomain->getSite() : $this->siteRepository->findDefault(); + + $this->view->assignMultiple([ + 'styles' => array_filter($this->getNeosSettings()['userInterface']['backendLoginForm']['stylesheets']), + 'scripts' => array_filter($this->getNeosSettings()['userInterface']['backendLoginForm']['scripts']), + 'username' => $username, + 'site' => $currentSite, + 'flashMessages' => $this->flashMessageService + ->getFlashMessageContainerForRequest($this->request) + ->getMessagesAndFlush(), + ]); + } + /** * @param string $secret * @param string $secondFactorFromApp @@ -199,12 +263,11 @@ public function createSecondFactorAction(string $secret, string $secondFactorFro '', Message::SEVERITY_WARNING ); - $this->redirect('setupSecondFactor'); + $this->redirect('setupTotp'); } $account = $this->securityContext->getAccount(); - - $this->secondFactorRepository->createSecondFactorForAccount($secret, $account); + $this->secondFactorRepository->createSecondFactorForAccount($secret, $account, SecondFactor::TYPE_TOTP); $this->addFlashMessage( $this->translator->translateById( @@ -218,36 +281,164 @@ public function createSecondFactorAction(string $secret, string $secondFactorFro ); $this->secondFactorSessionStorageService->setAuthenticationStatus(AuthenticationStatus::AUTHENTICATED); + $this->redirectToInterceptedRequestOrBackend(); + } - $originalRequest = $this->securityContext->getInterceptedRequest(); - if ($originalRequest !== null) { - $this->redirectToRequest($originalRequest); + // ------------------------------------------------------------------ + // WebAuthn XHR endpoints (registration ceremony) + // ------------------------------------------------------------------ + + /** + * @Flow\SkipCsrfProtection + */ + public function webAuthnRegisterOptionsAction(): string + { + $account = $this->securityContext->getAccount(); + $hostname = $this->request->getHttpRequest()->getUri()->getHost(); + $options = $this->webAuthnService->createRegistrationOptions($account, $hostname); + $this->secondFactorSessionStorageService->putValue( + SecondFactorSessionStorageService::SESSION_OBJECT_WEBAUTHN_REGISTRATION_OPTIONS, + json_encode($options, JSON_THROW_ON_ERROR) + ); + $this->response->setContentType('application/json'); + return json_encode($options, JSON_THROW_ON_ERROR); + } + + /** + * @Flow\SkipCsrfProtection + * @throws SessionNotStartedException + * @throws StopActionException + */ + public function webAuthnRegisterVerifyAction(string $attestation): string + { + $serialized = $this->secondFactorSessionStorageService->getValue( + SecondFactorSessionStorageService::SESSION_OBJECT_WEBAUTHN_REGISTRATION_OPTIONS + ); + if (!is_string($serialized)) { + return $this->jsonError('No registration in progress', 400); + } + $options = PublicKeyCredentialCreationOptions::createFromString($serialized); + $account = $this->securityContext->getAccount(); + try { + $this->webAuthnService->verifyAndPersistRegistration( + $attestation, + $options, + $account, + $this->request->getHttpRequest() + ); + } catch (\Throwable $e) { + return $this->jsonError($e->getMessage(), 400); } - $this->redirect('index', 'Backend\Backend', 'Neos.Neos'); + $this->secondFactorSessionStorageService->removeValue( + SecondFactorSessionStorageService::SESSION_OBJECT_WEBAUTHN_REGISTRATION_OPTIONS + ); + $this->secondFactorSessionStorageService->setAuthenticationStatus(AuthenticationStatus::AUTHENTICATED); + + $this->response->setContentType('application/json'); + return json_encode(['status' => 'ok'], JSON_THROW_ON_ERROR); + } + + // ------------------------------------------------------------------ + // WebAuthn XHR endpoints (authentication ceremony) + // ------------------------------------------------------------------ + + /** + * @Flow\SkipCsrfProtection + */ + public function webAuthnAuthenticateOptionsAction(): string + { + $account = $this->securityContext->getAccount(); + $options = $this->webAuthnService->createAuthenticationOptions($account); + $this->secondFactorSessionStorageService->putValue( + SecondFactorSessionStorageService::SESSION_OBJECT_WEBAUTHN_AUTHENTICATION_OPTIONS, + json_encode($options, JSON_THROW_ON_ERROR) + ); + $this->response->setContentType('application/json'); + return json_encode($options, JSON_THROW_ON_ERROR); + } + + /** + * @Flow\SkipCsrfProtection + */ + public function webAuthnAuthenticateVerifyAction(string $assertion): string + { + $serialized = $this->secondFactorSessionStorageService->getValue( + SecondFactorSessionStorageService::SESSION_OBJECT_WEBAUTHN_AUTHENTICATION_OPTIONS + ); + if (!is_string($serialized)) { + return $this->jsonError('No authentication in progress', 400); + } + $options = PublicKeyCredentialRequestOptions::createFromString($serialized); + $account = $this->securityContext->getAccount(); + + try { + $this->webAuthnService->verifyAuthenticationResponse( + $assertion, + $options, + $account, + $this->request->getHttpRequest() + ); + } catch (\Throwable $e) { + return $this->jsonError($e->getMessage(), 400); + } + + $this->secondFactorSessionStorageService->removeValue( + SecondFactorSessionStorageService::SESSION_OBJECT_WEBAUTHN_AUTHENTICATION_OPTIONS + ); + $this->secondFactorSessionStorageService->setAuthenticationStatus(AuthenticationStatus::AUTHENTICATED); + + $originalRequest = $this->securityContext->getInterceptedRequest(); + $redirectUri = $originalRequest !== null + ? (string)$this->controllerContext->getUriBuilder()->uriFor( + $originalRequest->getControllerActionName(), + $originalRequest->getArguments(), + $originalRequest->getControllerName(), + $originalRequest->getControllerPackageKey() + ) + : '/neos'; + + $this->response->setContentType('application/json'); + return json_encode(['status' => 'ok', 'redirect' => $redirectUri], JSON_THROW_ON_ERROR); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private function jsonError(string $message, int $code): string + { + $this->response->setStatusCode($code); + $this->response->setContentType('application/json'); + return json_encode(['status' => 'error', 'message' => $message], JSON_THROW_ON_ERROR); } /** - * Check if the given token matches any registered second factor - * - * @param string $enteredSecondFactor - * @param Account $account - * @return bool + * Check the submitted OTP against every TOTP factor for the account. */ - private function enteredTokenMatchesAnySecondFactor(string $enteredSecondFactor, Account $account): bool + private function enteredTotpMatchesAnyTotpFactor(string $enteredSecondFactor, Account $account): bool { - /** @var SecondFactor[] $secondFactors */ - $secondFactors = $this->secondFactorRepository->findByAccount($account); - foreach ($secondFactors as $secondFactor) { - $isValid = TOTPService::checkIfOtpIsValid($secondFactor->getSecret(), $enteredSecondFactor); - if ($isValid) { + $totpFactors = $this->secondFactorRepository->findByAccountAndType($account, SecondFactor::TYPE_TOTP); + foreach ($totpFactors as $secondFactor) { + if (TOTPService::checkIfOtpIsValid($secondFactor->getSecret(), $enteredSecondFactor)) { return true; } } - return false; } + /** + * @throws StopActionException + */ + private function redirectToInterceptedRequestOrBackend(): void + { + $originalRequest = $this->securityContext->getInterceptedRequest(); + if ($originalRequest !== null) { + $this->redirectToRequest($originalRequest); + } + $this->redirect('index', 'Backend\Backend', 'Neos.Neos'); + } + /** * @return array * @throws InvalidConfigurationTypeException diff --git a/Classes/Domain/Model/SecondFactor.php b/Classes/Domain/Model/SecondFactor.php index 569d9d4..7fb3d69 100644 --- a/Classes/Domain/Model/SecondFactor.php +++ b/Classes/Domain/Model/SecondFactor.php @@ -35,7 +35,11 @@ class SecondFactor protected int $type; /** + * For TYPE_TOTP this is a base32-encoded shared secret. + * For TYPE_PUBLIC_KEY this is the JSON-serialized WebAuthn credential source. + * * @var string + * @ORM\Column(type="text") */ protected string $secret; @@ -114,6 +118,31 @@ public function setCreationDate(DateTime $creationDate): void $this->creationDate = $creationDate; } + /** + * Decode the credential data stored in `secret` for non-TOTP factors. + * + * @return array + */ + public function getCredentialData(): array + { + $decoded = json_decode($this->secret, true); + if (!is_array($decoded)) { + throw new InvalidArgumentException('Stored credential data is not valid JSON'); + } + return $decoded; + } + + /** + * Encode credential data as JSON for non-TOTP factors. + * + * @param array $data + */ + public function setCredentialData(array $data): void + { + $encoded = json_encode($data, JSON_THROW_ON_ERROR); + $this->secret = $encoded; + } + public function __toString(): string { return $this->account->getAccountIdentifier() . " with " . self::typeToString($this->type); diff --git a/Classes/Domain/Repository/SecondFactorRepository.php b/Classes/Domain/Repository/SecondFactorRepository.php index 08aa1fe..7789c1a 100644 --- a/Classes/Domain/Repository/SecondFactorRepository.php +++ b/Classes/Domain/Repository/SecondFactorRepository.php @@ -24,14 +24,44 @@ class SecondFactorRepository extends Repository /** * @throws IllegalObjectTypeException */ - public function createSecondFactorForAccount(string $secret, Account $account): void + public function createSecondFactorForAccount(string $secret, Account $account, int $type = SecondFactor::TYPE_TOTP): SecondFactor { $secondFactor = new SecondFactor(); $secondFactor->setAccount($account); $secondFactor->setSecret($secret); - $secondFactor->setType(SecondFactor::TYPE_TOTP); + $secondFactor->setType($type); $secondFactor->setCreationDate(new \DateTime()); $this->add($secondFactor); $this->persistenceManager->persistAll(); + return $secondFactor; + } + + /** + * @return SecondFactor[] + */ + public function findByAccountAndType(Account $account, int $type): array + { + $query = $this->createQuery(); + return $query + ->matching( + $query->logicalAnd( + $query->equals('account', $account), + $query->equals('type', $type) + ) + ) + ->execute() + ->toArray(); + } + + /** + * @return SecondFactor[] + */ + public function findAllByType(int $type): array + { + $query = $this->createQuery(); + return $query + ->matching($query->equals('type', $type)) + ->execute() + ->toArray(); } } diff --git a/Classes/Domain/SecondFactorMethod/SecondFactorMethodInterface.php b/Classes/Domain/SecondFactorMethod/SecondFactorMethodInterface.php new file mode 100644 index 0000000..ce3e3a9 --- /dev/null +++ b/Classes/Domain/SecondFactorMethod/SecondFactorMethodInterface.php @@ -0,0 +1,41 @@ +register($this->totpMethod); + $this->register($this->webAuthnMethod); + } + + private function register(SecondFactorMethodInterface $method): void + { + $this->methodsByType[$method->getType()] = $method; + $this->methodsByIdentifier[$method->getIdentifier()] = $method; + } + + /** + * @return SecondFactorMethodInterface[] + */ + public function getAll(): array + { + return array_values($this->methodsByType); + } + + public function getByType(int $type): ?SecondFactorMethodInterface + { + return $this->methodsByType[$type] ?? null; + } + + public function getByIdentifier(string $identifier): ?SecondFactorMethodInterface + { + return $this->methodsByIdentifier[$identifier] ?? null; + } +} diff --git a/Classes/Domain/SecondFactorMethod/TotpMethod.php b/Classes/Domain/SecondFactorMethod/TotpMethod.php new file mode 100644 index 0000000..85bdf79 --- /dev/null +++ b/Classes/Domain/SecondFactorMethod/TotpMethod.php @@ -0,0 +1,37 @@ +iterateAllWebAuthnFactors() as [$factor, $source]) { + if ($source->getPublicKeyCredentialId() === $publicKeyCredentialId) { + return $source; + } + } + return null; + } + + /** + * @return PublicKeyCredentialSource[] + */ + public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity): array + { + $userHandle = $publicKeyCredentialUserEntity->getId(); + $sources = []; + foreach ($this->iterateAllWebAuthnFactors() as [$factor, $source]) { + if ($source->getUserHandle() === $userHandle) { + $sources[] = $source; + } + } + return $sources; + } + + public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource): void + { + // Update path: find the existing factor for this credential and bump the counter. + foreach ($this->iterateAllWebAuthnFactors() as [$factor, $source]) { + if ($source->getPublicKeyCredentialId() === $publicKeyCredentialSource->getPublicKeyCredentialId()) { + $factor->setCredentialData($publicKeyCredentialSource->jsonSerialize()); + $this->secondFactorRepository->update($factor); + return; + } + } + // No existing factor — initial registration is handled explicitly by + // WebAuthnService::persistNewCredential() so we ignore this branch. + } + + /** + * @return \Generator + */ + private function iterateAllWebAuthnFactors(): \Generator + { + foreach ($this->secondFactorRepository->findAllByType(SecondFactor::TYPE_PUBLIC_KEY) as $factor) { + yield [$factor, PublicKeyCredentialSource::createFromArray($factor->getCredentialData())]; + } + } +} diff --git a/Classes/Service/SecondFactorSessionStorageService.php b/Classes/Service/SecondFactorSessionStorageService.php index edb75f4..84e6e77 100644 --- a/Classes/Service/SecondFactorSessionStorageService.php +++ b/Classes/Service/SecondFactorSessionStorageService.php @@ -11,6 +11,8 @@ class SecondFactorSessionStorageService { const SESSION_OBJECT_ID = 'Sandstorm/NeosTwoFactorAuthentication'; const SESSION_OBJECT_AUTH_STATUS = 'authenticationStatus'; + const SESSION_OBJECT_WEBAUTHN_REGISTRATION_OPTIONS = 'webAuthnRegistrationOptions'; + const SESSION_OBJECT_WEBAUTHN_AUTHENTICATION_OPTIONS = 'webAuthnAuthenticationOptions'; /** * @Flow\Inject @@ -23,12 +25,10 @@ class SecondFactorSessionStorageService */ public function setAuthenticationStatus(string $authenticationStatus): void { - $this->sessionManager->getCurrentSession()->putData( - self::SESSION_OBJECT_ID, - [ - self::SESSION_OBJECT_AUTH_STATUS => $authenticationStatus, - ] - ); + $session = $this->sessionManager->getCurrentSession(); + $data = $session->getData(self::SESSION_OBJECT_ID) ?: []; + $data[self::SESSION_OBJECT_AUTH_STATUS] = $authenticationStatus; + $session->putData(self::SESSION_OBJECT_ID, $data); } /** @@ -50,4 +50,39 @@ public function initializeTwoFactorSessionObject(): void self::setAuthenticationStatus(AuthenticationStatus::AUTHENTICATION_NEEDED); } } + + /** + * Persist arbitrary data under a key inside the package's session object, + * preserving the existing authentication status entry. + * + * @throws SessionNotStartedException + */ + public function putValue(string $key, mixed $value): void + { + $session = $this->sessionManager->getCurrentSession(); + $data = $session->getData(self::SESSION_OBJECT_ID) ?: []; + $data[$key] = $value; + $session->putData(self::SESSION_OBJECT_ID, $data); + } + + /** + * @throws SessionNotStartedException + */ + public function getValue(string $key): mixed + { + $session = $this->sessionManager->getCurrentSession(); + $data = $session->getData(self::SESSION_OBJECT_ID) ?: []; + return $data[$key] ?? null; + } + + /** + * @throws SessionNotStartedException + */ + public function removeValue(string $key): void + { + $session = $this->sessionManager->getCurrentSession(); + $data = $session->getData(self::SESSION_OBJECT_ID) ?: []; + unset($data[$key]); + $session->putData(self::SESSION_OBJECT_ID, $data); + } } diff --git a/Classes/Service/WebAuthnService.php b/Classes/Service/WebAuthnService.php new file mode 100644 index 0000000..41deea2 --- /dev/null +++ b/Classes/Service/WebAuthnService.php @@ -0,0 +1,263 @@ +relyingPartyName ?: 'Neos', + $this->relyingPartyId ?: $hostname + ); + + $userEntity = $this->buildUserEntity($account); + + // Exclude already-registered credentials so the browser refuses to register the same key twice. + $excludeCredentials = array_map( + fn(PublicKeyCredentialSource $src): PublicKeyCredentialDescriptor => $src->getPublicKeyCredentialDescriptor(), + $this->credentialSourceRepository->findAllForUserEntity($userEntity) + ); + + $challenge = random_bytes(32); + + $publicKeyCredentialParametersList = [ + new PublicKeyCredentialParameters('public-key', ECDSA\ES256::ID), + new PublicKeyCredentialParameters('public-key', ECDSA\ES384::ID), + new PublicKeyCredentialParameters('public-key', ECDSA\ES512::ID), + new PublicKeyCredentialParameters('public-key', RSA\RS256::ID), + new PublicKeyCredentialParameters('public-key', EdDSA\Ed25519::ID), + ]; + + $authenticatorSelection = AuthenticatorSelectionCriteria::create() + ->setUserVerification($this->userVerification); + + return PublicKeyCredentialCreationOptions::create( + $rp, + $userEntity, + $challenge, + $publicKeyCredentialParametersList + ) + ->setTimeout($this->timeoutMs) + ->setAuthenticatorSelection($authenticatorSelection) + ->setAttestation(PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE) + ->excludeCredentials(...$excludeCredentials); + } + + /** + * Verify the attestation response returned by the browser and persist + * the new credential as a SecondFactor row. + * + * @throws \Throwable when validation fails + */ + public function verifyAndPersistRegistration( + string $attestationResponseJson, + PublicKeyCredentialCreationOptions $options, + Account $account, + ServerRequestInterface $request + ): SecondFactor { + $publicKeyCredentialLoader = $this->buildCredentialLoader(); + $publicKeyCredential = $publicKeyCredentialLoader->load($attestationResponseJson); + $authenticatorResponse = $publicKeyCredential->getResponse(); + if (!$authenticatorResponse instanceof AuthenticatorAttestationResponse) { + throw new \RuntimeException('Response is not an AuthenticatorAttestationResponse', 1747750000); + } + + $validator = $this->buildAttestationValidator(); + $credentialSource = $validator->check($authenticatorResponse, $options, $request); + + return $this->secondFactorRepository->createSecondFactorForAccount( + json_encode($credentialSource->jsonSerialize(), JSON_THROW_ON_ERROR), + $account, + SecondFactor::TYPE_PUBLIC_KEY + ); + } + + /** + * Build a request options object that the browser passes to + * `navigator.credentials.get()`. + */ + public function createAuthenticationOptions(Account $account): PublicKeyCredentialRequestOptions + { + $userEntity = $this->buildUserEntity($account); + $allowedCredentials = array_map( + fn(PublicKeyCredentialSource $src): PublicKeyCredentialDescriptor => $src->getPublicKeyCredentialDescriptor(), + $this->credentialSourceRepository->findAllForUserEntity($userEntity) + ); + + return PublicKeyCredentialRequestOptions::create(random_bytes(32)) + ->setTimeout($this->timeoutMs) + ->setRpId($this->relyingPartyId) + ->setUserVerification($this->userVerification) + ->allowCredentials(...$allowedCredentials); + } + + /** + * Verify the assertion response returned by the browser. On success returns + * the updated credential source (counter bumped) and the matching SecondFactor. + * + * @throws \Throwable when validation fails + */ + public function verifyAuthenticationResponse( + string $assertionResponseJson, + PublicKeyCredentialRequestOptions $options, + Account $account, + ServerRequestInterface $request + ): PublicKeyCredentialSource { + $publicKeyCredentialLoader = $this->buildCredentialLoader(); + $publicKeyCredential = $publicKeyCredentialLoader->load($assertionResponseJson); + $authenticatorResponse = $publicKeyCredential->getResponse(); + if (!$authenticatorResponse instanceof AuthenticatorAssertionResponse) { + throw new \RuntimeException('Response is not an AuthenticatorAssertionResponse', 1747750001); + } + + $userHandle = $this->buildUserHandle($account); + $validator = $this->buildAssertionValidator(); + + return $validator->check( + $publicKeyCredential->getRawId(), + $authenticatorResponse, + $options, + $request, + $userHandle + ); + } + + private function buildUserEntity(Account $account): PublicKeyCredentialUserEntity + { + return new PublicKeyCredentialUserEntity( + $account->getAccountIdentifier(), + $this->buildUserHandle($account), + $account->getAccountIdentifier() + ); + } + + private function buildUserHandle(Account $account): string + { + // Account identifier of the form `username@provider` is not PII-free; the persistence + // identifier (UUID) is a stable non-PII handle. Fall back to a hashed identifier if absent. + $id = $this->persistenceManager->getIdentifierByObject($account); + if (!is_string($id) || $id === '') { + $id = hash('sha256', $account->getAccountIdentifier(), true); + } + return $id; + } + + private function buildCredentialLoader(): PublicKeyCredentialLoader + { + $attestationManager = new AttestationStatementSupportManager(); + $attestationManager->add(new NoneAttestationStatementSupport()); + $attestationObjectLoader = new AttestationObjectLoader($attestationManager); + return new PublicKeyCredentialLoader($attestationObjectLoader); + } + + private function buildAttestationValidator(): AuthenticatorAttestationResponseValidator + { + $attestationManager = new AttestationStatementSupportManager(); + $attestationManager->add(new NoneAttestationStatementSupport()); + + return new AuthenticatorAttestationResponseValidator( + $attestationManager, + $this->credentialSourceRepository, + null, + new ExtensionOutputCheckerHandler() + ); + } + + private function buildAssertionValidator(): AuthenticatorAssertionResponseValidator + { + $algorithmManager = CoseAlgorithmManager::create() + ->add(new ECDSA\ES256()) + ->add(new ECDSA\ES384()) + ->add(new ECDSA\ES512()) + ->add(new RSA\RS256()) + ->add(new EdDSA\Ed25519()); + + return new AuthenticatorAssertionResponseValidator( + $this->credentialSourceRepository, + null, + new ExtensionOutputCheckerHandler(), + $algorithmManager + ); + } +} diff --git a/Configuration/Routes.yaml b/Configuration/Routes.yaml index c7682a7..92180b2 100644 --- a/Configuration/Routes.yaml +++ b/Configuration/Routes.yaml @@ -36,3 +36,59 @@ '@action': 'createSecondFactor' '@format': 'html' httpMethods: ['POST'] + +- name: 'Sandstorm Two Factor Authentication - Setup TOTP' + uriPattern: 'neos/second-factor-setup/totp' + defaults: + '@package': 'Sandstorm.NeosTwoFactorAuthentication' + '@controller': 'Login' + '@action': 'setupTotp' + '@format': 'html' + httpMethods: ['GET'] + appendExceedingArguments: true + +- name: 'Sandstorm Two Factor Authentication - Setup WebAuthn' + uriPattern: 'neos/second-factor-setup/webauthn' + defaults: + '@package': 'Sandstorm.NeosTwoFactorAuthentication' + '@controller': 'Login' + '@action': 'setupWebAuthn' + '@format': 'html' + httpMethods: ['GET'] + appendExceedingArguments: true + +- name: 'Sandstorm Two Factor Authentication - WebAuthn register options' + uriPattern: 'neos/second-factor-webauthn/register-options' + defaults: + '@package': 'Sandstorm.NeosTwoFactorAuthentication' + '@controller': 'Login' + '@action': 'webAuthnRegisterOptions' + '@format': 'json' + httpMethods: ['POST'] + +- name: 'Sandstorm Two Factor Authentication - WebAuthn register verify' + uriPattern: 'neos/second-factor-webauthn/register-verify' + defaults: + '@package': 'Sandstorm.NeosTwoFactorAuthentication' + '@controller': 'Login' + '@action': 'webAuthnRegisterVerify' + '@format': 'json' + httpMethods: ['POST'] + +- name: 'Sandstorm Two Factor Authentication - WebAuthn authenticate options' + uriPattern: 'neos/second-factor-webauthn/authenticate-options' + defaults: + '@package': 'Sandstorm.NeosTwoFactorAuthentication' + '@controller': 'Login' + '@action': 'webAuthnAuthenticateOptions' + '@format': 'json' + httpMethods: ['POST'] + +- name: 'Sandstorm Two Factor Authentication - WebAuthn authenticate verify' + uriPattern: 'neos/second-factor-webauthn/authenticate-verify' + defaults: + '@package': 'Sandstorm.NeosTwoFactorAuthentication' + '@controller': 'Login' + '@action': 'webAuthnAuthenticateVerify' + '@format': 'json' + httpMethods: ['POST'] diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml index 91f8dfc..6f6b232 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -17,6 +17,7 @@ Neos: - 'resource://Sandstorm.NeosTwoFactorAuthentication/Public/Styles/Backend.css' javaScripts: - 'resource://Sandstorm.NeosTwoFactorAuthentication/Public/index.js' + - 'resource://Sandstorm.NeosTwoFactorAuthentication/Public/JavaScript/webauthn.js' userInterface: translation: @@ -29,6 +30,7 @@ Neos: 'Sandstorm.NeosTwoFactorAuthentication:AdditionalStyles': 'resource://Sandstorm.NeosTwoFactorAuthentication/Public/Styles/Login.css' scripts: 'Sandstorm.NeosTwoFactorAuthentication:AdditionalScripts': 'resource://Sandstorm.NeosTwoFactorAuthentication/Public/index.js' + 'Sandstorm.NeosTwoFactorAuthentication:WebAuthnScripts': 'resource://Sandstorm.NeosTwoFactorAuthentication/Public/JavaScript/webauthn.js' Flow: http: @@ -60,3 +62,16 @@ Sandstorm: enforce2FAForRoles: [] # (optional) if set this will be used as a naming convention for the TOTP. If empty the Site name will be used issuerName: '' + + webAuthn: + # Human-readable relying party name shown in the browser's WebAuthn prompt. + relyingPartyName: 'Neos Backend' + # Optional override for the relying party identifier. If null, the request's + # hostname is used (which works for localhost and same-origin production deployments). + relyingPartyId: null + # 'required', 'preferred' or 'discouraged'. Required forces PIN/biometric on the authenticator. + userVerification: 'required' + # 'none', 'indirect' or 'direct'. 'none' accepts any authenticator and skips attestation verification. + attestation: 'none' + # Browser ceremony timeout in milliseconds. + timeout: 60000 diff --git a/Migrations/Mysql/Version20260519120000.php b/Migrations/Mysql/Version20260519120000.php new file mode 100644 index 0000000..b3ad1b6 --- /dev/null +++ b/Migrations/Mysql/Version20260519120000.php @@ -0,0 +1,39 @@ +abortIf( + !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\MySqlPlatform, + "Migration can only be executed safely on '\Doctrine\DBAL\Platforms\MySqlPlatform,'." + ); + + $this->addSql('ALTER TABLE sandstorm_neostwofactorauthentication_domain_model_secondfactor MODIFY secret TEXT NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->abortIf( + !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\MySqlPlatform, + "Migration can only be executed safely on '\Doctrine\DBAL\Platforms\MySqlPlatform,'." + ); + + $this->addSql('ALTER TABLE sandstorm_neostwofactorauthentication_domain_model_secondfactor MODIFY secret VARCHAR(255) NOT NULL'); + } +} diff --git a/README.md b/README.md index 4573608..cc71518 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,39 @@ # Neos Backend 2FA -Extend the Neos backend login to support second factors. At the moment we only support TOTP tokens. - -Support for WebAuthn is planed! +Extend the Neos backend login to support second factors. We support TOTP tokens (Authenticator apps) +and WebAuthn / FIDO2 hardware security keys (e.g. Yubikey). ## What this package does https://user-images.githubusercontent.com/12086990/153027757-ac715746-0575-4555-bce1-c44603747945.mov -This package allows all users to register their personal TOTP token (Authenticator App). As an Administrator you are -able to delete those token for the users again, in case they locked them self out. +This package allows all users to register their personal second factor — either a TOTP token +(Authenticator App) or a hardware security key (Yubikey / WebAuthn). Users can register one of each +and pick which to use at login. As an Administrator you are able to delete factors for users again, +in case they locked themselves out. + +### Security keys (WebAuthn / FIDO2) + +Browsers expose WebAuthn only over `https://` or on `localhost`. Make sure the Neos backend is served +over HTTPS in production, otherwise the security-key flow will fail. + +Configure the relying party identifier when your backend hostname differs from the registered domain: + +```yml +Sandstorm: + NeosTwoFactorAuthentication: + webAuthn: + relyingPartyName: 'My CMS' + # null means: derive from the request hostname (works for same-origin deployments). + # Set to the registrable domain ('example.com') if you serve the backend from a subdomain + # and want credentials to be usable across subdomains. + relyingPartyId: null + # 'required' forces a PIN or biometric on the authenticator (recommended). + userVerification: 'required' + # Attestation verification is disabled by default — accepts any FIDO2 authenticator. + attestation: 'none' + timeout: 60000 +``` ![Screenshot 2022-02-08 at 17 11 01](https://user-images.githubusercontent.com/12086990/153028043-93e9220e-cc22-4879-9edb-3e156c9accc8.png) diff --git a/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion b/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion index 2bcc028..5bb4ba7 100644 --- a/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion +++ b/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion @@ -5,130 +5,27 @@ Sandstorm.NeosTwoFactorAuthentication.BackendController.new = Sandstorm.NeosTwoF teaserTitle = ${I18n.id('module.new.title').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} teaserText = ${I18n.id('module.new.description').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} - content = Neos.Fusion:Component { - renderer = afx` - -
-

- {I18n.id('module.new.user-context').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').arguments([currentUser ? currentUser.name.fullName : accountIdentifier]).translate()} -

-
- 1 - {I18n.id('form.step.setup').package('Sandstorm.NeosTwoFactorAuthentication')} -
-
-
-

- {I18n.id('form.setup.qrcode.label').package('Sandstorm.NeosTwoFactorAuthentication')} -

- -
- -
-

- {I18n.id('form.setup.manual.label').package('Sandstorm.NeosTwoFactorAuthentication')} -

-
- - -
- -
- - -
-
-

- { - Array.join( - Array.map( - String.split(secret, ''), - char => Type.isNumeric(char) ? '' + char + '' : '' + char + '' - ), - '' - ) - } -

- - - -
- -
- - -
-
-
-
-
-
- -
- 2 - {I18n.id('form.step.verify').package('Sandstorm.NeosTwoFactorAuthentication')} -
-
- -
- -
- 3 - {I18n.id('form.step.register').package('Sandstorm.NeosTwoFactorAuthentication')} -
-
- - {I18n.id('module.new.submit-otp').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} - -
-
- - - - - ` + content = Sandstorm.NeosTwoFactorAuthentication:Component.MethodPicker { + items = Neos.Fusion:DataStructure { + totp = Neos.Fusion:DataStructure { + label = ${I18n.id('method.totp.label').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + description = ${I18n.id('method.totp.description').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + href = Neos.Fusion:UriBuilder { + package = 'Sandstorm.NeosTwoFactorAuthentication' + controller = 'Backend' + action = 'newTotp' + } + } + webauthn = Neos.Fusion:DataStructure { + label = ${I18n.id('method.webauthn.label').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + description = ${I18n.id('method.webauthn.description').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + href = Neos.Fusion:UriBuilder { + package = 'Sandstorm.NeosTwoFactorAuthentication' + controller = 'Backend' + action = 'newWebAuthn' + } + } + } } } } diff --git a/Resources/Private/Fusion/Integration/Controller/Backend/NewTotp.fusion b/Resources/Private/Fusion/Integration/Controller/Backend/NewTotp.fusion new file mode 100644 index 0000000..ee94bd9 --- /dev/null +++ b/Resources/Private/Fusion/Integration/Controller/Backend/NewTotp.fusion @@ -0,0 +1,134 @@ +Sandstorm.NeosTwoFactorAuthentication.BackendController.newTotp = Sandstorm.NeosTwoFactorAuthentication:Page.DefaultPage { + body = Sandstorm.NeosTwoFactorAuthentication:BodyLayout.Default { + flashMessages = ${flashMessages} + + teaserTitle = ${I18n.id('module.new.title').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} + teaserText = ${I18n.id('module.new.description').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} + + content = Neos.Fusion:Component { + renderer = afx` + +
+

+ {I18n.id('module.new.user-context').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').arguments([currentUser ? currentUser.name.fullName : accountIdentifier]).translate()} +

+
+ 1 + {I18n.id('form.step.setup').package('Sandstorm.NeosTwoFactorAuthentication')} +
+
+
+

+ {I18n.id('form.setup.qrcode.label').package('Sandstorm.NeosTwoFactorAuthentication')} +

+ +
+ +
+

+ {I18n.id('form.setup.manual.label').package('Sandstorm.NeosTwoFactorAuthentication')} +

+
+ + +
+ +
+ + +
+
+

+ { + Array.join( + Array.map( + String.split(secret, ''), + char => Type.isNumeric(char) ? '' + char + '' : '' + char + '' + ), + '' + ) + } +

+ + + +
+ +
+ + +
+
+
+
+
+
+ +
+ 2 + {I18n.id('form.step.verify').package('Sandstorm.NeosTwoFactorAuthentication')} +
+
+ +
+ +
+ 3 + {I18n.id('form.step.register').package('Sandstorm.NeosTwoFactorAuthentication')} +
+
+ + {I18n.id('module.new.submit-otp').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} + +
+
+ + + + + ` + } + } +} diff --git a/Resources/Private/Fusion/Integration/Controller/Backend/NewWebAuthn.fusion b/Resources/Private/Fusion/Integration/Controller/Backend/NewWebAuthn.fusion new file mode 100644 index 0000000..dce9e60 --- /dev/null +++ b/Resources/Private/Fusion/Integration/Controller/Backend/NewWebAuthn.fusion @@ -0,0 +1,30 @@ +Sandstorm.NeosTwoFactorAuthentication.BackendController.newWebAuthn = Sandstorm.NeosTwoFactorAuthentication:Page.DefaultPage { + body = Sandstorm.NeosTwoFactorAuthentication:BodyLayout.Default { + flashMessages = ${flashMessages} + + teaserTitle = ${I18n.id('module.newWebAuthn.title').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} + teaserText = ${I18n.id('module.newWebAuthn.description').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} + + content = Neos.Fusion:Component { + redirectUrl = Neos.Fusion:UriBuilder { + package = 'Sandstorm.NeosTwoFactorAuthentication' + controller = 'Backend' + action = 'index' + } + renderer = afx` +

+ {I18n.id('module.new.user-context').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').arguments([currentUser ? currentUser.name.fullName : accountIdentifier]).translate()} +

+ + + + + ` + } + } +} diff --git a/Resources/Private/Fusion/Integration/Controller/Login/AskForSecondFactor.fusion b/Resources/Private/Fusion/Integration/Controller/Login/AskForSecondFactor.fusion index 4d62c49..95d846a 100644 --- a/Resources/Private/Fusion/Integration/Controller/Login/AskForSecondFactor.fusion +++ b/Resources/Private/Fusion/Integration/Controller/Login/AskForSecondFactor.fusion @@ -1,6 +1,30 @@ Sandstorm.NeosTwoFactorAuthentication.LoginController.askForSecondFactor = Sandstorm.NeosTwoFactorAuthentication:Page.LoginSecondFactorPage { site = ${site} styles = ${styles} + scripts = ${scripts} username = ${username} flashMessages = ${flashMessages} + + body = Neos.Fusion:Case { + # If user has WebAuthn registered and didn't request the TOTP fallback, show the WebAuthn step. + webAuthn { + condition = ${hasWebAuthn && (request.arguments.useTotp != '1')} + renderer = Sandstorm.NeosTwoFactorAuthentication:Component.LoginWebAuthnStep { + flashMessages = ${flashMessages} + fallbackToTotp = ${hasTotp} + } + } + totp { + condition = ${hasTotp} + renderer = Sandstorm.NeosTwoFactorAuthentication:Component.LoginSecondFactorStep { + flashMessages = ${flashMessages} + } + } + default { + condition = true + renderer = Sandstorm.NeosTwoFactorAuthentication:Component.LoginSecondFactorStep { + flashMessages = ${flashMessages} + } + } + } } diff --git a/Resources/Private/Fusion/Integration/Controller/Login/SetupSecondFactor.fusion b/Resources/Private/Fusion/Integration/Controller/Login/SetupSecondFactor.fusion index 2c504d8..fe81db2 100644 --- a/Resources/Private/Fusion/Integration/Controller/Login/SetupSecondFactor.fusion +++ b/Resources/Private/Fusion/Integration/Controller/Login/SetupSecondFactor.fusion @@ -4,6 +4,27 @@ Sandstorm.NeosTwoFactorAuthentication.LoginController.setupSecondFactor = Sandst scripts = ${scripts} username = ${username} flashMessages = ${flashMessages} - qrCode = ${qrCode} - secret = ${secret} + + body = Sandstorm.NeosTwoFactorAuthentication:Component.MethodPicker { + items = Neos.Fusion:DataStructure { + totp = Neos.Fusion:DataStructure { + label = ${I18n.id('method.totp.label').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + description = ${I18n.id('method.totp.description').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + href = Neos.Fusion:UriBuilder { + package = 'Sandstorm.NeosTwoFactorAuthentication' + controller = 'Login' + action = 'setupTotp' + } + } + webauthn = Neos.Fusion:DataStructure { + label = ${I18n.id('method.webauthn.label').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + description = ${I18n.id('method.webauthn.description').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + href = Neos.Fusion:UriBuilder { + package = 'Sandstorm.NeosTwoFactorAuthentication' + controller = 'Login' + action = 'setupWebAuthn' + } + } + } + } } diff --git a/Resources/Private/Fusion/Integration/Controller/Login/SetupTotp.fusion b/Resources/Private/Fusion/Integration/Controller/Login/SetupTotp.fusion new file mode 100644 index 0000000..82fb055 --- /dev/null +++ b/Resources/Private/Fusion/Integration/Controller/Login/SetupTotp.fusion @@ -0,0 +1,13 @@ +Sandstorm.NeosTwoFactorAuthentication.LoginController.setupTotp = Sandstorm.NeosTwoFactorAuthentication:Page.SetupSecondFactorPage { + site = ${site} + styles = ${styles} + scripts = ${scripts} + username = ${username} + flashMessages = ${flashMessages} + + body = Sandstorm.NeosTwoFactorAuthentication:Component.SetupTotpStep { + secret = ${secret} + qrCode = ${qrCode} + targetAction = 'createSecondFactor' + } +} diff --git a/Resources/Private/Fusion/Integration/Controller/Login/SetupWebAuthn.fusion b/Resources/Private/Fusion/Integration/Controller/Login/SetupWebAuthn.fusion new file mode 100644 index 0000000..f7ce951 --- /dev/null +++ b/Resources/Private/Fusion/Integration/Controller/Login/SetupWebAuthn.fusion @@ -0,0 +1,12 @@ +Sandstorm.NeosTwoFactorAuthentication.LoginController.setupWebAuthn = Sandstorm.NeosTwoFactorAuthentication:Page.SetupSecondFactorPage { + site = ${site} + styles = ${styles} + scripts = ${scripts} + username = ${username} + flashMessages = ${flashMessages} + + body = Sandstorm.NeosTwoFactorAuthentication:Component.SetupWebAuthnStep { + flashMessages = ${flashMessages} + redirectUrl = '/neos' + } +} diff --git a/Resources/Private/Fusion/Presentation/Components/LoginWebAuthnStep.fusion b/Resources/Private/Fusion/Presentation/Components/LoginWebAuthnStep.fusion new file mode 100644 index 0000000..c911087 --- /dev/null +++ b/Resources/Private/Fusion/Presentation/Components/LoginWebAuthnStep.fusion @@ -0,0 +1,47 @@ +prototype(Sandstorm.NeosTwoFactorAuthentication:Component.LoginWebAuthnStep) < prototype(Neos.Fusion:Component) { + flashMessages = ${[]} + # offer fallback link if user also has a TOTP factor + fallbackToTotp = false + + severityMapping = Neos.Fusion:DataStructure { + OK = 'success' + Notice = 'notice' + Warning = 'warning' + Error = 'error' + } + + renderer = afx` +
+
+

+ {I18n.id('webauthn.login.prompt').package('Sandstorm.NeosTwoFactorAuthentication')} +

+
+ +
+ +

+ + {I18n.id('webauthn.login.fallback-to-totp').package('Sandstorm.NeosTwoFactorAuthentication')} + +

+ +
+
+ +
+
+
+
+ ` +} diff --git a/Resources/Private/Fusion/Presentation/Components/MethodPicker.fusion b/Resources/Private/Fusion/Presentation/Components/MethodPicker.fusion new file mode 100644 index 0000000..3e263f5 --- /dev/null +++ b/Resources/Private/Fusion/Presentation/Components/MethodPicker.fusion @@ -0,0 +1,22 @@ +prototype(Sandstorm.NeosTwoFactorAuthentication:Component.MethodPicker) < prototype(Neos.Fusion:Component) { + # array of { label, description, href } + items = ${[]} + + renderer = afx` +
+

+ {I18n.id('method.picker.intro').package('Sandstorm.NeosTwoFactorAuthentication')} +

+ +
+ ` +} diff --git a/Resources/Private/Fusion/Presentation/Components/SetupTotpStep.fusion b/Resources/Private/Fusion/Presentation/Components/SetupTotpStep.fusion new file mode 100644 index 0000000..5545905 --- /dev/null +++ b/Resources/Private/Fusion/Presentation/Components/SetupTotpStep.fusion @@ -0,0 +1,91 @@ +prototype(Sandstorm.NeosTwoFactorAuthentication:Component.SetupTotpStep) < prototype(Neos.Fusion:Component) { + secret = '' + qrCode = '' + targetAction = 'createSecondFactor' + + renderer = afx` + +
+ +
+ +
+ +
+ +
+ + +
+
+

+ { + Array.join( + Array.map( + String.split(props.secret, ''), + char => Type.isNumeric(char) ? '' + char + '' : '' + char + '' + ), + '' + ) + } +

+ + +
+ +
+ + +
+
+
+
+ +
+ +
+ +
+ + {I18n.id('module.new.submit-otp').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} + +
+
+ ` +} diff --git a/Resources/Private/Fusion/Presentation/Components/SetupWebAuthnStep.fusion b/Resources/Private/Fusion/Presentation/Components/SetupWebAuthnStep.fusion new file mode 100644 index 0000000..241f8fb --- /dev/null +++ b/Resources/Private/Fusion/Presentation/Components/SetupWebAuthnStep.fusion @@ -0,0 +1,36 @@ +prototype(Sandstorm.NeosTwoFactorAuthentication:Component.SetupWebAuthnStep) < prototype(Neos.Fusion:Component) { + flashMessages = ${[]} + # where to redirect on successful registration + redirectUrl = '/neos' + + renderer = afx` +
+
+

+ {I18n.id('webauthn.setup.intro').package('Sandstorm.NeosTwoFactorAuthentication')} +

+
+ +
+ + +
+
+ +
+
+
+
+ ` +} diff --git a/Resources/Private/Fusion/Presentation/Pages/LoginSecondFactorPage.fusion b/Resources/Private/Fusion/Presentation/Pages/LoginSecondFactorPage.fusion index c6d5bfc..d6a17e5 100644 --- a/Resources/Private/Fusion/Presentation/Pages/LoginSecondFactorPage.fusion +++ b/Resources/Private/Fusion/Presentation/Pages/LoginSecondFactorPage.fusion @@ -1,8 +1,13 @@ prototype(Sandstorm.NeosTwoFactorAuthentication:Page.LoginSecondFactorPage) < prototype(Neos.Fusion:Component) { site = null styles = ${[]} + scripts = ${[]} username = '' flashMessages = ${[]} + # the inner challenge step component — pass a LoginSecondFactorStep or LoginWebAuthnStep instance + body = Sandstorm.NeosTwoFactorAuthentication:Component.LoginSecondFactorStep { + flashMessages = ${props.flashMessages} + } renderer = Neos.Fusion:Join { doctype = '' @@ -75,7 +80,7 @@ prototype(Sandstorm.NeosTwoFactorAuthentication:Page.LoginSecondFactorPage) < pr @@ -89,11 +94,21 @@ prototype(Sandstorm.NeosTwoFactorAuthentication:Page.LoginSecondFactorPage) < pr + + + - ` } } diff --git a/Resources/Private/Fusion/Integration/Controller/Backend/NewWebAuthn.fusion b/Resources/Private/Fusion/Integration/Controller/Backend/NewWebAuthn.fusion index 367d4dc..59b219a 100644 --- a/Resources/Private/Fusion/Integration/Controller/Backend/NewWebAuthn.fusion +++ b/Resources/Private/Fusion/Integration/Controller/Backend/NewWebAuthn.fusion @@ -18,11 +18,6 @@ Sandstorm.NeosTwoFactorAuthentication.BackendController.newWebAuthn = Sandstorm. flashMessages={flashMessages} redirectUrl={props.redirectUrl} /> - - - ` } } From 716b1554c2fb57f05be2490bd4fc86fb8bac1724 Mon Sep 17 00:00:00 2001 From: Tobias Gruber Date: Sat, 30 May 2026 20:51:53 +0200 Subject: [PATCH 9/9] =?UTF-8?q?=F0=9F=92=84=20FIX:=20style=20error=20messa?= =?UTF-8?q?ge=20if=20adding=20webauthn=20second=20factor=20fails=20in=20ba?= =?UTF-8?q?ckend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Resources/Public/Styles/Backend.css | 60 +++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/Resources/Public/Styles/Backend.css b/Resources/Public/Styles/Backend.css index 37fdad2..f48b957 100644 --- a/Resources/Public/Styles/Backend.css +++ b/Resources/Public/Styles/Backend.css @@ -82,3 +82,63 @@ img.neos-two-factor__qr-code { font-size: 16px !important; height: unset !important; } + +/* + * Backend module pages DO load Neos core's Foundation/_tooltip.scss (the `.neos`-scoped base + * tooltip: absolute, black, centred 5px arrow) but NOT Login.scss (which adds the in-flow layout + * and the red `.neos-tooltip-error` variant). So the error tooltip showed up as core's neutral + * black tooltip. Reproduce the second-factor-login look here, prefixed with `.neos + * .neos-two-factor__webauthn-step` so every rule outranks its core counterpart — notably core's + * 4-class `.neos .neos-tooltip.neos-bottom .neos-tooltip-arrow`, which set the black, centred arrow. + */ +.neos .neos-two-factor__webauthn-step .neos-tooltip { + position: relative; + left: 0; + top: 0; + width: 100%; + clear: both; + float: none; + display: block; + opacity: 1; +} + +.neos .neos-two-factor__webauthn-step .neos-tooltip.neos-bottom { + margin: 8px 0 0 0; + padding: 8px 0 0 0; +} + +.neos .neos-two-factor__webauthn-step .neos-tooltip-inner { + /* Cap the width (~2x the register button) instead of stretching the full page. */ + max-width: 480px; + padding: 8px; + color: #fff; + font-size: 13px; + background-color: #000; + border-radius: 0; + box-sizing: border-box; +} + +/* Left-align the arrow (core centres it at left: 50%) for all variants. Colour is left to + core (#000) for neutral flash messages and overridden per-variant below. */ +.neos .neos-two-factor__webauthn-step .neos-tooltip.neos-bottom .neos-tooltip-arrow { + top: 0; + left: 24px; + margin-left: 0; + border-width: 0 8px 8px 8px; +} + +.neos .neos-two-factor__webauthn-step .neos-tooltip-error .neos-tooltip-inner { + background-color: #ff460d; +} + +.neos .neos-two-factor__webauthn-step .neos-tooltip-error.neos-bottom .neos-tooltip-arrow { + border-bottom-color: #ff460d; +} + +.neos .neos-two-factor__webauthn-step .neos-tooltip-notice .neos-tooltip-inner { + background-color: #00a338; +} + +.neos .neos-two-factor__webauthn-step .neos-tooltip-notice.neos-bottom .neos-tooltip-arrow { + border-bottom-color: #00a338; +}