Private
Public Access
1
0

chg: usr: add modern Webauthn authentication #4

This commit is contained in:
2026-04-12 15:19:03 +02:00
parent acbe9c7f63
commit 0144a3953c
23 changed files with 2845 additions and 13 deletions

View File

@@ -12,6 +12,7 @@ namespace App\Controller;
use App\Entity\User;
use App\Repository\PlayedGameRepository;
use App\Service\WebAuthnService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
@@ -30,7 +31,10 @@ use Symfony\Component\Routing\Attribute\Route;
#[AsController]
class ProfileController extends AbstractController
{
public function __construct(private readonly PlayedGameRepository $repo) { }
public function __construct(
private readonly PlayedGameRepository $repo,
private readonly WebAuthnService $webAuthnService
) { }
#[Route('/profile', name: 'MineSeekerBundle_profile')]
public function index(): Response
@@ -49,4 +53,26 @@ class ProfileController extends AbstractController
'recent' => $this->repo->findRecentFinishedForUser($user),
]);
}
#[Route('/profile/security', name: 'MineSeekerBundle_profile_security')]
public function security(): Response
{
/** @var User $user */
$user = $this->getUser();
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
$credentials = $this->webAuthnService->getCredentialsForUser($user);
$credentialsData = array_map(fn ($cred) => [
'id' => $cred->getId(),
'credentialName' => $cred->getCredentialName(),
'createdAt' => $cred->getCreatedAt()?->format('Y-m-d H:i:s'),
'lastUsedAt' => $cred->getLastUsedAt()?->format('Y-m-d H:i:s'),
'isBackupEligible' => $cred->isBackupEligible(),
'isBackupAuthenticated' => $cred->isBackupAuthenticated(),
], $credentials);
return $this->render('Security/profile_security.html.twig', [
'credentials' => $credentialsData,
]);
}
}

View File

