* @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 ); } } }