diff --git a/Classes/Controller/BackendController.php b/Classes/Controller/BackendController.php index e7b32d5..c6be578 100644 --- a/Classes/Controller/BackendController.php +++ b/Classes/Controller/BackendController.php @@ -118,9 +118,26 @@ 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(), + '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 +147,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 +158,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 +199,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..4947eab 100644 --- a/Classes/Controller/LoginController.php +++ b/Classes/Controller/LoginController.php @@ -26,6 +26,9 @@ use Sandstorm\NeosTwoFactorAuthentication\Domain\Repository\SecondFactorRepository; 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 +37,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 +86,12 @@ class LoginController extends ActionController */ protected $tOTPService; + /** + * @Flow\Inject + * @var WebAuthnService + */ + protected $webAuthnService; + /** * @Flow\Inject * @var Translator @@ -83,21 +99,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 +136,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 +162,33 @@ 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, + '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 +210,27 @@ 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, + 'redirectUrl' => $this->interceptedRequestOrBackendUri(), + 'flashMessages' => $this->flashMessageService + ->getFlashMessageContainerForRequest($this->request) + ->getMessagesAndFlush(), + ]); + } + /** * @param string $secret * @param string $secondFactorFromApp @@ -200,12 +257,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 +275,192 @@ 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(); + if ($account === null) { + return $this->jsonError('No authentication in progress', 401); } + $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); + } - $this->redirect('index', 'Backend\Backend', 'Neos.Neos'); + /** + * @Flow\SkipCsrfProtection + * @throws SessionNotStartedException + * @throws StopActionException + */ + public function webAuthnRegisterVerifyAction(string $attestation, string $name = ''): 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(); + if ($account === null) { + return $this->jsonError('No authentication in progress', 401); + } + try { + $this->webAuthnService->verifyAndPersistRegistration( + $attestation, + $options, + $account, + $this->request->getHttpRequest(), + $name + ); + } catch (\Throwable $e) { + return $this->jsonError($e->getMessage(), 400); + } + + $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(); + if ($account === null) { + return $this->jsonError('No authentication in progress', 401); + } + $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(); + if ($account === null) { + return $this->jsonError('No authentication in progress', 401); + } + + 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); + + $this->response->setContentType('application/json'); + return json_encode([ + 'status' => 'ok', + 'redirect' => $this->interceptedRequestOrBackendUri(), + ], 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'); + } + + /** + * Build the post-2FA redirect URI as a string: the originally intercepted + * request if there is one, otherwise the Neos backend index. Resolved through + * routing (never a hardcoded path) so it works regardless of where the backend + * is mounted. Used by the WebAuthn XHR flow, which returns the URI for the + * client to follow instead of issuing a server-side redirect. + */ + private function interceptedRequestOrBackendUri(): string + { + $uriBuilder = $this->controllerContext->getUriBuilder(); + $originalRequest = $this->securityContext->getInterceptedRequest(); + if ($originalRequest !== null) { + return (string)$uriBuilder->uriFor( + $originalRequest->getControllerActionName(), + $originalRequest->getArguments(), + $originalRequest->getControllerName(), + $originalRequest->getControllerPackageKey() + ); + } + return (string)$uriBuilder->uriFor('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..435c2d2 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); @@ -144,9 +173,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 8e076b4..231d21f 100644 --- a/Classes/Domain/Repository/SecondFactorRepository.php +++ b/Classes/Domain/Repository/SecondFactorRepository.php @@ -13,6 +13,7 @@ * @Flow\Scope("singleton") * * @method QueryResult findByAccount(Account $account) + * @method int countByAccount(Account $account) */ class SecondFactorRepository extends Repository { @@ -24,15 +25,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/Http/Middleware/SecondFactorMiddleware.php b/Classes/Http/Middleware/SecondFactorMiddleware.php index ad1c922..d583ac7 100644 --- a/Classes/Http/Middleware/SecondFactorMiddleware.php +++ b/Classes/Http/Middleware/SecondFactorMiddleware.php @@ -24,6 +24,27 @@ class SecondFactorMiddleware implements MiddlewareInterface const SECOND_FACTOR_LOGIN_URI = '/neos/second-factor-login'; const SECOND_FACTOR_SETUP_URI = '/neos/second-factor-setup'; + /** + * URIs that must remain reachable while the user is still on the 2FA challenge step, + * so the WebAuthn JS can complete the ceremony via XHR without being redirected back + * to the HTML challenge page. + */ + const SECOND_FACTOR_CHALLENGE_ALLOWED_URI_PREFIXES = [ + '/neos/second-factor-login', + '/neos/second-factor-webauthn/authenticate-options', + '/neos/second-factor-webauthn/authenticate-verify', + ]; + + /** + * URIs that must remain reachable while the user is still on the enforced-setup step + * (method picker, TOTP wizard, WebAuthn wizard, plus the WebAuthn registration XHRs). + */ + const SECOND_FACTOR_SETUP_ALLOWED_URI_PREFIXES = [ + '/neos/second-factor-setup', + '/neos/second-factor-webauthn/register-options', + '/neos/second-factor-webauthn/register-verify', + ]; + /** * @Flow\Inject * @var SecurityContext @@ -56,7 +77,7 @@ class SecondFactorMiddleware implements MiddlewareInterface /** * This middleware checks if the user is authenticated with a second factor "if necessary". - * This middleware runs _after_ the 'securityEndpoint' middleware. This means if we are on a secured route we would + * This middleware runs _after_ the 'securityEntryPoint' middleware. This means if we are on a secured route we would * have an authenticated session by now. * * The the process looks like this: @@ -66,9 +87,9 @@ class SecondFactorMiddleware implements MiddlewareInterface * ▼ * ... middlewares ... * ▼ - * ┌─────────────────────────────┐ - * │ SecurityEndpointMiddleware │ - * └─────────────────────────────┘ + * ┌───────────────────────────────┐ + * │ SecurityEntryPointMiddleware │ + * └───────────────────────────────┘ * ▼ * ┌───────────────────────────────────────────────────────────────────┐ * │ SecondFactorMiddleware │ @@ -175,9 +196,9 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface $isEnabledForAccount && $authenticationStatus === AuthenticationStatus::AUTHENTICATION_NEEDED ) { - // WHY: We use the request URI as state here to keep the middleware from entering a redirect loop. - $isAskingForOTP = str_ends_with($request->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..6eede86 --- /dev/null +++ b/Classes/Service/PublicKeyCredentialSourceRepositoryAdapter.php @@ -0,0 +1,105 @@ +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) { + try { + $source = PublicKeyCredentialSource::createFromArray($factor->getCredentialData()); + } catch (\Throwable $exception) { + // A single corrupt/truncated credential row must not break the lookup for all + // other users. Skip it and log so the broken factor can be investigated. + $this->securityLogger->warning( + sprintf( + 'Skipping WebAuthn second factor with corrupt credential data (id %s): %s', + $this->persistenceManager->getIdentifierByObject($factor), + $exception->getMessage() + ) + ); + continue; + } + yield [$factor, $source]; + } + } +} diff --git a/Classes/Service/SecondFactorService.php b/Classes/Service/SecondFactorService.php index 57fd988..edd998f 100644 --- a/Classes/Service/SecondFactorService.php +++ b/Classes/Service/SecondFactorService.php @@ -60,8 +60,7 @@ public function isSecondFactorEnforcedForAccount(Account $account): bool */ public function isSecondFactorEnabledForAccount(Account $account): bool { - $factors = $this->secondFactorRepository->findByAccount($account); - return count($factors) > 0; + return $this->secondFactorRepository->countByAccount($account) > 0; } /** @@ -72,7 +71,7 @@ public function isSecondFactorEnabledForAccount(Account $account): bool public function canOneSecondFactorBeDeletedForAccount(Account $account): bool { $isEnforcedForAccount = $this->isSecondFactorEnforcedForAccount($account); - $hasMultipleFactors = count($this->secondFactorRepository->findByAccount($account)) > 1; + $hasMultipleFactors = $this->secondFactorRepository->countByAccount($account) > 1; return !$isEnforcedForAccount || $hasMultipleFactors; } 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..69028ec --- /dev/null +++ b/Classes/Service/WebAuthnService.php @@ -0,0 +1,283 @@ + + */ + 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, + string $name = '' + ): 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, + $name + ); + } + + /** + * 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.2FA.yaml b/Configuration/Settings.2FA.yaml index 3ee0629..148e510 100644 --- a/Configuration/Settings.2FA.yaml +++ b/Configuration/Settings.2FA.yaml @@ -2,9 +2,30 @@ 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' + # 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 3c342e2..8b7e951 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: 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..7254bf9 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,55 @@ # 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' + timeout: 60000 +``` + +#### Attestation + +There is no setting for attestation. We always request the `none` conveyance preference, so the +browser does not return identifying attestation data about the authenticator. Only the `none` and +`fido-u2f` attestation statement formats are accepted when loading a credential (the latter is +required for U2F-only authenticators registered via the browser's U2F-compat fallback). Other +attestation statement types are not supported yet. + +#### 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) @@ -86,9 +126,9 @@ Thx to @Sebobo @Benjamin-K for creating a list of supported and testet apps! ▼ ... middleware chain ... ▼ - ┌─────────────────────────────┐ - │ SecurityEndpointMiddleware │ - └─────────────────────────────┘ + ┌───────────────────────────────┐ + │ SecurityEntryPointMiddleware │ + └───────────────────────────────┘ ▼ ┌───────────────────────────────────────────────────────────────────┐ │ SecondFactorMiddleware │ diff --git a/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion b/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion index 1c987ae..6816c25 100644 --- a/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion +++ b/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion @@ -3,141 +3,31 @@ 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()} + testId = 'select-method-totp' + 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()} + testId = 'select-method-webauthn' + 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..3e17bbf --- /dev/null +++ b/Resources/Private/Fusion/Integration/Controller/Backend/NewTotp.fusion @@ -0,0 +1,138 @@ +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..ffd517e 100644 --- a/Resources/Private/Fusion/Integration/Controller/Login/SetupSecondFactor.fusion +++ b/Resources/Private/Fusion/Integration/Controller/Login/SetupSecondFactor.fusion @@ -4,6 +4,29 @@ 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()} + testId = 'select-method-totp' + 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()} + testId = 'select-method-webauthn' + 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..85c8602 --- /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 = ${redirectUrl} + } +} 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..17d216d --- /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, testId } + 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..7aca70a --- /dev/null +++ b/Resources/Private/Fusion/Presentation/Components/SetupTotpStep.fusion @@ -0,0 +1,99 @@ +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..9472142 --- /dev/null +++ b/Resources/Private/Fusion/Presentation/Components/SetupWebAuthnStep.fusion @@ -0,0 +1,67 @@ +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 + + +