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

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