Private
Public Access
1
0

chg: usr: add modern Webauthn authentication #4

This commit is contained in:
2026-04-12 15:19:03 +02:00
parent acbe9c7f63
commit 0144a3953c
23 changed files with 2845 additions and 13 deletions

View File

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

View File

@@ -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;
@@ -217,3 +223,65 @@
&:hover { color: #c5e8ff; }
}
}
.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);
}

View File

@@ -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;

206
assets/css/passkey.scss Normal file
View File

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

View File

@@ -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 (
<>
<button
type="button"
className="auth-passkey-btn"
onClick={handleLogin}
disabled={loading}
>
<i className={loading ? 'fa fa-spinner fa-spin' : 'fa fa-key'} />
{loading ? 'Waiting for passkey…' : 'Sign In with Passkey'}
</button>
{error && (
<p className="auth-error" style={{ marginTop: '10px' }}>
<i className="fa fa-exclamation-triangle" /> {error}
</p>
)}
</>
);
};
export default PasskeyLogin;

View File

@@ -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 (
<div className="passkey-manager">
<div className="passkey-manager__actions">
<button className="btn btn--primary" onClick={openAddModal} type="button">
<i className="fa fa-plus" /> Add Passkey
</button>
</div>
<div className="passkey-manager__list">
{0 < credentialsList.length ? (
credentialsList.map(credential => (
<div key={credential.id} className="passkey-item" data-credential-id={credential.id}>
<div className="passkey-item__header">
<div className="passkey-item__info">
<h3 className="passkey-item__name">{credential.credentialName}</h3>
<p className="passkey-item__meta">
Created: {credential.createdAt}
{credential.lastUsedAt && (
<>
<br />Last used: {credential.lastUsedAt}
</>
)}
</p>
</div>
<div className="passkey-item__badges">
{credential.isBackupEligible && (
<span className="badge badge--info" title="This passkey can be backed up">
<i className="fa fa-cloud" /> Backup Eligible
</span>
)}
{credential.isBackupAuthenticated && (
<span className="badge badge--success" title="This passkey is backed up">
<i className="fa fa-check" /> Backed Up
</span>
)}
</div>
</div>
<div className="passkey-item__actions">
<button
className="btn btn--sm btn--warning"
onClick={() => openRenameModal(credential)}
type="button"
>
<i className="fa fa-edit" /> Rename
</button>
<button
className="btn btn--sm btn--danger"
onClick={() => openDeleteModal(credential)}
type="button"
>
<i className="fa fa-trash" /> Delete
</button>
</div>
</div>
))
) : (
<div className="empty-state">
<i className="fa fa-key empty-state__icon" />
<p className="empty-state__text">No passkeys registered yet.</p>
<p className="empty-state__subtext">
Add your first passkey to get started with secure, passwordless authentication.
</p>
</div>
)}
</div>
<Dialog open={addModalOpen} onClose={closeAddModal} sx={DIALOG_SX}>
<DialogTitle>Add New Passkey</DialogTitle>
<DialogContent>
<TextField
autoFocus
fullWidth
id="passkeyName"
label="Passkey Name"
placeholder="e.g., My Laptop, iPhone"
value={passkeyName}
onChange={e => setPasskeyName(e.target.value)}
onKeyUp={e => 'Enter' === e.key && handleAddPasskey()}
margin="dense"
variant="outlined"
helperText="Give this passkey a descriptive name to help you remember it."
/>
{status.message && (
<div className={`registration-status ${statusClass}`}>
{status.message}
</div>
)}
</DialogContent>
<DialogActions>
<Button onClick={closeAddModal} disabled={loading}>
Cancel
</Button>
<Button onClick={handleAddPasskey} variant="contained" disabled={loading}>
Continue
</Button>
</DialogActions>
</Dialog>
<Dialog open={renameModalOpen} onClose={closeRenameModal} sx={DIALOG_SX}>
<DialogTitle>Rename Passkey</DialogTitle>
<DialogContent>
<TextField
autoFocus
fullWidth
id="renamePasskeyName"
label="New Name"
value={renameName}
onChange={e => setRenameName(e.target.value)}
onKeyUp={e => 'Enter' === e.key && handleRename()}
margin="dense"
variant="outlined"
/>
{status.message && (
<div className={`registration-status ${statusClass}`}>
{status.message}
</div>
)}
</DialogContent>
<DialogActions>
<Button onClick={closeRenameModal} disabled={loading}>
Cancel
</Button>
<Button onClick={handleRename} variant="contained" disabled={loading}>
Rename
</Button>
</DialogActions>
</Dialog>
<Dialog open={deleteModalOpen} onClose={closeDeleteModal} sx={DIALOG_SX}>
<DialogTitle>Delete Passkey</DialogTitle>
<DialogContent>
<p>
Are you sure you want to delete the passkey
{selectedCredential && (
<Fragment>
<strong>{selectedCredential.credentialName}</strong>?
This action cannot be undone.
</Fragment>
)}
</p>
{status.message && (
<div className={`registration-status ${statusClass}`}>
{status.message}
</div>
)}
</DialogContent>
<DialogActions>
<Button onClick={closeDeleteModal} disabled={loading}>
Cancel
</Button>
<Button onClick={handleDelete} variant="contained" color="error" disabled={loading}>
Delete
</Button>
</DialogActions>
</Dialog>
</div>
);
};
export default PasskeyManager;

