From a9bdc781491902f3284bf170ae123e4564309923 Mon Sep 17 00:00:00 2001 From: Tobias Gruber Date: Tue, 19 May 2026 22:49:01 +0200 Subject: [PATCH 01/20] Add WebAuthn (Yubikey) as a second factor - Claude Code assisted --- Classes/Controller/BackendController.php | 60 +++- 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 | 26 ++ Migrations/Mysql/Version20260519120000.php | 39 +++ README.md | 34 ++- .../Integration/Controller/Backend/New.fusion | 154 ++-------- .../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 | 99 +------ 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, 1893 insertions(+), 289 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 e7b32d5..8768db2 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,15 @@ public function createAction(string $secret, string $secondFactorFromApp, string '', Message::SEVERITY_WARNING ); - $this->redirect('new'); + $this->redirect('newTotp'); } - $this->secondFactorRepository->createSecondFactorForAccount($secret, $this->securityContext->getAccount(), $name); + $this->secondFactorRepository->createSecondFactorForAccount( + $secret, + $this->securityContext->getAccount(), + SecondFactor::TYPE_TOTP, + $name, + ); $this->secondFactorSessionStorageService->setAuthenticationStatus(AuthenticationStatus::AUTHENTICATED); diff --git a/Classes/Controller/LoginController.php b/Classes/Controller/LoginController.php index 2a59f6c..2506e12 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 @@ -200,12 +264,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, $name); + $this->secondFactorRepository->createSecondFactorForAccount($secret, $account, SecondFactor::TYPE_TOTP, $name); $this->addFlashMessage( $this->translator->translateById( @@ -219,36 +282,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 bbde9d5..e2b0c03 100644 --- a/Classes/Domain/Model/SecondFactor.php +++ b/Classes/Domain/Model/SecondFactor.php @@ -37,7 +37,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; @@ -135,6 +139,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 8e076b4..92c2984 100644 --- a/Classes/Domain/Repository/SecondFactorRepository.php +++ b/Classes/Domain/Repository/SecondFactorRepository.php @@ -24,15 +24,45 @@ class SecondFactorRepository extends Repository /** * @throws IllegalObjectTypeException */ - public function createSecondFactorForAccount(string $secret, Account $account, string $name): void + public function createSecondFactorForAccount(string $secret, Account $account, int $type = SecondFactor::TYPE_TOTP, string $name): SecondFactor { $secondFactor = new SecondFactor(); $secondFactor->setAccount($account); $secondFactor->setSecret($secret); - $secondFactor->setType(SecondFactor::TYPE_TOTP); + $secondFactor->setType($type); $secondFactor->setName($name); $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 3c342e2..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: @@ -49,3 +51,27 @@ Neos: pattern: 'ControllerObjectName' patternOptions: controllerObjectNamePattern: 'Sandstorm\NeosTwoFactorAuthentication\Controller\(LoginController|BackendController)' + +Sandstorm: + NeosTwoFactorAuthentication: + # enforce 2FA for all users + enforceTwoFactorAuthentication: false + # enforce 2FA for specific authentication providers + enforce2FAForAuthenticationProviders : [] + # enforce 2FA for specific roles + 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 26247fc..df227b0 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 1c987ae..5bb4ba7 100644 --- a/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion +++ b/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion @@ -5,139 +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 e0530a502333219bcf733539796789486a4d4479 Mon Sep 17 00:00:00 2001 From: Tobias Gruber Date: Sat, 30 May 2026 20:51:53 +0200 Subject: [PATCH 09/20] =?UTF-8?q?=F0=9F=92=84=20FIX:=20style=20error=20mes?= =?UTF-8?q?sage=20if=20adding=20webauthn=20second=20factor=20fails=20in=20?= =?UTF-8?q?backend?= 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; +} From 099c4313cc60eaabc5a0a0c8c2288f38dec3d531 Mon Sep 17 00:00:00 2001 From: Robert Baruck Date: Thu, 18 Jun 2026 21:09:35 +0200 Subject: [PATCH 10/20] TASK: Merge fixups --- Configuration/Settings.2FA.yaml | 27 ++++++++++++++-- Configuration/Settings.yaml | 32 ------------------- .../Controller/Backend/NewTotp.fusion | 8 +++++ Resources/Private/Translations/de/Backend.xlf | 6 ++-- Resources/Private/Translations/de/Main.xlf | 32 +++++++++---------- Resources/Private/Translations/en/Backend.xlf | 6 ++-- Resources/Private/Translations/en/Main.xlf | 22 ++++++------- 7 files changed, 66 insertions(+), 67 deletions(-) diff --git a/Configuration/Settings.2FA.yaml b/Configuration/Settings.2FA.yaml index 3ee0629..5195ea7 100644 --- a/Configuration/Settings.2FA.yaml +++ b/Configuration/Settings.2FA.yaml @@ -2,9 +2,32 @@ Sandstorm: NeosTwoFactorAuthentication: # enforce 2FA for all users enforceTwoFactorAuthentication: false - # enforce 2FA for specific authentication providers (e.g. Neos.Neos:Backend) + # enforce 2FA for specific authentication providers enforce2FAForAuthenticationProviders : [] - # enforce 2FA for specific roles (e.g. Neos.Neos:Administrator) + # enforce 2FA for specific roles 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'. + # 'discouraged' is the most permissive — works with FIDO U2F-only keys (e.g. YubiKey 4) + # via the browser's U2F-compat fallback. Set to 'preferred' or 'required' to demand + # PIN/biometric on the authenticator; note that 'required' excludes U2F-only keys. + userVerification: 'discouraged' + # 'none', 'indirect' or 'direct'. 'none' accepts any authenticator and skips attestation verification. + attestation: 'none' + # Browser ceremony timeout in milliseconds. + timeout: 60000 + # Relying-party hostnames for which the server-side library should accept non-HTTPS + # requests. The browser already treats *.localhost as a secure context (RFC 6761), + # but the PHP validator only special-cases the literal 'localhost' — list any + # additional dev hostnames here. + # + # Leave empty in production! + securedRelyingPartyIds: [] diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml index cd61006..8b7e951 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -51,35 +51,3 @@ Neos: pattern: 'ControllerObjectName' patternOptions: controllerObjectNamePattern: 'Sandstorm\NeosTwoFactorAuthentication\Controller\(LoginController|BackendController)' - -Sandstorm: - NeosTwoFactorAuthentication: - # enforce 2FA for all users - enforceTwoFactorAuthentication: false - # enforce 2FA for specific authentication providers - enforce2FAForAuthenticationProviders : [] - # enforce 2FA for specific roles - 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'. - # 'discouraged' is the most permissive — works with FIDO U2F-only keys (e.g. YubiKey 4) - # via the browser's U2F-compat fallback. Set to 'preferred' or 'required' to demand - # PIN/biometric on the authenticator; note that 'required' excludes U2F-only keys. - userVerification: 'discouraged' - # 'none', 'indirect' or 'direct'. 'none' accepts any authenticator and skips attestation verification. - attestation: 'none' - # Browser ceremony timeout in milliseconds. - timeout: 60000 - # Relying-party hostnames for which the server-side library should accept non-HTTPS - # requests. The browser already treats *.localhost as a secure context (RFC 6761), - # but the PHP validator only special-cases the literal 'localhost' — list any - # additional dev hostnames here. Leave empty in production. - securedRelyingPartyIds: [] diff --git a/Resources/Private/Fusion/Integration/Controller/Backend/NewTotp.fusion b/Resources/Private/Fusion/Integration/Controller/Backend/NewTotp.fusion index d41e488..d445187 100644 --- a/Resources/Private/Fusion/Integration/Controller/Backend/NewTotp.fusion +++ b/Resources/Private/Fusion/Integration/Controller/Backend/NewTotp.fusion @@ -110,6 +110,14 @@ Sandstorm.NeosTwoFactorAuthentication.BackendController.newTotp = Sandstorm.Neos attributes.autocomplete="off" attributes.class="neos-span6" /> +
diff --git a/Resources/Private/Translations/de/Backend.xlf b/Resources/Private/Translations/de/Backend.xlf index dada81b..120bffe 100644 --- a/Resources/Private/Translations/de/Backend.xlf +++ b/Resources/Private/Translations/de/Backend.xlf @@ -86,13 +86,13 @@ Zweiten Faktor registrieren - Submitted OTP was incorrect. + Submitted OTP was incorrect. Das eingegebene OTP ist nicht korrekt. - OTP was registered successfully. + OTP was registered successfully. Das OTP wurde erfolgreich registriert. - + Choose which second factor you would like to set up: Wählen Sie, welchen zweiten Faktor Sie einrichten möchten: diff --git a/Resources/Private/Translations/de/Main.xlf b/Resources/Private/Translations/de/Main.xlf index 37e17ed..0ec7dec 100644 --- a/Resources/Private/Translations/de/Main.xlf +++ b/Resources/Private/Translations/de/Main.xlf @@ -10,6 +10,10 @@ Verification code from your app Bestätigungscode aus der App + + Name of the second factor (optional) + Name des zweiten Faktors (optional) + Set up your authenticator Authenticator einrichten @@ -23,21 +27,21 @@ Registrieren - Error - Fehler - + Error + Fehler + - The provided OTP is invalid. - Das eingegebene OTP ist ungültig. - + The provided OTP is invalid. + Das eingegebene OTP ist ungültig. + - Submitted OTP was incorrect. - Das eingegebene OTP ist nicht korrekt. - + Submitted OTP was incorrect. + Das eingegebene OTP ist nicht korrekt. + - OTP was registered successfully. - Das OTP wurde erfolgreich registriert. - + OTP was registered successfully. + Das OTP wurde erfolgreich registriert. + Scan QR code QR-Code scannen @@ -154,10 +158,6 @@ An unexpected error occurred while communicating with your security key. Please try again. Bei der Kommunikation mit Ihrem Sicherheitsschlüssel ist ein unerwarteter Fehler aufgetreten. Bitte versuchen Sie es erneut. - - Name (optional) - Name (optional) - diff --git a/Resources/Private/Translations/en/Backend.xlf b/Resources/Private/Translations/en/Backend.xlf index 820e4e3..593c517 100644 --- a/Resources/Private/Translations/en/Backend.xlf +++ b/Resources/Private/Translations/en/Backend.xlf @@ -66,11 +66,11 @@ Register second factor - Submitted OTP was incorrect. + Submitted OTP was incorrect. - OTP was registered successfully. - + OTP was registered successfully. + Choose which second factor you would like to set up: diff --git a/Resources/Private/Translations/en/Main.xlf b/Resources/Private/Translations/en/Main.xlf index 7b80747..7ca5b7f 100644 --- a/Resources/Private/Translations/en/Main.xlf +++ b/Resources/Private/Translations/en/Main.xlf @@ -8,6 +8,9 @@ Verification code from your app + + Name of the second factor (optional) + Set up your authenticator @@ -18,17 +21,17 @@ Register - Error - + Error + - The provided OTP is invalid. - + The provided OTP is invalid. + - Submitted OTP was incorrect. - + Submitted OTP was incorrect. + - OTP was registered successfully. - + OTP was registered successfully. + Scan QR code @@ -116,9 +119,6 @@ An unexpected error occurred while communicating with your security key. Please try again. - - Name (optional) - From 89e1bc42f53fd48478c51e4f77a928fdd9aae920 Mon Sep 17 00:00:00 2001 From: Robert Baruck Date: Fri, 19 Jun 2026 10:56:14 +0200 Subject: [PATCH 11/20] SECURITY: Pin otphp package to ensure security fixed version --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index af58dc3..8e49b30 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,7 @@ "neos/fusion": "*", "neos/fusion-afx": "*", "neos/fusion-form": "*", - "spomky-labs/otphp": "^11.0", + "spomky-labs/otphp": "^11.5", "chillerlan/php-qrcode": "^5.0", "web-auth/webauthn-lib": "^4.8" }, From 6ddbb8dad5524af10c4bca893deac4c225670aa2 Mon Sep 17 00:00:00 2001 From: Robert Baruck Date: Fri, 19 Jun 2026 10:56:53 +0200 Subject: [PATCH 12/20] TASK: Fix Tests & add tests for WebAuthn --- Classes/Controller/LoginController.php | 5 +- .../Repository/SecondFactorRepository.php | 2 +- Classes/Service/WebAuthnService.php | 6 +- .../Integration/Controller/Backend/New.fusion | 2 + .../Controller/Backend/NewTotp.fusion | 4 +- .../Controller/Login/SetupSecondFactor.fusion | 2 + .../Components/MethodPicker.fusion | 4 +- .../Components/SetupTotpStep.fusion | 8 ++ .../Components/SetupWebAuthnStep.fusion | 10 ++ Resources/Public/JavaScript/webauthn.js | 4 +- Tests/E2E/Makefile | 14 ++- Tests/E2E/README.md | 8 ++ .../features/default/backend-module.feature | 21 ++++ Tests/E2E/features/default/login.feature | 10 ++ .../features/enforce-for-all/login.feature | 19 +++ Tests/E2E/helpers/2fa-pages.ts | 116 ++++++++++++++++-- Tests/E2E/helpers/webauthn.ts | 30 +++++ Tests/E2E/steps/2fa-login.steps.ts | 42 ++++++- Tests/E2E/steps/backend-module.steps.ts | 18 ++- .../E2E/system_under_test/neos8/entrypoint.sh | 2 +- .../E2E/system_under_test/neos9/entrypoint.sh | 2 +- 21 files changed, 299 insertions(+), 30 deletions(-) create mode 100644 Tests/E2E/helpers/webauthn.ts diff --git a/Classes/Controller/LoginController.php b/Classes/Controller/LoginController.php index 2506e12..5cbae57 100644 --- a/Classes/Controller/LoginController.php +++ b/Classes/Controller/LoginController.php @@ -310,7 +310,7 @@ public function webAuthnRegisterOptionsAction(): string * @throws SessionNotStartedException * @throws StopActionException */ - public function webAuthnRegisterVerifyAction(string $attestation): string + public function webAuthnRegisterVerifyAction(string $attestation, string $name = ''): string { $serialized = $this->secondFactorSessionStorageService->getValue( SecondFactorSessionStorageService::SESSION_OBJECT_WEBAUTHN_REGISTRATION_OPTIONS @@ -325,7 +325,8 @@ public function webAuthnRegisterVerifyAction(string $attestation): string $attestation, $options, $account, - $this->request->getHttpRequest() + $this->request->getHttpRequest(), + $name ); } catch (\Throwable $e) { return $this->jsonError($e->getMessage(), 400); diff --git a/Classes/Domain/Repository/SecondFactorRepository.php b/Classes/Domain/Repository/SecondFactorRepository.php index 92c2984..844cd41 100644 --- a/Classes/Domain/Repository/SecondFactorRepository.php +++ b/Classes/Domain/Repository/SecondFactorRepository.php @@ -24,7 +24,7 @@ class SecondFactorRepository extends Repository /** * @throws IllegalObjectTypeException */ - public function createSecondFactorForAccount(string $secret, Account $account, int $type = SecondFactor::TYPE_TOTP, string $name): SecondFactor + public function createSecondFactorForAccount(string $secret, Account $account, int $type = SecondFactor::TYPE_TOTP, string $name = ''): SecondFactor { $secondFactor = new SecondFactor(); $secondFactor->setAccount($account); diff --git a/Classes/Service/WebAuthnService.php b/Classes/Service/WebAuthnService.php index 4e7d618..69028ec 100644 --- a/Classes/Service/WebAuthnService.php +++ b/Classes/Service/WebAuthnService.php @@ -144,7 +144,8 @@ public function verifyAndPersistRegistration( string $attestationResponseJson, PublicKeyCredentialCreationOptions $options, Account $account, - ServerRequestInterface $request + ServerRequestInterface $request, + string $name = '' ): SecondFactor { $publicKeyCredentialLoader = $this->buildCredentialLoader(); $publicKeyCredential = $publicKeyCredentialLoader->load($attestationResponseJson); @@ -159,7 +160,8 @@ public function verifyAndPersistRegistration( return $this->secondFactorRepository->createSecondFactorForAccount( json_encode($credentialSource->jsonSerialize(), JSON_THROW_ON_ERROR), $account, - SecondFactor::TYPE_PUBLIC_KEY + SecondFactor::TYPE_PUBLIC_KEY, + $name ); } diff --git a/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion b/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion index 47d74b2..6816c25 100644 --- a/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion +++ b/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion @@ -10,6 +10,7 @@ Sandstorm.NeosTwoFactorAuthentication.BackendController.new = Sandstorm.NeosTwoF totp = Neos.Fusion:DataStructure { label = ${I18n.id('method.totp.label').package('Sandstorm.NeosTwoFactorAuthentication').translate()} description = ${I18n.id('method.totp.description').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + testId = 'select-method-totp' href = Neos.Fusion:UriBuilder { package = 'Sandstorm.NeosTwoFactorAuthentication' controller = 'Backend' @@ -19,6 +20,7 @@ Sandstorm.NeosTwoFactorAuthentication.BackendController.new = Sandstorm.NeosTwoF webauthn = Neos.Fusion:DataStructure { label = ${I18n.id('method.webauthn.label').package('Sandstorm.NeosTwoFactorAuthentication').translate()} description = ${I18n.id('method.webauthn.description').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + testId = 'select-method-webauthn' href = Neos.Fusion:UriBuilder { package = 'Sandstorm.NeosTwoFactorAuthentication' controller = 'Backend' diff --git a/Resources/Private/Fusion/Integration/Controller/Backend/NewTotp.fusion b/Resources/Private/Fusion/Integration/Controller/Backend/NewTotp.fusion index d445187..3e17bbf 100644 --- a/Resources/Private/Fusion/Integration/Controller/Backend/NewTotp.fusion +++ b/Resources/Private/Fusion/Integration/Controller/Backend/NewTotp.fusion @@ -125,7 +125,9 @@ Sandstorm.NeosTwoFactorAuthentication.BackendController.newTotp = Sandstorm.Neos {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/Login/SetupSecondFactor.fusion b/Resources/Private/Fusion/Integration/Controller/Login/SetupSecondFactor.fusion index fe81db2..ffd517e 100644 --- a/Resources/Private/Fusion/Integration/Controller/Login/SetupSecondFactor.fusion +++ b/Resources/Private/Fusion/Integration/Controller/Login/SetupSecondFactor.fusion @@ -10,6 +10,7 @@ Sandstorm.NeosTwoFactorAuthentication.LoginController.setupSecondFactor = Sandst totp = Neos.Fusion:DataStructure { label = ${I18n.id('method.totp.label').package('Sandstorm.NeosTwoFactorAuthentication').translate()} description = ${I18n.id('method.totp.description').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + testId = 'select-method-totp' href = Neos.Fusion:UriBuilder { package = 'Sandstorm.NeosTwoFactorAuthentication' controller = 'Login' @@ -19,6 +20,7 @@ Sandstorm.NeosTwoFactorAuthentication.LoginController.setupSecondFactor = Sandst webauthn = Neos.Fusion:DataStructure { label = ${I18n.id('method.webauthn.label').package('Sandstorm.NeosTwoFactorAuthentication').translate()} description = ${I18n.id('method.webauthn.description').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + testId = 'select-method-webauthn' href = Neos.Fusion:UriBuilder { package = 'Sandstorm.NeosTwoFactorAuthentication' controller = 'Login' diff --git a/Resources/Private/Fusion/Presentation/Components/MethodPicker.fusion b/Resources/Private/Fusion/Presentation/Components/MethodPicker.fusion index d10b48a..17d216d 100644 --- a/Resources/Private/Fusion/Presentation/Components/MethodPicker.fusion +++ b/Resources/Private/Fusion/Presentation/Components/MethodPicker.fusion @@ -1,5 +1,5 @@ prototype(Sandstorm.NeosTwoFactorAuthentication:Component.MethodPicker) < prototype(Neos.Fusion:Component) { - # array of { label, description, href } + # array of { label, description, href, testId } items = ${[]} renderer = afx` @@ -7,7 +7,7 @@ prototype(Sandstorm.NeosTwoFactorAuthentication:Component.MethodPicker) < protot
- {item.description} diff --git a/Resources/Private/Fusion/Presentation/Components/SetupTotpStep.fusion b/Resources/Private/Fusion/Presentation/Components/SetupTotpStep.fusion index 5545905..7aca70a 100644 --- a/Resources/Private/Fusion/Presentation/Components/SetupTotpStep.fusion +++ b/Resources/Private/Fusion/Presentation/Components/SetupTotpStep.fusion @@ -79,6 +79,14 @@ prototype(Sandstorm.NeosTwoFactorAuthentication:Component.SetupTotpStep) < proto attributes.aria-label={I18n.id('otp-placeholder').package('Sandstorm.NeosTwoFactorAuthentication')} attributes.autocomplete="off" /> +
diff --git a/Resources/Private/Fusion/Presentation/Components/SetupWebAuthnStep.fusion b/Resources/Private/Fusion/Presentation/Components/SetupWebAuthnStep.fusion index 11ebd53..9472142 100644 --- a/Resources/Private/Fusion/Presentation/Components/SetupWebAuthnStep.fusion +++ b/Resources/Private/Fusion/Presentation/Components/SetupWebAuthnStep.fusion @@ -33,6 +33,16 @@ prototype(Sandstorm.NeosTwoFactorAuthentication:Component.SetupWebAuthnStep) < p {I18n.id('webauthn.setup.intro').package('Sandstorm.NeosTwoFactorAuthentication')}


+
+ +