chg: usr: add modern Webauthn authentication #4
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user