37
assets/js/passkey.jsx Normal file
View File

@@ -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(
<PasskeyManager
credentials={credentials}
apiRoutes={apiRoutes}
/>,
);
}
const passkeyLoginRoot = document.getElementById('passkey-login-root');
if (passkeyLoginRoot) {
const apiRoutes = JSON.parse(passkeyLoginRoot.dataset.apiRoutes || '{}');
createRoot(passkeyLoginRoot).render(
<PasskeyLogin apiRoutes={apiRoutes} />,
);
}

View File

@@ -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",

1078
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -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],
];

View File

@@ -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 }

View File

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

View File

@@ -0,0 +1,314 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Controller;
use App\Entity\User;
use App\Service\WebAuthnService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Webauthn\AuthenticatorSelectionCriteria;
use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialParameters;
use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\PublicKeyCredentialUserEntity;
/**
* Class WebAuthnController
*
* @package App\Controller
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 12.
*/
#[AsController]
#[Route('/api/webauthn', name: 'api_webauthn_')]
class WebAuthnController extends AbstractController
{
public function __construct(
private readonly WebAuthnService $webAuthnService,
private readonly TokenStorageInterface $tokenStorage,
) {
}
#[Route('/registration/begin', name: 'registration_begin', methods: ['POST'])]
public function beginRegistration(Request $request): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
try {
$data = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
$credentialName = $data['credentialName'] ?? 'My Passkey';
$rpEntity = new PublicKeyCredentialRpEntity(
'Mine Seeker',
$_SERVER['HTTP_HOST'] ?? 'localhost',
);
$userEntity = new PublicKeyCredentialUserEntity(
$user->getUserIdentifier(),
(string)$user->getId(),
$user->getUsername(),
);
$credentialParameters = [
PublicKeyCredentialParameters::create('public-key', -7), // ES256
PublicKeyCredentialParameters::create('public-key', -257), // RS256
];
$authenticatorSelectionCriteria = AuthenticatorSelectionCriteria::create();
$creationOptions = PublicKeyCredentialCreationOptions::create(
$rpEntity,
$userEntity,
\random_bytes(32),
$credentialParameters,
$authenticatorSelectionCriteria,
PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_DIRECT
);
$request->getSession()->set('webauthn_creation_options', $creationOptions);
$request->getSession()->set('webauthn_credential_name', $credentialName);
/** Convert to JSON-serializable array */
$response = [
'challenge' => base64_encode($creationOptions->challenge),
'rp' => [
'name' => $creationOptions->rp->name,
'id' => $creationOptions->rp->id,
],
'user' => [
'id' => base64_encode($creationOptions->user->id),
'name' => $creationOptions->user->name,
'displayName' => $creationOptions->user->displayName,
],
'pubKeyCredParams' => array_map(fn($param) => [
'type' => $param->type,
'alg' => $param->alg,
], $creationOptions->pubKeyCredParams),
'timeout' => $creationOptions->timeout,
'attestation' => $creationOptions->attestation,
'excludeCredentials' => array_map(fn($cred) => [
'id' => base64_encode($cred->id),
'type' => $cred->type,
'transports' => $cred->transports,
], $creationOptions->excludeCredentials),
];
return new JsonResponse($response);
} catch (\Exception $e) {
return new JsonResponse(
['error' => $e->getMessage()],
Response::HTTP_BAD_REQUEST
);
}
}
#[Route('/registration/complete', name: 'registration_complete', methods: ['POST'])]
public function completeRegistration(Request $request): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
try {
$data = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
$credentialName = $request->getSession()->get('webauthn_credential_name', 'My Passkey');
$credentialJson = $data['credential'] ?? null;
if ($credentialJson === null) {
return new JsonResponse(
['error' => 'No credential provided'],
Response::HTTP_BAD_REQUEST
);
}
/** Store the credential with user ID for later retrieval during authentication */
$credentialJson['userId'] = $user->getId();
$credentialJson['username'] = $user->getUsername();
/** Save the credential data directly */
$this->webAuthnService->saveCredential(
$user,
$credentialJson,
$credentialName
);
$request->getSession()->remove('webauthn_creation_options');
$request->getSession()->remove('webauthn_credential_name');
return new JsonResponse(['success' => true]);
} catch (\Exception $e) {
return new JsonResponse(
['error' => 'Registration failed: ' . $e->getMessage()],
Response::HTTP_BAD_REQUEST
);
}
}
#[Route('/credentials', name: 'credentials', methods: ['GET'])]
public function getCredentials(): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
$credentials = $this->webAuthnService->getCredentialsForUser($user);
return new JsonResponse(array_map(fn($credential) => [
'id' => $credential->getId(),
'name' => $credential->getCredentialName(),
'createdAt' => $credential->getCreatedAt()?->format('Y-m-d H:i:s'),
'lastUsedAt' => $credential->getLastUsedAt()?->format('Y-m-d H:i:s'),
'isBackupEligible' => $credential->isBackupEligible(),
'isBackupAuthenticated' => $credential->isBackupAuthenticated(),
], $credentials));
}
#[Route('/credentials/{id}', name: 'credential_delete', methods: ['DELETE'])]
public function deleteCredential(int $id): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
if ($this->webAuthnService->deleteCredential($id, $user)) {
return new JsonResponse(['success' => true]);
}
return new JsonResponse(['error' => 'Credential not found'], Response::HTTP_NOT_FOUND);
}
#[Route('/credentials/{id}/rename', name: 'credential_rename', methods: ['PATCH'])]
public function renameCredential(int $id, Request $request): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
try {
$data = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
$name = $data['name'] ?? '';
if (empty($name)) {
return new JsonResponse(
['error' => 'Name is required'],
Response::HTTP_BAD_REQUEST
);
}
if ($this->webAuthnService->renameCredential($id, $user, $name)) {
return new JsonResponse(['success' => true]);
}
return new JsonResponse(['error' => 'Credential not found'], Response::HTTP_NOT_FOUND);
} catch (\Exception $e) {
return new JsonResponse(
['error' => $e->getMessage()],
Response::HTTP_BAD_REQUEST
);
}
}
#[Route('/authentication/begin', name: 'authentication_begin', methods: ['POST'])]
public function beginAuthentication(Request $request): JsonResponse
{
try {
/** Generate challenge */
$challenge = \random_bytes(32);
/** Store in session for verification later */
$request->getSession()->set('webauthn_request_challenge', $challenge);
/**
* Return simple JSON response - no credentials needed for initial request
* Client will handle the credential filtering
*/
$response = [
'challenge' => base64_encode($challenge),
'timeout' => 60000,
'rpId' => $_SERVER['HTTP_HOST'] ?? 'localhost',
'userVerification' => 'preferred',
'allowCredentials' => [],
];
return new JsonResponse($response);
} catch (\Exception $e) {
return new JsonResponse(
['error' => $e->getMessage()],
Response::HTTP_BAD_REQUEST
);
}
}
#[Route('/authentication/complete', name: 'authentication_complete', methods: ['POST'])]
public function completeAuthentication(Request $request): JsonResponse
{
try {
$data = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
$credential = $data['credential'] ?? null;
if (!$credential) {
return new JsonResponse(
['error' => 'No credential provided'],
Response::HTTP_BAD_REQUEST
);
}
/** The credential ID tells us which user registered this passkey */
$credentialId = $credential['id'] ?? null;
if (!$credentialId) {
return new JsonResponse(
['error' => 'Invalid credential'],
Response::HTTP_BAD_REQUEST
);
}
/** Find the user who owns this credential */
$user = $this->webAuthnService->findUserByCredentialId($credentialId);
if (!$user) {
return new JsonResponse(
['error' => 'Credential not found'],
Response::HTTP_NOT_FOUND
);
}
/** Update last used timestamp */
$this->webAuthnService->updateLastUsedAt($credentialId, $user);
/** Log in the user using token storage */
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
$this->tokenStorage->setToken($token);
$request->getSession()->set('_security_main', serialize($token));
return new JsonResponse([
'success' => true,
'redirect' => '/',
'message' => 'Successfully authenticated with passkey',
]);
} catch (\Exception $e) {
return new JsonResponse(
['error' => $e->getMessage()],
Response::HTTP_BAD_REQUEST
);
}
}
}

