added passkey support (closes #6)
All checks were successful
test-and-lint / test-and-lint (pull_request) Successful in 2m50s
All checks were successful
test-and-lint / test-and-lint (pull_request) Successful in 2m50s
This commit is contained in:
@@ -7,16 +7,70 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const twoFactorInput = document.getElementById("two-factor-code");
|
||||
const twoFactorGroup = document.getElementById("two-factor-group");
|
||||
const submitButton = document.getElementById("login-submit");
|
||||
const passkeyLoginButton = document.getElementById("passkey-login-button");
|
||||
const passkeyLoginHint = document.getElementById("passkey-login-hint");
|
||||
|
||||
let pendingTwoFactorToken = null;
|
||||
|
||||
if (!form) return;
|
||||
|
||||
setupPasswordVisibilityToggles();
|
||||
|
||||
function showError(message) {
|
||||
errorBox.textContent = message || "Login failed.";
|
||||
errorBox.style.display = "block";
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
errorBox.textContent = "";
|
||||
errorBox.style.display = "none";
|
||||
}
|
||||
|
||||
function setupPasswordVisibilityToggles(root = document) {
|
||||
const eyeIcon = `
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M2.25 12s3.5-6.75 9.75-6.75S21.75 12 21.75 12 18.25 18.75 12 18.75 2.25 12 2.25 12Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="12" cy="12" r="2.75" fill="none" stroke="currentColor" stroke-width="1.8"/>
|
||||
</svg>`;
|
||||
const eyeOffIcon = `
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M3 3l18 18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||
<path d="M10.58 10.58A2.75 2.75 0 0 0 13.42 13.42" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||
<path d="M7.1 7.7C3.95 9.55 2.25 12 2.25 12s3.5 6.75 9.75 6.75c1.65 0 3.08-.47 4.29-1.15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.8 5.55A9.2 9.2 0 0 1 12 5.25c6.25 0 9.75 6.75 9.75 6.75a15.3 15.3 0 0 1-2.3 2.95" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>`;
|
||||
|
||||
root.querySelectorAll('input[type="password"]').forEach((input) => {
|
||||
if (input.dataset.visibilityToggleAttached === 'true') return;
|
||||
input.dataset.visibilityToggleAttached = 'true';
|
||||
|
||||
let wrapper = input.closest('.password-input-wrapper');
|
||||
if (!wrapper) {
|
||||
wrapper = document.createElement('div');
|
||||
wrapper.className = 'password-input-wrapper';
|
||||
input.parentNode.insertBefore(wrapper, input);
|
||||
wrapper.appendChild(input);
|
||||
}
|
||||
|
||||
const toggle = document.createElement('button');
|
||||
toggle.type = 'button';
|
||||
toggle.className = 'password-toggle';
|
||||
toggle.innerHTML = eyeIcon;
|
||||
toggle.setAttribute('aria-label', `Show ${input.placeholder || 'password'}`);
|
||||
toggle.setAttribute('title', 'Show password');
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const show = input.type === 'password';
|
||||
input.type = show ? 'text' : 'password';
|
||||
toggle.innerHTML = show ? eyeOffIcon : eyeIcon;
|
||||
toggle.setAttribute('aria-label', `${show ? 'Hide' : 'Show'} ${input.placeholder || 'password'}`);
|
||||
toggle.setAttribute('title', show ? 'Hide password' : 'Show password');
|
||||
});
|
||||
|
||||
wrapper.appendChild(toggle);
|
||||
});
|
||||
}
|
||||
|
||||
function storeTokens(data) {
|
||||
localStorage.setItem("access_token", data.access_token);
|
||||
localStorage.setItem("refresh_token", data.refresh_token);
|
||||
@@ -26,15 +80,131 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
pendingTwoFactorToken = token;
|
||||
usernameInput.disabled = true;
|
||||
passwordInput.disabled = true;
|
||||
if (passkeyLoginButton) passkeyLoginButton.disabled = true;
|
||||
twoFactorGroup.style.display = "block";
|
||||
twoFactorInput.required = true;
|
||||
twoFactorInput.focus();
|
||||
submitButton.textContent = "Verify code";
|
||||
}
|
||||
|
||||
function webauthnSupported() {
|
||||
return window.PublicKeyCredential && navigator.credentials;
|
||||
}
|
||||
|
||||
function base64URLToBuffer(value) {
|
||||
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
|
||||
const binary = atob(padded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
function bufferToBase64URL(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
function prepareCredentialRequestOptions(options) {
|
||||
const publicKey = options.publicKey || options;
|
||||
publicKey.challenge = base64URLToBuffer(publicKey.challenge);
|
||||
if (Array.isArray(publicKey.allowCredentials)) {
|
||||
publicKey.allowCredentials = publicKey.allowCredentials.map((credential) => ({
|
||||
...credential,
|
||||
id: base64URLToBuffer(credential.id)
|
||||
}));
|
||||
}
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
function credentialToJSON(credential) {
|
||||
const response = credential.response;
|
||||
return {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64URL(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: bufferToBase64URL(response.clientDataJSON),
|
||||
authenticatorData: bufferToBase64URL(response.authenticatorData),
|
||||
signature: bufferToBase64URL(response.signature),
|
||||
userHandle: response.userHandle ? bufferToBase64URL(response.userHandle) : null
|
||||
},
|
||||
clientExtensionResults: credential.getClientExtensionResults()
|
||||
};
|
||||
}
|
||||
|
||||
async function handlePasskeyLogin() {
|
||||
if (!webauthnSupported()) {
|
||||
showError("Passkeys are not supported by this browser.");
|
||||
return;
|
||||
}
|
||||
|
||||
clearError();
|
||||
if (passkeyLoginButton) passkeyLoginButton.disabled = true;
|
||||
submitButton.disabled = true;
|
||||
|
||||
try {
|
||||
const optionsResponse = await fetch("/api/passkeys/login/options", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
if (!optionsResponse.ok) {
|
||||
throw new Error(await optionsResponse.text());
|
||||
}
|
||||
const optionsData = await optionsResponse.json();
|
||||
const publicKey = prepareCredentialRequestOptions(optionsData.options);
|
||||
const assertion = await navigator.credentials.get({ publicKey });
|
||||
if (!assertion) {
|
||||
throw new Error("Passkey authentication was cancelled.");
|
||||
}
|
||||
|
||||
const finishResponse = await fetch("/api/passkeys/login/finish", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
session_token: optionsData.session_token,
|
||||
credential: credentialToJSON(assertion)
|
||||
})
|
||||
});
|
||||
if (!finishResponse.ok) {
|
||||
throw new Error(await finishResponse.text());
|
||||
}
|
||||
const data = await finishResponse.json();
|
||||
|
||||
if (data.requires_2fa) {
|
||||
switchToTwoFactorMode(data.two_factor_token);
|
||||
return;
|
||||
}
|
||||
|
||||
storeTokens(data);
|
||||
window.location.href = "/dashboard";
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
} finally {
|
||||
if (!pendingTwoFactorToken && passkeyLoginButton) passkeyLoginButton.disabled = false;
|
||||
submitButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (passkeyLoginButton) {
|
||||
if (!webauthnSupported()) {
|
||||
passkeyLoginButton.disabled = true;
|
||||
if (passkeyLoginHint) passkeyLoginHint.textContent = "Passkeys are not supported by this browser.";
|
||||
} else {
|
||||
passkeyLoginButton.addEventListener("click", handlePasskeyLogin);
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
errorBox.style.display = "none";
|
||||
clearError();
|
||||
submitButton.disabled = true;
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user