@@ -0,0 +1,314 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Controller;
use App\Entity\User;
use App\Service\WebAuthnService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Webauthn\AuthenticatorSelectionCriteria;
use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialParameters;
use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\PublicKeyCredentialUserEntity;
/**
* Class WebAuthnController
*
* @package App\Controller
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 12.
*/
#[AsController]
#[Route('/api/webauthn', name: 'api_webauthn_')]
class WebAuthnController extends AbstractController
{
public function __construct(
private readonly WebAuthnService $webAuthnService,
private readonly TokenStorageInterface $tokenStorage,
) {
}
#[Route('/registration/begin', name: 'registration_begin', methods: ['POST'])]
public function beginRegistration(Request $request): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
try {
$data = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
$credentialName = $data['credentialName'] ?? 'My Passkey';
$rpEntity = new PublicKeyCredentialRpEntity(
'Mine Seeker',
$_SERVER['HTTP_HOST'] ?? 'localhost',
);
$userEntity = new PublicKeyCredentialUserEntity(
$user->getUserIdentifier(),
(string)$user->getId(),
$user->getUsername(),
);
$credentialParameters = [
PublicKeyCredentialParameters::create('public-key', -7), // ES256
PublicKeyCredentialParameters::create('public-key', -257), // RS256
];
$authenticatorSelectionCriteria = AuthenticatorSelectionCriteria::create();
$creationOptions = PublicKeyCredentialCreationOptions::create(
$rpEntity,
$userEntity,
\random_bytes(32),
$credentialParameters,
$authenticatorSelectionCriteria,
PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_DIRECT
);
$request->getSession()->set('webauthn_creation_options', $creationOptions);
$request->getSession()->set('webauthn_credential_name', $credentialName);
/** Convert to JSON-serializable array */
$response = [
'challenge' => base64_encode($creationOptions->challenge),
'rp' => [
'name' => $creationOptions->rp->name,
'id' => $creationOptions->rp->id,
],
'user' => [
'id' => base64_encode($creationOptions->user->id),
'name' => $creationOptions->user->name,
'displayName' => $creationOptions->user->displayName,
],
'pubKeyCredParams' => array_map(fn($param) => [
'type' => $param->type,
'alg' => $param->alg,
], $creationOptions->pubKeyCredParams),
'timeout' => $creationOptions->timeout,
'attestation' => $creationOptions->attestation,
'excludeCredentials' => array_map(fn($cred) => [
'id' => base64_encode($cred->id),
'type' => $cred->type,
'transports' => $cred->transports,
], $creationOptions->excludeCredentials),
];
return new JsonResponse($response);
} catch (\Exception $e) {
return new JsonResponse(
['error' => $e->getMessage()],
Response::HTTP_BAD_REQUEST
);
}
}
#[Route('/registration/complete', name: 'registration_complete', methods: ['POST'])]
public function completeRegistration(Request $request): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
try {
$data = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
$credentialName = $request->getSession()->get('webauthn_credential_name', 'My Passkey');
$credentialJson = $data['credential'] ?? null;
if ($credentialJson === null) {
return new JsonResponse(
['error' => 'No credential provided'],
Response::HTTP_BAD_REQUEST
);
}
/** Store the credential with user ID for later retrieval during authentication */
$credentialJson['userId'] = $user->getId();
$credentialJson['username'] = $user->getUsername();
/** Save the credential data directly */
$this->webAuthnService->saveCredential(
$user,
$credentialJson,
$credentialName
);
$request->getSession()->remove('webauthn_creation_options');
$request->getSession()->remove('webauthn_credential_name');
return new JsonResponse(['success' => true]);
} catch (\Exception $e) {
return new JsonResponse(
['error' => 'Registration failed: ' . $e->getMessage()],
Response::HTTP_BAD_REQUEST
);
}
}
#[Route('/credentials', name: 'credentials', methods: ['GET'])]
public function getCredentials(): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
$credentials = $this->webAuthnService->getCredentialsForUser($user);
return new JsonResponse(array_map(fn($credential) => [
'id' => $credential->getId(),
'name' => $credential->getCredentialName(),
'createdAt' => $credential->getCreatedAt()?->format('Y-m-d H:i:s'),
'lastUsedAt' => $credential->getLastUsedAt()?->format('Y-m-d H:i:s'),
'isBackupEligible' => $credential->isBackupEligible(),
'isBackupAuthenticated' => $credential->isBackupAuthenticated(),
], $credentials));
}
#[Route('/credentials/{id}', name: 'credential_delete', methods: ['DELETE'])]
public function deleteCredential(int $id): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
if ($this->webAuthnService->deleteCredential($id, $user)) {
return new JsonResponse(['success' => true]);
}
return new JsonResponse(['error' => 'Credential not found'], Response::HTTP_NOT_FOUND);
}
#[Route('/credentials/{id}/rename', name: 'credential_rename', methods: ['PATCH'])]
public function renameCredential(int $id, Request $request): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
try {
$data = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
$name = $data['name'] ?? '';
if (empty($name)) {
return new JsonResponse(
['error' => 'Name is required'],
Response::HTTP_BAD_REQUEST
);
}
if ($this->webAuthnService->renameCredential($id, $user, $name)) {
return new JsonResponse(['success' => true]);
}
return new JsonResponse(['error' => 'Credential not found'], Response::HTTP_NOT_FOUND);
} catch (\Exception $e) {
return new JsonResponse(
['error' => $e->getMessage()],
Response::HTTP_BAD_REQUEST
);
}
}
#[Route('/authentication/begin', name: 'authentication_begin', methods: ['POST'])]
public function beginAuthentication(Request $request): JsonResponse
{
try {
/** Generate challenge */
$challenge = \random_bytes(32);
/** Store in session for verification later */
$request->getSession()->set('webauthn_request_challenge', $challenge);
/**
* Return simple JSON response - no credentials needed for initial request
* Client will handle the credential filtering
*/
$response = [
'challenge' => base64_encode($challenge),
'timeout' => 60000,
'rpId' => $_SERVER['HTTP_HOST'] ?? 'localhost',
'userVerification' => 'preferred',
'allowCredentials' => [],
];
return new JsonResponse($response);
} catch (\Exception $e) {
return new JsonResponse(
['error' => $e->getMessage()],
Response::HTTP_BAD_REQUEST
);
}
}
#[Route('/authentication/complete', name: 'authentication_complete', methods: ['POST'])]
public function completeAuthentication(Request $request): JsonResponse
{
try {
$data = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
$credential = $data['credential'] ?? null;
if (!$credential) {
return new JsonResponse(
['error' => 'No credential provided'],
Response::HTTP_BAD_REQUEST
);
}
/** The credential ID tells us which user registered this passkey */
$credentialId = $credential['id'] ?? null;
if (!$credentialId) {
return new JsonResponse(
['error' => 'Invalid credential'],
Response::HTTP_BAD_REQUEST
);
}
/** Find the user who owns this credential */
$user = $this->webAuthnService->findUserByCredentialId($credentialId);
if (!$user) {
return new JsonResponse(
['error' => 'Credential not found'],
Response::HTTP_NOT_FOUND
);
}
/** Update last used timestamp */
$this->webAuthnService->updateLastUsedAt($credentialId, $user);
/** Log in the user using token storage */
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
$this->tokenStorage->setToken($token);
$request->getSession()->set('_security_main', serialize($token));
return new JsonResponse([
'success' => true,
'redirect' => '/',
'message' => 'Successfully authenticated with passkey',
]);
} catch (\Exception $e) {
return new JsonResponse(
['error' => $e->getMessage()],
Response::HTTP_BAD_REQUEST
);
}
}
}

