diff --git a/Classes/Controller/BackendController.php b/Classes/Controller/BackendController.php index 15b017f..bab1d22 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,27 @@ 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([ + '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(); @@ -130,8 +155,6 @@ public function newAction(): void $currentUser = $this->partyService->getAssignedPartyOfAccount($account); $this->view->assignMultiple([ - 'styles' => array_filter($this->getNeosSettings()['userInterface']['backendLoginForm']['stylesheets']), - 'scripts' => array_filter($this->getNeosSettings()['userInterface']['backendLoginForm']['scripts']), 'secret' => $secret, 'qrCode' => $qrCode, 'currentUser' => $currentUser instanceof User ? $currentUser : null, @@ -143,7 +166,25 @@ 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([ + '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 +207,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..c0c88ba 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); @@ -123,9 +152,9 @@ public static function typeToString(int $type): string { switch ($type) { case self::TYPE_TOTP: - return 'OTP'; + return 'OTP code'; case self::TYPE_PUBLIC_KEY: - return 'Public Key'; + return 'Security Key'; default: throw new InvalidArgumentException('Unsupported second factor type with index ' . $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 @@ +getUri()->getPath(), self::SECOND_FACTOR_LOGIN_URI); - if ($isAskingForOTP) { + // WHY: We use the request URI as state here to keep the middleware from entering a redirect loop, + // and to let the WebAuthn JS reach its XHR endpoints during the challenge. + if ($this->pathMatchesAnyPrefix($request->getUri()->getPath(), self::SECOND_FACTOR_CHALLENGE_ALLOWED_URI_PREFIXES)) { return $handler->handle($request); } @@ -193,9 +214,9 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface // 7. Redirect to 2FA setup, if second factor is not set up for account but is enforced by system. // Skip, if already on 2FA setup route. if ($isEnforcedForAccount && !$isEnabledForAccount) { - // WHY: We use the request URI as state here to keep the middleware from entering a redirect loop. - $isSettingUp2FA = str_ends_with($request->getUri()->getPath(), self::SECOND_FACTOR_SETUP_URI); - if ($isSettingUp2FA) { + // WHY: We use the request URI as state here to keep the middleware from entering a redirect loop, + // and to let the WebAuthn JS reach its registration XHR endpoints during enforced setup. + if ($this->pathMatchesAnyPrefix($request->getUri()->getPath(), self::SECOND_FACTOR_SETUP_ALLOWED_URI_PREFIXES)) { return $handler->handle($request); } @@ -224,4 +245,17 @@ private function log(string $message, array $context = []): void { $this->securityLogger->debug(self::LOGGING_PREFIX . $message, $context); } + + /** + * @param string[] $prefixes + */ + private function pathMatchesAnyPrefix(string $path, array $prefixes): bool + { + foreach ($prefixes as $prefix) { + if (str_starts_with($path, $prefix) || str_ends_with($path, $prefix)) { + return true; + } + } + return false; + } } diff --git a/Classes/Service/PublicKeyCredentialSourceRepositoryAdapter.php b/Classes/Service/PublicKeyCredentialSourceRepositoryAdapter.php new file mode 100644 index 0000000..e9d6625 --- /dev/null +++ b/Classes/Service/PublicKeyCredentialSourceRepositoryAdapter.php @@ -0,0 +1,77 @@ +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..4e7d618 --- /dev/null +++ b/Classes/Service/WebAuthnService.php @@ -0,0 +1,281 @@ + + */ + protected $securedRelyingPartyIds = []; + + /** + * `lazy=false` so the real adapter (not a DependencyProxy) is passed into the + * web-auth validator constructors, which strict-type-hint the interface. + * + * @Flow\Inject(lazy=false) + * @var PublicKeyCredentialSourceRepositoryAdapter + */ + protected $credentialSourceRepository; + + /** + * @Flow\Inject + * @var SecondFactorRepository + */ + protected $secondFactorRepository; + + /** + * @Flow\Inject + * @var PersistenceManagerInterface + */ + protected $persistenceManager; + + /** + * Build a registration options object that the browser passes to + * `navigator.credentials.create()`. + */ + public function createRegistrationOptions(Account $account, string $hostname): PublicKeyCredentialCreationOptions + { + $rp = new PublicKeyCredentialRpEntity( + $this->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, $this->securedRelyingPartyIds); + + 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, + $this->securedRelyingPartyIds + ); + } + + 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 = $this->buildAttestationStatementSupportManager(); + $attestationObjectLoader = new AttestationObjectLoader($attestationManager); + return new PublicKeyCredentialLoader($attestationObjectLoader); + } + + private function buildAttestationValidator(): AuthenticatorAttestationResponseValidator + { + return new AuthenticatorAttestationResponseValidator( + $this->buildAttestationStatementSupportManager(), + $this->credentialSourceRepository, + null, + new ExtensionOutputCheckerHandler() + ); + } + + private function buildAttestationStatementSupportManager(): AttestationStatementSupportManager + { + $manager = new AttestationStatementSupportManager(); + $manager->add(new NoneAttestationStatementSupport()); + // FidoU2F is needed for U2F-only authenticators (e.g. YubiKey 4) registered via + // the browser's U2F-compat fallback — they return `fido-u2f` attestation regardless + // of the requested `attestation: none` conveyance preference. + $manager->add(new FidoU2FAttestationStatementSupport()); + return $manager; + } + + 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..cd61006 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,24 @@ 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'. + # '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/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..7debb28 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,49 @@ # 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 + # Default is 'discouraged' so FIDO U2F-only authenticators (e.g. YubiKey 4) work + # via the browser's U2F-compat fallback. Set to 'preferred' or 'required' to + # demand PIN/biometric — note that 'required' excludes U2F-only keys. + userVerification: 'discouraged' + # Attestation verification is disabled by default — accepts any FIDO2/U2F authenticator. + attestation: 'none' + timeout: 60000 +``` + +#### Authenticator compatibility + +| Authenticator | `userVerification: discouraged` | `userVerification: required` | +| ------------------------------------- | ------------------------------- | ---------------------------- | +| YubiKey 5 / FIDO2 keys | ✅ touch | ✅ PIN + touch | +| YubiKey 4 / older U2F-only keys | ✅ touch (U2F-compat) | ❌ not supported | +| Platform authenticators (Touch ID, Windows Hello) | ✅ biometric | ✅ biometric | ![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..47d74b2 100644 --- a/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion +++ b/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion @@ -3,132 +3,29 @@ Sandstorm.NeosTwoFactorAuthentication.BackendController.new = Sandstorm.NeosTwoF 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()} - -
-
- - - - - ` + teaserText = ${I18n.id('module.new.description.picker').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..d41e488 --- /dev/null +++ b/Resources/Private/Fusion/Integration/Controller/Backend/NewTotp.fusion @@ -0,0 +1,128 @@ +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..59b219a --- /dev/null +++ b/Resources/Private/Fusion/Integration/Controller/Backend/NewWebAuthn.fusion @@ -0,0 +1,24 @@ +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()} + + 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..1f71b56 100644 --- a/Resources/Private/Fusion/Integration/Controller/Login/AskForSecondFactor.fusion +++ b/Resources/Private/Fusion/Integration/Controller/Login/AskForSecondFactor.fusion @@ -1,6 +1,26 @@ Sandstorm.NeosTwoFactorAuthentication.LoginController.askForSecondFactor = Sandstorm.NeosTwoFactorAuthentication:Page.LoginSecondFactorPage { site = ${site} styles = ${styles} + scripts = ${scripts} username = ${username} flashMessages = ${flashMessages} + + body = Neos.Fusion:Component { + hasWebAuthn = ${hasWebAuthn} + hasTotp = ${hasTotp} + flashMessages = ${flashMessages} + + renderer = afx` + +
+ {I18n.id('login.or').package('Sandstorm.NeosTwoFactorAuthentication')} +
+ + ` + } } 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..246801c --- /dev/null +++ b/Resources/Private/Fusion/Presentation/Components/LoginWebAuthnStep.fusion @@ -0,0 +1,45 @@ +prototype(Sandstorm.NeosTwoFactorAuthentication:Component.LoginWebAuthnStep) < prototype(Neos.Fusion:Component) { + # Localized error strings handed to webauthn.js. Keys match the + # DOMException name thrown by the browser; "result" / "default" / + # "unsupported" are the fixed fallbacks the script always needs. + errorMessages = Neos.Fusion:DataStructure { + result = ${I18n.id('webauthn.login.error.result').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + default = ${I18n.id('webauthn.login.error.generic').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + unsupported = ${I18n.id('webauthn.error.unsupportedBrowser').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + NotAllowedError = ${I18n.id('webauthn.error.notAllowed').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + InvalidStateError = ${I18n.id('webauthn.error.invalidState').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + NotSupportedError = ${I18n.id('webauthn.error.notSupported').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + SecurityError = ${I18n.id('webauthn.error.security').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + AbortError = ${I18n.id('webauthn.error.abort').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + ConstraintError = ${I18n.id('webauthn.error.constraint').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + UnknownError = ${I18n.id('webauthn.error.unknown').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + } + + renderer = afx` +
+
+

+ {I18n.id('webauthn.login.prompt').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..d10b48a --- /dev/null +++ b/Resources/Private/Fusion/Presentation/Components/MethodPicker.fusion @@ -0,0 +1,18 @@ +prototype(Sandstorm.NeosTwoFactorAuthentication:Component.MethodPicker) < prototype(Neos.Fusion:Component) { + # array of { label, description, href } + items = ${[]} + + renderer = afx` +
+
+ +
+ + {item.description} +
+
+
+ ` +} 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..11ebd53 --- /dev/null +++ b/Resources/Private/Fusion/Presentation/Components/SetupWebAuthnStep.fusion @@ -0,0 +1,57 @@ +prototype(Sandstorm.NeosTwoFactorAuthentication:Component.SetupWebAuthnStep) < prototype(Neos.Fusion:Component) { + flashMessages = ${[]} + # where to redirect on successful registration + redirectUrl = '/neos' + + # Localized error strings handed to webauthn.js. Keys match the + # DOMException name thrown by the browser; "result" / "default" / + # "unsupported" are the fixed fallbacks the script always needs. + errorMessages = Neos.Fusion:DataStructure { + result = ${I18n.id('webauthn.setup.error.result').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + default = ${I18n.id('webauthn.setup.error.generic').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + unsupported = ${I18n.id('webauthn.error.unsupportedBrowser').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + NotAllowedError = ${I18n.id('webauthn.error.notAllowed').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + InvalidStateError = ${I18n.id('webauthn.error.invalidState').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + NotSupportedError = ${I18n.id('webauthn.error.notSupported').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + SecurityError = ${I18n.id('webauthn.error.security').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + AbortError = ${I18n.id('webauthn.error.abort').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + ConstraintError = ${I18n.id('webauthn.error.constraint').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + UnknownError = ${I18n.id('webauthn.error.unknown').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + } + + 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..3bf99e4 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,22 @@ prototype(Sandstorm.NeosTwoFactorAuthentication:Page.LoginSecondFactorPage) < pr + + +