chg: usr: add modern Webauthn authentication #4
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
314
src/Controller/WebAuthnController.php
Normal file
314
src/Controller/WebAuthnController.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
171
src/Entity/WebAuthnCredential.php
Normal file
171
src/Entity/WebAuthnCredential.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
47
src/Migrations/2026/04/Version20260412070922.php
Normal file
47
src/Migrations/2026/04/Version20260412070922.php
Normal 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');
|
||||
}
|
||||
}
|
||||
56
src/Repository/WebAuthnCredentialRepository.php
Normal file
56
src/Repository/WebAuthnCredentialRepository.php
Normal 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();
|
||||
}
|
||||
}
|
||||
157
src/Service/WebAuthnService.php
Normal file
157
src/Service/WebAuthnService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user