From 6cdc90a5db891e3c6f32162450d8d9031da9c13f Mon Sep 17 00:00:00 2001 From: Tobias Gruber Date: Sat, 27 Jun 2026 22:36:17 +0200 Subject: [PATCH 01/14] =?UTF-8?q?=E2=9C=A8=20FEATURE:=20add=20opt-in=20pas?= =?UTF-8?q?swordless=20passkey=20login=20(core)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add usernameless, passwordless passkey login to the Neos backend login screen, alongside the existing WebAuthn 2nd factor. A user-verified discoverable passkey authenticates the Neos.Neos:Backend account directly (no password) and satisfies the 2FA gate in one tap. Off by default: gated behind webAuthn.passwordlessLoginEnabled, enforced server-side in the controller (not just by hiding the button). Architecture: a parallel Flow auth provider + token (WebAuthnPasswordlessProvider / WebAuthnPasswordlessToken) authenticate the same backend account in-request; with Neos' oneToken strategy and party-based user resolution this grants full backend access. The token is persisted via refreshTokens() and survives the redirect to /neos. - WebAuthnService: createPasswordlessAuthenticationOptions (empty allowCredentials, UV required), verifyPasswordlessAssertion, and the unit-tested resolveBackendAccountByUserHandle guard (only Neos.Neos:Backend accounts may use this path). Registration requests discoverable (resident) credentials only when passwordless is enabled, so U2F-only 2nd-factor behaviour is unchanged when off. - PasswordlessLoginController (options/verify XHR, SkipCsrfProtection), Routes.yaml, Settings.yaml (provider), Policy.yaml (public grant for the endpoints), Settings.2FA.yaml (passwordlessLoginEnabled: false), new session key, and session start for the logged-out flow. - Login screen: Views.yaml extends the core Neos login FusionView to load a server-gated PasswordlessLoginButton that also loads webauthn.js; JS reuses the assertion ceremony (click-only, no auto-trigger). - Tests: unit test for the account-resolution guard; E2E "passwordless" variant (happy path) and "disabled by default" scenarios (no button + 403). - E2E infra: reuseExistingServer + KEEP_SUT for a fast local loop, mount Tests/ into the SUT so PHPUnit runs there, and make removeAllUsers tolerant of zero users. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PasswordlessLoginController.php | 164 ++++++++++++++++++ .../WebAuthnPasswordlessProvider.php | 46 +++++ .../Token/WebAuthnPasswordlessToken.php | 29 ++++ .../SecondFactorSessionStorageService.php | 20 ++- Classes/Service/WebAuthnService.php | 94 ++++++++++ Configuration/Policy.yaml | 12 ++ Configuration/Routes.yaml | 18 ++ Configuration/Settings.2FA.yaml | 6 + Configuration/Settings.yaml | 8 + Configuration/Views.yaml | 12 ++ .../Integration/Override/LoginForm.fusion | 10 ++ .../Components/PasswordlessLoginButton.fusion | 53 ++++++ Resources/Private/Translations/de/Main.xlf | 4 + Resources/Private/Translations/en/Main.xlf | 3 + Resources/Public/JavaScript/webauthn.js | 10 ++ Tests/E2E/Makefile | 8 + .../default/passwordless-disabled.feature | 12 ++ Tests/E2E/features/passwordless/login.feature | 18 ++ Tests/E2E/global-teardown.ts | 6 + Tests/E2E/helpers/general-pages.ts | 11 ++ Tests/E2E/helpers/system.ts | 15 +- Tests/E2E/package-lock.json | 2 + Tests/E2E/package.json | 2 + Tests/E2E/playwright.config.ts | 5 + Tests/E2E/steps/general-login.steps.ts | 24 +++ .../sut-base-docker-compose.yaml | 3 + .../E2E-SUT/Passwordless/Settings.2FA.yaml | 9 + Tests/Unit/Service/WebAuthnServiceTest.php | 70 ++++++++ 28 files changed, 667 insertions(+), 7 deletions(-) create mode 100644 Classes/Controller/PasswordlessLoginController.php create mode 100644 Classes/Security/Authentication/WebAuthnPasswordlessProvider.php create mode 100644 Classes/Security/Token/WebAuthnPasswordlessToken.php create mode 100644 Resources/Private/Fusion/Integration/Override/LoginForm.fusion create mode 100644 Resources/Private/Fusion/Presentation/Components/PasswordlessLoginButton.fusion create mode 100644 Tests/E2E/features/default/passwordless-disabled.feature create mode 100644 Tests/E2E/features/passwordless/login.feature create mode 100644 Tests/E2E/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/Passwordless/Settings.2FA.yaml create mode 100644 Tests/Unit/Service/WebAuthnServiceTest.php 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/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 @@ +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..909e466 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. @@ -122,6 +128,16 @@ public function createRegistrationOptions(Account $account, string $hostname): P $authenticatorSelection = AuthenticatorSelectionCriteria::create() ->setUserVerification($this->userVerification); + // When passwordless login is enabled, register discoverable (resident) credentials + // with user verification so a single credential works both for one-tap usernameless + // login AND as a strong second factor. When disabled the behaviour is unchanged, so + // U2F-only keys (which cannot store a resident credential) keep working as a 2nd factor. + if ($this->passwordlessLoginEnabled) { + $authenticatorSelection + ->setResidentKey(AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_REQUIRED) + ->setUserVerification(AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED); + } + return PublicKeyCredentialCreationOptions::create( $rp, $userEntity, @@ -216,6 +232,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..c221a48 100644 --- a/Configuration/Settings.2FA.yaml +++ b/Configuration/Settings.2FA.yaml @@ -26,6 +26,12 @@ Sandstorm: 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, new WebAuthn + # registrations create discoverable credentials so one passkey serves both passwordless + # login and 2nd-factor use. + 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/Resources/Private/Fusion/Integration/Override/LoginForm.fusion b/Resources/Private/Fusion/Integration/Override/LoginForm.fusion new file mode 100644 index 0000000..5ccd23a --- /dev/null +++ b/Resources/Private/Fusion/Integration/Override/LoginForm.fusion @@ -0,0 +1,10 @@ +# Append the passwordless passkey button to the core Neos backend login form. +# Uses Neos.Fusion:Join (not afx interpolation) so the already-rendered form HTML is +# concatenated verbatim instead of being HTML-escaped. The button component renders nothing +# unless passwordless login is enabled, so when the feature is off the login screen is unchanged. +prototype(Neos.Neos:Component.Login.Form) { + renderer.@process.appendPasswordlessPasskey = Neos.Fusion:Join { + loginForm = ${value} + passwordlessButton = Sandstorm.NeosTwoFactorAuthentication:Component.PasswordlessLoginButton + } +} diff --git a/Resources/Private/Fusion/Presentation/Components/PasswordlessLoginButton.fusion b/Resources/Private/Fusion/Presentation/Components/PasswordlessLoginButton.fusion new file mode 100644 index 0000000..7485736 --- /dev/null +++ b/Resources/Private/Fusion/Presentation/Components/PasswordlessLoginButton.fusion @@ -0,0 +1,53 @@ +prototype(Sandstorm.NeosTwoFactorAuthentication:Component.PasswordlessLoginButton) < prototype(Neos.Fusion:Component) { + # Server-side gate: render nothing unless passwordless login is explicitly enabled. + # This is what keeps the button off the login screen by default (and is the server-side + # half of the protection — the endpoints themselves also reject when disabled). + enabled = ${Configuration.setting('Sandstorm.NeosTwoFactorAuthentication.webAuthn.passwordlessLoginEnabled')} + + # The core Neos login screen has no script-injection hook, so the (gated) button loads the + # WebAuthn ceremony script itself. Only emitted when enabled, so the default login is untouched. + scriptUri = Neos.Fusion:ResourceUri { + path = 'resource://Sandstorm.NeosTwoFactorAuthentication/Public/JavaScript/webauthn.js' + } + + # Localized error strings handed to webauthn.js. Keys match the DOMException name thrown + # by the browser; "result" / "default" / "unsupported" are the fixed fallbacks. + 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` + +
+
+ +
+ +
+ +
+ ` +} diff --git a/Resources/Private/Translations/de/Main.xlf b/Resources/Private/Translations/de/Main.xlf index e3f01ef..326923e 100644 --- a/Resources/Private/Translations/de/Main.xlf +++ b/Resources/Private/Translations/de/Main.xlf @@ -110,6 +110,10 @@ Use security key Sicherheitsschlüssel verwenden + + Sign in with a passkey + Mit einem Passkey anmelden + or oder diff --git a/Resources/Private/Translations/en/Main.xlf b/Resources/Private/Translations/en/Main.xlf index d37b095..f794093 100644 --- a/Resources/Private/Translations/en/Main.xlf +++ b/Resources/Private/Translations/en/Main.xlf @@ -83,6 +83,9 @@ Use security key + + Sign in with a passkey + or diff --git a/Resources/Public/JavaScript/webauthn.js b/Resources/Public/JavaScript/webauthn.js index 8411e8b..aab3062 100644 --- a/Resources/Public/JavaScript/webauthn.js +++ b/Resources/Public/JavaScript/webauthn.js @@ -186,6 +186,16 @@ setTimeout(function () { runAuthentication(container); }, 200); } }); + + // Passwordless (usernameless) login on the main login screen. The assertion ceremony + // is identical to the 2nd-factor login (options -> get -> verify -> redirect), so we + // reuse runAuthentication. Click-only (no auto-trigger): the OS passkey prompt must + // not pop up on every visit to the login page — the user opts in by clicking. + document.querySelectorAll('[data-webauthn-passwordless]').forEach(function (container) { + if (!checkSupport(container)) return; + var trigger = container.querySelector('[data-webauthn-trigger]'); + if (trigger) trigger.addEventListener('click', function () { runAuthentication(container); }); + }); } if (document.readyState === 'loading') { diff --git a/Tests/E2E/Makefile b/Tests/E2E/Makefile index 2d372c4..ee2963e 100644 --- a/Tests/E2E/Makefile +++ b/Tests/E2E/Makefile @@ -94,6 +94,14 @@ test-neos9-enforce-role: test-neos9-enforce-provider: cd $(E2E_DIR) && npm run test:neos9:enforce-provider +## Run neos8 E2E tests for passwordless passkey login +test-neos8-passwordless: + cd $(E2E_DIR) && npm run test:neos8:passwordless + +## Run neos9 E2E tests for passwordless passkey login +test-neos9-passwordless: + cd $(E2E_DIR) && npm run test:neos9:passwordless + ## Start the neos8 SUT containers start-sut-neos8: docker compose -f $(NEOS8_COMPOSE) up -d --build diff --git a/Tests/E2E/features/default/passwordless-disabled.feature b/Tests/E2E/features/default/passwordless-disabled.feature new file mode 100644 index 0000000..644eb25 --- /dev/null +++ b/Tests/E2E/features/default/passwordless-disabled.feature @@ -0,0 +1,12 @@ +@default-context +Feature: Passwordless login is disabled by default + + Passwordless passkey login is opt-in. With the default configuration it must be impossible: + the login screen shows no passkey button, and the endpoint refuses to authenticate anyone. + + Scenario: The passkey sign-in button is not shown when passwordless login is disabled + When I open the login page + Then I should not see the passkey sign-in button + + Scenario: The passwordless verify endpoint is forbidden when passwordless login is disabled + Then the passwordless verify endpoint is forbidden diff --git a/Tests/E2E/features/passwordless/login.feature b/Tests/E2E/features/passwordless/login.feature new file mode 100644 index 0000000..baab28c --- /dev/null +++ b/Tests/E2E/features/passwordless/login.feature @@ -0,0 +1,18 @@ +@passwordless +Feature: Passwordless passkey login + + When passwordless login is enabled, a user who has registered a (discoverable) + passkey can sign in with a single tap on the login screen — no username, no + password — and lands straight in the Neos backend. + + Background: + Given A user with username "admin", password "password" and role "Neos.Neos:Administrator" exists + + Scenario: User can log in passwordlessly with a registered passkey + Given I have a virtual security key + When I log in with username "admin" and password "password" + And I navigate to the 2FA management page + And I add a new WebAuthn 2FA device + And I log out + And I sign in with a passkey + Then I should see the Neos content page diff --git a/Tests/E2E/global-teardown.ts b/Tests/E2E/global-teardown.ts index 6a273ae..d9f685c 100644 --- a/Tests/E2E/global-teardown.ts +++ b/Tests/E2E/global-teardown.ts @@ -2,6 +2,12 @@ import { execSync } from 'node:child_process'; import { dirname } from 'node:path'; export default async function globalTeardown() { + // KEEP_SUT=1 leaves the SUT running so the next run reuses it (see reuseExistingServer + // in playwright.config.ts) instead of paying the multi-minute cold-boot every time. + // Useful for a tight local dev loop; CI leaves it unset so the environment is torn down. + if (process.env.KEEP_SUT) { + return; + } const sut = process.env.SUT || 'neos8'; execSync( `docker compose -f ./system_under_test/${sut}/docker-compose.yaml down -v`, diff --git a/Tests/E2E/helpers/general-pages.ts b/Tests/E2E/helpers/general-pages.ts index 9ff8832..b69679b 100644 --- a/Tests/E2E/helpers/general-pages.ts +++ b/Tests/E2E/helpers/general-pages.ts @@ -12,6 +12,17 @@ export class NeosLoginPage { await this.page.locator('input[type="password"]').fill(password); await this.page.locator('.neos-login-btn:not(.neos-disabled):not(.neos-hidden)').click(); } + + /** + * Start the passwordless (usernameless) passkey ceremony from the login screen: + * navigate to the login page and click the "Sign in with a passkey" button. + * With a virtual authenticator the ceremony auto-resolves and the page redirects + * to the backend; the caller asserts the resulting URL. + */ + async signInWithPasskey() { + await this.goto(); + await this.page.locator('[data-webauthn-passwordless] [data-webauthn-trigger]').click(); + } } export class NeosContentPage { diff --git a/Tests/E2E/helpers/system.ts b/Tests/E2E/helpers/system.ts index b86bf68..dd5d7f8 100644 --- a/Tests/E2E/helpers/system.ts +++ b/Tests/E2E/helpers/system.ts @@ -12,10 +12,17 @@ export function createUser(name: string, password: string, roles: string[]) { } export function removeAllUsers() { - execSync( - `docker exec -u www-data -w /app ${CONTAINER} bash -c "./flow user:delete --assume-yes '*'"`, - { stdio: 'ignore', cwd: dirname('.') } - ) + // `./flow user:delete '*'` exits non-zero when there are no users to delete. A teardown + // must not fail just because a scenario created no users (e.g. tests that only check the + // logged-out login screen), so swallow that error. + try { + execSync( + `docker exec -u www-data -w /app ${CONTAINER} bash -c "./flow user:delete --assume-yes '*'"`, + { stdio: 'ignore', cwd: dirname('.') } + ) + } catch { + // no users to delete — nothing to clean up + } } export async function logout(page: Page) { diff --git a/Tests/E2E/package-lock.json b/Tests/E2E/package-lock.json index f9fe6ba..35deede 100644 --- a/Tests/E2E/package-lock.json +++ b/Tests/E2E/package-lock.json @@ -123,6 +123,7 @@ "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-27.2.0.tgz", "integrity": "sha512-f2o/HqKHgsqzFLdq6fAhfG1FNOQPdBdyMGpKwhb7hZqg0yZtx9BVqkTyuoNk83Fcvk3wjMVfouFXXHNEk4nddA==", "license": "MIT", + "peer": true, "dependencies": { "@types/uuid": "10.0.0", "class-transformer": "0.5.1", @@ -270,6 +271,7 @@ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright": "1.58.2" }, diff --git a/Tests/E2E/package.json b/Tests/E2E/package.json index 5eab146..2de4125 100644 --- a/Tests/E2E/package.json +++ b/Tests/E2E/package.json @@ -8,7 +8,9 @@ "test:neos8:enforce-all": "npm run generate-tests && SUT=neos8 FLOW_CONTEXT=Production/E2E-SUT/EnforceForAll npx playwright test --grep @enforce-for-all", "test:neos8:enforce-role": "npm run generate-tests && SUT=neos8 FLOW_CONTEXT=Production/E2E-SUT/EnforceForRole npx playwright test --grep @enforce-for-role", "test:neos8:enforce-provider": "npm run generate-tests && SUT=neos8 FLOW_CONTEXT=Production/E2E-SUT/EnforceForProvider npx playwright test --grep @enforce-for-provider", + "test:neos8:passwordless": "npm run generate-tests && SUT=neos8 FLOW_CONTEXT=Production/E2E-SUT/Passwordless npx playwright test --grep @passwordless", "test:neos9:defaults": "npm run generate-tests && SUT=neos9 FLOW_CONTEXT=Production/E2E-SUT npx playwright test --grep @default-context", + "test:neos9:passwordless": "npm run generate-tests && SUT=neos9 FLOW_CONTEXT=Production/E2E-SUT/Passwordless npx playwright test --grep @passwordless", "test:neos9:enforce-all": "npm run generate-tests && SUT=neos9 FLOW_CONTEXT=Production/E2E-SUT/EnforceForAll npx playwright test --grep @enforce-for-all", "test:neos9:enforce-role": "npm run generate-tests && SUT=neos9 FLOW_CONTEXT=Production/E2E-SUT/EnforceForRole npx playwright test --grep @enforce-for-role", "test:neos9:enforce-provider": "npm run generate-tests && SUT=neos9 FLOW_CONTEXT=Production/E2E-SUT/EnforceForProvider npx playwright test --grep @enforce-for-provider" diff --git a/Tests/E2E/playwright.config.ts b/Tests/E2E/playwright.config.ts index 5fb4228..81f1283 100644 --- a/Tests/E2E/playwright.config.ts +++ b/Tests/E2E/playwright.config.ts @@ -50,6 +50,11 @@ export default defineConfig({ // After editing the Dockerfile: Re-run `make setup-sut` to pick up the changes. command: `echo "starting system under test ${SUT} with context ${FLOW_CONTEXT}"; FLOW_CONTEXT=${FLOW_CONTEXT} docker compose -f ./system_under_test/${SUT}/docker-compose.yaml up`, url: 'http://localhost:8081/', + // Reuse an already-running SUT instead of erroring when port 8081 is taken. + // The SUT cold-boot re-downloads composer packages (~3-4 min) on every start, + // so reusing a healthy container keeps the local dev loop fast. In CI nothing + // is listening yet, so Playwright still boots a fresh one. + reuseExistingServer: true, timeout: 600_000, stdout: 'pipe', stderr: 'pipe', diff --git a/Tests/E2E/steps/general-login.steps.ts b/Tests/E2E/steps/general-login.steps.ts index 94b44f3..e063e60 100644 --- a/Tests/E2E/steps/general-login.steps.ts +++ b/Tests/E2E/steps/general-login.steps.ts @@ -28,6 +28,30 @@ When('I log out', async ({ page }) => { await logout(page); }); +When('I sign in with a passkey', async ({ page }) => { + const loginPage = new NeosLoginPage(page); + await loginPage.signInWithPasskey(); + await page.waitForLoadState('networkidle'); +}); + +When('I open the login page', async ({ page }) => { + await new NeosLoginPage(page).goto(); +}); + +Then('I should not see the passkey sign-in button', async ({ page }) => { + await expect(page.locator('[data-webauthn-passwordless]')).toHaveCount(0); +}); + +Then('the passwordless verify endpoint is forbidden', async ({ page }) => { + // The endpoint must reject everyone while passwordless login is disabled — server-side, + // not just by hiding the button. + const response = await page.request.post('/neos/passwordless-webauthn/verify', { + data: { assertion: '{}' }, + failOnStatusCode: false, + }); + expect(response.status()).toBe(403); +}); + When('I open {string} while logged out', async ({ page }, path: string) => { // Requesting a protected backend URL while unauthenticated makes Neos remember // it as the intercepted request and bounce to the login form. After the (2FA) diff --git a/Tests/E2E/system_under_test/sut-base-docker-compose.yaml b/Tests/E2E/system_under_test/sut-base-docker-compose.yaml index a783e2a..088b37a 100644 --- a/Tests/E2E/system_under_test/sut-base-docker-compose.yaml +++ b/Tests/E2E/system_under_test/sut-base-docker-compose.yaml @@ -31,6 +31,9 @@ services: - ../../../Configuration:/app/DistributionPackages/Sandstorm.NeosTwoFactorAuthentication/Configuration:cached - ../../../Migrations:/app/DistributionPackages/Sandstorm.NeosTwoFactorAuthentication/Migrations:cached - ../../../Resources:/app/DistributionPackages/Sandstorm.NeosTwoFactorAuthentication/Resources:cached + # Tests are mounted so the package's PHPUnit unit/functional tests can run inside the SUT + # (the E2E Playwright suite lives here too but runs from the host). + - ../../../Tests:/app/DistributionPackages/Sandstorm.NeosTwoFactorAuthentication/Tests:cached - ../../../composer.json:/app/DistributionPackages/Sandstorm.NeosTwoFactorAuthentication/composer.json:cached networks: - neos_SUT diff --git a/Tests/E2E/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/Passwordless/Settings.2FA.yaml b/Tests/E2E/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/Passwordless/Settings.2FA.yaml new file mode 100644 index 0000000..e81296e --- /dev/null +++ b/Tests/E2E/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/Passwordless/Settings.2FA.yaml @@ -0,0 +1,9 @@ +Sandstorm: + NeosTwoFactorAuthentication: + webAuthn: + # Opt-in to passwordless (usernameless) passkey login for this E2E variant. + # Off by default; this variant exercises the enabled path. + passwordlessLoginEnabled: true + # Passwordless usernameless login requires user verification; the virtual + # authenticator used in the E2E suite supports it. + userVerification: 'required' diff --git a/Tests/Unit/Service/WebAuthnServiceTest.php b/Tests/Unit/Service/WebAuthnServiceTest.php new file mode 100644 index 0000000..a22d065 --- /dev/null +++ b/Tests/Unit/Service/WebAuthnServiceTest.php @@ -0,0 +1,70 @@ +service = new WebAuthnService(); + $this->persistenceManager = $this->createMock(PersistenceManagerInterface::class); + $this->inject($this->service, 'persistenceManager', $this->persistenceManager); + } + + /** + * @test + */ + public function resolvesBackendAccountByUserHandle(): void + { + $account = $this->createMock(Account::class); + $account->method('getAuthenticationProviderName')->willReturn('Neos.Neos:Backend'); + $this->persistenceManager + ->method('getObjectByIdentifier') + ->with('the-user-handle', Account::class) + ->willReturn($account); + + self::assertSame($account, $this->service->resolveBackendAccountByUserHandle('the-user-handle')); + } + + /** + * @test + */ + public function throwsWhenNoAccountExistsForUserHandle(): void + { + $this->persistenceManager->method('getObjectByIdentifier')->willReturn(null); + + $this->expectException(\RuntimeException::class); + $this->service->resolveBackendAccountByUserHandle('unknown-handle'); + } + + /** + * @test + */ + public function throwsWhenAccountIsNotANeosBackendAccount(): void + { + $account = $this->createMock(Account::class); + $account->method('getAuthenticationProviderName')->willReturn('Some.Other:Provider'); + $this->persistenceManager->method('getObjectByIdentifier')->willReturn($account); + + $this->expectException(\RuntimeException::class); + $this->service->resolveBackendAccountByUserHandle('the-user-handle'); + } +} From 532649e407d701440e2c362d240fe928de957812 Mon Sep 17 00:00:00 2001 From: Tobias Gruber Date: Sat, 27 Jun 2026 23:10:14 +0200 Subject: [PATCH 02/14] =?UTF-8?q?=F0=9F=9A=B8=20TASK=20distinguish=20disco?= =?UTF-8?q?verable=20Passkeys=20from=202nd-factor=20credentials?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `discoverable` flag to SecondFactor so a passwordless-capable credential ("Passkey") is distinguished from a non-discoverable one ("Passkey as 2nd factor"). Credentials registered while passwordless login is enabled use residentKey:required and are persisted as discoverable; legacy rows default to non-discoverable (migration). Surface the distinction as a per-credential badge in the management module, and nudge users without a Passkey to register one via a CTA banner (gated by the passwordless setting). Rename all user-facing "security key" wording to "passkey" across labels and translations. Add E2E coverage for the Passkey badge, the registration banner, the post-login redirect to the originally requested page, and cancelling the passkey sign-in. Co-Authored-By: Claude Opus 4.8 (1M context) --- Classes/Controller/BackendController.php | 27 +++++++ Classes/Domain/Model/SecondFactor.php | 32 +++++++- .../Repository/SecondFactorRepository.php | 3 +- Classes/Service/WebAuthnService.php | 7 +- Migrations/Mysql/Version20260627120000.php | 41 ++++++++++ .../Controller/Backend/Index.fusion | 14 +++- .../Components/RegisterPasskeyBanner.fusion | 22 ++++++ .../Components/SecondFactorList.fusion | 6 +- Resources/Private/Translations/de/Backend.xlf | 16 +++- Resources/Private/Translations/de/Main.xlf | 76 +++++++++---------- Resources/Private/Translations/en/Backend.xlf | 11 ++- Resources/Private/Translations/en/Main.xlf | 38 +++++----- .../features/default/backend-module.feature | 4 +- .../features/enforce-for-all/login.feature | 2 +- Tests/E2E/features/passwordless/login.feature | 36 +++++++++ Tests/E2E/helpers/2fa-pages.ts | 30 +++++++- Tests/E2E/steps/backend-module.steps.ts | 12 +++ Tests/E2E/steps/general-login.steps.ts | 12 +++ 18 files changed, 316 insertions(+), 73 deletions(-) create mode 100644 Migrations/Mysql/Version20260627120000.php create mode 100644 Resources/Private/Fusion/Presentation/Components/RegisterPasskeyBanner.fusion diff --git a/Classes/Controller/BackendController.php b/Classes/Controller/BackendController.php index 4a4ff86..6e3738b 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,33 @@ public function indexAction() $this->view->assignMultiple([ 'factorsAndPerson' => $factorsAndPerson, + 'showRegisterPasskeyBanner' => $this->shouldNudgePasskeyRegistration($account), 'flashMessages' => $this->flashMessageService ->getFlashMessageContainerForRequest($this->request) ->getMessagesAndFlush(), ]); } + /** + * Whether to nudge the current user to register a passkey: only when passwordless login is + * enabled and the user does not yet have a discoverable (passwordless-capable) credential of + * their own. Non-discoverable "Passkey as 2nd factor" credentials do not count, as they cannot + * be used for passwordless login. + */ + protected function shouldNudgePasskeyRegistration(\Neos\Flow\Security\Account $account): bool + { + if (!$this->passwordlessLoginEnabled) { + return false; + } + $ownPublicKeyFactors = $this->secondFactorRepository->findByAccountAndType($account, SecondFactor::TYPE_PUBLIC_KEY); + foreach ($ownPublicKeyFactors as $factor) { + if ($factor->isDiscoverable()) { + return false; + } + } + return true; + } + /** * Method picker shown when the user clicks "Add second factor" inside the backend module. */ 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/Service/WebAuthnService.php b/Classes/Service/WebAuthnService.php index 909e466..b8df3c0 100644 --- a/Classes/Service/WebAuthnService.php +++ b/Classes/Service/WebAuthnService.php @@ -173,11 +173,16 @@ public function verifyAndPersistRegistration( $validator = $this->buildAttestationValidator(); $credentialSource = $validator->check($authenticatorResponse, $options, $request, $this->securedRelyingPartyIds); + // A credential registered while passwordless login is enabled is created with + // `residentKey: required` (see createRegistrationOptions), so a successful registration + // is necessarily a discoverable credential — a "Passkey". When passwordless login is + // disabled the credential is non-discoverable and usable only as a second factor. return $this->secondFactorRepository->createSecondFactorForAccount( json_encode($credentialSource->jsonSerialize(), JSON_THROW_ON_ERROR), $account, SecondFactor::TYPE_PUBLIC_KEY, - $name + $name, + $this->passwordlessLoginEnabled ); } 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/Resources/Private/Fusion/Integration/Controller/Backend/Index.fusion b/Resources/Private/Fusion/Integration/Controller/Backend/Index.fusion index 118337b..38da0ee 100644 --- a/Resources/Private/Fusion/Integration/Controller/Backend/Index.fusion +++ b/Resources/Private/Fusion/Integration/Controller/Backend/Index.fusion @@ -4,8 +4,18 @@ Sandstorm.NeosTwoFactorAuthentication.BackendController.index = Sandstorm.NeosTw teaserTitle = ${I18n.id('module.index.title').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} - content = Sandstorm.NeosTwoFactorAuthentication:Component.SecondFactorList { - factorsAndPerson = ${factorsAndPerson} + content = Neos.Fusion:Join { + passkeyBanner = Sandstorm.NeosTwoFactorAuthentication:Component.RegisterPasskeyBanner { + show = ${showRegisterPasskeyBanner} + registerUri = Neos.Fusion:UriBuilder { + package = 'Sandstorm.NeosTwoFactorAuthentication' + controller = 'Backend' + action = 'newWebAuthn' + } + } + list = Sandstorm.NeosTwoFactorAuthentication:Component.SecondFactorList { + factorsAndPerson = ${factorsAndPerson} + } } footer = Sandstorm.NeosTwoFactorAuthentication:Component.Footer { diff --git a/Resources/Private/Fusion/Presentation/Components/RegisterPasskeyBanner.fusion b/Resources/Private/Fusion/Presentation/Components/RegisterPasskeyBanner.fusion new file mode 100644 index 0000000..4ddd281 --- /dev/null +++ b/Resources/Private/Fusion/Presentation/Components/RegisterPasskeyBanner.fusion @@ -0,0 +1,22 @@ +prototype(Sandstorm.NeosTwoFactorAuthentication:Component.RegisterPasskeyBanner) < prototype(Neos.Fusion:Component) { + show = false + registerUri = '' + + @propTypes { + @strict = true + show = ${PropTypes.bool} + registerUri = ${PropTypes.string} + } + + 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/Translations/de/Backend.xlf b/Resources/Private/Translations/de/Backend.xlf index b5f7067..0183ee6 100644 --- a/Resources/Private/Translations/de/Backend.xlf +++ b/Resources/Private/Translations/de/Backend.xlf @@ -15,6 +15,18 @@ List of all registered second factors Liste aller registrierten zweiten Faktoren
+ + Set up a passkey for faster sign-in + Richten Sie einen Passkey für die schnellere Anmeldung ein + + + Register a passkey to sign in without a password — one tap with your fingerprint, face, or device PIN. + Registrieren Sie einen Passkey, um sich ohne Passwort anzumelden — mit Fingerabdruck, Gesicht oder Geräte-PIN. + + + Register a passkey + Passkey registrieren + User Nutzer @@ -102,8 +114,8 @@ Wählen Sie, welchen zweiten Faktor Sie einrichten möchten: - Register a security key - Sicherheitsschlüssel registrieren + Register a passkey + Passkey registrieren diff --git a/Resources/Private/Translations/de/Main.xlf b/Resources/Private/Translations/de/Main.xlf index 326923e..6c6912a 100644 --- a/Resources/Private/Translations/de/Main.xlf +++ b/Resources/Private/Translations/de/Main.xlf @@ -75,40 +75,40 @@ Verwenden Sie Google Authenticator, Authy, 1Password oder eine andere TOTP-kompatible App. - Security key (Yubikey / WebAuthn) - Sicherheitsschlüssel (Yubikey / WebAuthn) + Passkey + Passkey - Use a hardware security key such as a Yubikey, or another FIDO2 authenticator. - Verwenden Sie einen Hardware-Sicherheitsschlüssel wie einen Yubikey oder einen anderen FIDO2-Authenticator. + Use a passkey stored on your phone or computer, or a hardware security key such as a Yubikey. + Verwenden Sie einen Passkey auf Ihrem Smartphone oder Computer oder einen Hardware-Sicherheitsschlüssel wie einen Yubikey. - Plug in your security key and press the button below. When prompted, tap your key and enter its PIN if requested. - Stecken Sie Ihren Sicherheitsschlüssel ein und drücken Sie unten auf die Schaltfläche. Tippen Sie bei Aufforderung auf den Schlüssel und geben Sie ggf. die PIN ein. + Press the button below to create a passkey. When prompted, confirm with your fingerprint, face, device PIN, or hardware key. + Drücken Sie unten auf die Schaltfläche, um einen Passkey zu erstellen. Bestätigen Sie bei Aufforderung mit Fingerabdruck, Gesicht, Geräte-PIN oder Hardwareschlüssel. - Register security key - Sicherheitsschlüssel registrieren + Register passkey + Passkey registrieren - Security key registered successfully. - Sicherheitsschlüssel erfolgreich registriert. + Passkey registered successfully. + Passkey erfolgreich registriert. - Your browser does not support security keys. - Ihr Browser unterstützt keine Sicherheitsschlüssel. + Your browser does not support passkeys. + Ihr Browser unterstützt keine Passkeys. - Could not register security key. Please try again. - Der Sicherheitsschlüssel konnte nicht registriert werden. Bitte versuchen Sie es erneut. + Could not register passkey. Please try again. + Der Passkey konnte nicht registriert werden. Bitte versuchen Sie es erneut. - Touch your security key to continue. - Berühren Sie Ihren Sicherheitsschlüssel, um fortzufahren. + Use your passkey to continue. + Verwenden Sie Ihren Passkey, um fortzufahren. - Use security key - Sicherheitsschlüssel verwenden + Use passkey + Passkey verwenden Sign in with a passkey @@ -123,48 +123,48 @@ Abbrechen und zurück zur Anmeldung - Could not verify your security key. Please try again. - Der Sicherheitsschlüssel konnte nicht überprüft werden. Bitte versuchen Sie es erneut. + Could not verify your passkey. Please try again. + Der Passkey konnte nicht überprüft werden. Bitte versuchen Sie es erneut. - Security key verification failed - Verifizierung des Sicherheitsschlüssels fehlgeschlagen + Passkey verification failed + Verifizierung des Passkeys fehlgeschlagen - Security key registration failed - Registrierung des Sicherheitsschlüssels fehlgeschlagen + Passkey registration failed + Registrierung des Passkeys fehlgeschlagen - Your browser or device does not support security keys. Use a supported browser, or choose another login method. - Ihr Browser oder Gerät unterstützt keine Sicherheitsschlüssel. Verwenden Sie einen unterstützten Browser oder wählen Sie eine andere Anmeldemethode. + Your browser or device does not support passkeys. Use a supported browser, or choose another login method. + Ihr Browser oder Gerät unterstützt keine Passkeys. Verwenden Sie einen unterstützten Browser oder wählen Sie eine andere Anmeldemethode. - The request was cancelled or timed out. Connect your security key and tap it when the browser prompts you, then try again. - Die Anfrage wurde abgebrochen oder ist abgelaufen. Verbinden Sie Ihren Sicherheitsschlüssel und berühren Sie ihn, sobald der Browser Sie dazu auffordert, und versuchen Sie es dann erneut. + The request was cancelled or timed out. Confirm with your passkey when the browser prompts you, then try again. + Die Anfrage wurde abgebrochen oder ist abgelaufen. Bestätigen Sie mit Ihrem Passkey, sobald der Browser Sie dazu auffordert, und versuchen Sie es dann erneut. - This security key is already registered. Use a different key, or sign in with this one instead. - Dieser Sicherheitsschlüssel ist bereits registriert. Verwenden Sie einen anderen Schlüssel oder melden Sie sich mit diesem an. + This passkey is already registered. Use a different one, or sign in with it instead. + Dieser Passkey ist bereits registriert. Verwenden Sie einen anderen oder melden Sie sich mit diesem an. - Your browser or device does not support security keys. Use a supported browser, or choose another login method. - Ihr Browser oder Gerät unterstützt keine Sicherheitsschlüssel. Verwenden Sie einen unterstützten Browser oder wählen Sie eine andere Anmeldemethode. + Your browser or device does not support passkeys. Use a supported browser, or choose another login method. + Ihr Browser oder Gerät unterstützt keine Passkeys. Verwenden Sie einen unterstützten Browser oder wählen Sie eine andere Anmeldemethode. - The page origin is not allowed for security keys. Make sure you are on the correct, secure (https) address. - Die Herkunft der Seite ist für Sicherheitsschlüssel nicht zulässig. Stellen Sie sicher, dass Sie sich auf der korrekten, sicheren (https) Adresse befinden. + The page origin is not allowed for passkeys. Make sure you are on the correct, secure (https) address. + Die Herkunft der Seite ist für Passkeys nicht zulässig. Stellen Sie sicher, dass Sie sich auf der korrekten, sicheren (https) Adresse befinden. The request was aborted before it could finish. Please try again. Die Anfrage wurde abgebrochen, bevor sie abgeschlossen werden konnte. Bitte versuchen Sie es erneut. - Your authenticator could not meet the requirements for this login. Try a different security key. - Ihr Authentifikator konnte die Anforderungen für diese Anmeldung nicht erfüllen. Verwenden Sie einen anderen Sicherheitsschlüssel. + Your authenticator could not meet the requirements for this login. Try a different passkey. + Ihr Authentifikator konnte die Anforderungen für diese Anmeldung nicht erfüllen. Verwenden Sie einen anderen Passkey. - An unexpected error occurred while communicating with your security key. Please try again. - Bei der Kommunikation mit Ihrem Sicherheitsschlüssel ist ein unerwarteter Fehler aufgetreten. Bitte versuchen Sie es erneut. + An unexpected error occurred while communicating with your passkey. Please try again. + Bei der Kommunikation mit Ihrem Passkey ist ein unerwarteter Fehler aufgetreten. Bitte versuchen Sie es erneut. diff --git a/Resources/Private/Translations/en/Backend.xlf b/Resources/Private/Translations/en/Backend.xlf index fe5433b..d15ef22 100644 --- a/Resources/Private/Translations/en/Backend.xlf +++ b/Resources/Private/Translations/en/Backend.xlf @@ -12,6 +12,15 @@ List of all registered second factors + + Set up a passkey for faster sign-in + + + Register a passkey to sign in without a password — one tap with your fingerprint, face, or device PIN. + + + Register a passkey + User @@ -78,7 +87,7 @@ Choose which second factor you would like to set up: - Register a security key + Register a passkey diff --git a/Resources/Private/Translations/en/Main.xlf b/Resources/Private/Translations/en/Main.xlf index f794093..d04b449 100644 --- a/Resources/Private/Translations/en/Main.xlf +++ b/Resources/Private/Translations/en/Main.xlf @@ -57,31 +57,31 @@ Use Google Authenticator, Authy, 1Password or any TOTP-compatible app on your phone. - Security key (Yubikey / WebAuthn) + Passkey - Use a hardware security key such as a Yubikey, or another FIDO2 authenticator. + Use a passkey stored on your phone or computer, or a hardware security key such as a Yubikey. - Plug in your security key and press the button below. When prompted, tap your key and enter its PIN if requested. + Press the button below to create a passkey. When prompted, confirm with your fingerprint, face, device PIN, or hardware key. - Register security key + Register passkey - Security key registered successfully. + Passkey registered successfully. - Your browser does not support security keys. + Your browser does not support passkeys. - Could not register security key. Please try again. + Could not register passkey. Please try again. - Touch your security key to continue. + Use your passkey to continue. - Use security key + Use passkey Sign in with a passkey @@ -93,37 +93,37 @@ Cancel and return to login - Could not verify your security key. Please try again. + Could not verify your passkey. Please try again. - Security key verification failed + Passkey verification failed - Security key registration failed + Passkey registration failed - Your browser or device does not support security keys. Use a supported browser, or choose another login method. + Your browser or device does not support passkeys. Use a supported browser, or choose another login method. - The request was cancelled or timed out. Connect your security key and tap it when the browser prompts you, then try again. + The request was cancelled or timed out. Confirm with your passkey when the browser prompts you, then try again. - This security key is already registered. Use a different key, or sign in with this one instead. + This passkey is already registered. Use a different one, or sign in with it instead. - Your browser or device does not support security keys. Use a supported browser, or choose another login method. + Your browser or device does not support passkeys. Use a supported browser, or choose another login method. - The page origin is not allowed for security keys. Make sure you are on the correct, secure (https) address. + The page origin is not allowed for passkeys. Make sure you are on the correct, secure (https) address. The request was aborted before it could finish. Please try again. - Your authenticator could not meet the requirements for this login. Try a different security key. + Your authenticator could not meet the requirements for this login. Try a different passkey. - An unexpected error occurred while communicating with your security key. Please try again. + An unexpected error occurred while communicating with your passkey. Please try again. diff --git a/Tests/E2E/features/default/backend-module.feature b/Tests/E2E/features/default/backend-module.feature index ff9ea99..97d2ed3 100644 --- a/Tests/E2E/features/default/backend-module.feature +++ b/Tests/E2E/features/default/backend-module.feature @@ -18,7 +18,7 @@ Feature: Backend module for two-factor authentication management with default se And I navigate to the 2FA management page And I add a new WebAuthn 2FA device with name "Admin Security Key" Then There should be 1 enrolled 2FA device - And There should be 1 enrolled "Security Key" 2FA device + And There should be 1 enrolled "Passkey as 2nd factor" 2FA device And There should be a 2FA device with the name "Admin Security Key" Scenario: Admin user can have both a TOTP and a WebAuthn 2FA device @@ -29,7 +29,7 @@ Feature: Backend module for two-factor authentication management with default se And I add a new WebAuthn 2FA device with name "Admin Security Key" Then There should be 2 enrolled 2FA devices And There should be 1 enrolled "OTP code" 2FA device - And There should be 1 enrolled "Security Key" 2FA device + And There should be 1 enrolled "Passkey as 2nd factor" 2FA device And There should be a 2FA device with the name "Admin Test Device" And There should be a 2FA device with the name "Admin Security Key" diff --git a/Tests/E2E/features/enforce-for-all/login.feature b/Tests/E2E/features/enforce-for-all/login.feature index 6b59352..b9fd93c 100644 --- a/Tests/E2E/features/enforce-for-all/login.feature +++ b/Tests/E2E/features/enforce-for-all/login.feature @@ -42,5 +42,5 @@ Feature: Login flow with 2FA enforced for all users When I log in with username "editor" and password "password" And I set up a WebAuthn 2FA device with name "Editor Security Key" And I navigate to the 2FA management page - Then There should be 1 enrolled "Security Key" 2FA device + Then There should be 1 enrolled "Passkey as 2nd factor" 2FA device And There should be a 2FA device with the name "Editor Security Key" diff --git a/Tests/E2E/features/passwordless/login.feature b/Tests/E2E/features/passwordless/login.feature index baab28c..6a6f98b 100644 --- a/Tests/E2E/features/passwordless/login.feature +++ b/Tests/E2E/features/passwordless/login.feature @@ -16,3 +16,39 @@ Feature: Passwordless passkey login And I log out And I sign in with a passkey Then I should see the Neos content page + + Scenario: A credential registered while passwordless login is enabled is labelled "Passkey" + Given I have a virtual security key + When I log in with username "admin" and password "password" + And I navigate to the 2FA management page + And I add a new WebAuthn 2FA device with name "Admin Passkey" + Then There should be 1 enrolled "Passkey" 2FA device + And There should be a 2FA device with the name "Admin Passkey" + + Scenario: The management module nudges users without a passkey to register one + Given I have a virtual security key + When I log in with username "admin" and password "password" + And I navigate to the 2FA management page + Then I should see the register-a-passkey banner + When I register a passkey from the banner + Then There should be 1 enrolled "Passkey" 2FA device + And I should not see the register-a-passkey banner + + Scenario: User lands on the originally requested page after a passwordless login + Given I have a virtual security key + When I log in with username "admin" and password "password" + And I navigate to the 2FA management page + And I add a new WebAuthn 2FA device + And I log out + And I open "/neos/management/twoFactorAuthentication" while logged out + And I sign in with a passkey + Then I should land on "/neos/management/twoFactorAuthentication" + + Scenario: Cancelling the passkey sign-in keeps the user on the login screen + Given I have a virtual security key + When I log in with username "admin" and password "password" + And I navigate to the 2FA management page + And I add a new WebAuthn 2FA device + And I log out + And I start a passkey sign-in but cancel it + Then I should see the login page diff --git a/Tests/E2E/helpers/2fa-pages.ts b/Tests/E2E/helpers/2fa-pages.ts index e0b885b..ace18b2 100644 --- a/Tests/E2E/helpers/2fa-pages.ts +++ b/Tests/E2E/helpers/2fa-pages.ts @@ -8,7 +8,7 @@ import { generateOtp } from './totp.js'; */ const METHOD_LINK_NAME = { totp: 'Authenticator app', - webauthn: 'Security key (Yubikey / WebAuthn)', + webauthn: 'Passkey', } as const; /** @@ -229,6 +229,24 @@ export class BackendModulePage { await this.page.locator('.neos-table').waitFor(); } + /** The "register a passkey" CTA banner shown when passwordless login is enabled and the user has no passkey yet. */ + bannerLocator() { + return this.page.locator('[data-test-id="register-passkey-banner"]'); + } + + /** + * Follow the banner's call-to-action into the passkey registration wizard and complete it. + * Requires a virtual authenticator on the browser context (see helpers/webauthn.ts). + */ + async registerPasskeyFromBanner(name?: string): Promise { + await this.bannerLocator().locator('[data-test-id="register-passkey-cta"]').click(); + if (name) { + await this.page.fill('[data-webauthn-register] [data-webauthn-name]', name); + } + await this.page.locator('[data-webauthn-register] [data-webauthn-trigger]').click(); + await this.page.locator('.neos-table').waitFor(); + } + /** Find the table row for the named device and click the delete button, then confirm. */ async deleteDeviceByName(name: string): Promise { const row = this.locatorForDeviceRow(name); @@ -268,8 +286,14 @@ export class BackendModulePage { return this.page.locator('.neos-table tbody tr').filter({ hasText: name }); } - /** Locator for table rows of a given type, e.g. "OTP code" or "Security Key". */ + /** + * Locator for table rows of a given type, e.g. "OTP code", "Passkey" or + * "Passkey as 2nd factor". Matches the type cell exactly so "Passkey" does not also + * match "Passkey as 2nd factor" (substring matching would conflate the two). + */ locatorForDeviceRowsOfType(typeLabel: string) { - return this.page.locator('.neos-table tbody tr').filter({ hasText: typeLabel }); + return this.page.locator('.neos-table tbody tr').filter({ + has: this.page.getByRole('cell', { name: typeLabel, exact: true }), + }); } } diff --git a/Tests/E2E/steps/backend-module.steps.ts b/Tests/E2E/steps/backend-module.steps.ts index b93630e..78a9149 100644 --- a/Tests/E2E/steps/backend-module.steps.ts +++ b/Tests/E2E/steps/backend-module.steps.ts @@ -71,3 +71,15 @@ Then('There should be {int} enrolled {string} 2FA device(s)', await expect(modulePage.locatorForDeviceRowsOfType(typeLabel)).toHaveCount(parseInt(countStr, 10)); }, ); + +Then('I should see the register-a-passkey banner', async ({ page }) => { + await expect(new BackendModulePage(page).bannerLocator()).toBeVisible(); +}); + +When('I register a passkey from the banner', async ({ page }) => { + await new BackendModulePage(page).registerPasskeyFromBanner(); +}); + +Then('I should not see the register-a-passkey banner', async ({ page }) => { + await expect(new BackendModulePage(page).bannerLocator()).toHaveCount(0); +}); diff --git a/Tests/E2E/steps/general-login.steps.ts b/Tests/E2E/steps/general-login.steps.ts index e063e60..b58510b 100644 --- a/Tests/E2E/steps/general-login.steps.ts +++ b/Tests/E2E/steps/general-login.steps.ts @@ -2,6 +2,7 @@ import { expect } from '@playwright/test'; import { createBdd } from 'playwright-bdd'; import { NeosLoginPage, NeosContentPage } from '../helpers/general-pages.ts'; import { createUser, logout } from '../helpers/system.ts'; +import { armWebAuthnCancellation } from '../helpers/webauthn.ts'; const { Given, When, Then } = createBdd(); @@ -38,6 +39,17 @@ When('I open the login page', async ({ page }) => { await new NeosLoginPage(page).goto(); }); +When('I start a passkey sign-in but cancel it', async ({ page }) => { + // Arm the one-shot cancellation before navigating, so the assertion rejects with + // NotAllowedError (as if the user dismissed the OS passkey prompt) instead of the + // virtual authenticator silently approving it. webauthn.js then reveals the error + // tooltip and the page stays on the login screen. + await armWebAuthnCancellation(page); + await new NeosLoginPage(page).goto(); + await page.locator('[data-webauthn-passwordless] [data-webauthn-trigger]').click(); + await page.locator('[data-webauthn-passwordless] [data-webauthn-error]').waitFor({ state: 'visible' }); +}); + Then('I should not see the passkey sign-in button', async ({ page }) => { await expect(page.locator('[data-webauthn-passwordless]')).toHaveCount(0); }); From 8c7d7a613e0ea307eade3f12b4adffb037866981 Mon Sep 17 00:00:00 2001 From: Tobias Gruber Date: Sat, 27 Jun 2026 23:19:12 +0200 Subject: [PATCH 03/14] =?UTF-8?q?=E2=9C=85=20ADD=20new=20passkey=20tests?= =?UTF-8?q?=20to=20makefile=20and=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/e2e.yml | 4 ++++ Tests/E2E/Makefile | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 8c0a4b5..08bf47f 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -51,6 +51,10 @@ 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: Upload Playwright report uses: actions/upload-artifact@v7 if: ${{ !cancelled() }} diff --git a/Tests/E2E/Makefile b/Tests/E2E/Makefile index ee2963e..5533410 100644 --- a/Tests/E2E/Makefile +++ b/Tests/E2E/Makefile @@ -6,8 +6,8 @@ NEOS9_COMPOSE = $(E2E_DIR)/system_under_test/neos9/docker-compose.yaml .SILENT: .PHONY: help setup setup-sut setup-test generate-bdd-files test test-neos8 test-neos9 \ - test-neos8-defaults test-neos8-enforce-all test-neos8-enforce-role test-neos8-enforce-provider \ - test-neos9-defaults test-neos9-enforce-all test-neos9-enforce-role test-neos9-enforce-provider \ + test-neos8-defaults test-neos8-enforce-all test-neos8-enforce-role test-neos8-enforce-provider test-neos8-passwordless \ + test-neos9-defaults test-neos9-enforce-all test-neos9-enforce-role test-neos9-enforce-provider test-neos9-passwordless \ start-sut-neos8 start-sut-neos9 log-sut-neos8 log-sut-neos9 enter-sut-neos8 enter-sut-neos9 sut-down # COLORS @@ -57,10 +57,10 @@ generate-bdd-files: test: test-neos8 test-neos9 ## Run all neos8 E2E tests -test-neos8: test-neos8-defaults test-neos8-enforce-all test-neos8-enforce-role test-neos8-enforce-provider +test-neos8: test-neos8-defaults test-neos8-enforce-all test-neos8-enforce-role test-neos8-enforce-provider test-neos8-passwordless ## Run all neos9 E2E tests -test-neos9: test-neos9-defaults test-neos9-enforce-all test-neos9-enforce-role test-neos9-enforce-provider +test-neos9: test-neos9-defaults test-neos9-enforce-all test-neos9-enforce-role test-neos9-enforce-provider test-neos9-passwordless ## Run neos8 E2E tests with default 2FA settings test-neos8-defaults: From 8144e7322db76619b734a8b6e2a4f0bb61843e6f Mon Sep 17 00:00:00 2001 From: Tobias Gruber Date: Sat, 27 Jun 2026 23:30:06 +0200 Subject: [PATCH 04/14] =?UTF-8?q?=F0=9F=93=9D=20UPDATE=20docs=20with=20pas?= =?UTF-8?q?swordless=20passkeys=20and=20new=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 99 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 87 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index ede91fc..7303f47 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,34 @@ Sandstorm: timeout: 60000 ``` +#### 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 +82,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 +238,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 `