From 0144a3953cf0728528fc1f50a04940dd2cea22b4 Mon Sep 17 00:00:00 2001
From: Lang <7system7@gmail.com>
Date: Sun, 12 Apr 2026 15:19:03 +0200
Subject: [PATCH] chg: usr: add modern Webauthn authentication #4
---
assets/css/homepage/_auth-bar.scss | 12 +
assets/css/homepage/_auth.scss | 70 +-
assets/css/homepage/_profile.scss | 38 +
assets/css/passkey.scss | 206 ++++
assets/js/components/PasskeyLogin.jsx | 110 ++
assets/js/components/PasskeyManager.jsx | 418 +++++++
assets/js/passkey.jsx | 37 +
composer.json | 3 +-
composer.lock | 1078 ++++++++++++++++-
config/bundles.php | 2 +
config/packages/security.yaml | 2 +
src/Controller/ProfileController.php | 28 +-
src/Controller/WebAuthnController.php | 314 +++++
src/Entity/WebAuthnCredential.php | 171 +++
.../2026/04/Version20260412070922.php | 47 +
.../WebAuthnCredentialRepository.php | 56 +
src/Service/WebAuthnService.php | 157 +++
symfony.lock | 12 +
templates/Game/index.html.twig | 3 +
templates/Security/login.html.twig | 33 +-
templates/Security/profile.html.twig | 6 +
templates/Security/profile_security.html.twig | 53 +
vite.config.js | 2 +
23 files changed, 2845 insertions(+), 13 deletions(-)
create mode 100644 assets/css/passkey.scss
create mode 100644 assets/js/components/PasskeyLogin.jsx
create mode 100644 assets/js/components/PasskeyManager.jsx
create mode 100644 assets/js/passkey.jsx
create mode 100644 src/Controller/WebAuthnController.php
create mode 100644 src/Entity/WebAuthnCredential.php
create mode 100644 src/Migrations/2026/04/Version20260412070922.php
create mode 100644 src/Repository/WebAuthnCredentialRepository.php
create mode 100644 src/Service/WebAuthnService.php
create mode 100644 templates/Security/profile_security.html.twig
diff --git a/assets/css/homepage/_auth-bar.scss b/assets/css/homepage/_auth-bar.scss
index d858b1d..c98bd17 100644
--- a/assets/css/homepage/_auth-bar.scss
+++ b/assets/css/homepage/_auth-bar.scss
@@ -53,6 +53,18 @@
}
}
+ &--security {
+ color: rgba(149, 207, 245, 0.55);
+ border-color: rgba(35, 111, 135, 0.22);
+ background: transparent;
+
+ &:hover {
+ color: rgba(149, 207, 245, 0.9);
+ background: rgba(35, 111, 135, 0.14);
+ border-color: rgba(35, 111, 135, 0.45);
+ }
+ }
+
&--out {
background: transparent;
border-color: rgba(173, 10, 5, 0.3);
diff --git a/assets/css/homepage/_auth.scss b/assets/css/homepage/_auth.scss
index 6a60d6b..c4eb643 100644
--- a/assets/css/homepage/_auth.scss
+++ b/assets/css/homepage/_auth.scss
@@ -161,6 +161,12 @@
letter-spacing: 0.3px;
}
+.auth-below-password {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+}
+
.auth-remember {
display: flex;
align-items: center;
@@ -216,4 +222,66 @@
&:hover { color: #c5e8ff; }
}
-}
\ No newline at end of file
+}
+
+.auth-forgot-password {
+ font: 400 13px 'Rajdhani', sans-serif;
+ color: rgba(255, 255, 255, 0.35);
+ text-align: center;
+ letter-spacing: 0.3px;
+
+ a {
+ color: #95cff5;
+ text-decoration: none;
+ font-weight: 600;
+ transition: color 180ms;
+
+ &:hover { color: #c5e8ff; }
+ }
+}
+
+.auth-divider {
+ display: flex;
+ align-items: center;
+ margin: 20px 0;
+ color: #999;
+}
+
+.auth-divider::before,
+.auth-divider::after {
+ content: '';
+ flex: 1;
+ height: 1px;
+ background: #ddd;
+}
+
+.auth-divider span {
+ margin: 0 10px;
+ font-size: 14px;
+}
+
+.auth-passkey-btn {
+ width: 100%;
+ padding: 12px 16px;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 16px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+}
+
+.auth-passkey-btn:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
+}
+
+.auth-passkey-btn:active {
+ transform: translateY(0);
+}
diff --git a/assets/css/homepage/_profile.scss b/assets/css/homepage/_profile.scss
index e0d14ba..e7a6167 100644
--- a/assets/css/homepage/_profile.scss
+++ b/assets/css/homepage/_profile.scss
@@ -144,12 +144,50 @@
color: rgba(255, 255, 255, 0.3);
}
+.profile-actions {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.profile-action-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 9px 18px;
+ background: rgba(35, 111, 135, 0.12);
+ color: rgba(149, 207, 245, 0.75);
+ border: 1px solid rgba(35, 111, 135, 0.3);
+ border-radius: 6px;
+ text-decoration: none;
+ font: 600 12px 'Rajdhani', sans-serif;
+ letter-spacing: 1.5px;
+ text-transform: uppercase;
+ transition: background 200ms ease, border-color 200ms ease, color 200ms ease;
+
+ i { font-size: 11px; opacity: 0.8; }
+
+ &:hover {
+ background: rgba(35, 111, 135, 0.22);
+ border-color: rgba(35, 111, 135, 0.55);
+ color: rgba(149, 207, 245, 1);
+ text-decoration: none;
+ }
+}
+
.profile-section {
display: flex;
flex-direction: column;
gap: 14px;
}
+.profile-section__description {
+ font-size: 13px;
+ color: rgba(149, 207, 245, 0.65);
+ line-height: 1.6;
+ margin: 0;
+}
+
.profile-section__title {
font: 700 11px 'Rajdhani', sans-serif;
text-transform: uppercase;
diff --git a/assets/css/passkey.scss b/assets/css/passkey.scss
new file mode 100644
index 0000000..418d8af
--- /dev/null
+++ b/assets/css/passkey.scss
@@ -0,0 +1,206 @@
+$primary: #236f87;
+$primary-dark: #1a5a70;
+$danger: #c0392b;
+$warning: #d68910;
+$success: #388e3c;
+$text: #e0e0e0;
+$text-muted: #9e9e9e;
+$border: rgba(35, 111, 135, 0.3);
+$bg-card: #0a0e14;
+$bg-hover: rgba(35, 111, 135, 0.15);
+
+.passkey-manager {
+ &__actions {
+ margin: 20px 0;
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+ }
+
+ &__list {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+ margin-top: 20px;
+ }
+}
+
+.passkey-item {
+ border: 1px solid $border;
+ border-radius: 6px;
+ padding: 15px;
+ background: $bg-card;
+ transition: all 0.3s ease;
+
+ &:hover {
+ background: $bg-hover;
+ border-color: rgba(35, 111, 135, 0.5);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+ }
+
+ &__header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 12px;
+ }
+
+ &__info {
+ flex: 1;
+ }
+
+ &__name {
+ margin: 0 0 5px 0;
+ font-size: 16px;
+ font-weight: 600;
+ color: $text;
+ }
+
+ &__meta {
+ margin: 0;
+ font-size: 13px;
+ color: $text-muted;
+ line-height: 1.5;
+ }
+
+ &__badges {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ }
+
+ &__actions {
+ display: flex;
+ gap: 10px;
+ justify-content: flex-end;
+ padding-top: 12px;
+ border-top: 1px solid $border;
+ }
+}
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 12px;
+ font-weight: 500;
+
+ &--info {
+ background: rgba(25, 118, 210, 0.2);
+ color: #64b5f6;
+ }
+
+ &--success {
+ background: rgba(56, 142, 60, 0.2);
+ color: #81c784;
+ }
+}
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 16px;
+ border: none;
+ border-radius: 4px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ text-decoration: none;
+
+ &--primary {
+ background: $primary;
+ color: white;
+
+ &:hover {
+ background: $primary-dark;
+ }
+ }
+
+ &--secondary {
+ background: #546e7a;
+ color: white;
+
+ &:hover {
+ background: #455a64;
+ }
+ }
+
+ &--warning {
+ background: $warning;
+ color: white;
+
+ &:hover {
+ background: darken($warning, 10%);
+ }
+ }
+
+ &--danger {
+ background: $danger;
+ color: white;
+
+ &:hover {
+ background: darken($danger, 10%);
+ }
+ }
+
+ &--sm {
+ padding: 6px 12px;
+ font-size: 13px;
+ }
+}
+
+.empty-state {
+ text-align: center;
+ padding: 40px 20px;
+ border: 1px dashed $border;
+ border-radius: 8px;
+
+ &__icon {
+ font-size: 48px;
+ color: #455a64;
+ margin-bottom: 15px;
+ display: block;
+ }
+
+ &__text {
+ font-size: 16px;
+ color: $text;
+ margin-bottom: 5px;
+ }
+
+ &__subtext {
+ font-size: 13px;
+ color: $text-muted;
+ margin: 0;
+ }
+}
+
+.registration-status {
+ padding: 15px;
+ border-radius: 4px;
+ margin-top: 15px;
+ font-size: 14px;
+
+ &--success {
+ background: rgba(56, 142, 60, 0.15);
+ color: #81c784;
+ border: 1px solid rgba(56, 142, 60, 0.3);
+ }
+
+ &--error {
+ background: rgba(192, 57, 43, 0.15);
+ color: #e57373;
+ border: 1px solid rgba(192, 57, 43, 0.3);
+ }
+
+ &--loading {
+ background: rgba(25, 118, 210, 0.15);
+ color: #64b5f6;
+ border: 1px solid rgba(25, 118, 210, 0.3);
+ }
+}
\ No newline at end of file
diff --git a/assets/js/components/PasskeyLogin.jsx b/assets/js/components/PasskeyLogin.jsx
new file mode 100644
index 0000000..81725cd
--- /dev/null
+++ b/assets/js/components/PasskeyLogin.jsx
@@ -0,0 +1,110 @@
+/**
+ * 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.
+ */
+
+import React, { useState, useCallback } from 'react';
+
+const base64ToArrayBuffer = base64 => {
+ const binary = atob(base64.replace(/([-_])/g, m => ('-' === m ? '+' : '/')));
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes.buffer;
+};
+
+const arrayBufferToBase64url = buffer =>
+ btoa(String.fromCharCode(...new Uint8Array(buffer)))
+ .replace(/([+\/=])/g, m => ('+' === m ? '-' : '/' === m ? '_' : ''));
+
+const credentialToJSON = credential => ({
+ id: credential.id,
+ rawId: arrayBufferToBase64url(credential.rawId),
+ type: credential.type,
+ response: {
+ authenticatorData: arrayBufferToBase64url(credential.response.authenticatorData),
+ clientDataJSON: arrayBufferToBase64url(credential.response.clientDataJSON),
+ signature: arrayBufferToBase64url(credential.response.signature),
+ userHandle: credential.response.userHandle
+ ? arrayBufferToBase64url(credential.response.userHandle)
+ : null,
+ },
+});
+
+const PasskeyLogin = ({ apiRoutes }) => {
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+
+ const handleLogin = useCallback(async () => {
+ setLoading(true);
+ setError('');
+
+ try {
+ const beginResponse = await fetch(apiRoutes.authenticationBegin, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ });
+
+ if (!beginResponse.ok) throw new Error('Failed to get authentication options');
+ const options = await beginResponse.json();
+
+ const publicKey = {
+ ...options,
+ challenge: base64ToArrayBuffer(options.challenge),
+ allowCredentials: (options.allowCredentials ?? []).map(cred => ({
+ ...cred,
+ id: base64ToArrayBuffer(cred.id),
+ })),
+ };
+
+ const credential = await navigator.credentials.get({ publicKey });
+ if (!credential) throw new Error('Authentication was cancelled');
+
+ const completeResponse = await fetch(apiRoutes.authenticationComplete, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ credential: credentialToJSON(credential) }),
+ });
+
+ if (!completeResponse.ok) {
+ const body = await completeResponse.json();
+ throw new Error(body.error || 'Authentication failed');
+ }
+
+ const result = await completeResponse.json();
+ if (result.success) {
+ window.location.href = result.redirect || '/';
+ }
+ } catch (err) {
+ setError(err.message);
+ }
+
+ setLoading(false);
+ }, [apiRoutes]);
+
+ return (
+ <>
+
+ {error && (
+
+ {error}
+
+ )}
+ >
+ );
+};
+
+export default PasskeyLogin;
\ No newline at end of file
diff --git a/assets/js/components/PasskeyManager.jsx b/assets/js/components/PasskeyManager.jsx
new file mode 100644
index 0000000..720ab2f
--- /dev/null
+++ b/assets/js/components/PasskeyManager.jsx
@@ -0,0 +1,418 @@
+/**
+ * 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.
+ */
+
+import React, { Fragment, useCallback, useEffect, useState } from 'react';
+import Dialog from '@mui/material/Dialog';
+import DialogTitle from '@mui/material/DialogTitle';
+import DialogContent from '@mui/material/DialogContent';
+import DialogActions from '@mui/material/DialogActions';
+import Button from '@mui/material/Button';
+import TextField from '@mui/material/TextField';
+
+const DIALOG_SX = {
+ '& .MuiDialog-paper': {
+ background: '#0a0e14',
+ color: '#e0e0e0',
+ },
+ '& .MuiTextField-root .MuiInputLabel-root': {
+ color: '#9e9e9e',
+ },
+ '& .MuiTextField-root .MuiOutlinedInput-root': {
+ '& fieldset': {
+ borderColor: 'rgba(35, 111, 135, 0.5)',
+ },
+ '&:hover fieldset': {
+ borderColor: 'rgba(35, 111, 135, 0.8)',
+ },
+ '&.Mui-focused fieldset': {
+ borderColor: '#236f87',
+ },
+ },
+ '& .MuiTextField-root .MuiOutlinedInput-input': {
+ color: '#e0e0e0',
+ },
+ '& .MuiFormHelperText-root': {
+ backgroundImage: `
+ linear-gradient(rgba(35, 111, 135, 0.08) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(35, 111, 135, 0.08) 1px, transparent 1px)
+ `,
+ backgroundSize: '46px 46px',
+ border: '1px solid rgba(35, 111, 135, 0.4)',
+ borderRadius: '12px',
+ boxShadow: '0 0 80px rgba(35, 111, 135, 0.15), 0 32px 80px rgba(0, 0, 0, 0.9)',
+ width: '500px',
+ maxWidth: '94vw',
+ overflow: 'hidden',
+ color: '#e0e0e0',
+ },
+ '& .MuiBackdrop-root': {
+ background: 'rgba(2, 4, 8, 0.88)',
+ backdropFilter: 'blur(4px)',
+ },
+};
+
+const base64ToArrayBuffer = base64 => {
+ const binary = atob(base64.replace(/([-_])/g, m => ('-' === m ? '+' : '/')));
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes.buffer;
+};
+
+const credentialToJSON = credential => {
+ const attestationObject = credential.response.attestationObject;
+ const clientDataJSON = credential.response.clientDataJSON;
+
+ return {
+ id: credential.id,
+ rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))).replace(/([+\/=])/g, m => ('+' === m ? '-' : '/' === m ? '_' : '')),
+ type: credential.type,
+ response: {
+ attestationObject: btoa(String.fromCharCode(...new Uint8Array(attestationObject))).replace(/([+\/=])/g, m => ('+' === m ? '-' : '/' === m ? '_' : '')),
+ clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(clientDataJSON))).replace(/([+\/=])/g, m => ('+' === m ? '-' : '/' === m ? '_' : '')),
+ },
+ };
+};
+
+const PasskeyManager = ({ credentials, apiRoutes }) => {
+ const [addModalOpen, setAddModalOpen] = useState(false);
+ const [renameModalOpen, setRenameModalOpen] = useState(false);
+ const [deleteModalOpen, setDeleteModalOpen] = useState(false);
+ const [selectedCredential, setSelectedCredential] = useState(null);
+ const [passkeyName, setPasskeyName] = useState('');
+ const [renameName, setRenameName] = useState('');
+ const [status, setStatus] = useState({ type: '', message: '' });
+ const [loading, setLoading] = useState(false);
+ const [credentialsList, setCredentialsList] = useState(credentials);
+
+ useEffect(() => {
+ setCredentialsList(credentials);
+ }, [credentials]);
+
+ const showStatus = useCallback((message, type) => {
+ setStatus({ message, type });
+ }, []);
+
+ const closeAddModal = useCallback(() => {
+ setAddModalOpen(false);
+ setPasskeyName('');
+ setStatus({ type: '', message: '' });
+ }, []);
+
+ const openAddModal = useCallback(() => {
+ setPasskeyName('');
+ setStatus({ type: '', message: '' });
+ setAddModalOpen(true);
+ }, []);
+
+ const handleAddPasskey = useCallback(async () => {
+ if (!passkeyName.trim()) {
+ showStatus('Please enter a passkey name', 'error');
+ return;
+ }
+
+ setLoading(true);
+ showStatus('Starting registration...', 'loading');
+
+ try {
+ const optionsResponse = await fetch(apiRoutes.registrationBegin, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ credentialName: passkeyName.trim() }),
+ });
+
+ if (!optionsResponse.ok) throw new Error('Failed to get registration options');
+ const options = await optionsResponse.json();
+
+ showStatus('Please touch your security key or use biometric authentication...', 'loading');
+
+ const publicKey = {
+ ...options,
+ challenge: base64ToArrayBuffer(options.challenge),
+ user: {
+ ...options.user,
+ id: base64ToArrayBuffer(options.user.id),
+ },
+ attestation: 'direct',
+ };
+
+ const credential = await navigator.credentials.create({ publicKey });
+
+ if (!credential) {
+ showStatus('Registration was cancelled', 'error');
+ setLoading(false);
+ return;
+ }
+
+ showStatus('Verifying credential...', 'loading');
+
+ const completeResponse = await fetch(apiRoutes.registrationComplete, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ credential: credentialToJSON(credential),
+ }),
+ });
+
+ if (!completeResponse.ok) throw new Error('Failed to complete registration');
+
+ showStatus('Passkey registered successfully!', 'success');
+ setTimeout(() => window.location.reload(), 1500);
+ } catch (error) {
+ console.error('Registration error:', error);
+ showStatus('Error: ' + error.message, 'error');
+ }
+
+ setLoading(false);
+ }, [passkeyName, apiRoutes, showStatus]);
+
+ const openRenameModal = useCallback(credential => {
+ setSelectedCredential(credential);
+ setRenameName(credential.credentialName);
+ setStatus({ type: '', message: '' });
+ setRenameModalOpen(true);
+ }, []);
+
+ const closeRenameModal = useCallback(() => {
+ setRenameModalOpen(false);
+ setSelectedCredential(null);
+ setRenameName('');
+ setStatus({ type: '', message: '' });
+ }, []);
+
+ const handleRename = useCallback(async () => {
+ if (!renameName.trim() || !selectedCredential) {
+ showStatus('Please enter a new name', 'error');
+ return;
+ }
+
+ setLoading(true);
+
+ try {
+ const response = await fetch(`${apiRoutes.credentials}/${selectedCredential.id}/rename`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: renameName.trim() }),
+ });
+
+ if (!response.ok) throw new Error('Failed to rename passkey');
+
+ window.location.reload();
+ } catch (error) {
+ console.error('Rename error:', error);
+ showStatus('Error: ' + error.message, 'error');
+ }
+
+ setLoading(false);
+ }, [renameName, selectedCredential, apiRoutes, showStatus]);
+
+ const openDeleteModal = useCallback(credential => {
+ setSelectedCredential(credential);
+ setDeleteModalOpen(true);
+ }, []);
+
+ const closeDeleteModal = useCallback(() => {
+ setDeleteModalOpen(false);
+ setSelectedCredential(null);
+ }, []);
+
+ const handleDelete = useCallback(async () => {
+ if (!selectedCredential) return;
+
+ setLoading(true);
+
+ try {
+ const response = await fetch(`${apiRoutes.credentials}/${selectedCredential.id}`, {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ });
+
+ if (response.ok) {
+ window.location.reload();
+ } else {
+ showStatus('Failed to delete passkey', 'error');
+ }
+ } catch (error) {
+ console.error('Delete error:', error);
+ showStatus('Error: ' + error.message, 'error');
+ }
+
+ setLoading(false);
+ setDeleteModalOpen(false);
+ }, [selectedCredential, apiRoutes, showStatus]);
+
+ const statusClass = 'error' === status.type
+ ? 'registration-status--error'
+ : 'success' === status.type
+ ? 'registration-status--success'
+ : 'loading' === status.type
+ ? 'registration-status--loading'
+ : '';
+
+ return (
+
+
+
+
+
+
+ {0 < credentialsList.length ? (
+ credentialsList.map(credential => (
+
+
+
+
{credential.credentialName}
+
+ Created: {credential.createdAt}
+ {credential.lastUsedAt && (
+ <>
+
Last used: {credential.lastUsedAt}
+ >
+ )}
+
+
+
+ {credential.isBackupEligible && (
+
+ Backup Eligible
+
+ )}
+ {credential.isBackupAuthenticated && (
+
+ Backed Up
+
+ )}
+
+
+
+
+
+
+
+ ))
+ ) : (
+
+
+
No passkeys registered yet.
+
+ Add your first passkey to get started with secure, passwordless authentication.
+
+
+ )}
+
+
+
+
+
+
+
+
+ );
+};
+
+export default PasskeyManager;
diff --git a/assets/js/passkey.jsx b/assets/js/passkey.jsx
new file mode 100644
index 0000000..47a39b1
--- /dev/null
+++ b/assets/js/passkey.jsx
@@ -0,0 +1,37 @@
+/**
+ * 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.
+ */
+
+import React from 'react';
+import { createRoot } from 'react-dom/client';
+import PasskeyManager from './components/PasskeyManager';
+import PasskeyLogin from './components/PasskeyLogin';
+
+const passkeyManagerRoot = document.getElementById('passkey-manager-root');
+
+if (passkeyManagerRoot) {
+ const credentials = JSON.parse(passkeyManagerRoot.dataset.credentials || '[]');
+ const apiRoutes = JSON.parse(passkeyManagerRoot.dataset.apiRoutes || '{}');
+
+ createRoot(passkeyManagerRoot).render(
+ ,
+ );
+}
+
+const passkeyLoginRoot = document.getElementById('passkey-login-root');
+
+if (passkeyLoginRoot) {
+ const apiRoutes = JSON.parse(passkeyLoginRoot.dataset.apiRoutes || '{}');
+
+ createRoot(passkeyLoginRoot).render(
+ ,
+ );
+}
diff --git a/composer.json b/composer.json
index e73d1ae..00a5878 100644
--- a/composer.json
+++ b/composer.json
@@ -23,7 +23,8 @@
"symfony/translation": "7.4.*",
"symfony/twig-bundle": "7.4.*",
"symfony/validator": "7.4.*",
- "symfony/yaml": "7.4.*"
+ "symfony/yaml": "7.4.*",
+ "web-auth/webauthn-framework": "^5.2"
},
"require-dev": {
"firebase/php-jwt": "^7.0",
diff --git a/composer.lock b/composer.lock
index 6df18da..f89d219 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,8 +4,67 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "42a3734dddfef28ddaa1352541b4d23f",
+ "content-hash": "2d8ff385b04f98203cb1c117c260f6c4",
"packages": [
+ {
+ "name": "brick/math",
+ "version": "0.17.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/brick/math.git",
+ "reference": "a62af7ab2e3cee9f9bf4cf77a5d1e6ba408a44ee"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/brick/math/zipball/a62af7ab2e3cee9f9bf4cf77a5d1e6ba408a44ee",
+ "reference": "a62af7ab2e3cee9f9bf4cf77a5d1e6ba408a44ee",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.2"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "2.1.22",
+ "phpunit/phpunit": "^11.5"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Brick\\Math\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Arbitrary-precision arithmetic library",
+ "keywords": [
+ "Arbitrary-precision",
+ "BigInteger",
+ "BigRational",
+ "arithmetic",
+ "bigdecimal",
+ "bignum",
+ "bignumber",
+ "brick",
+ "decimal",
+ "integer",
+ "math",
+ "mathematics",
+ "rational"
+ ],
+ "support": {
+ "issues": "https://github.com/brick/math/issues",
+ "source": "https://github.com/brick/math/tree/0.17.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/BenMorel",
+ "type": "github"
+ }
+ ],
+ "time": "2026-03-17T12:54:54+00:00"
+ },
{
"name": "doctrine/cache",
"version": "2.2.0",
@@ -1569,6 +1628,75 @@
],
"time": "2026-01-02T08:56:05+00:00"
},
+ {
+ "name": "paragonie/constant_time_encoding",
+ "version": "v3.1.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/paragonie/constant_time_encoding.git",
+ "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
+ "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8"
+ },
+ "require-dev": {
+ "infection/infection": "^0",
+ "nikic/php-fuzzer": "^0",
+ "phpunit/phpunit": "^9|^10|^11",
+ "vimeo/psalm": "^4|^5|^6"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "ParagonIE\\ConstantTime\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Paragon Initiative Enterprises",
+ "email": "security@paragonie.com",
+ "homepage": "https://paragonie.com",
+ "role": "Maintainer"
+ },
+ {
+ "name": "Steve 'Sc00bz' Thomas",
+ "email": "steve@tobtu.com",
+ "homepage": "https://www.tobtu.com",
+ "role": "Original Developer"
+ }
+ ],
+ "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
+ "keywords": [
+ "base16",
+ "base32",
+ "base32_decode",
+ "base32_encode",
+ "base64",
+ "base64_decode",
+ "base64_encode",
+ "bin2hex",
+ "encoding",
+ "hex",
+ "hex2bin",
+ "rfc4648"
+ ],
+ "support": {
+ "email": "info@paragonie.com",
+ "issues": "https://github.com/paragonie/constant_time_encoding/issues",
+ "source": "https://github.com/paragonie/constant_time_encoding"
+ },
+ "time": "2025-09-24T15:06:41+00:00"
+ },
{
"name": "pentatrion/vite-bundle",
"version": "v8.2.4",
@@ -1630,6 +1758,228 @@
},
"time": "2026-03-22T11:41:50+00:00"
},
+ {
+ "name": "phpdocumentor/reflection-common",
+ "version": "2.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpDocumentor/ReflectionCommon.git",
+ "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b",
+ "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-2.x": "2.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "phpDocumentor\\Reflection\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jaap van Otterdijk",
+ "email": "opensource@ijaap.nl"
+ }
+ ],
+ "description": "Common reflection classes used by phpdocumentor to reflect the code structure",
+ "homepage": "http://www.phpdoc.org",
+ "keywords": [
+ "FQSEN",
+ "phpDocumentor",
+ "phpdoc",
+ "reflection",
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues",
+ "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x"
+ },
+ "time": "2020-06-27T09:03:43+00:00"
+ },
+ {
+ "name": "phpdocumentor/reflection-docblock",
+ "version": "5.6.7",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
+ "reference": "31a105931bc8ffa3a123383829772e832fd8d903"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/31a105931bc8ffa3a123383829772e832fd8d903",
+ "reference": "31a105931bc8ffa3a123383829772e832fd8d903",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/deprecations": "^1.1",
+ "ext-filter": "*",
+ "php": "^7.4 || ^8.0",
+ "phpdocumentor/reflection-common": "^2.2",
+ "phpdocumentor/type-resolver": "^1.7",
+ "phpstan/phpdoc-parser": "^1.7|^2.0",
+ "webmozart/assert": "^1.9.1 || ^2"
+ },
+ "require-dev": {
+ "mockery/mockery": "~1.3.5 || ~1.6.0",
+ "phpstan/extension-installer": "^1.1",
+ "phpstan/phpstan": "^1.8",
+ "phpstan/phpstan-mockery": "^1.1",
+ "phpstan/phpstan-webmozart-assert": "^1.2",
+ "phpunit/phpunit": "^9.5",
+ "psalm/phar": "^5.26"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "phpDocumentor\\Reflection\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mike van Riel",
+ "email": "me@mikevanriel.com"
+ },
+ {
+ "name": "Jaap van Otterdijk",
+ "email": "opensource@ijaap.nl"
+ }
+ ],
+ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
+ "support": {
+ "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues",
+ "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.7"
+ },
+ "time": "2026-03-18T20:47:46+00:00"
+ },
+ {
+ "name": "phpdocumentor/type-resolver",
+ "version": "1.12.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpDocumentor/TypeResolver.git",
+ "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195",
+ "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/deprecations": "^1.0",
+ "php": "^7.3 || ^8.0",
+ "phpdocumentor/reflection-common": "^2.0",
+ "phpstan/phpdoc-parser": "^1.18|^2.0"
+ },
+ "require-dev": {
+ "ext-tokenizer": "*",
+ "phpbench/phpbench": "^1.2",
+ "phpstan/extension-installer": "^1.1",
+ "phpstan/phpstan": "^1.8",
+ "phpstan/phpstan-phpunit": "^1.1",
+ "phpunit/phpunit": "^9.5",
+ "rector/rector": "^0.13.9",
+ "vimeo/psalm": "^4.25"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-1.x": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "phpDocumentor\\Reflection\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mike van Riel",
+ "email": "me@mikevanriel.com"
+ }
+ ],
+ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
+ "support": {
+ "issues": "https://github.com/phpDocumentor/TypeResolver/issues",
+ "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0"
+ },
+ "time": "2025-11-21T15:09:14+00:00"
+ },
+ {
+ "name": "phpstan/phpdoc-parser",
+ "version": "2.3.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpstan/phpdoc-parser.git",
+ "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a",
+ "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4 || ^8.0"
+ },
+ "require-dev": {
+ "doctrine/annotations": "^2.0",
+ "nikic/php-parser": "^5.3.0",
+ "php-parallel-lint/php-parallel-lint": "^1.2",
+ "phpstan/extension-installer": "^1.0",
+ "phpstan/phpstan": "^2.0",
+ "phpstan/phpstan-phpunit": "^2.0",
+ "phpstan/phpstan-strict-rules": "^2.0",
+ "phpunit/phpunit": "^9.6",
+ "symfony/process": "^5.2"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "PHPStan\\PhpDocParser\\": [
+ "src/"
+ ]
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "PHPDoc parser with support for nullable, intersection and generic types",
+ "support": {
+ "issues": "https://github.com/phpstan/phpdoc-parser/issues",
+ "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2"
+ },
+ "time": "2026-01-25T14:56:51+00:00"
+ },
{
"name": "psr/cache",
"version": "3.0.0",
@@ -1936,6 +2286,187 @@
},
"time": "2024-09-11T13:17:53+00:00"
},
+ {
+ "name": "spomky-labs/cbor-php",
+ "version": "3.2.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Spomky-Labs/cbor-php.git",
+ "reference": "dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32",
+ "reference": "dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32",
+ "shasum": ""
+ },
+ "require": {
+ "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17",
+ "ext-mbstring": "*",
+ "php": ">=8.0"
+ },
+ "require-dev": {
+ "ext-json": "*",
+ "roave/security-advisories": "dev-latest",
+ "symfony/error-handler": "^6.4|^7.1|^8.0",
+ "symfony/var-dumper": "^6.4|^7.1|^8.0"
+ },
+ "suggest": {
+ "ext-bcmath": "GMP or BCMath extensions will drastically improve the library performance. BCMath extension needed to handle the Big Float and Decimal Fraction Tags",
+ "ext-gmp": "GMP or BCMath extensions will drastically improve the library performance"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "CBOR\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Florent Morselli",
+ "homepage": "https://github.com/Spomky"
+ },
+ {
+ "name": "All contributors",
+ "homepage": "https://github.com/Spomky-Labs/cbor-php/contributors"
+ }
+ ],
+ "description": "CBOR Encoder/Decoder for PHP",
+ "keywords": [
+ "Concise Binary Object Representation",
+ "RFC7049",
+ "cbor"
+ ],
+ "support": {
+ "issues": "https://github.com/Spomky-Labs/cbor-php/issues",
+ "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.2.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Spomky",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/FlorentMorselli",
+ "type": "patreon"
+ }
+ ],
+ "time": "2026-04-01T12:15:20+00:00"
+ },
+ {
+ "name": "spomky-labs/pki-framework",
+ "version": "1.4.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Spomky-Labs/pki-framework.git",
+ "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/aa576cbd07128075bef97ac2f8af9854e67513d8",
+ "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8",
+ "shasum": ""
+ },
+ "require": {
+ "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17",
+ "ext-mbstring": "*",
+ "php": ">=8.1",
+ "psr/clock": "^1.0"
+ },
+ "require-dev": {
+ "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0",
+ "ext-gmp": "*",
+ "ext-openssl": "*",
+ "infection/infection": "^0.28|^0.29|^0.31|^0.32",
+ "php-parallel-lint/php-parallel-lint": "^1.3",
+ "phpstan/extension-installer": "^1.3|^2.0",
+ "phpstan/phpstan": "^1.8|^2.0",
+ "phpstan/phpstan-deprecation-rules": "^1.0|^2.0",
+ "phpstan/phpstan-phpunit": "^1.1|^2.0",
+ "phpstan/phpstan-strict-rules": "^1.3|^2.0",
+ "phpunit/phpunit": "^10.1|^11.0|^12.0|^13.0",
+ "rector/rector": "^1.0|^2.0",
+ "roave/security-advisories": "dev-latest",
+ "symfony/string": "^6.4|^7.0|^8.0",
+ "symfony/var-dumper": "^6.4|^7.0|^8.0",
+ "symplify/easy-coding-standard": "^12.0|^13.0"
+ },
+ "suggest": {
+ "ext-bcmath": "For better performance (or GMP)",
+ "ext-gmp": "For better performance (or BCMath)",
+ "ext-openssl": "For OpenSSL based cyphering"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "SpomkyLabs\\Pki\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Joni Eskelinen",
+ "email": "jonieske@gmail.com",
+ "role": "Original developer"
+ },
+ {
+ "name": "Florent Morselli",
+ "email": "florent.morselli@spomky-labs.com",
+ "role": "Spomky-Labs PKI Framework developer"
+ }
+ ],
+ "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.",
+ "homepage": "https://github.com/spomky-labs/pki-framework",
+ "keywords": [
+ "DER",
+ "Private Key",
+ "ac",
+ "algorithm identifier",
+ "asn.1",
+ "asn1",
+ "attribute certificate",
+ "certificate",
+ "certification request",
+ "cryptography",
+ "csr",
+ "decrypt",
+ "ec",
+ "encrypt",
+ "pem",
+ "pkcs",
+ "public key",
+ "rsa",
+ "sign",
+ "signature",
+ "verify",
+ "x.509",
+ "x.690",
+ "x509",
+ "x690"
+ ],
+ "support": {
+ "issues": "https://github.com/Spomky-Labs/pki-framework/issues",
+ "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Spomky",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/FlorentMorselli",
+ "type": "patreon"
+ }
+ ],
+ "time": "2026-03-23T22:56:56+00:00"
+ },
{
"name": "symfony/asset",
"version": "v7.4.8",
@@ -5204,6 +5735,89 @@
],
"time": "2026-04-10T16:50:15+00:00"
},
+ {
+ "name": "symfony/polyfill-uuid",
+ "version": "v1.34.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-uuid.git",
+ "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94",
+ "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "provide": {
+ "ext-uuid": "*"
+ },
+ "suggest": {
+ "ext-uuid": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Uuid\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Grégoire Pineau",
+ "email": "lyrixx@lyrixx.info"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for uuid functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "uuid"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-uuid/tree/v1.34.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-04-10T16:19:22+00:00"
+ },
{
"name": "symfony/property-access",
"version": "v7.4.8",
@@ -5829,6 +6443,110 @@
],
"time": "2026-03-24T13:12:05+00:00"
},
+ {
+ "name": "symfony/serializer",
+ "version": "v7.4.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/serializer.git",
+ "reference": "006fd51717addf2df2bd1a64dafef6b7fab6b455"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/serializer/zipball/006fd51717addf2df2bd1a64dafef6b7fab6b455",
+ "reference": "006fd51717addf2df2bd1a64dafef6b7fab6b455",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3",
+ "symfony/polyfill-ctype": "~1.8",
+ "symfony/polyfill-php84": "^1.30"
+ },
+ "conflict": {
+ "phpdocumentor/reflection-docblock": "<5.2|>=7",
+ "phpdocumentor/type-resolver": "<1.5.1",
+ "symfony/dependency-injection": "<6.4",
+ "symfony/property-access": "<6.4",
+ "symfony/property-info": "<6.4",
+ "symfony/type-info": "<7.2.5",
+ "symfony/uid": "<6.4",
+ "symfony/validator": "<6.4",
+ "symfony/yaml": "<6.4"
+ },
+ "require-dev": {
+ "phpdocumentor/reflection-docblock": "^5.2|^6.0",
+ "phpstan/phpdoc-parser": "^1.0|^2.0",
+ "seld/jsonlint": "^1.10",
+ "symfony/cache": "^6.4|^7.0|^8.0",
+ "symfony/config": "^6.4|^7.0|^8.0",
+ "symfony/console": "^6.4|^7.0|^8.0",
+ "symfony/dependency-injection": "^7.2|^8.0",
+ "symfony/error-handler": "^6.4|^7.0|^8.0",
+ "symfony/filesystem": "^6.4|^7.0|^8.0",
+ "symfony/form": "^6.4|^7.0|^8.0",
+ "symfony/http-foundation": "^6.4|^7.0|^8.0",
+ "symfony/http-kernel": "^6.4|^7.0|^8.0",
+ "symfony/messenger": "^6.4|^7.0|^8.0",
+ "symfony/mime": "^6.4|^7.0|^8.0",
+ "symfony/property-access": "^6.4|^7.0|^8.0",
+ "symfony/property-info": "^6.4|^7.0|^8.0",
+ "symfony/translation-contracts": "^2.5|^3",
+ "symfony/type-info": "^7.2.5|^8.0",
+ "symfony/uid": "^6.4|^7.0|^8.0",
+ "symfony/validator": "^6.4|^7.0|^8.0",
+ "symfony/var-dumper": "^6.4|^7.0|^8.0",
+ "symfony/var-exporter": "^6.4|^7.0|^8.0",
+ "symfony/yaml": "^6.4|^7.0|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Serializer\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/serializer/tree/v7.4.8"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-03-30T21:34:42+00:00"
+ },
{
"name": "symfony/service-contracts",
"version": "v3.6.1",
@@ -6543,6 +7261,84 @@
],
"time": "2026-03-24T13:12:05+00:00"
},
+ {
+ "name": "symfony/uid",
+ "version": "v7.4.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/uid.git",
+ "reference": "6883ebdf7bf6a12b37519dbc0df62b0222401b56"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/uid/zipball/6883ebdf7bf6a12b37519dbc0df62b0222401b56",
+ "reference": "6883ebdf7bf6a12b37519dbc0df62b0222401b56",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/polyfill-uuid": "^1.15"
+ },
+ "require-dev": {
+ "symfony/console": "^6.4|^7.0|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Uid\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Grégoire Pineau",
+ "email": "lyrixx@lyrixx.info"
+ },
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides an object-oriented API to generate and represent UIDs",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "UID",
+ "ulid",
+ "uuid"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/uid/tree/v7.4.8"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-03-24T13:12:05+00:00"
+ },
{
"name": "symfony/validator",
"version": "v7.4.8",
@@ -7057,6 +7853,286 @@
}
],
"time": "2026-03-17T21:31:11+00:00"
+ },
+ {
+ "name": "web-auth/cose-lib",
+ "version": "4.5.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/web-auth/cose-lib.git",
+ "reference": "3185af4df10dc537b65c140c315b88d15ae15b80"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/3185af4df10dc537b65c140c315b88d15ae15b80",
+ "reference": "3185af4df10dc537b65c140c315b88d15ae15b80",
+ "shasum": ""
+ },
+ "require": {
+ "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17",
+ "ext-json": "*",
+ "ext-openssl": "*",
+ "php": ">=8.1",
+ "spomky-labs/pki-framework": "^1.0"
+ },
+ "require-dev": {
+ "spomky-labs/cbor-php": "^3.2.2"
+ },
+ "suggest": {
+ "ext-bcmath": "For better performance, please install either GMP (recommended) or BCMath extension",
+ "ext-gmp": "For better performance, please install either GMP (recommended) or BCMath extension",
+ "spomky-labs/cbor-php": "For COSE Signature support"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Cose\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Florent Morselli",
+ "homepage": "https://github.com/Spomky"
+ },
+ {
+ "name": "All contributors",
+ "homepage": "https://github.com/web-auth/cose/contributors"
+ }
+ ],
+ "description": "CBOR Object Signing and Encryption (COSE) For PHP",
+ "homepage": "https://github.com/web-auth",
+ "keywords": [
+ "COSE",
+ "RFC8152"
+ ],
+ "support": {
+ "issues": "https://github.com/web-auth/cose-lib/issues",
+ "source": "https://github.com/web-auth/cose-lib/tree/4.5.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Spomky",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/FlorentMorselli",
+ "type": "patreon"
+ }
+ ],
+ "time": "2026-04-01T12:47:39+00:00"
+ },
+ {
+ "name": "web-auth/webauthn-framework",
+ "version": "5.2.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/web-auth/webauthn-framework.git",
+ "reference": "8ee765444e2307e08dcb8f242562e1fa4972f1ef"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/web-auth/webauthn-framework/zipball/8ee765444e2307e08dcb8f242562e1fa4972f1ef",
+ "reference": "8ee765444e2307e08dcb8f242562e1fa4972f1ef",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-openssl": "*",
+ "paragonie/constant_time_encoding": "^2.6|^3.0",
+ "php": ">=8.2",
+ "phpdocumentor/reflection-docblock": "^5.3",
+ "psr/clock": "^1.0",
+ "psr/event-dispatcher": "^1.0",
+ "psr/log": "^1.0|^2.0|^3.0",
+ "spomky-labs/cbor-php": "^3.0",
+ "spomky-labs/pki-framework": "^1.0",
+ "symfony/clock": "^6.4|^7.0",
+ "symfony/config": "^6.4|^7.0",
+ "symfony/dependency-injection": "^6.4|^7.0",
+ "symfony/deprecation-contracts": "^3.2",
+ "symfony/framework-bundle": "^6.4|^7.0",
+ "symfony/http-client": "^6.4|^7.0",
+ "symfony/property-access": "^6.4|^7.0",
+ "symfony/property-info": "^6.4|^7.0",
+ "symfony/security-bundle": "^6.4|^7.0",
+ "symfony/security-core": "^6.4|^7.0",
+ "symfony/security-http": "^6.4|^7.0",
+ "symfony/serializer": "^6.4|^7.0",
+ "symfony/uid": "^6.4|^7.0",
+ "symfony/validator": "^6.4|^7.0",
+ "web-auth/cose-lib": "^4.2.3"
+ },
+ "replace": {
+ "web-auth/metadata-service": "self.version",
+ "web-auth/webauthn-lib": "self.version",
+ "web-auth/webauthn-stimulus": "self.version",
+ "web-auth/webauthn-symfony-bundle": "self.version"
+ },
+ "require-dev": {
+ "doctrine/dbal": "^3.8|^4.0",
+ "doctrine/doctrine-bundle": "^2.12",
+ "doctrine/orm": "^2.14|^3.0",
+ "doctrine/persistence": "^3.1|^4.0",
+ "ekino/phpstan-banned-code": "^3.0",
+ "ergebnis/phpunit-slow-test-detector": "^2.18",
+ "infection/infection": "^0.29",
+ "matthiasnoback/symfony-dependency-injection-test": "^5.1|^6.0",
+ "php-parallel-lint/php-parallel-lint": "^1.3",
+ "phpstan/extension-installer": "^1.4",
+ "phpstan/phpstan": "^2.0",
+ "phpstan/phpstan-deprecation-rules": "^2.0",
+ "phpstan/phpstan-doctrine": "^2.0",
+ "phpstan/phpstan-phpunit": "^2.0",
+ "phpstan/phpstan-strict-rules": "^2.0",
+ "phpstan/phpstan-symfony": "^2.0",
+ "phpunit/phpunit": "^11.5",
+ "psr/http-client": "^1.0",
+ "psr/http-factory": "^1.1",
+ "psr/http-message": "^2.0",
+ "qossmic/deptrac": "^2.0",
+ "rector/rector": "^2.0",
+ "roave/security-advisories": "dev-latest",
+ "staabm/phpstan-todo-by": "^0.2",
+ "struggle-for-php/sfp-phpstan-psr-log": "^0.23",
+ "symfony/asset": "^6.4|^7.0",
+ "symfony/asset-mapper": "^6.4|^7.0",
+ "symfony/browser-kit": "^6.4|^7.0",
+ "symfony/filesystem": "^6.4|^7.0",
+ "symfony/finder": "^6.4|^7.0",
+ "symfony/monolog-bundle": "^3.8",
+ "symfony/twig-bundle": "^6.4|^7.0",
+ "symfony/var-dumper": "^6.4|^7.0",
+ "symfony/var-exporter": "^6.4|^7.0",
+ "symfony/yaml": "^6.4|^7.0",
+ "symplify/easy-coding-standard": "^12.0",
+ "web-token/jwt-library": "^4.0"
+ },
+ "suggest": {
+ "psr/log-implementation": "Recommended to receive logs from the library",
+ "symfony/event-dispatcher": "Recommended to use dispatched events",
+ "symfony/security-bundle": "Symfony firewall using a JSON API (perfect for script applications)",
+ "web-token/jwt-library": "Mandatory for fetching Metadata Statement from distant sources"
+ },
+ "type": "symfony-bundle",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/web-auth/webauthn-framework",
+ "name": "web-auth/webauthn-framework"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Webauthn\\": "src/webauthn/src/",
+ "Webauthn\\Bundle\\": "src/symfony/src/",
+ "Webauthn\\Stimulus\\": "src/stimulus/src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Florent Morselli",
+ "homepage": "https://github.com/Spomky"
+ },
+ {
+ "name": "All contributors",
+ "homepage": "https://github.com/web-auth/webauthn-framework/contributors"
+ }
+ ],
+ "description": "FIDO2/Webauthn library for PHP and Symfony Bundle.",
+ "homepage": "https://github.com/web-auth/webauthn-framework",
+ "keywords": [
+ "FIDO2",
+ "bundle",
+ "fido",
+ "symfony",
+ "symfony-bundle",
+ "symfony-ux",
+ "webauthn"
+ ],
+ "support": {
+ "issues": "https://github.com/web-auth/webauthn-framework/issues",
+ "source": "https://github.com/web-auth/webauthn-framework/tree/5.2.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Spomky",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/FlorentMorselli",
+ "type": "patreon"
+ }
+ ],
+ "time": "2026-03-23T21:43:02+00:00"
+ },
+ {
+ "name": "webmozart/assert",
+ "version": "2.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/webmozarts/assert.git",
+ "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/webmozarts/assert/zipball/eb0d790f735ba6cff25c683a85a1da0eadeff9e4",
+ "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4",
+ "shasum": ""
+ },
+ "require": {
+ "ext-ctype": "*",
+ "ext-date": "*",
+ "ext-filter": "*",
+ "php": "^8.2"
+ },
+ "suggest": {
+ "ext-intl": "",
+ "ext-simplexml": "",
+ "ext-spl": ""
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-feature/2-0": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Webmozart\\Assert\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@gmail.com"
+ },
+ {
+ "name": "Woody Gilk",
+ "email": "woody.gilk@gmail.com"
+ }
+ ],
+ "description": "Assertions to validate method input/output with nice error messages.",
+ "keywords": [
+ "assert",
+ "check",
+ "validate"
+ ],
+ "support": {
+ "issues": "https://github.com/webmozarts/assert/issues",
+ "source": "https://github.com/webmozarts/assert/tree/2.3.0"
+ },
+ "time": "2026-04-11T10:33:05+00:00"
}
],
"packages-dev": [
diff --git a/config/bundles.php b/config/bundles.php
index 5d41c7f..4a9df2d 100644
--- a/config/bundles.php
+++ b/config/bundles.php
@@ -11,4 +11,6 @@ return [
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
Pentatrion\ViteBundle\PentatrionViteBundle::class => ['all' => true],
+ Webauthn\Bundle\WebauthnBundle::class => ['all' => true],
+ Webauthn\Stimulus\WebauthnStimulusBundle::class => ['all' => true],
];
diff --git a/config/packages/security.yaml b/config/packages/security.yaml
index 99e67ec..17a2604 100644
--- a/config/packages/security.yaml
+++ b/config/packages/security.yaml
@@ -33,3 +33,5 @@ security:
remember_me_parameter: _remember_me
access_control:
+ - { path: ^/api/webauthn/authentication/begin, roles: PUBLIC_ACCESS }
+ - { path: ^/api/webauthn/authentication/complete, roles: PUBLIC_ACCESS }
diff --git a/src/Controller/ProfileController.php b/src/Controller/ProfileController.php
index 0dc522e..942ec8a 100644
--- a/src/Controller/ProfileController.php
+++ b/src/Controller/ProfileController.php
@@ -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,
+ ]);
+ }
}
diff --git a/src/Controller/WebAuthnController.php b/src/Controller/WebAuthnController.php
new file mode 100644
index 0000000..8365750
--- /dev/null
+++ b/src/Controller/WebAuthnController.php
@@ -0,0 +1,314 @@
+
+ * @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
+ );
+ }
+ }
+}
diff --git a/src/Entity/WebAuthnCredential.php b/src/Entity/WebAuthnCredential.php
new file mode 100644
index 0000000..c7ef163
--- /dev/null
+++ b/src/Entity/WebAuthnCredential.php
@@ -0,0 +1,171 @@
+
+ * @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;
+ }
+}
+
diff --git a/src/Migrations/2026/04/Version20260412070922.php b/src/Migrations/2026/04/Version20260412070922.php
new file mode 100644
index 0000000..6e4d180
--- /dev/null
+++ b/src/Migrations/2026/04/Version20260412070922.php
@@ -0,0 +1,47 @@
+
+ * @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');
+ }
+}
diff --git a/src/Repository/WebAuthnCredentialRepository.php b/src/Repository/WebAuthnCredentialRepository.php
new file mode 100644
index 0000000..34ed038
--- /dev/null
+++ b/src/Repository/WebAuthnCredentialRepository.php
@@ -0,0 +1,56 @@
+
+ *
+ * @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();
+ }
+}
diff --git a/src/Service/WebAuthnService.php b/src/Service/WebAuthnService.php
new file mode 100644
index 0000000..1d0092f
--- /dev/null
+++ b/src/Service/WebAuthnService.php
@@ -0,0 +1,157 @@
+
+ * @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;
+ }
+}
+
+
+
+
diff --git a/symfony.lock b/symfony.lock
index 0e4a7da..ee081b3 100644
--- a/symfony.lock
+++ b/symfony.lock
@@ -381,6 +381,15 @@
"ref": "f75ac166398e107796ca94cc57fa1edaa06ec47f"
}
},
+ "symfony/uid": {
+ "version": "7.4",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "7.0",
+ "ref": "0df5844274d871b37fc3816c57a768ffc60a43a5"
+ }
+ },
"symfony/validator": {
"version": "7.4",
"recipe": {
@@ -423,6 +432,9 @@
"twig/twig": {
"version": "v2.4.8"
},
+ "web-auth/webauthn-framework": {
+ "version": "5.2.5"
+ },
"zendframework/zend-code": {
"version": "3.3.0"
},
diff --git a/templates/Game/index.html.twig b/templates/Game/index.html.twig
index 34baebb..84d2c0d 100644
--- a/templates/Game/index.html.twig
+++ b/templates/Game/index.html.twig
@@ -21,6 +21,9 @@
{{ app.user.username }}
+
+ Security
+
-
- Forgot your password?
-
+
+ or
+
+
+
No account yet?
Create one
-
{% endblock %}
{% block javascripts %}
{{ parent() }}
+
+
+ {{ vite_entry_script_tags('passkey') }}
{% endblock %}
diff --git a/templates/Security/profile.html.twig b/templates/Security/profile.html.twig
index e6cfd93..c4f3fc6 100644
--- a/templates/Security/profile.html.twig
+++ b/templates/Security/profile.html.twig
@@ -45,6 +45,12 @@
+
+
{% if recent|length > 0 %}
diff --git a/templates/Security/profile_security.html.twig b/templates/Security/profile_security.html.twig
new file mode 100644
index 0000000..dda5f59
--- /dev/null
+++ b/templates/Security/profile_security.html.twig
@@ -0,0 +1,53 @@
+{% extends 'Game/index.html.twig' %}
+
+{% block title %} - Security Settings{% endblock %}
+
+{% block body %}
+
+
+
+
+
+
+
+ Passkeys (WebAuthn)
+
+
+ Passkeys provide a secure, passwordless way to sign in to your account. Manage your registered passkeys below.
+
+
+
+
+
+{% endblock %}
+
+{% block stylesheets %}
+ {{ parent() }}
+ {{ vite_entry_link_tags('passkeyStyle') }}
+{% endblock %}
+
+{% block javascripts %}
+ {{ parent() }}
+ {{ vite_entry_script_tags('passkey') }}
+{% endblock %}
diff --git a/vite.config.js b/vite.config.js
index 67a6886..c39e512 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -23,8 +23,10 @@ export default defineConfig({
rollupOptions: {
input: {
mineseeker: './assets/js/app.jsx',
+ passkey: './assets/js/passkey.jsx',
mineseekerStyle: './assets/css/style.mineseeker.scss',
homeStyle: './assets/css/style.layout.scss',
+ passkeyStyle: './assets/css/passkey.scss',
},
},
},