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
16 changes: 8 additions & 8 deletions Classes/Controller/LoginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,6 @@
use Sandstorm\NeosTwoFactorAuthentication\Service\SecondFactorSessionStorageService;
use Sandstorm\NeosTwoFactorAuthentication\Service\TOTPService;
use Sandstorm\NeosTwoFactorAuthentication\Service\WebAuthnService;
use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialRequestOptions;

class LoginController extends ActionController
{
Expand Down Expand Up @@ -324,12 +322,13 @@ public function webAuthnRegisterOptionsAction(bool $discoverable = false): strin
}
$hostname = $this->request->getHttpRequest()->getUri()->getHost();
$options = $this->webAuthnService->createRegistrationOptions($account, $hostname, $discoverable);
$optionsJson = $this->webAuthnService->optionsToJson($options);
$this->secondFactorSessionStorageService->putValue(
SecondFactorSessionStorageService::SESSION_OBJECT_WEBAUTHN_REGISTRATION_OPTIONS,
json_encode($options, JSON_THROW_ON_ERROR)
$optionsJson
);
$this->response->setContentType('application/json');
return json_encode($options, JSON_THROW_ON_ERROR);
return $optionsJson;
}

/**
Expand All @@ -345,7 +344,7 @@ public function webAuthnRegisterVerifyAction(string $attestation, string $name =
if (!is_string($serialized)) {
return $this->jsonError('No registration in progress', 400);
}
$options = PublicKeyCredentialCreationOptions::createFromString($serialized);
$options = $this->webAuthnService->creationOptionsFromJson($serialized);
$account = $this->securityContext->getAccount();
if ($account === null) {
return $this->jsonError('No authentication in progress', 401);
Expand Down Expand Up @@ -385,12 +384,13 @@ public function webAuthnAuthenticateOptionsAction(): string
return $this->jsonError('No authentication in progress', 401);
}
$options = $this->webAuthnService->createAuthenticationOptions($account);
$optionsJson = $this->webAuthnService->optionsToJson($options);
$this->secondFactorSessionStorageService->putValue(
SecondFactorSessionStorageService::SESSION_OBJECT_WEBAUTHN_AUTHENTICATION_OPTIONS,
json_encode($options, JSON_THROW_ON_ERROR)
$optionsJson
);
$this->response->setContentType('application/json');
return json_encode($options, JSON_THROW_ON_ERROR);
return $optionsJson;
}

/**
Expand All @@ -404,7 +404,7 @@ public function webAuthnAuthenticateVerifyAction(string $assertion): string
if (!is_string($serialized)) {
return $this->jsonError('No authentication in progress', 400);
}
$options = PublicKeyCredentialRequestOptions::createFromString($serialized);
$options = $this->webAuthnService->requestOptionsFromJson($serialized);
$account = $this->securityContext->getAccount();
if ($account === null) {
return $this->jsonError('No authentication in progress', 401);
Expand Down
8 changes: 4 additions & 4 deletions Classes/Controller/PasswordlessLoginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
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.
Expand Down Expand Up @@ -71,12 +70,13 @@ public function optionsAction(): string
$this->secondFactorSessionStorageService->startSessionIfNotStarted();
$hostname = $this->request->getHttpRequest()->getUri()->getHost();
$options = $this->webAuthnService->createPasswordlessAuthenticationOptions($hostname);
$optionsJson = $this->webAuthnService->optionsToJson($options);
$this->secondFactorSessionStorageService->putValue(
SecondFactorSessionStorageService::SESSION_OBJECT_WEBAUTHN_PASSWORDLESS_OPTIONS,
json_encode($options, JSON_THROW_ON_ERROR)
$optionsJson
);
$this->response->setContentType('application/json');
return json_encode($options, JSON_THROW_ON_ERROR);
return $optionsJson;
}

/**
Expand All @@ -98,7 +98,7 @@ public function verifyAction(string $assertion): string
if (!is_string($serialized)) {
return $this->jsonError('No passwordless login in progress', 400);
}
$options = PublicKeyCredentialRequestOptions::createFromString($serialized);
$options = $this->webAuthnService->requestOptionsFromJson($serialized);

try {
$account = $this->webAuthnService->verifyPasswordlessAssertion(
Expand Down
53 changes: 34 additions & 19 deletions Classes/Service/PublicKeyCredentialSourceRepositoryAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,31 @@
use Psr\Log\LoggerInterface;
use Sandstorm\NeosTwoFactorAuthentication\Domain\Model\SecondFactor;
use Sandstorm\NeosTwoFactorAuthentication\Domain\Repository\SecondFactorRepository;
use Webauthn\PublicKeyCredentialSource;
use Webauthn\PublicKeyCredentialSourceRepository;
use Webauthn\CredentialRecord;
use Webauthn\PublicKeyCredentialUserEntity;

/**
* Adapter implementing the web-auth library's credential repository on top of
* our generic {@see SecondFactorRepository}.
* Repository for stored WebAuthn credentials, backed by our generic {@see SecondFactorRepository}.
*
* Each row of TYPE_PUBLIC_KEY stores a JSON-serialized PublicKeyCredentialSource
* in the `secret` column.
* Each row of TYPE_PUBLIC_KEY stores a JSON-serialized credential (written by
* {@see WebAuthnService}) in the `secret` column. Under web-auth/webauthn-lib v5 these are read
* back via the Symfony serializer as {@see CredentialRecord} objects.
*
* In v4 this class implemented the library's `PublicKeyCredentialSourceRepository` interface and the
* ceremony validators called it back to look up and save credentials. v5 removed that interface —
* the validators no longer touch a repository — so this is now plain application code that
* {@see WebAuthnService} drives directly (look up before `check()`, save the counter bump after).
*
* @Flow\Scope("singleton")
*/
class PublicKeyCredentialSourceRepositoryAdapter implements PublicKeyCredentialSourceRepository
class PublicKeyCredentialSourceRepositoryAdapter
{
/**
* @Flow\Inject
* @var WebAuthnSerializerProvider
*/
protected $serializerProvider;

/**
* @Flow\Inject
* @var SecondFactorRepository
Expand All @@ -40,53 +50,58 @@ class PublicKeyCredentialSourceRepositoryAdapter implements PublicKeyCredentialS
*/
protected $persistenceManager;

public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKeyCredentialSource
public function findOneByCredentialId(string $publicKeyCredentialId): ?CredentialRecord
{
foreach ($this->iterateAllWebAuthnFactors() as [$factor, $source]) {
if ($source->getPublicKeyCredentialId() === $publicKeyCredentialId) {
if ($source->publicKeyCredentialId === $publicKeyCredentialId) {
return $source;
}
}
return null;
}

/**
* @return PublicKeyCredentialSource[]
* @return CredentialRecord[]
*/
public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity): array
{
$userHandle = $publicKeyCredentialUserEntity->getId();
$userHandle = $publicKeyCredentialUserEntity->id;
$sources = [];
foreach ($this->iterateAllWebAuthnFactors() as [$factor, $source]) {
if ($source->getUserHandle() === $userHandle) {
if ($source->userHandle === $userHandle) {
$sources[] = $source;
}
}
return $sources;
}

public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource): void
/**
* Persist an updated credential (e.g. the counter bump returned by the assertion ceremony).
* In v5 the library no longer saves credentials itself, so {@see WebAuthnService} calls this
* after a successful `check()`.
*/
public function saveCredential(CredentialRecord $credentialRecord): void
{
// Update path: find the existing factor for this credential and bump the counter.
foreach ($this->iterateAllWebAuthnFactors() as [$factor, $source]) {
if ($source->getPublicKeyCredentialId() === $publicKeyCredentialSource->getPublicKeyCredentialId()) {
$factor->setCredentialData($publicKeyCredentialSource->jsonSerialize());
if ($source->publicKeyCredentialId === $credentialRecord->publicKeyCredentialId) {
$factor->setSecret($this->serializerProvider->getSerializer()->serialize($credentialRecord, 'json'));
$this->secondFactorRepository->update($factor);
return;
}
}
// No existing factor — initial registration is handled explicitly by
// WebAuthnService::persistNewCredential() so we ignore this branch.
// WebAuthnService::verifyAndPersistRegistration() so we ignore this branch.
}

/**
* @return \Generator<array{0: SecondFactor, 1: PublicKeyCredentialSource}>
* @return \Generator<array{0: SecondFactor, 1: CredentialRecord}>
*/
private function iterateAllWebAuthnFactors(): \Generator
{
$serializer = $this->serializerProvider->getSerializer();
foreach ($this->secondFactorRepository->findAllByType(SecondFactor::TYPE_PUBLIC_KEY) as $factor) {
try {
$source = PublicKeyCredentialSource::createFromArray($factor->getCredentialData());
$source = $serializer->deserialize($factor->getSecret(), CredentialRecord::class, 'json');
} catch (\Throwable $exception) {
// A single corrupt/truncated credential row must not break the lookup for all
// other users. Skip it and log so the broken factor can be investigated.
Expand Down
44 changes: 44 additions & 0 deletions Classes/Service/WebAuthnSerializerProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

namespace Sandstorm\NeosTwoFactorAuthentication\Service;

use Neos\Flow\Annotations as Flow;
use Symfony\Component\Serializer\SerializerInterface;
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\AttestationStatement\FidoU2FAttestationStatementSupport;
use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
use Webauthn\Denormalizer\WebauthnSerializerFactory;

/**
* Builds (and caches) the Symfony serializer that web-auth/webauthn-lib v5 uses to load and
* persist WebAuthn value objects — credential sources, options and browser responses.
*
* In v4 this was done by the now-removed PublicKeyCredentialLoader and by
* PublicKeyCredentialSource::createFromArray()/jsonSerialize(). v5 moved all (de)serialization to
* the Symfony serializer assembled by {@see WebauthnSerializerFactory}. Centralising it here keeps
* webauthn-lib serialization in one place and lets it be reused by {@see WebAuthnService} and
* {@see PublicKeyCredentialSourceRepositoryAdapter}.
*
* The attestation-statement support (None + FidoU2F) must match what the ceremony validators
* accept, so that attestation objects serialize/deserialize consistently.
*
* @Flow\Scope("singleton")
*/
class WebAuthnSerializerProvider
{
private ?SerializerInterface $serializer = null;

public function getSerializer(): SerializerInterface
{
if ($this->serializer === null) {
$attestationStatementSupportManager = AttestationStatementSupportManager::create();
$attestationStatementSupportManager->add(NoneAttestationStatementSupport::create());
// FidoU2F is needed for U2F-only authenticators registered via the browser's
// U2F-compat fallback (see WebAuthnService); their attestation must round-trip too.
$attestationStatementSupportManager->add(FidoU2FAttestationStatementSupport::create());

$this->serializer = (new WebauthnSerializerFactory($attestationStatementSupportManager))->create();
}
return $this->serializer;
}
}
Loading