diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 8c0a4b5..82f6513 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -51,6 +51,14 @@ jobs: working-directory: Tests/E2E run: npm run test:${{ matrix.neos }}:enforce-provider + - name: Test - passwordless + working-directory: Tests/E2E + run: npm run test:${{ matrix.neos }}:passwordless + + - name: Test - enforce-all-passwordless + working-directory: Tests/E2E + run: npm run test:${{ matrix.neos }}:enforce-all-passwordless + - name: Upload Playwright report uses: actions/upload-artifact@v7 if: ${{ !cancelled() }} diff --git a/Classes/Controller/BackendController.php b/Classes/Controller/BackendController.php index 4a4ff86..82b3e65 100644 --- a/Classes/Controller/BackendController.php +++ b/Classes/Controller/BackendController.php @@ -86,6 +86,12 @@ class BackendController extends AbstractModuleController */ protected $persistenceManager; + /** + * @Flow\InjectConfiguration(path="webAuthn.passwordlessLoginEnabled") + * @var bool + */ + protected $passwordlessLoginEnabled = false; + /** * used to list all second factors of the current user */ @@ -111,12 +117,23 @@ public function indexAction() $this->view->assignMultiple([ 'factorsAndPerson' => $factorsAndPerson, + 'showPasskeyRegistration' => $this->showPasskeyRegistration(), 'flashMessages' => $this->flashMessageService ->getFlashMessageContainerForRequest($this->request) ->getMessagesAndFlush(), ]); } + /** + * Whether to show the "Passkey Registration" section. Shown whenever passwordless login is + * enabled — a user may register several passwordless passkeys (e.g. a platform passkey plus a + * hardware key as backup), so the section stays available even after the first one. + */ + protected function showPasskeyRegistration(): bool + { + return $this->passwordlessLoginEnabled; + } + /** * Method picker shown when the user clicks "Add second factor" inside the backend module. */ @@ -160,8 +177,13 @@ public function newTotpAction(): void /** * WebAuthn setup wizard. The JS on the page talks to LoginController's * webAuthnRegister(Options|Verify)Action XHR endpoints. + * + * @param bool $discoverable whether to register a discoverable passkey (reached from the + * "Passkey Registration" section) or a plain second factor (reached + * from the "Create second factor" method picker). The screen renders + * a single button accordingly. */ - public function newWebAuthnAction(): void + public function newWebAuthnAction(bool $discoverable = false): void { $account = $this->securityContext->getAccount(); $currentUser = $this->partyService->getAssignedPartyOfAccount($account); @@ -169,6 +191,7 @@ public function newWebAuthnAction(): void $this->view->assignMultiple([ 'currentUser' => $currentUser instanceof User ? $currentUser : null, 'accountIdentifier' => $account->getAccountIdentifier(), + 'discoverable' => $discoverable, 'flashMessages' => $this->flashMessageService ->getFlashMessageContainerForRequest($this->request) ->getMessagesAndFlush(), diff --git a/Classes/Controller/LoginController.php b/Classes/Controller/LoginController.php index 0c23b80..001b154 100644 --- a/Classes/Controller/LoginController.php +++ b/Classes/Controller/LoginController.php @@ -213,8 +213,13 @@ public function setupTotpAction(?string $username = null): void /** * WebAuthn-specific setup wizard. The page loads JS which calls the * register-options and register-verify XHR endpoints. + * + * @param bool $discoverable whether to register a discoverable, passwordless-capable passkey + * rather than a plain second factor. Reaches here from the enforced + * method picker's "Register a passkey" option; only honoured when + * passwordless login is enabled (enforced server-side in WebAuthnService). */ - public function setupWebAuthnAction(?string $username = null): void + public function setupWebAuthnAction(?string $username = null, bool $discoverable = false): void { $currentDomain = $this->domainRepository->findOneByActiveRequest(); $currentSite = $currentDomain !== null ? $currentDomain->getSite() : $this->siteRepository->findDefault(); @@ -224,6 +229,7 @@ public function setupWebAuthnAction(?string $username = null): void 'scripts' => array_filter($this->getNeosSettings()['userInterface']['backendLoginForm']['scripts']), 'username' => $username, 'site' => $currentSite, + 'discoverable' => $discoverable, 'redirectUrl' => $this->interceptedRequestOrBackendUri(), 'flashMessages' => $this->flashMessageService ->getFlashMessageContainerForRequest($this->request) @@ -305,15 +311,19 @@ public function cancelLoginAction(): void /** * @Flow\SkipCsrfProtection + * + * @param bool $discoverable whether to register a discoverable passkey (passwordless-capable) + * rather than a plain second factor; only honoured when passwordless + * login is enabled. */ - public function webAuthnRegisterOptionsAction(): string + public function webAuthnRegisterOptionsAction(bool $discoverable = false): 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); + $options = $this->webAuthnService->createRegistrationOptions($account, $hostname, $discoverable); $this->secondFactorSessionStorageService->putValue( SecondFactorSessionStorageService::SESSION_OBJECT_WEBAUTHN_REGISTRATION_OPTIONS, json_encode($options, JSON_THROW_ON_ERROR) diff --git a/Classes/Controller/PasswordlessLoginController.php b/Classes/Controller/PasswordlessLoginController.php new file mode 100644 index 0000000..c4bdc5e --- /dev/null +++ b/Classes/Controller/PasswordlessLoginController.php @@ -0,0 +1,164 @@ +passwordlessLoginEnabled) { + return $this->jsonError('Passwordless login is disabled', 403); + } + // The visitor is not authenticated yet, so no session exists — start one to hold the + // challenge across the options -> verify round trip. + $this->secondFactorSessionStorageService->startSessionIfNotStarted(); + $hostname = $this->request->getHttpRequest()->getUri()->getHost(); + $options = $this->webAuthnService->createPasswordlessAuthenticationOptions($hostname); + $this->secondFactorSessionStorageService->putValue( + SecondFactorSessionStorageService::SESSION_OBJECT_WEBAUTHN_PASSWORDLESS_OPTIONS, + json_encode($options, JSON_THROW_ON_ERROR) + ); + $this->response->setContentType('application/json'); + return json_encode($options, JSON_THROW_ON_ERROR); + } + + /** + * Ceremony step 2: verify the assertion, resolve the account, authenticate the parallel + * WebAuthn token (which logs the user into the Neos backend without a password), and + * return the URI for the client to redirect to. + * + * @Flow\SkipCsrfProtection + */ + public function verifyAction(string $assertion): string + { + if (!$this->passwordlessLoginEnabled) { + return $this->jsonError('Passwordless login is disabled', 403); + } + + $serialized = $this->secondFactorSessionStorageService->getValue( + SecondFactorSessionStorageService::SESSION_OBJECT_WEBAUTHN_PASSWORDLESS_OPTIONS + ); + if (!is_string($serialized)) { + return $this->jsonError('No passwordless login in progress', 400); + } + $options = PublicKeyCredentialRequestOptions::createFromString($serialized); + + try { + $account = $this->webAuthnService->verifyPasswordlessAssertion( + $assertion, + $options, + $this->request->getHttpRequest() + ); + } catch (\Throwable $e) { + return $this->jsonError($e->getMessage(), 400); + } + + $token = $this->securityContext->getAuthenticationTokensOfType(WebAuthnPasswordlessToken::class)[0] ?? null; + if ($token === null) { + return $this->jsonError('Passwordless authentication token not available', 500); + } + + // Authenticate the parallel token with the resolved backend account and persist it to + // the session so the authentication survives the redirect to the backend. + $token->setAccount($account); + $token->setAuthenticationStatus(TokenInterface::AUTHENTICATION_SUCCESSFUL); + $this->securityContext->refreshTokens(); + + // A user-verified passkey is itself multi-factor, so it also satisfies the 2FA gate. + $this->secondFactorSessionStorageService->initializeTwoFactorSessionObject(); + $this->secondFactorSessionStorageService->setAuthenticationStatus(AuthenticationStatus::AUTHENTICATED); + + $this->secondFactorSessionStorageService->removeValue( + SecondFactorSessionStorageService::SESSION_OBJECT_WEBAUTHN_PASSWORDLESS_OPTIONS + ); + + $this->response->setContentType('application/json'); + return json_encode([ + 'status' => 'ok', + 'redirect' => $this->interceptedRequestOrBackendUri(), + ], JSON_THROW_ON_ERROR); + } + + 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); + } + + /** + * Resolve the post-login redirect: the originally intercepted request if there is one, + * otherwise the Neos backend index — always through routing, never a hardcoded path. + */ + 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'); + } +} diff --git a/Classes/Domain/Model/SecondFactor.php b/Classes/Domain/Model/SecondFactor.php index 435c2d2..f5d15bc 100644 --- a/Classes/Domain/Model/SecondFactor.php +++ b/Classes/Domain/Model/SecondFactor.php @@ -59,6 +59,17 @@ class SecondFactor */ protected DateTime|null $creationDate; + /** + * Whether this credential is a discoverable (resident) credential — a "Passkey" that can be + * used for passwordless login. Non-discoverable credentials work only as a second factor + * ("Passkey as 2nd factor"). Only meaningful for TYPE_PUBLIC_KEY; TOTP factors are never + * discoverable. Legacy rows registered before passkey support default to false. + * + * @var bool + * @ORM\Column(options={"default": false}) + */ + protected bool $discoverable = false; + /** * @return Account */ @@ -84,14 +95,31 @@ public function getType(): int } /** - * Used in Fusion rendering + * Used in Fusion rendering. For WebAuthn credentials the label distinguishes a + * passwordless-capable "Passkey" (discoverable) from a "Passkey as 2nd factor" + * (non-discoverable, e.g. a legacy security key or one registered while passwordless + * login was disabled). + * * @return string */ public function getTypeAsName(): string { + if ($this->type === self::TYPE_PUBLIC_KEY) { + return $this->discoverable ? 'Passkey' : 'Passkey as 2nd factor'; + } return self::typeToString($this->getType()); } + public function isDiscoverable(): bool + { + return $this->discoverable; + } + + public function setDiscoverable(bool $discoverable): void + { + $this->discoverable = $discoverable; + } + /** * @param int $type */ @@ -175,7 +203,7 @@ public static function typeToString(int $type): string case self::TYPE_TOTP: return 'OTP code'; case self::TYPE_PUBLIC_KEY: - return 'Security Key'; + return 'Passkey'; 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 231d21f..67c6d0f 100644 --- a/Classes/Domain/Repository/SecondFactorRepository.php +++ b/Classes/Domain/Repository/SecondFactorRepository.php @@ -25,13 +25,14 @@ class SecondFactorRepository extends Repository /** * @throws IllegalObjectTypeException */ - public function createSecondFactorForAccount(string $secret, Account $account, int $type = SecondFactor::TYPE_TOTP, string $name = ''): SecondFactor + public function createSecondFactorForAccount(string $secret, Account $account, int $type = SecondFactor::TYPE_TOTP, string $name = '', bool $discoverable = false): SecondFactor { $secondFactor = new SecondFactor(); $secondFactor->setAccount($account); $secondFactor->setSecret($secret); $secondFactor->setType($type); $secondFactor->setName($name); + $secondFactor->setDiscoverable($discoverable); $secondFactor->setCreationDate(new \DateTime()); $this->add($secondFactor); $this->persistenceManager->persistAll(); diff --git a/Classes/Security/Authentication/WebAuthnPasswordlessProvider.php b/Classes/Security/Authentication/WebAuthnPasswordlessProvider.php new file mode 100644 index 0000000..c16ee9a --- /dev/null +++ b/Classes/Security/Authentication/WebAuthnPasswordlessProvider.php @@ -0,0 +1,46 @@ + + */ + public function getTokenClassNames() + { + return [WebAuthnPasswordlessToken::class]; + } + + /** + * The AuthenticationProviderManager only calls this for tokens in state + * AUTHENTICATION_NEEDED. The controller drives this token straight from + * NO_CREDENTIALS_GIVEN to AUTHENTICATION_SUCCESSFUL, so there is nothing to do here: + * never downgrade an already-successful (session-restored) token. + */ + public function authenticate(TokenInterface $authenticationToken) + { + if (!$authenticationToken instanceof WebAuthnPasswordlessToken) { + return; + } + if ($authenticationToken->getAuthenticationStatus() !== TokenInterface::AUTHENTICATION_SUCCESSFUL) { + $authenticationToken->setAuthenticationStatus(TokenInterface::NO_CREDENTIALS_GIVEN); + } + } +} diff --git a/Classes/Security/Token/WebAuthnPasswordlessToken.php b/Classes/Security/Token/WebAuthnPasswordlessToken.php new file mode 100644 index 0000000..b642a2c --- /dev/null +++ b/Classes/Security/Token/WebAuthnPasswordlessToken.php @@ -0,0 +1,29 @@ +sessionManager->getCurrentSession()->getData(self::SESSION_OBJECT_ID); - return $storageObject[self::SESSION_OBJECT_AUTH_STATUS]; + // The container may exist without an auth status: the passwordless login flow writes its + // options into the same container (via putValue) before any status is set. Treat a missing + // status as "not yet authenticated" instead of returning null and violating the return type. + return $storageObject[self::SESSION_OBJECT_AUTH_STATUS] ?? AuthenticationStatus::AUTHENTICATION_NEEDED; } /** @@ -46,8 +50,13 @@ public function getAuthenticationStatus(): string */ public function initializeTwoFactorSessionObject(): void { - if (!$this->sessionManager->getCurrentSession()->hasKey(self::SESSION_OBJECT_ID)) { - self::setAuthenticationStatus(AuthenticationStatus::AUTHENTICATION_NEEDED); + // Gate on the auth status key, not merely the container's presence: the passwordless login + // flow populates the same container with its options (via putValue) before any status is + // written, so checking hasKey(SESSION_OBJECT_ID) would wrongly treat it as initialized and + // leave the status unset. + $data = $this->sessionManager->getCurrentSession()->getData(self::SESSION_OBJECT_ID) ?: []; + if (!isset($data[self::SESSION_OBJECT_AUTH_STATUS])) { + $this->setAuthenticationStatus(AuthenticationStatus::AUTHENTICATION_NEEDED); } } @@ -65,16 +74,29 @@ public function putValue(string $key, mixed $value): void $session->putData(self::SESSION_OBJECT_ID, $data); } - /** - * @throws SessionNotStartedException - */ public function getValue(string $key): mixed { $session = $this->sessionManager->getCurrentSession(); + if (!$session->isStarted()) { + return null; + } $data = $session->getData(self::SESSION_OBJECT_ID) ?: []; return $data[$key] ?? null; } + /** + * Start the current session if it has not been started yet. Needed for the passwordless + * login flow, which runs for a not-yet-authenticated visitor (so, unlike the 2nd-factor + * flow, there is no session started by the preceding password authentication). + */ + public function startSessionIfNotStarted(): void + { + $session = $this->sessionManager->getCurrentSession(); + if (!$session->isStarted()) { + $session->start(); + } + } + /** * @throws SessionNotStartedException */ diff --git a/Classes/Service/WebAuthnService.php b/Classes/Service/WebAuthnService.php index 69028ec..913a6ee 100644 --- a/Classes/Service/WebAuthnService.php +++ b/Classes/Service/WebAuthnService.php @@ -69,6 +69,12 @@ class WebAuthnService */ protected $securedRelyingPartyIds = []; + /** + * @Flow\InjectConfiguration(path="webAuthn.passwordlessLoginEnabled") + * @var bool + */ + protected $passwordlessLoginEnabled = false; + /** * `lazy=false` so the real adapter (not a DependencyProxy) is passed into the * web-auth validator constructors, which strict-type-hint the interface. @@ -93,8 +99,14 @@ class WebAuthnService /** * Build a registration options object that the browser passes to * `navigator.credentials.create()`. + * + * When $discoverable is true (and passwordless login is enabled) the credential is + * registered as a resident, user-verified passkey usable for passwordless login. Otherwise + * it is registered as a plain second factor: no resident key, and user verification follows + * the configured `webAuthn.userVerification` level — so a touch-only security key (no PIN) + * keeps working as a 2nd factor even while passwordless login is enabled. */ - public function createRegistrationOptions(Account $account, string $hostname): PublicKeyCredentialCreationOptions + public function createRegistrationOptions(Account $account, string $hostname, bool $discoverable = false): PublicKeyCredentialCreationOptions { $rp = new PublicKeyCredentialRpEntity( $this->relyingPartyName ?: 'Neos', @@ -122,6 +134,19 @@ public function createRegistrationOptions(Account $account, string $hostname): P $authenticatorSelection = AuthenticatorSelectionCriteria::create() ->setUserVerification($this->userVerification); + // Register a discoverable (resident), user-verified passkey only when the user opted into + // it AND passwordless login is enabled — such a credential works both for one-tap + // usernameless login AND as a strong second factor. The guard means a discoverable + // credential can never be minted while passwordless login is off. Any other registration + // keeps the configured user-verification level and no resident-key requirement, so + // touch-only / U2F-only keys keep working as a plain 2nd factor. + $registerAsPasskey = $discoverable && $this->passwordlessLoginEnabled; + if ($registerAsPasskey) { + $authenticatorSelection + ->setResidentKey(AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_REQUIRED) + ->setUserVerification(AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED); + } + return PublicKeyCredentialCreationOptions::create( $rp, $userEntity, @@ -134,6 +159,17 @@ public function createRegistrationOptions(Account $account, string $hostname): P ->excludeCredentials(...$excludeCredentials); } + /** + * Whether the given registration options describe a discoverable (resident) passkey. This is + * the single source of truth for the stored `discoverable` flag: it reflects exactly what was + * requested when the options were created, so it round-trips with the choice made there. + */ + public function isDiscoverableRegistration(PublicKeyCredentialCreationOptions $options): bool + { + return ($options->authenticatorSelection?->residentKey ?? null) + === AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_REQUIRED; + } + /** * Verify the attestation response returned by the browser and persist * the new credential as a SecondFactor row. @@ -157,11 +193,16 @@ public function verifyAndPersistRegistration( $validator = $this->buildAttestationValidator(); $credentialSource = $validator->check($authenticatorResponse, $options, $request, $this->securedRelyingPartyIds); + // Whether this credential is a discoverable "Passkey" is derived from the options it was + // registered with (see isDiscoverableRegistration / createRegistrationOptions): a passkey + // registration requested a resident key, a 2nd-factor registration did not. This keeps the + // stored flag faithful to the per-registration choice rather than the global setting. return $this->secondFactorRepository->createSecondFactorForAccount( json_encode($credentialSource->jsonSerialize(), JSON_THROW_ON_ERROR), $account, SecondFactor::TYPE_PUBLIC_KEY, - $name + $name, + $this->isDiscoverableRegistration($options) ); } @@ -216,6 +257,84 @@ public function verifyAuthenticationResponse( ); } + /** + * Build request options for a usernameless (passwordless) login: no allowed + * credentials, so the browser offers any discoverable credential for this + * relying party, and user verification is required (a verified passkey is a + * full multi-factor authentication on its own). + */ + public function createPasswordlessAuthenticationOptions(string $hostname): PublicKeyCredentialRequestOptions + { + return PublicKeyCredentialRequestOptions::create(random_bytes(32)) + ->setTimeout($this->timeoutMs) + ->setRpId($this->relyingPartyId ?: $hostname) + ->setUserVerification(PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_REQUIRED); + } + + /** + * Verify a usernameless assertion and resolve the Neos backend account it belongs to. + * + * The assertion carries both the credential id and a user handle. We look the credential + * up by its id first (proving we actually stored it), then let the library verify the + * assertion against that credential's stored user handle, and finally map the user handle + * (the account's persistence identifier) back to the Flow account. Only Neos backend + * accounts may use this login path. + * + * @throws \Throwable when validation fails or no matching backend account exists + */ + public function verifyPasswordlessAssertion( + string $assertionResponseJson, + PublicKeyCredentialRequestOptions $options, + ServerRequestInterface $request + ): Account { + $publicKeyCredentialLoader = $this->buildCredentialLoader(); + $publicKeyCredential = $publicKeyCredentialLoader->load($assertionResponseJson); + $authenticatorResponse = $publicKeyCredential->getResponse(); + if (!$authenticatorResponse instanceof AuthenticatorAssertionResponse) { + throw new \RuntimeException('Response is not an AuthenticatorAssertionResponse', 1751200000); + } + + $rawId = $publicKeyCredential->getRawId(); + $credentialSource = $this->credentialSourceRepository->findOneByCredentialId($rawId); + if ($credentialSource === null) { + throw new \RuntimeException('Unknown passkey credential', 1751200001); + } + + $userHandle = $credentialSource->getUserHandle(); + $validator = $this->buildAssertionValidator(); + $validator->check( + $rawId, + $authenticatorResponse, + $options, + $request, + $userHandle, + $this->securedRelyingPartyIds + ); + + return $this->resolveBackendAccountByUserHandle($userHandle); + } + + /** + * Map a passkey user handle (the account's persistence identifier) back to its Flow account, + * guarding that it is a Neos backend account. Extracted so this security-critical mapping can + * be unit-tested without WebAuthn crypto. + * + * @throws \RuntimeException when no matching Neos backend account exists + */ + public function resolveBackendAccountByUserHandle(string $userHandle): Account + { + $account = $this->persistenceManager->getObjectByIdentifier($userHandle, Account::class); + if (!$account instanceof Account) { + throw new \RuntimeException('No account found for the passkey user handle', 1751200002); + } + // Guard: only Neos backend accounts may authenticate passwordlessly through this path. + if ($account->getAuthenticationProviderName() !== 'Neos.Neos:Backend') { + throw new \RuntimeException('Passkey does not belong to a Neos backend account', 1751200003); + } + + return $account; + } + private function buildUserEntity(Account $account): PublicKeyCredentialUserEntity { return new PublicKeyCredentialUserEntity( diff --git a/Configuration/Policy.yaml b/Configuration/Policy.yaml index 674084e..0968f00 100644 --- a/Configuration/Policy.yaml +++ b/Configuration/Policy.yaml @@ -8,7 +8,19 @@ privilegeTargets: 'Sandstorm.NeosTwoFactorAuthentication:BackendModule': matcher: 'method(Sandstorm\NeosTwoFactorAuthentication\Controller\BackendController->(.*)Action())' + # Passwordless login must work for not-yet-authenticated users, so it has to be exempt from + # the Neos.Neos:AllControllerActions privilege (which requires a backend role). The actions + # are still hard-gated at runtime by the `webAuthn.passwordlessLoginEnabled` setting. + 'Sandstorm.NeosTwoFactorAuthentication:PasswordlessLogin': + matcher: 'method(Sandstorm\NeosTwoFactorAuthentication\Controller\PasswordlessLoginController->(options|verify)Action())' + roles: + 'Neos.Flow:Everybody': + privileges: + - + privilegeTarget: 'Sandstorm.NeosTwoFactorAuthentication:PasswordlessLogin' + permission: GRANT + 'Neos.Neos:AbstractEditor': privileges: - diff --git a/Configuration/Routes.yaml b/Configuration/Routes.yaml index 09433cc..39d1031 100644 --- a/Configuration/Routes.yaml +++ b/Configuration/Routes.yaml @@ -101,3 +101,21 @@ '@action': 'webAuthnAuthenticateVerify' '@format': 'json' httpMethods: ['POST'] + +- name: 'Sandstorm Two Factor Authentication - Passwordless WebAuthn options' + uriPattern: 'neos/passwordless-webauthn/options' + defaults: + '@package': 'Sandstorm.NeosTwoFactorAuthentication' + '@controller': 'PasswordlessLogin' + '@action': 'options' + '@format': 'json' + httpMethods: ['POST'] + +- name: 'Sandstorm Two Factor Authentication - Passwordless WebAuthn verify' + uriPattern: 'neos/passwordless-webauthn/verify' + defaults: + '@package': 'Sandstorm.NeosTwoFactorAuthentication' + '@controller': 'PasswordlessLogin' + '@action': 'verify' + '@format': 'json' + httpMethods: ['POST'] diff --git a/Configuration/Settings.2FA.yaml b/Configuration/Settings.2FA.yaml index 8200bb5..2100ca3 100644 --- a/Configuration/Settings.2FA.yaml +++ b/Configuration/Settings.2FA.yaml @@ -19,13 +19,22 @@ Sandstorm: # 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. + # 'required', 'preferred' or 'discouraged'. Governs the SECOND-FACTOR (non-discoverable) + # registration and 2FA authentication path ONLY — passwordless passkey registration always + # requires user verification by definition, regardless of this value. + # 'discouraged' is the most permissive — works with FIDO U2F-only keys (e.g. YubiKey 4) and + # no-PIN security keys via touch only. Set to 'preferred' or 'required' to demand + # PIN/biometric on the authenticator; note that 'required' excludes touch-only / U2F-only keys. userVerification: 'discouraged' # Browser ceremony timeout in milliseconds. timeout: 60000 + # Opt-in: allow usernameless, passwordless login with a discoverable passkey directly + # from the Neos login screen (a user-verified passkey is a full multi-factor login on its + # own). OFF by default — must be explicitly enabled. When enabled, the registration screen + # offers a choice: register a discoverable passkey (passwordless-capable, user verification + # required) OR a plain touch-only security key as a 2nd factor (user verification per + # `userVerification` above). When disabled, only the latter is available. + passwordlessLoginEnabled: false # 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 diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml index 268f2e0..3df3ad9 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -58,3 +58,11 @@ Neos: pattern: 'ControllerObjectName' patternOptions: controllerObjectNamePattern: 'Sandstorm\NeosTwoFactorAuthentication\Controller\(LoginController|BackendController)' + # Parallel provider for usernameless passwordless passkey login. It has NO request + # pattern, so its token is always active and can be authenticated in-request by the + # PasswordlessLoginController; and NO entry point, so the existing Neos.Neos:Backend + # WebRedirect still serves the login screen. With Neos' `oneToken` strategy, an + # authenticated token here logs the (Neos.Neos:Backend) account into the backend. + 'Sandstorm.NeosTwoFactorAuthentication:PasswordlessWebAuthn': + provider: 'Sandstorm\NeosTwoFactorAuthentication\Security\Authentication\WebAuthnPasswordlessProvider' + token: 'Sandstorm\NeosTwoFactorAuthentication\Security\Token\WebAuthnPasswordlessToken' diff --git a/Configuration/Views.yaml b/Configuration/Views.yaml index e99ede6..e14b1de 100644 --- a/Configuration/Views.yaml +++ b/Configuration/Views.yaml @@ -4,3 +4,15 @@ options: fusionPathPatterns: - 'resource://Sandstorm.NeosTwoFactorAuthentication/Private/Fusion/Login' + +# Extend the CORE Neos backend login screen so we can append the passwordless passkey button. +# Neos' own FusionView for this action loads only 'resource://Neos.Neos/Private/Fusion/Backend', +# and view configurations are selected by weight (not merged), so this entry must repeat that +# path AND add our Fusion. Our package loads after Neos.Neos, so this higher-order entry wins. +- + requestFilter: 'isPackage("Neos.Neos") && isController("Login") && isAction("index") && isFormat("html")' + viewObjectName: 'Neos\Fusion\View\FusionView' + options: + fusionPathPatterns: + - 'resource://Neos.Neos/Private/Fusion/Backend' + - 'resource://Sandstorm.NeosTwoFactorAuthentication/Private/Fusion' diff --git a/Migrations/Mysql/Version20260627120000.php b/Migrations/Mysql/Version20260627120000.php new file mode 100644 index 0000000..dea8f20 --- /dev/null +++ b/Migrations/Mysql/Version20260627120000.php @@ -0,0 +1,41 @@ +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 ADD discoverable TINYINT(1) DEFAULT 0 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 DROP discoverable'); + } +} diff --git a/README.md b/README.md index ede91fc..158ddf5 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,30 @@ # Neos Backend 2FA -Extend the Neos backend login to support second factors. We support TOTP tokens (Authenticator apps) -and WebAuthn / FIDO2 hardware security keys (e.g. Yubikey). +Extend the Neos backend login to support second factors and passwordless passkey login. We support +TOTP tokens (Authenticator apps) and WebAuthn / FIDO2 passkeys — both platform authenticators +(Touch ID, Windows Hello) and 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 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. +(Authenticator App) or a **passkey** (WebAuthn / FIDO2: a platform authenticator such as Touch ID or +Windows Hello, or a hardware key such as a Yubikey). Users can register one of each and pick which to +use at login. When passwordless login is enabled (see [Passwordless passkey login](#passwordless-passkey-login)), +a discoverable passkey can also be used to sign in straight from the login screen **without a +password**. As an Administrator you are able to delete factors for users again, in case they locked +themselves out. -### Security keys (WebAuthn / FIDO2) +The management module distinguishes the two kinds of WebAuthn credential by a badge: a discoverable, +passwordless-capable credential is shown as **"Passkey"**, a non-discoverable one (usable only as a +second factor, e.g. a U2F-only key) as **"Passkey as 2nd factor"**. + +### Passkeys (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. +over HTTPS in production, otherwise the passkey flow will fail. Note that an IP address (e.g. +`127.0.0.1`) cannot be used as the relying-party id — use `localhost` for local development. Configure the relying party identifier when your backend hostname differs from the registered domain: @@ -35,6 +44,57 @@ Sandstorm: timeout: 60000 ``` +##### Local development over plain HTTP + +WebAuthn requires a secure context. The browser treats `localhost` as secure, but the verification +library is stricter: it only exempts the **exact** host `localhost` from the HTTPS requirement, and +rejects any other host served over `http://` (including `localhost` subdomains such as +`myproject.localhost`) with `Invalid scheme. HTTPS required.`. + +If your local backend runs over plain HTTP on a host other than `localhost`, list that host under +`securedRelyingPartyIds` to skip the HTTPS check for it: + +```yml +Sandstorm: + NeosTwoFactorAuthentication: + webAuthn: + # LOCAL DEVELOPMENT ONLY — never add a real production hostname here. + # Relying-party ids listed here are treated as secured even without HTTPS. + securedRelyingPartyIds: + - 'myproject.localhost' +``` + +Keep this scoped to a development context (e.g. `Configuration/Development/Settings.yaml`). In +production the backend must be served over HTTPS and this setting should stay empty (the default). + +#### Passwordless passkey login + +A discoverable passkey is inherently multi-factor (something you have + the verification you perform +on the device), so it can serve as the **only** login step. When enabled, a "Sign in with a passkey" +button appears on the Neos login screen and a single tap signs the user into the backend — no +username, no password. + +This is **disabled by default** and must be turned on explicitly. The gate is enforced server-side +(the endpoints reject requests while disabled): + +```yml +Sandstorm: + NeosTwoFactorAuthentication: + webAuthn: + # Default false. When true: the login screen shows "Sign in with a passkey", and credentials + # registered from then on are created as discoverable (resident) credentials with user + # verification required — i.e. real "Passkeys". When false, WebAuthn credentials are + # registered as non-discoverable "Passkey as 2nd factor" and the login button is not shown. + passwordlessLoginEnabled: true +``` + +Notes: +- A passkey usable for passwordless login must be **discoverable** (resident) and user-verifying. + Credentials registered while this setting was off, or on U2F-only keys (e.g. YubiKey 4), are + non-discoverable and remain usable only as a second factor. +- The management module shows a "Register a passkey" banner nudging users who have no discoverable + credential yet, so they can opt into faster sign-in. + #### Attestation There is no setting for attestation. We always request the `none` conveyance preference, so the @@ -45,11 +105,21 @@ 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 | +The first two columns describe registering a credential **as a second factor** (the effect of the +`userVerification` setting). The last column describes whether the authenticator can be used for +**passwordless login**, which always requires a discoverable (resident) credential with user +verification — independent of the `userVerification` setting. + +| Authenticator | 2nd factor — `userVerification: discouraged` | 2nd factor — `userVerification: required` | Passwordless passkey | +| ------------------------------------------------- | -------------------------------------------- | ----------------------------------------- | ---------------------------- | +| YubiKey 5 / FIDO2 keys | ✅ touch | ✅ PIN + touch | ✅ resident key + PIN/touch | +| YubiKey 4 / older U2F-only keys | ✅ touch (U2F-compat) | ❌ not supported | ❌ no resident credentials | +| Platform authenticators (Touch ID, Windows Hello) | ✅ biometric | ✅ biometric | ✅ resident key + biometric | + +> When `passwordlessLoginEnabled` is on, **every** new registration requires a resident key + user +> verification, so U2F-only keys (e.g. YubiKey 4) can no longer be registered at all while it is +> enabled — turn it off if you need to enrol such a key as a second factor. Bear in mind that +> resident credentials also occupy a limited number of slots on hardware keys. ![Screenshot 2022-02-08 at 17 11 01](https://user-images.githubusercontent.com/12086990/153028043-93e9220e-cc22-4879-9edb-3e156c9accc8.png) @@ -191,11 +261,37 @@ Thx to @Sebobo @Benjamin-K for creating a list of supported and testet apps! ``` +- **Passwordless passkey login** is a separate, opt-in mechanism that does *primary* authentication — + the middleware above is only a post-login gate. Because a Flow provider name maps to exactly one + token class, we did not try to make the username/password provider also accept a passkey. Instead + we added a parallel authentication provider + token (`WebAuthnPasswordlessProvider` / + `WebAuthnPasswordlessToken`), registered with **no request pattern** and **no entry point**. + - `PasswordlessLoginController` verifies the WebAuthn assertion, resolves the `Neos.Neos:Backend` + account from the assertion's user handle (the account's persistence id), sets the parallel token + to `AUTHENTICATION_SUCCESSFUL`, calls `securityContext->refreshTokens()` to persist it to the + session, and marks the package's 2FA session status `AUTHENTICATED`. + - It survives the redirect to `/neos` because `AuthenticationProviderManager` only re-runs + providers for tokens that are `AUTHENTICATION_NEEDED`, and the Neos backend uses + `authenticationStrategy: oneToken` — so a single authenticated token authenticates the request. + `UserService::getCurrentUser()` resolves the Neos user from the *account's party* regardless of + which provider authenticated it, so roles and the backend UI work normally. + - A user-verified passkey is inherently multi-factor, so the 2FA gate is satisfied in one tap + (passwordless login sets the 2FA session status `AUTHENTICATED`, which is check #5 above). + - The "Sign in with a passkey" button is injected into the core Neos login screen by overriding + `Neos.Neos:Component.Login.Form` (`renderer.@process`) plus a `Views.yaml` entry. The core login + is rendered by a plain `FusionView` that does not apply `fusion.autoInclude` and has no script + hook, so the (gated) button also emits its own ` + + ` +} diff --git a/Resources/Private/Fusion/Presentation/Components/RegisterPasskeyBanner.fusion b/Resources/Private/Fusion/Presentation/Components/RegisterPasskeyBanner.fusion new file mode 100644 index 0000000..2fc9668 --- /dev/null +++ b/Resources/Private/Fusion/Presentation/Components/RegisterPasskeyBanner.fusion @@ -0,0 +1,18 @@ +prototype(Sandstorm.NeosTwoFactorAuthentication:Component.RegisterPasskeyBanner) < prototype(Neos.Fusion:Component) { + # No @propTypes here on purpose: the other components in this package (Footer, SecondFactorList) + # don't declare them, and the globally-enabled AtomicFusion PropTypes validator would otherwise + # reject a null `show` (e.g. before the controller assignment is picked up). The defaults below + # keep this safe. + show = false + registerUri = '' + + renderer = afx` +
+ {I18n.id('module.index.passkeyBanner.title').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} +

{I18n.id('module.index.passkeyBanner.text').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()}

+ + {I18n.id('module.index.passkeyBanner.cta').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} + +
+ ` +} diff --git a/Resources/Private/Fusion/Presentation/Components/SecondFactorList.fusion b/Resources/Private/Fusion/Presentation/Components/SecondFactorList.fusion index 00a24c7..d9498cc 100644 --- a/Resources/Private/Fusion/Presentation/Components/SecondFactorList.fusion +++ b/Resources/Private/Fusion/Presentation/Components/SecondFactorList.fusion @@ -43,7 +43,11 @@ prototype(Sandstorm.NeosTwoFactorAuthentication:Component.SecondFactorList.Entry renderer = afx` {props.factorAndPerson.user.name.fullName} ({props.factorAndPerson.secondFactor.account.accountIdentifier}) - {props.factorAndPerson.secondFactor.typeAsName} + + + {props.factorAndPerson.secondFactor.typeAsName} + + {props.factorAndPerson.secondFactor.name == null ? '-' : props.factorAndPerson.secondFactor.name} {props.factorAndPerson.secondFactor.creationDate == null ? '-' : Date.format(props.factorAndPerson.secondFactor.creationDate, 'Y-m-d H:i')} diff --git a/Resources/Private/Fusion/Presentation/Components/SetupTotpStep.fusion b/Resources/Private/Fusion/Presentation/Components/SetupTotpStep.fusion index 8e4d11c..3355b2b 100644 --- a/Resources/Private/Fusion/Presentation/Components/SetupTotpStep.fusion +++ b/Resources/Private/Fusion/Presentation/Components/SetupTotpStep.fusion @@ -71,6 +71,13 @@ prototype(Sandstorm.NeosTwoFactorAuthentication:Component.SetupTotpStep) < proto
+ - {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 index 9472142..83bcce8 100644 --- a/Resources/Private/Fusion/Presentation/Components/SetupWebAuthnStep.fusion +++ b/Resources/Private/Fusion/Presentation/Components/SetupWebAuthnStep.fusion @@ -3,6 +3,12 @@ prototype(Sandstorm.NeosTwoFactorAuthentication:Component.SetupWebAuthnStep) < p # where to redirect on successful registration redirectUrl = '/neos' + # Whether this screen registers a discoverable, passwordless-capable passkey (true) or a plain + # second factor (false). The credential type is decided by the entry point that led here — the + # "Passkey Registration" section passes true, the "Create second factor" picker passes false — + # so this screen always shows a single button. + discoverable = false + # 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. @@ -47,8 +53,11 @@ prototype(Sandstorm.NeosTwoFactorAuthentication:Component.SetupWebAuthnStep) < p