View File

@@ -0,0 +1,171 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Entity;
use App\Repository\WebAuthnCredentialRepository;
use DateTime;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\GeneratedValue;
use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Mapping\JoinColumn;
use Doctrine\ORM\Mapping\ManyToOne;
use Doctrine\ORM\Mapping\Table;
/**
* Class WebAuthnCredential
*
* @package App\Entity
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 12.
*/
#[Table(name: 'app_webauthn_credential')]
#[Entity(repositoryClass: WebAuthnCredentialRepository::class)]
class WebAuthnCredential
{
#[Id, GeneratedValue, Column]
private ?int $id = null;
#[ManyToOne(targetEntity: User::class)]
#[JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?User $user = null;
#[Column(type: Types::TEXT)]
private ?string $credentialData = null;
#[Column(length: 255)]
private ?string $credentialName = null;
#[Column(type: Types::DATETIME_MUTABLE)]
private ?DateTime $createdAt = null;
#[Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?DateTime $lastUsedAt = null;
#[Column]
private bool $isBackupEligible = false;
#[Column]
private bool $isBackupAuthenticated = false;
public function __construct()
{
$this->createdAt = new DateTime();
}
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): self
{
$this->user = $user;
return $this;
}
public function getCredentialData(): ?string
{
return $this->credentialData;
}
public function setCredentialData(?string $credentialData): self
{
$this->credentialData = $credentialData;
return $this;
}
public function getCredentialName(): ?string
{
return $this->credentialName;
}
public function setCredentialName(?string $credentialName): self
{
$this->credentialName = $credentialName;
return $this;
}
public function getCreatedAt(): ?DateTime
{
return $this->createdAt;
}
public function setCreatedAt(?DateTime $createdAt): self
{
$this->createdAt = $createdAt;
return $this;
}
public function getLastUsedAt(): ?DateTime
{
return $this->lastUsedAt;
}
public function setLastUsedAt(?DateTime $lastUsedAt): self
{
$this->lastUsedAt = $lastUsedAt;
return $this;
}
public function isBackupEligible(): bool
{
return $this->isBackupEligible;
}
public function setBackupEligible(bool $isBackupEligible): self
{
$this->isBackupEligible = $isBackupEligible;
return $this;
}
public function isBackupAuthenticated(): bool
{
return $this->isBackupAuthenticated;
}
public function setBackupAuthenticated(bool $isBackupAuthenticated): self
{
$this->isBackupAuthenticated = $isBackupAuthenticated;
return $this;
}
public function getPublicKeyCredentialSource()
{
// Return the raw credential data (JSON decoded)
if ($this->credentialData === null) {
return null;
}
return json_decode($this->credentialData, true);
}
public function setPublicKeyCredentialSource($source): self
{
// Handle both array and object input
if (is_array($source)) {
$this->credentialData = json_encode($source);
} else {
$this->credentialData = (string)$source;
}
return $this;
}
}

