Skip to content

Commit b6f6fdf

Browse files
committed
Refactor Symfony tests and introduce Webauthn authentication
Reorganized Symfony test configurations by splitting `config.yml` into modular files (`common.yml`, `badges.yml`, and `legacy_authenticator.yml`). Implemented a Webauthn authentication system with related classes (`WebauthnAuthenticator`, `WebauthnBadge`, etc.) and adapted test cases to utilize the new structure.
1 parent a05dc1a commit b6f6fdf

File tree

16 files changed

+403
-84
lines changed

16 files changed

+403
-84
lines changed

src/symfony/src/Resources/config/security.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Webauthn\Bundle\DependencyInjection\Factory\Security\WebauthnFactory;
99
use Webauthn\Bundle\Repository\PublicKeyCredentialSourceRepositoryInterface;
1010
use Webauthn\Bundle\Repository\PublicKeyCredentialUserEntityRepositoryInterface;
11+
use Webauthn\Bundle\Security\Authentication\WebauthnBadgeListener;
1112
use Webauthn\Bundle\Security\Authorization\Voter\IsUserPresentVoter;
1213
use Webauthn\Bundle\Security\Authorization\Voter\IsUserVerifiedVoter;
1314
use Webauthn\Bundle\Security\Guesser\CurrentUserEntityGuesser;
@@ -51,9 +52,7 @@
5152
service(PublicKeyCredentialUserEntityRepositoryInterface::class),
5253
service(SerializerInterface::class),
5354
abstract_arg('Authenticator Assertion Response Validator'),
54-
abstract_arg(
55-
'Authenticator Attestation Response Validator'
56-
), //service(AuthenticatorAttestationResponseValidator::class)
55+
abstract_arg('Authenticator Attestation Response Validator'),
5756
]);
5857
$service
5958
->set(WebauthnFactory::FIREWALL_CONFIG_DEFINITION_ID, WebauthnFirewallConfig::class)
@@ -62,4 +61,5 @@
6261

