chg: usr: add modern Webauthn authentication #4
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -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
206
assets/css/passkey.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
110
assets/js/components/PasskeyLogin.jsx
Normal file
110
assets/js/components/PasskeyLogin.jsx
Normal 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;
|
||||
418
assets/js/components/PasskeyManager.jsx
Normal file
418
assets/js/components/PasskeyManager.jsx
Normal 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
37
assets/js/passkey.jsx
Normal 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} />,
|
||||
);
|
||||
}
|
||||
@@ -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
1078
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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],
|
||||
];
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
314
src/Controller/WebAuthnController.php
Normal file
314
src/Controller/WebAuthnController.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
171
src/Entity/WebAuthnCredential.php
Normal file
171
src/Entity/WebAuthnCredential.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
47
src/Migrations/2026/04/Version20260412070922.php
Normal file
47
src/Migrations/2026/04/Version20260412070922.php
Normal 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');
|
||||
}
|
||||
}
|
||||
56
src/Repository/WebAuthnCredentialRepository.php
Normal file
56
src/Repository/WebAuthnCredentialRepository.php
Normal 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();
|
||||
}
|
||||
}
|
||||
157
src/Service/WebAuthnService.php
Normal file
157
src/Service/WebAuthnService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
12
symfony.lock
12
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"
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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">
|
||||
|
||||
53
templates/Security/profile_security.html.twig
Normal file
53
templates/Security/profile_security.html.twig
Normal 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 %}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user