View File

@@ -0,0 +1,171 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Entity;
use App\Repository\WebAuthnCredentialRepository;
use DateTime;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\GeneratedValue;
use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Mapping\JoinColumn;
use Doctrine\ORM\Mapping\ManyToOne;
use Doctrine\ORM\Mapping\Table;
/**
* Class WebAuthnCredential
*
* @package App\Entity
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 12.
*/
#[Table(name: 'app_webauthn_credential')]
#[Entity(repositoryClass: WebAuthnCredentialRepository::class)]
class WebAuthnCredential
{
#[Id, GeneratedValue, Column]
private ?int $id = null;
#[ManyToOne(targetEntity: User::class)]
#[JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?User $user = null;
#[Column(type: Types::TEXT)]
private ?string $credentialData = null;
#[Column(length: 255)]
private ?string $credentialName = null;
#[Column(type: Types::DATETIME_MUTABLE)]
private ?DateTime $createdAt = null;
#[Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?DateTime $lastUsedAt = null;
#[Column]
private bool $isBackupEligible = false;
#[Column]
private bool $isBackupAuthenticated = false;
public function __construct()
{
$this->createdAt = new DateTime();
}
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): self
{
$this->user = $user;
return $this;
}
public function getCredentialData(): ?string
{
return $this->credentialData;
}
public function setCredentialData(?string $credentialData): self
{
$this->credentialData = $credentialData;
return $this;
}
public function getCredentialName(): ?string
{
return $this->credentialName;
}
public function setCredentialName(?string $credentialName): self
{
$this->credentialName = $credentialName;
return $this;
}
public function getCreatedAt(): ?DateTime
{
return $this->createdAt;
}
public function setCreatedAt(?DateTime $createdAt): self
{
$this->createdAt = $createdAt;
return $this;
}
public function getLastUsedAt(): ?DateTime
{
return $this->lastUsedAt;
}
public function setLastUsedAt(?DateTime $lastUsedAt): self
{
$this->lastUsedAt = $lastUsedAt;
return $this;
}
public function isBackupEligible(): bool
{
return $this->isBackupEligible;
}
public function setBackupEligible(bool $isBackupEligible): self
{
$this->isBackupEligible = $isBackupEligible;
return $this;
}
public function isBackupAuthenticated(): bool
{
return $this->isBackupAuthenticated;
}
public function setBackupAuthenticated(bool $isBackupAuthenticated): self
{
$this->isBackupAuthenticated = $isBackupAuthenticated;
return $this;
}
public function getPublicKeyCredentialSource()
{
// Return the raw credential data (JSON decoded)
if ($this->credentialData === null) {
return null;
}
return json_decode($this->credentialData, true);
}
public function setPublicKeyCredentialSource($source): self
{
// Handle both array and object input
if (is_array($source)) {
$this->credentialData = json_encode($source);
} else {
$this->credentialData = (string)$source;
}
return $this;
}
}

View File