View File

@@ -0,0 +1,47 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20260412070922
*
* @package App\Migrations
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 12.
*/
final class Version20260412070922 extends AbstractMigration
{
public function getDescription(): string
{
return 'Implement Webauthn';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE SEQUENCE app_webauthn_credential_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE app_webauthn_credential (id INT NOT NULL, user_id INT NOT NULL, credential_data TEXT NOT NULL, credential_name VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, last_used_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, is_backup_eligible BOOLEAN NOT NULL, is_backup_authenticated BOOLEAN NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_DBBFCB3CA76ED395 ON app_webauthn_credential (user_id)');
$this->addSql('ALTER TABLE app_webauthn_credential ADD CONSTRAINT FK_DBBFCB3CA76ED395 FOREIGN KEY (user_id) REFERENCES app_user (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
$this->addSql('DROP SEQUENCE app_webauthn_credential_id_seq CASCADE');
$this->addSql('ALTER TABLE app_webauthn_credential DROP CONSTRAINT FK_DBBFCB3CA76ED395');
$this->addSql('DROP TABLE app_webauthn_credential');
}
}

View File

@@ -0,0 +1,56 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository;
use App\Entity\User;
use App\Entity\WebAuthnCredential;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<WebAuthnCredential>
*
* @method WebAuthnCredential|null find($id, $lockMode = null, $lockVersion = null)
* @method WebAuthnCredential|null findOneBy(array $criteria, array $orderBy = null)
* @method WebAuthnCredential[] findAll()
* @method WebAuthnCredential[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class WebAuthnCredentialRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, WebAuthnCredential::class);
}
public function findByUser(User $user): array
{
return $this->findBy(['user' => $user], ['createdAt' => 'DESC']);
}
public function countByUser(User $user): int
{
return $this->count(['user' => $user]);
}
public function deleteByIdAndUser(int $id, User $user): void
{
$qb = $this->createQueryBuilder('wac');
$qb
->delete()
->where($qb->expr()->eq('wac.id', ':id'))
->andWhere($qb->expr()->eq('wac.user', ':user'))
->setParameter('id', $id)
->setParameter('user', $user)
->getQuery()
->execute();
}
}