6362
$service->set(CurrentUserEntityGuesser::class);
6463
$service->set(RequestBodyUserEntityGuesser::class);
64+
$service->set(WebauthnBadgeListener::class);
6565
};
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Webauthn\Bundle\Security\Authentication;
6+
7+
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
8+
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
9+
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
10+
use Webauthn\AuthenticatorAssertionResponse;
11+
use Webauthn\Bundle\Security\Authentication\Token\WebauthnToken;
12+
use function assert;
13+
14+
abstract class WebauthnAuthenticator extends AbstractLoginFormAuthenticator
15+
{
16+
public function createToken(Passport $passport, string $firewallName): TokenInterface
17+
{
18+
assert($passport instanceof WebauthnPassport, 'Invalid passport');
19+
$webauthnBadge = $passport->getBadge(WebauthnBadge::class);
20+
assert($webauthnBadge instanceof WebauthnBadge, 'Invalid badge');
21+
if ($webauthnBadge->getAuthenticatorResponse() instanceof AuthenticatorAssertionResponse) {
22+
$authData = $webauthnBadge->getAuthenticatorResponse()
23+
->authenticatorData;
24+
} else {
25+
$authData = $webauthnBadge->getAuthenticatorResponse()
26+
->attestationObject
27+
->authData;
28+
}
29+
30+
$token = new WebauthnToken(
31+
$webauthnBadge->getPublicKeyCredentialUserEntity(),
32+
$webauthnBadge->getPublicKeyCredentialOptions(),
33+
$webauthnBadge->getPublicKeyCredentialSource()
34+
->getPublicKeyCredentialDescriptor(),
35+
$authData->isUserPresent(),
36+
$authData->isUserVerified(),
37+
$authData->getReservedForFutureUse1(),
38+
$authData->getReservedForFutureUse2(),
39+
$authData->signCount,
40+
$authData->extensions,
41+
$firewallName,
42+
$webauthnBadge->getUser()
43+
->getRoles(),
44+
$authData->isBackupEligible(),
45+
$authData->isBackedUp(),
46+
);
47+
$token->setUser($webauthnBadge->getUser());
48+
49+
return $token;
50+
}
51+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Webauthn\Bundle\Security\Authentication;
6+
7+
use LogicException;
8+
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
9+
use Symfony\Component\Security\Core\User\UserInterface;
10+
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
11+
use Webauthn\AuthenticatorResponse;
12+
use Webauthn\PublicKeyCredentialOptions;
13+
use Webauthn\PublicKeyCredentialSource;
14+
use Webauthn\PublicKeyCredentialUserEntity;
15+
use function sprintf;
16+
17+
final class WebauthnBadge implements BadgeInterface
18+
{
19+
private bool $isResolved = false;
20+
21+
private AuthenticatorResponse $authenticatorResponse;
22+
23+
private PublicKeyCredentialOptions $publicKeyCredentialOptions;
24+
25+
private PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity;
26+
27+
private PublicKeyCredentialSource $publicKeyCredentialSource;
28+
29+
private UserInterface $user;
30+
31+
/**
32+
* @var callable|null
33+
*/
34+
private $userLoader;
35+
36+
public function __construct(
37+
public readonly string $host,
38+
public readonly string $response,
39+
?callable $userLoader = null,
40+
private readonly ?array $attributes = null,
41+
) {
42+
$this->userLoader = $userLoader;
43+
}
44+
45+
public function isResolved(): bool
46+
{
47+
return $this->isResolved;
48+
}
49+
50+
public function getAuthenticatorResponse(): AuthenticatorResponse
51+
{
52+
if (! $this->isResolved) {
53+
throw new LogicException('The badge is not resolved.');
54+
}
55+
return $this->authenticatorResponse;
56+
}
57+
58+
public function getPublicKeyCredentialOptions(): PublicKeyCredentialOptions
59+
{
60+
if (! $this->isResolved) {
61+
throw new LogicException('The badge is not resolved.');
62+
}
63+
return $this->publicKeyCredentialOptions;
64+
}
65+
66+
public function getPublicKeyCredentialUserEntity(): PublicKeyCredentialUserEntity
67+
{
68+
if (! $this->isResolved) {
69+
throw new LogicException('The badge is not resolved.');
70+
}
71+
return $this->publicKeyCredentialUserEntity;
72+
}
73+
74+
public function getPublicKeyCredentialSource(): PublicKeyCredentialSource
75+
{
76+
if (! $this->isResolved) {
77+
throw new LogicException('The badge is not resolved.');
78+
}
79+
return $this->publicKeyCredentialSource;
80+
}
81+
82+
public function getUser(): UserInterface
83+
{
84+
if (! $this->isResolved) {
85+
throw new LogicException('The badge is not resolved.');
86+
}
87+
return $this->user;
88+
}
89+
90+
public function markResolved(
91+
AuthenticatorResponse $authenticatorResponse,
92+
PublicKeyCredentialOptions $publicKeyCredentialOptions,
93+
PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity,
94+
PublicKeyCredentialSource $publicKeyCredentialSource,
95+
): void {
96+
if ($this->userLoader === null) {
97+
throw new LogicException(sprintf(
98+
'No user loader is configured, did you forget to register the "%s" listener?',
99+
WebauthnBadgeListener::class
100+
));
101+
}
102+
$this->authenticatorResponse = $authenticatorResponse;
103+
$this->publicKeyCredentialOptions = $publicKeyCredentialOptions;
104+
$this->publicKeyCredentialUserEntity = $publicKeyCredentialUserEntity;
105+
$this->publicKeyCredentialSource = $publicKeyCredentialSource;
106+
$user = ($this->userLoader)($publicKeyCredentialUserEntity->name, $this->attributes);
107+
if ($user === null) {
108+
$exception = new UserNotFoundException();
109+
$exception->setUserIdentifier($publicKeyCredentialSource->userHandle);
110+
111+
throw $exception;
112+
}
113+
$this->user = $user;
114+
$this->isResolved = true;
115+
}
116+
117+
public function setUserLoader(callable $userLoader): void
118+
{
119+
$this->userLoader = $userLoader;
120+
}
121+
122+
public function getUserLoader(): ?callable
123+
{
124+
return $this->userLoader;
125+
}
126+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Webauthn\Bundle\Security\Authentication;
6+
7+
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
8+
use Symfony\Component\Security\Core\User\UserProviderInterface;
9+
use Symfony\Component\Security\Http\Event\CheckPassportEvent;
10+
use Symfony\Component\Serializer\Encoder\JsonEncoder;
11+
use Symfony\Component\Serializer\SerializerInterface;
12+
use Throwable;
13+
use Webauthn\AuthenticatorAssertionResponse;
14+
use Webauthn\AuthenticatorAssertionResponseValidator;
15+
use Webauthn\Bundle\Repository\CanSaveCredentialSource;
16+
use Webauthn\Bundle\Repository\PublicKeyCredentialSourceRepositoryInterface;
17+
use Webauthn\Bundle\Repository\PublicKeyCredentialUserEntityRepositoryInterface;
18+
use Webauthn\Bundle\Security\Storage\OptionsStorage;
19+
use Webauthn\PublicKeyCredential;
20+
use Webauthn\PublicKeyCredentialRequestOptions;
21+
use Webauthn\PublicKeyCredentialUserEntity;
22+
23+
final readonly class WebauthnBadgeListener implements EventSubscriberInterface
24+
{
25+
public function __construct(
26+
private OptionsStorage $optionsStorage,
27+
private SerializerInterface $publicKeyCredentialLoader,
28+
private PublicKeyCredentialUserEntityRepositoryInterface $credentialUserEntityRepository,
29+
private PublicKeyCredentialSourceRepositoryInterface $publicKeyCredentialSourceRepository,
30+
private AuthenticatorAssertionResponseValidator $assertionResponseValidator,
31+
private UserProviderInterface $userProvider,
32+
) {
33+
}
34+
35+
public function checkPassport(CheckPassportEvent $event): void
36+
{
37+
$passport = $event->getPassport();
38+
if (! $passport->hasBadge(WebauthnBadge::class)) {
39+
return;
40+
}
41+
42+
/** @var WebauthnBadge $badge */
43+
$badge = $passport->getBadge(WebauthnBadge::class);
44+
if ($badge->isResolved()) {
45+
return;
46+
}
47+
if ($badge->getUserLoader() === null) {
48+
$badge->setUserLoader($this->userProvider->loadUserByIdentifier(...));
49+
}
50+
51+
try {
52+
$publicKeyCredential = $this->publicKeyCredentialLoader->deserialize(
53+
$badge->response,
54+
PublicKeyCredential::class,
55+
JsonEncoder::FORMAT
56+
);
57+
$response = $publicKeyCredential->response;
58+
if (! $response instanceof AuthenticatorAssertionResponse) {
59+
return;
60+
}
61+
$data = $this->optionsStorage->get($response->clientDataJSON->challenge);
62+
$publicKeyCredentialRequestOptions = $data->getPublicKeyCredentialOptions();
63+
if (! $publicKeyCredentialRequestOptions instanceof PublicKeyCredentialRequestOptions) {
64+
return;
65+
}
66+
$userEntity = $data->getPublicKeyCredentialUserEntity();
67+
68+
$publicKeyCredentialSource = $this->publicKeyCredentialSourceRepository->findOneByCredentialId(
69+
$publicKeyCredential->rawId
70+
);
71+
if ($publicKeyCredentialSource === null) {
72+
return;
73+
}
74+
$publicKeyCredentialSource = $this->assertionResponseValidator->check(
75+
$publicKeyCredentialSource,
76+
$response,
77+
$publicKeyCredentialRequestOptions,
78+
$badge->host,
79+
$userEntity?->id
80+
);
81+
$userEntity = $this->credentialUserEntityRepository->findOneByUserHandle(
82+
$publicKeyCredentialSource->userHandle
83+
);
84+
if (! $userEntity instanceof PublicKeyCredentialUserEntity) {
85+
return;
86+
}
87+
if ($this->publicKeyCredentialSourceRepository instanceof CanSaveCredentialSource) {
88+
$this->publicKeyCredentialSourceRepository->saveCredentialSource($publicKeyCredentialSource);
89+
}
90+
$badge->markResolved(
91+
$response,
92+
$publicKeyCredentialRequestOptions,
93+
$userEntity,
94+
$publicKeyCredentialSource,
95+
);
96+
} catch (Throwable $e) {
97+
return;
98+
}
99+
}
100+
101+
public static function getSubscribedEvents(): array
102+
{
103+
return [
104+
CheckPassportEvent::class => ['checkPassport', 512],
105+
];
106+
}
107+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Webauthn\Bundle\Security\Authentication;
6+
7+
use LogicException;
8+
use Symfony\Component\Security\Core\User\UserInterface;
9+
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
10+
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
11+
12+
final class WebauthnPassport extends Passport
13+
{
14+
/**
15+
* @param BadgeInterface[] $badges
16+
*/
17+
public function __construct(WebauthnBadge $webauthnBadge, array $badges = [])
18+
{
19+
$this->addBadge($webauthnBadge);
20+
foreach ($badges as $badge) {
21+
$this->addBadge($badge);
22+
}
23+
}
24+
25+
public function getUser(): UserInterface
26+
{
27+
$webauthnBadge = $this->getBadge(WebauthnBadge::class);
28+
if ($webauthnBadge === null || ! $webauthnBadge instanceof WebauthnBadge) {
29+
throw new LogicException('No WebauthnBadge found in the passport.');
30+
}
31+
32+
return $webauthnBadge->getUser();
33+
}
34+
}

tests/symfony/config/badges.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
imports:
2+
- { resource: common.yml }
3+
4+
security:
5+
providers:
6+
default:
7+
id: 'Webauthn\Tests\Bundle\Functional\UserProvider'
8+
9+
firewalls:
10+
main:
11+
logout:
12+
path: /logout
13+
target: /
14+
15+
access_control:
16+
- { path: '^/devices/add', roles: 'ROLE_USER', requires_channel: 'https' }
17+
- { path: '^/logout', roles: 'PUBLIC_ACCESS' , requires_channel: 'https' }
18+
- { path: '^/api/login', roles: 'PUBLIC_ACCESS' , requires_channel: 'https' }
19+
- { path: '^/api/register', roles: 'PUBLIC_ACCESS' , requires_channel: 'https' }
20+
- { path: '^/admin', roles: 'ROLE_ADMIN', requires_channel: 'https' }
21+
- { path: '^/page', roles: 'ROLE_USER', requires_channel: 'https' }
22+
- { path: '^/', roles: 'PUBLIC_ACCESS' , requires_channel: 'https' }

0 commit comments

Comments
 (0)