Private
Public Access
1
0
Files
MineSeeker/src/Controller/WebAuthnController.php

315 lines
12 KiB
PHP

<?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\Security\PasskeyToken;
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 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->id,
$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->id;
$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->id,
'name' => $credential->credentialName,
'createdAt' => $credential->createdAt?->format('Y-m-d H:i:s'),
'lastUsedAt' => $credential->lastUsedAt?->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 PasskeyToken($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
);
}
}
}