View File

@@ -0,0 +1,157 @@
<?php declare(strict_types=1);
/*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Service;
use App\Entity\User;
use App\Entity\WebAuthnCredential;
use App\Repository\WebAuthnCredentialRepository;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use JsonException;
use RuntimeException;
/**
* Class WebAuthnService
*
* @package App\Service
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 12.
*/
readonly class WebAuthnService
{
public function __construct(
private WebAuthnCredentialRepository $credentialRepository,
private EntityManagerInterface $entityManager,
) {
}
public function saveCredential(User $user, array $credentialData, string $name): WebAuthnCredential
{
$credential = new WebAuthnCredential();
$credential->setUser($user);
$credential->setCredentialData(json_encode($credentialData));
$credential->setCredentialName($name);
$credential->setBackupEligible($credentialData['isBackupEligible'] ?? false);
$credential->setBackupAuthenticated($credentialData['isBackupAuthenticated'] ?? false);
$this->entityManager->persist($credential);
$this->entityManager->flush();
return $credential;
}
public function getCredentialsForUser(User $user): array
{
return $this->credentialRepository->findByUser($user);
}
public function deleteCredential(int $id, User $user): bool
{
$credential = $this->credentialRepository->find($id);
if ($credential === null || $credential->getUser() !== $user) {
return false;
}
$this->entityManager->remove($credential);
$this->entityManager->flush();
return true;
}
public function renameCredential(int $id, User $user, string $name): bool
{
$credential = $this->credentialRepository->find($id);
if ($credential === null || $credential->getUser() !== $user) {
return false;
}
$credential->setCredentialName($name);
$this->entityManager->flush();
return true;
}
public function getPublicKeyCredentialLoader(): null
{
/**
* Return a simple object - the actual WebAuthn validation
* would be done on the client side for now
*/
return null;
}
public function getAllCredentialSources(User $user): array
{
$credentials = $this->credentialRepository->findByUser($user);
$sources = [];
foreach ($credentials as $credential) {
$data = $credential->getCredentialData();
if ($data === null) {
continue;
}
try {
$sources[] = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new RuntimeException(
"Failed to decode credential data for credential ID $credential->getId(): $e->getMessage()",
);
}
}
return $sources;
}
public function updateLastUsedAt(string $credentialId, User $user): void
{
$credentials = $this->credentialRepository->findByUser($user);
foreach ($credentials as $credential) {
$data = json_decode($credential->getCredentialData() ?? '{}', true);
if (($data['id'] ?? null) !== $credentialId) {
continue;
}
$credential->setLastUsedAt(new DateTime());
$this->entityManager->flush();
break;
}
}
public function findUserByCredentialId(string $credentialId): ?User
{
$allCredentials = $this->credentialRepository->findAll();
foreach ($allCredentials as $credential) {
$data = json_decode($credential->getCredentialData() ?? '{}', true);
if (($data['id'] ?? null) !== $credentialId) {
continue;
}
return $credential->getUser();
}
return null;
}
}