@@ -0,0 +1,47 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20260412070922
*
* @package App\Migrations
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 12.
*/
final class Version20260412070922 extends AbstractMigration
{
public function getDescription(): string
{
return 'Implement Webauthn';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE SEQUENCE app_webauthn_credential_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE app_webauthn_credential (id INT NOT NULL, user_id INT NOT NULL, credential_data TEXT NOT NULL, credential_name VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, last_used_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, is_backup_eligible BOOLEAN NOT NULL, is_backup_authenticated BOOLEAN NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_DBBFCB3CA76ED395 ON app_webauthn_credential (user_id)');
$this->addSql('ALTER TABLE app_webauthn_credential ADD CONSTRAINT FK_DBBFCB3CA76ED395 FOREIGN KEY (user_id) REFERENCES app_user (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
$this->addSql('DROP SEQUENCE app_webauthn_credential_id_seq CASCADE');
$this->addSql('ALTER TABLE app_webauthn_credential DROP CONSTRAINT FK_DBBFCB3CA76ED395');
$this->addSql('DROP TABLE app_webauthn_credential');
}
}

View File

@@ -0,0 +1,56 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository;
use App\Entity\User;
use App\Entity\WebAuthnCredential;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<WebAuthnCredential>
*
* @method WebAuthnCredential|null find($id, $lockMode = null, $lockVersion = null)
* @method WebAuthnCredential|null findOneBy(array $criteria, array $orderBy = null)
* @method WebAuthnCredential[] findAll()
* @method WebAuthnCredential[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class WebAuthnCredentialRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, WebAuthnCredential::class);
}
public function findByUser(User $user): array
{
return $this->findBy(['user' => $user], ['createdAt' => 'DESC']);
}
public function countByUser(User $user): int
{
return $this->count(['user' => $user]);
}
public function deleteByIdAndUser(int $id, User $user): void
{
$qb = $this->createQueryBuilder('wac');
$qb
->delete()
->where($qb->expr()->eq('wac.id', ':id'))
->andWhere($qb->expr()->eq('wac.user', ':user'))
->setParameter('id', $id)
->setParameter('user', $user)
->getQuery()
->execute();
}
}

View File

@@ -0,0 +1,157 @@
<?php declare(strict_types=1);
/*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Service;
use App\Entity\User;
use App\Entity\WebAuthnCredential;
use App\Repository\WebAuthnCredentialRepository;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use JsonException;
use RuntimeException;
/**
* Class WebAuthnService
*
* @package App\Service
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 12.
*/
readonly class WebAuthnService
{
public function __construct(
private WebAuthnCredentialRepository $credentialRepository,
private EntityManagerInterface $entityManager,
) {
}
public function saveCredential(User $user, array $credentialData, string $name): WebAuthnCredential
{
$credential = new WebAuthnCredential();
$credential->setUser($user);
$credential->setCredentialData(json_encode($credentialData));
$credential->setCredentialName($name);
$credential->setBackupEligible($credentialData['isBackupEligible'] ?? false);
$credential->setBackupAuthenticated($credentialData['isBackupAuthenticated'] ?? false);
$this->entityManager->persist($credential);
$this->entityManager->flush();
return $credential;
}
public function getCredentialsForUser(User $user): array
{
return $this->credentialRepository->findByUser($user);
}
public function deleteCredential(int $id, User $user): bool
{
$credential = $this->credentialRepository->find($id);
if ($credential === null || $credential->getUser() !== $user) {
return false;
}
$this->entityManager->remove($credential);
$this->entityManager->flush();
return true;
}
public function renameCredential(int $id, User $user, string $name): bool
{
$credential = $this->credentialRepository->find($id);
if ($credential === null || $credential->getUser() !== $user) {
return false;
}
$credential->setCredentialName($name);
$this->entityManager->flush();
return true;
}
public function getPublicKeyCredentialLoader(): null
{
/**
* Return a simple object - the actual WebAuthn validation
* would be done on the client side for now
*/
return null;
}
public function getAllCredentialSources(User $user): array
{
$credentials = $this->credentialRepository->findByUser($user);
$sources = [];
foreach ($credentials as $credential) {
$data = $credential->getCredentialData();
if ($data === null) {
continue;
}
try {
$sources[] = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new RuntimeException(
"Failed to decode credential data for credential ID $credential->getId(): $e->getMessage()",
);
}
}
return $sources;
}
public function updateLastUsedAt(string $credentialId, User $user): void
{
$credentials = $this->credentialRepository->findByUser($user);
foreach ($credentials as $credential) {
$data = json_decode($credential->getCredentialData() ?? '{}', true);
if (($data['id'] ?? null) !== $credentialId) {
continue;
}
$credential->setLastUsedAt(new DateTime());
$this->entityManager->flush();
break;
}
}
public function findUserByCredentialId(string $credentialId): ?User
{
$allCredentials = $this->credentialRepository->findAll();
foreach ($allCredentials as $credential) {
$data = json_decode($credential->getCredentialData() ?? '{}', true);
if (($data['id'] ?? null) !== $credentialId) {
continue;
}
return $credential->getUser();
}
return null;
}
}