317 lines
12 KiB
PHP
317 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 Exception;
|
|
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;
|
|
use function random_bytes;
|
|
|
|
/**
|
|
* 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
|
|
);
|
|
}
|
|
}
|
|
}
|