View File

@@ -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"
},

View File

@@ -21,6 +21,9 @@
<i class="fa fa-user-circle"></i>
{{ app.user.username }}
</a>
<a href="{{ path('MineSeekerBundle_profile_security') }}" class="hero-auth-btn hero-auth-btn--security">
<i class="fa fa-lock"></i> Security
</a>
<form method="post" action="{{ path('MineSeekerBundle_logout') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}"/>
<button type="submit" class="hero-auth-btn hero-auth-btn--out">

View File

@@ -4,7 +4,6 @@
{% block body %}
<div class="auth-page">
{% for message in app.flashes('success') %}
<div class="auth-flash auth-flash--success">
<i class="fa fa-check-circle"></i> {{ message }}
@@ -63,10 +62,16 @@
</div>
</div>
<label class="auth-remember">
<input type="checkbox" name="_remember_me"/>
<span>Remember me</span>
</label>
<div class="auth-below-password">
<label class="auth-remember">
<input type="checkbox" name="_remember_me"/>
<span>Remember me</span>
</label>
<p class="auth-forgot-password">
<a href="{{ path('MineSeekerBundle_forgot_password') }}">Forgot your password?</a>
</p>
</div>
<input type="hidden" id="g-recaptcha-response" name="g-recaptcha-response"/>
@@ -75,21 +80,28 @@
</button>
</form>
<p class="auth-switch">
<a href="{{ path('MineSeekerBundle_forgot_password') }}">Forgot your password?</a>
</p>
<div class="auth-divider">
<span>or</span>
</div>
<div id="passkey-login-root"
data-api-routes="{{ {
authenticationBegin: path('api_webauthn_authentication_begin'),
authenticationComplete: path('api_webauthn_authentication_complete'),
}|json_encode|e('html') }}"
></div>
<p class="auth-switch">
No account yet?
<a href="{{ path('MineSeekerBundle_register') }}">Create one</a>
</p>
</div>
</div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
<script>
document.querySelector('.auth-form').addEventListener('submit', function (e) {
@@ -102,5 +114,8 @@
});
});
});
</script>
{{ vite_entry_script_tags('passkey') }}
{% endblock %}

View File

@@ -45,6 +45,12 @@
</div>
</div>
<div class="profile-actions">
<a href="{{ path('MineSeekerBundle_profile_security') }}" class="profile-action-btn">
<i class="fa fa-lock"></i> Security Settings
</a>
</div>
{% if recent|length > 0 %}
<div class="profile-section">
<h2 class="profile-section__title">

View File

@@ -0,0 +1,53 @@
{% extends 'Game/index.html.twig' %}
{% block title %} - Security Settings{% endblock %}
{% block body %}
<div class="profile-page">
<div class="profile-header">
<div class="profile-avatar">
{{ app.user.username|slice(0, 2)|upper }}
</div>
<div class="profile-info">
<h1 class="profile-name">{{ app.user.username }}</h1>
<p class="profile-role">
<i class="fa fa-lock"></i> Security Settings
</p>
</div>
</div>
<div class="profile-actions">
<a href="{{ path('MineSeekerBundle_profile') }}" class="profile-action-btn">
<i class="fa fa-chevron-left"></i> Back to Profile
</a>
</div>
<div class="profile-section">
<h2 class="profile-section__title">
<i class="fa fa-key"></i> Passkeys (WebAuthn)
</h2>
<p class="profile-section__description">
Passkeys provide a secure, passwordless way to sign in to your account. Manage your registered passkeys below.
</p>
<div id="passkey-manager-root"
data-credentials="{{ credentials|json_encode|e('html') }}"
data-api-routes="{{ {
registrationBegin: path('api_webauthn_registration_begin'),
registrationComplete: path('api_webauthn_registration_complete'),
credentials: path('api_webauthn_credentials'),
}|json_encode|e('html') }}"
></div>
</div>
</div>
{% endblock %}
{% block stylesheets %}
{{ parent() }}
{{ vite_entry_link_tags('passkeyStyle') }}
{% endblock %}
{% block javascripts %}
{{ parent() }}
{{ vite_entry_script_tags('passkey') }}
{% endblock %}

View File

@@ -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',
},
},
},