Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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() }}
Expand Down
25 changes: 24 additions & 1 deletion Classes/Controller/BackendController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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.
*/
Expand Down Expand Up @@ -160,15 +177,21 @@ 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);

$this->view->assignMultiple([
'currentUser' => $currentUser instanceof User ? $currentUser : null,
'accountIdentifier' => $account->getAccountIdentifier(),
'discoverable' => $discoverable,
'flashMessages' => $this->flashMessageService
->getFlashMessageContainerForRequest($this->request)
->getMessagesAndFlush(),
Expand Down
16 changes: 13 additions & 3 deletions Classes/Controller/LoginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
164 changes: 164 additions & 0 deletions Classes/Controller/PasswordlessLoginController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
<?php

namespace Sandstorm\NeosTwoFactorAuthentication\Controller;

/*
* This file is part of the Sandstorm.NeosTwoFactorAuthentication package.
*/

use Neos\Flow\Annotations as Flow;
use Neos\Flow\Mvc\Controller\ActionController;
use Neos\Flow\Mvc\View\JsonView;
use Neos\Flow\Security\Authentication\TokenInterface;
use Neos\Flow\Security\Context as SecurityContext;
use Sandstorm\NeosTwoFactorAuthentication\Domain\AuthenticationStatus;
use Sandstorm\NeosTwoFactorAuthentication\Security\Token\WebAuthnPasswordlessToken;
use Sandstorm\NeosTwoFactorAuthentication\Service\SecondFactorSessionStorageService;
use Sandstorm\NeosTwoFactorAuthentication\Service\WebAuthnService;
use Webauthn\PublicKeyCredentialRequestOptions;

/**
* XHR endpoints for usernameless, passwordless passkey login from the Neos login screen.
*
* Both actions are reachable while logged out (this controller is intentionally NOT behind
* the Neos.Neos:Backend request pattern). They are hard-gated by the
* `webAuthn.passwordlessLoginEnabled` setting (off by default) so that, while disabled, no
* one can authenticate through this path even if the UI button were somehow present.
*/
class PasswordlessLoginController extends ActionController
{
protected $supportedMediaTypes = ['application/json'];

protected $defaultViewObjectName = JsonView::class;

/**
* @Flow\Inject
* @var SecurityContext
*/
protected $securityContext;

/**
* @Flow\Inject
* @var WebAuthnService
*/
protected $webAuthnService;

/**
* @Flow\Inject
* @var SecondFactorSessionStorageService
*/
protected $secondFactorSessionStorageService;

/**
* @Flow\InjectConfiguration(path="webAuthn.passwordlessLoginEnabled")
* @var bool
*/
protected $passwordlessLoginEnabled = false;

/**
* Ceremony step 1: return the request options for navigator.credentials.get().
* Usernameless — no allowCredentials, so the browser offers any discoverable passkey.
*
* @Flow\SkipCsrfProtection
*/
public function optionsAction(): string
{
if (!$this->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');
}
}
32 changes: 30 additions & 2 deletions Classes/Domain/Model/SecondFactor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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
*/
Expand Down Expand Up @@ -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);
}
Expand Down
3 changes: 2 additions & 1 deletion Classes/Domain/Repository/SecondFactorRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading