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.

@@ -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.text').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()}