673 lines
27 KiB
JavaScript
673 lines
27 KiB
JavaScript
/**
|
|
* f0ckm Anonymous Passkey Identity Manager
|
|
*
|
|
* Replaces the old OpenSSH Ed25519 approach.
|
|
* Private keys NEVER touch localStorage — they live in the OS / Bitwarden credential store.
|
|
*
|
|
* Flow:
|
|
* First visit: "Login as Anonymous" → register/begin → browser passkey prompt → register/finish → session
|
|
* Return visit: "Login as Anonymous" → auth/begin → browser passkey picker → auth/finish → session
|
|
*
|
|
* For registered users:
|
|
* Settings page calls window.f0ckPasskeyManager.addPasskey() to add a passkey.
|
|
*/
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
// ─── base64url helpers (browser) ───────────────────────────────────────────
|
|
|
|
function b64urlToArr(b64) {
|
|
const bin = atob(b64.replace(/-/g, '+').replace(/_/g, '/'));
|
|
const arr = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
|
return arr;
|
|
}
|
|
|
|
function arrToB64url(buf) {
|
|
const arr = buf instanceof ArrayBuffer ? new Uint8Array(buf) : new Uint8Array(buf);
|
|
let bin = '';
|
|
arr.forEach(b => bin += String.fromCharCode(b));
|
|
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
}
|
|
|
|
// ─── Hardware fingerprint (kept for ban enforcement) ──────────────────────
|
|
|
|
async function getHardwareFingerprint() {
|
|
try {
|
|
const cached = localStorage.getItem('f0ck_anon_hw_fp');
|
|
if (cached) return cached;
|
|
} catch (e) {}
|
|
|
|
try {
|
|
let glVendor = '', glRenderer = '', glLimits = '';
|
|
try {
|
|
const canvas = document.createElement('canvas');
|
|
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
|
if (gl) {
|
|
const ext = gl.getExtension('WEBGL_debug_renderer_info');
|
|
if (ext) {
|
|
glVendor = gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) || '';
|
|
glRenderer = gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) || '';
|
|
}
|
|
glLimits = [
|
|
gl.getParameter(gl.MAX_TEXTURE_SIZE) || 0,
|
|
gl.getParameter(gl.MAX_RENDERBUFFER_SIZE) || 0,
|
|
gl.getParameter(gl.MAX_VERTEX_ATTRIBS) || 0,
|
|
gl.getParameter(gl.MAX_VERTEX_UNIFORM_VECTORS) || 0,
|
|
gl.getParameter(gl.MAX_VARYING_VECTORS) || 0,
|
|
gl.getParameter(gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS) || 0
|
|
].join(',');
|
|
}
|
|
} catch (e) {}
|
|
|
|
let gpuArch = '';
|
|
try {
|
|
if (navigator.gpu) {
|
|
const adapter = await navigator.gpu.requestAdapter();
|
|
if (adapter && adapter.info) {
|
|
gpuArch = [adapter.info.architecture, adapter.info.vendor, adapter.info.description].filter(Boolean).join(':');
|
|
}
|
|
}
|
|
} catch (e) {}
|
|
|
|
const concurrency = navigator.hardwareConcurrency || 0;
|
|
const memory = navigator.deviceMemory || 0;
|
|
const platform = navigator.platform || '';
|
|
const screenInfo = [
|
|
window.screen ? window.screen.width : 0,
|
|
window.screen ? window.screen.height : 0,
|
|
window.screen ? window.screen.colorDepth : 0,
|
|
window.devicePixelRatio || 1
|
|
].join('x');
|
|
const touchPoints = navigator.maxTouchPoints || 0;
|
|
|
|
let canvasFp = '';
|
|
try {
|
|
const c2d = document.createElement('canvas');
|
|
c2d.width = 240; c2d.height = 60;
|
|
const ctx = c2d.getContext('2d');
|
|
if (ctx) {
|
|
ctx.fillStyle = '#f60'; ctx.fillRect(10, 5, 60, 20);
|
|
ctx.fillStyle = '#069'; ctx.font = '14pt Arial, sans-serif';
|
|
ctx.fillText('f0ck.dev 😃', 4, 35);
|
|
const imgData = ctx.getImageData(0, 0, 240, 60).data;
|
|
let sum = 0;
|
|
for (let i = 0; i < imgData.length; i += 4) {
|
|
sum = (sum * 31 + imgData[i] + imgData[i+1] + imgData[i+2] + imgData[i+3]) >>> 0;
|
|
}
|
|
canvasFp = sum.toString(16);
|
|
}
|
|
} catch (e) {}
|
|
|
|
let audioFp = '';
|
|
try {
|
|
const AudioCtx = window.OfflineAudioContext || window.webkitOfflineAudioContext;
|
|
if (AudioCtx) {
|
|
const actx = new AudioCtx(1, 44100, 44100);
|
|
const osc = actx.createOscillator();
|
|
osc.type = 'triangle';
|
|
osc.frequency.setValueAtTime(10000, actx.currentTime);
|
|
const comp = actx.createDynamicsCompressor();
|
|
comp.threshold.setValueAtTime(-50, actx.currentTime);
|
|
comp.knee.setValueAtTime(40, actx.currentTime);
|
|
comp.ratio.setValueAtTime(12, actx.currentTime);
|
|
comp.attack.setValueAtTime(0, actx.currentTime);
|
|
comp.release.setValueAtTime(0.25, actx.currentTime);
|
|
osc.connect(comp); comp.connect(actx.destination); osc.start(0);
|
|
const buf = await actx.startRendering();
|
|
const ch = buf.getChannelData(0);
|
|
let sum = 0;
|
|
for (let i = 4500; i < Math.min(ch.length, 5000); i++) sum += Math.abs(ch[i] || 0);
|
|
audioFp = sum.toFixed(7);
|
|
}
|
|
} catch (e) {}
|
|
|
|
const raw = [glVendor, glRenderer, glLimits, gpuArch, concurrency, memory, platform, screenInfo, touchPoints, canvasFp, audioFp].join('~~~');
|
|
const hashBuf = await window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(raw));
|
|
const hashHex = Array.from(new Uint8Array(hashBuf)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
const fp = `HW:${hashHex}`;
|
|
try { localStorage.setItem('f0ck_anon_hw_fp', fp); } catch (e) {}
|
|
return fp;
|
|
} catch (err) {
|
|
console.warn('[ANON_PASSKEY] Failed to compute hardware fingerprint:', err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ─── Tombstone (ban state persisted client-side) ───────────────────────────
|
|
|
|
function getTombstone() {
|
|
try { return JSON.parse(localStorage.getItem('f0ck_anon_tombstone') || 'null'); } catch (e) { return null; }
|
|
}
|
|
|
|
function setTombstone(t) {
|
|
try {
|
|
localStorage.setItem('f0ck_anon_tombstone', JSON.stringify(t));
|
|
if (t && t.banned) {
|
|
document.cookie = `f0ck_banned=${encodeURIComponent(JSON.stringify(t))}; Path=/; Max-Age=31536000; SameSite=Lax`;
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
// ─── AnonPasskey class ────────────────────────────────────────────────────
|
|
|
|
class AnonPasskey {
|
|
constructor() {
|
|
this.isSessionReady = false;
|
|
this._loginInProgress = false;
|
|
}
|
|
|
|
// ── Check WebAuthn support ──────────────────────────────────────────────
|
|
|
|
get supported() {
|
|
return !!(window.PublicKeyCredential && navigator.credentials && navigator.credentials.create);
|
|
}
|
|
|
|
// ── UI helpers ──────────────────────────────────────────────────────────
|
|
|
|
updateNavUI(isAnon) {
|
|
const icon = document.getElementById('nav-visitor-icon');
|
|
const label = document.getElementById('nav-anon-label');
|
|
if (icon) {
|
|
icon.classList.toggle('fa-user-secret', isAnon);
|
|
icon.classList.toggle('fa-user', !isAnon);
|
|
}
|
|
if (label) label.textContent = isAnon ? 'anonymous' : 'guest';
|
|
|
|
if (window.f0ckSession && window.f0ckSession.enable_anonymous_access === false) {
|
|
if (label) label.textContent = 'guest';
|
|
['nav-login-anon-btn', 'nav-anon-identity-btn', 'nav-anon-settings-btn', 'nav-anon-logout-btn', 'nav-anon-divider'].forEach(id => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.style.display = 'none';
|
|
});
|
|
const modalAnonBtn = document.getElementById('modal-login-as-anon-btn');
|
|
if (modalAnonBtn) {
|
|
const w = modalAnonBtn.closest('div');
|
|
if (w) w.style.display = 'none'; else modalAnonBtn.style.display = 'none';
|
|
}
|
|
return;
|
|
}
|
|
|
|
const loginAnonBtn = document.getElementById('nav-login-anon-btn');
|
|
const anonIdentityBtn = document.getElementById('nav-anon-identity-btn');
|
|
const anonSettingsBtn = document.getElementById('nav-anon-settings-btn');
|
|
const anonLogoutBtn = document.getElementById('nav-anon-logout-btn');
|
|
const anonDivider = document.getElementById('nav-anon-divider');
|
|
const guestFavsNav = document.getElementById('nav-guest-favs');
|
|
const guestFavsLink = document.getElementById('nav-guest-favs-link');
|
|
|
|
if (loginAnonBtn) loginAnonBtn.style.display = isAnon ? 'none' : '';
|
|
if (anonIdentityBtn) anonIdentityBtn.style.display = isAnon ? '' : 'none';
|
|
if (anonSettingsBtn) anonSettingsBtn.style.display = isAnon ? '' : 'none';
|
|
if (anonLogoutBtn) anonLogoutBtn.style.display = isAnon ? '' : 'none';
|
|
if (anonDivider) anonDivider.style.display = isAnon ? '' : 'none';
|
|
if (guestFavsNav) guestFavsNav.style.display = isAnon ? '' : 'none';
|
|
if (guestFavsLink) guestFavsLink.style.display = isAnon ? '' : 'none';
|
|
|
|
if (isAnon && window.f0ckSession) {
|
|
window.f0ckSession.user = window.f0ckSession.user || 'anonymous';
|
|
window.f0ckSession.is_anon = true;
|
|
window.f0ckSession.logged_in = true;
|
|
}
|
|
if (typeof window.syncRatingButtonUI === 'function') window.syncRatingButtonUI();
|
|
}
|
|
|
|
_applySessionData(data, hwFingerprint) {
|
|
this.isSessionReady = true;
|
|
if (window.f0ckSession) {
|
|
window.f0ckSession.user = 'anonymous';
|
|
window.f0ckSession.is_anon = true;
|
|
window.f0ckSession.logged_in = true;
|
|
window.f0ckSession.id = data.user_id;
|
|
window.f0ckSession.user_id = data.user_id;
|
|
if (data.csrf_token) window.f0ckSession.csrf_token = data.csrf_token;
|
|
}
|
|
const metaCsrf = document.querySelector('meta[name="csrf-token"]');
|
|
if (metaCsrf && data.csrf_token) metaCsrf.content = data.csrf_token;
|
|
window.f0ckAnonIdentity = {
|
|
userId: data.user_id,
|
|
fingerprint: data.fingerprint,
|
|
shortFingerprint: data.short_fingerprint,
|
|
hwFingerprint: data.hw_fingerprint || hwFingerprint,
|
|
credentialId: data.credential_id
|
|
};
|
|
window.dispatchEvent(new CustomEvent('f0ck:anon_session_ready', { detail: window.f0ckAnonIdentity }));
|
|
if (typeof window.syncRatingButtonUI === 'function') window.syncRatingButtonUI();
|
|
}
|
|
|
|
// ── Register a new passkey (anonymous user) ────────────────────────────
|
|
|
|
async register() {
|
|
if (!this.supported) throw new Error('WebAuthn / Passkeys are not supported in this browser.');
|
|
|
|
// 1. Get registration options from server
|
|
const beginRes = await fetch('/api/v2/anon/passkey/register/begin', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({})
|
|
});
|
|
const beginData = await beginRes.json();
|
|
if (!beginData.success) throw new Error(beginData.msg || 'Server error during registration setup');
|
|
|
|
const rawChallenge = beginData.options.challenge;
|
|
const opts = beginData.options;
|
|
|
|
// 2. Decode options for the WebAuthn API
|
|
const pkOpts = {
|
|
rp: opts.rp,
|
|
user: {
|
|
id: b64urlToArr(opts.user.id),
|
|
name: opts.user.name,
|
|
displayName: opts.user.displayName
|
|
},
|
|
challenge: b64urlToArr(rawChallenge),
|
|
pubKeyCredParams: opts.pubKeyCredParams,
|
|
timeout: opts.timeout || 60000,
|
|
excludeCredentials: (opts.excludeCredentials || []).map(c => ({ type: c.type, id: b64urlToArr(c.id) })),
|
|
authenticatorSelection: opts.authenticatorSelection,
|
|
attestation: opts.attestation || 'none'
|
|
};
|
|
|
|
// 3. Browser passkey creation prompt
|
|
const cred = await navigator.credentials.create({ publicKey: pkOpts });
|
|
|
|
// 4. Send attestation to server
|
|
const hwFp = await getHardwareFingerprint();
|
|
const tombstone = getTombstone();
|
|
const finishRes = await fetch('/api/v2/anon/passkey/register/finish', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
challenge: rawChallenge,
|
|
credentialId: arrToB64url(cred.rawId),
|
|
clientDataJSON: arrToB64url(cred.response.clientDataJSON),
|
|
attestationObject: arrToB64url(cred.response.attestationObject),
|
|
hw_fingerprint: hwFp,
|
|
tombstone: tombstone
|
|
})
|
|
});
|
|
const finishData = await finishRes.json();
|
|
|
|
if (finishData.banned) {
|
|
setTombstone({
|
|
banned: true,
|
|
fingerprint: finishData.fingerprint,
|
|
hw_fingerprint: finishData.hw_fingerprint || hwFp,
|
|
reason: finishData.reason,
|
|
expires: finishData.expires
|
|
});
|
|
if (window.location.pathname !== '/banned') window.location.href = finishData.redirect || '/banned';
|
|
throw new Error('Banned: ' + (finishData.reason || ''));
|
|
}
|
|
if (!finishData.success) throw new Error(finishData.msg || 'Registration failed');
|
|
|
|
// Mark this browser as having a registered passkey for this site
|
|
this._markLocalPasskey();
|
|
return finishData;
|
|
}
|
|
|
|
// ── Authenticate with an existing passkey (anonymous user) ────────────
|
|
|
|
async authenticate() {
|
|
if (!this.supported) throw new Error('WebAuthn / Passkeys are not supported in this browser.');
|
|
|
|
// 1. Get auth challenge from server
|
|
const beginRes = await fetch('/api/v2/anon/passkey/auth/begin', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({})
|
|
});
|
|
const beginData = await beginRes.json();
|
|
if (!beginData.success) throw new Error(beginData.msg || 'Server error during authentication setup');
|
|
|
|
const rawChallenge = beginData.options.challenge;
|
|
const opts = beginData.options;
|
|
|
|
// 2. Invoke browser passkey picker
|
|
const pkOpts = {
|
|
challenge: b64urlToArr(rawChallenge),
|
|
rpId: opts.rpId,
|
|
userVerification: opts.userVerification || 'preferred',
|
|
timeout: opts.timeout || 60000,
|
|
allowCredentials: (opts.allowCredentials || []).map(c => ({ type: c.type, id: b64urlToArr(c.id) }))
|
|
};
|
|
|
|
const assertion = await navigator.credentials.get({ publicKey: pkOpts });
|
|
|
|
// 3. Send assertion to server
|
|
const hwFp = await getHardwareFingerprint();
|
|
const tombstone = getTombstone();
|
|
const finishRes = await fetch('/api/v2/anon/passkey/auth/finish', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
challenge: rawChallenge,
|
|
credentialId: arrToB64url(assertion.rawId),
|
|
clientDataJSON: arrToB64url(assertion.response.clientDataJSON),
|
|
authenticatorData: arrToB64url(assertion.response.authenticatorData),
|
|
signature: arrToB64url(assertion.response.signature),
|
|
hw_fingerprint: hwFp,
|
|
tombstone: tombstone
|
|
})
|
|
});
|
|
const finishData = await finishRes.json();
|
|
|
|
if (finishData.banned) {
|
|
setTombstone({
|
|
banned: true,
|
|
fingerprint: finishData.fingerprint,
|
|
hw_fingerprint: finishData.hw_fingerprint || hwFp,
|
|
reason: finishData.reason,
|
|
expires: finishData.expires
|
|
});
|
|
if (window.location.pathname !== '/banned') window.location.href = finishData.redirect || '/banned';
|
|
throw new Error('Banned: ' + (finishData.reason || ''));
|
|
}
|
|
if (!finishData.success) throw new Error(finishData.msg || 'Authentication failed');
|
|
|
|
return finishData;
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
_hasLocalPasskey() {
|
|
try { return !!localStorage.getItem('f0ck_anon_has_passkey'); } catch (e) { return false; }
|
|
}
|
|
|
|
_markLocalPasskey() {
|
|
try { localStorage.setItem('f0ck_anon_has_passkey', '1'); } catch (e) {}
|
|
}
|
|
|
|
async _finishLogin(data) {
|
|
const hwFp = await getHardwareFingerprint();
|
|
this._applySessionData(data, hwFp);
|
|
if (window.f0ckGuestFavs && typeof window.f0ckGuestFavs.importToAccount === 'function') {
|
|
await window.f0ckGuestFavs.importToAccount();
|
|
}
|
|
this.updateNavUI(true);
|
|
// Close both modals
|
|
const sm = document.getElementById('anon-setup-modal'); if (sm) sm.style.display = 'none';
|
|
const lm = document.getElementById('login-modal'); if (lm) lm.style.display = 'none';
|
|
if (typeof window.showToastNotification === 'function') {
|
|
window.showToastNotification('Logged in as anonymous');
|
|
}
|
|
window.location.reload();
|
|
}
|
|
|
|
// ── Public: called by the setup modal's "Create my passkey" button ────────
|
|
|
|
async doRegister() {
|
|
if (this._loginInProgress) return;
|
|
this._loginInProgress = true;
|
|
try {
|
|
const data = await this.register(); // throws on error/cancel
|
|
this._markLocalPasskey();
|
|
await this._finishLogin(data);
|
|
} catch (err) {
|
|
this._loginInProgress = false;
|
|
throw err; // modal handles the error display
|
|
}
|
|
}
|
|
|
|
// ── Public: called by the setup modal's "Use my passkey" button ───────────
|
|
|
|
async doAuthenticate() {
|
|
if (this._loginInProgress) return;
|
|
this._loginInProgress = true;
|
|
try {
|
|
const data = await this.authenticate();
|
|
await this._finishLogin(data);
|
|
} catch (err) {
|
|
this._loginInProgress = false;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// ── Public: opens the setup modal (new vs returning view) ─────────────────
|
|
|
|
openSetupModal() {
|
|
if (window.f0ckSession && window.f0ckSession.enable_anonymous_access === false) return;
|
|
const tombstone = getTombstone();
|
|
if (tombstone && tombstone.banned) {
|
|
if (window.location.pathname !== '/banned') window.location.href = '/banned';
|
|
return;
|
|
}
|
|
if (!this.supported) {
|
|
alert('Passkeys are not supported in this browser. Please use a modern browser with WebAuthn support.');
|
|
return;
|
|
}
|
|
|
|
// Close the login modal first
|
|
const lm = document.getElementById('login-modal'); if (lm) lm.style.display = 'none';
|
|
|
|
const hasPasskey = this._hasLocalPasskey();
|
|
const newView = document.getElementById('anon-setup-new');
|
|
const retView = document.getElementById('anon-setup-returning');
|
|
if (newView) newView.style.display = hasPasskey ? 'none' : '';
|
|
if (retView) retView.style.display = hasPasskey ? '' : 'none';
|
|
|
|
const modal = document.getElementById('anon-setup-modal');
|
|
if (modal) modal.style.display = 'flex';
|
|
}
|
|
|
|
// ── Legacy: clicking "Login as anonymous" anywhere just opens the modal ───
|
|
|
|
loginAsAnonymous() {
|
|
this.openSetupModal();
|
|
}
|
|
|
|
// ── Logout ────────────────────────────────────────────────────────────────
|
|
|
|
async logoutAnonymous() {
|
|
try {
|
|
await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {});
|
|
} finally {
|
|
this.updateNavUI(false);
|
|
if (typeof window.showToastNotification === 'function') {
|
|
window.showToastNotification('Logged out from anonymous session');
|
|
}
|
|
window.location.reload();
|
|
}
|
|
}
|
|
|
|
// ── Identity modal (shown when already logged in as anon) ─────────────────
|
|
|
|
openModal() {
|
|
const modal = document.getElementById('anon-passkey-modal');
|
|
if (!modal) return;
|
|
this._refreshModalContent();
|
|
modal.style.display = 'flex';
|
|
}
|
|
|
|
closeModal() {
|
|
const modal = document.getElementById('anon-passkey-modal');
|
|
if (modal) modal.style.display = 'none';
|
|
}
|
|
|
|
async _refreshModalContent() {
|
|
try {
|
|
const res = await fetch('/api/v2/anon/identity');
|
|
const data = await res.json();
|
|
const fpEl = document.getElementById('anon-pk-fp-display');
|
|
const credEl = document.getElementById('anon-pk-cred-display');
|
|
if (fpEl) fpEl.textContent = data.fingerprint ? data.fingerprint.slice(7, 15) : 'none';
|
|
if (credEl) credEl.textContent = data.credential_id || '—';
|
|
} catch (e) {}
|
|
}
|
|
|
|
// ── Init ──────────────────────────────────────────────────────────────────
|
|
|
|
async init() {
|
|
if (window.location.pathname === '/banned') return;
|
|
|
|
if (window.f0ckSession && window.f0ckSession.enable_anonymous_access === false) {
|
|
if (window.f0ckSession.is_anon && window.f0ckSession.logged_in) {
|
|
await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {});
|
|
window.location.reload();
|
|
return;
|
|
}
|
|
this.updateNavUI(false);
|
|
return;
|
|
}
|
|
|
|
// Registered user — nothing to do
|
|
if (window.f0ckSession && window.f0ckSession.user && !window.f0ckSession.is_anon) return;
|
|
|
|
// Check tombstone — if banned, don't auto-login
|
|
const tombstone = getTombstone();
|
|
if (tombstone && tombstone.banned) {
|
|
this.updateNavUI(false);
|
|
this.attachUIListeners();
|
|
return;
|
|
}
|
|
|
|
// If backend session already shows is_anon, mark ready
|
|
if (window.f0ckSession && window.f0ckSession.is_anon && window.f0ckSession.logged_in) {
|
|
this.isSessionReady = true;
|
|
this.updateNavUI(true);
|
|
} else {
|
|
// Stale backend session without a local key no longer applies — just show guest
|
|
if (window.f0ckSession && window.f0ckSession.is_anon && !window.f0ckSession.logged_in) {
|
|
await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {});
|
|
window.location.reload();
|
|
return;
|
|
}
|
|
this.updateNavUI(false);
|
|
}
|
|
|
|
this.attachUIListeners();
|
|
}
|
|
|
|
attachUIListeners() {
|
|
// Modal login-as-anonymous button
|
|
const modalAnonBtn = document.getElementById('modal-login-as-anon-btn');
|
|
if (modalAnonBtn) {
|
|
modalAnonBtn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); this.loginAsAnonymous(); });
|
|
}
|
|
|
|
document.addEventListener('click', e => {
|
|
if (e.target.closest('#nav-anon-identity-btn')) {
|
|
e.preventDefault(); this.openModal(); return;
|
|
}
|
|
if (e.target.closest('#nav-login-anon-btn, #modal-login-as-anon-btn, .trigger-login-anon')) {
|
|
e.preventDefault(); this.loginAsAnonymous(); return;
|
|
}
|
|
const logoutTarget = e.target.closest('#nav-anon-logout-btn, a[href="/logout"]');
|
|
if (logoutTarget && window.f0ckSession && window.f0ckSession.is_anon && window.f0ckSession.logged_in) {
|
|
e.preventDefault(); this.logoutAnonymous(); return;
|
|
}
|
|
});
|
|
|
|
const anonBtn = document.getElementById('nav-anon-identity-btn');
|
|
if (anonBtn) anonBtn.addEventListener('click', e => { e.preventDefault(); this.openModal(); });
|
|
|
|
const modalClose = document.getElementById('anon-passkey-modal-close');
|
|
if (modalClose) modalClose.addEventListener('click', () => this.closeModal());
|
|
|
|
const modalOverlay = document.getElementById('anon-passkey-modal');
|
|
if (modalOverlay) modalOverlay.addEventListener('click', e => { if (e.target === modalOverlay) this.closeModal(); });
|
|
|
|
// Add-passkey button inside modal (for anonymous users to add a second passkey)
|
|
const addBtn = document.getElementById('anon-pk-add-btn');
|
|
if (addBtn) {
|
|
addBtn.addEventListener('click', async () => {
|
|
addBtn.disabled = true;
|
|
try {
|
|
await this.register();
|
|
if (typeof window.showToastNotification === 'function') window.showToastNotification('New passkey registered!');
|
|
this._refreshModalContent();
|
|
} catch (err) {
|
|
if (err.name !== 'NotAllowedError') alert('Failed to add passkey: ' + err.message);
|
|
} finally {
|
|
addBtn.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Passkey manager for registered users (used by settings page) ──────────
|
|
|
|
class PasskeyManager {
|
|
async listPasskeys() {
|
|
const res = await fetch('/api/v2/settings/passkeys');
|
|
return (await res.json()).passkeys || [];
|
|
}
|
|
|
|
async addPasskey(name) {
|
|
// 1. Begin
|
|
const beginRes = await fetch('/api/v2/settings/passkeys/register/begin', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: name || 'Passkey' })
|
|
});
|
|
const beginData = await beginRes.json();
|
|
if (!beginData.success) throw new Error(beginData.msg || 'Server error');
|
|
|
|
const rawChallenge = beginData.options.challenge;
|
|
const opts = beginData.options;
|
|
|
|
const pkOpts = {
|
|
rp: opts.rp,
|
|
user: {
|
|
id: b64urlToArr(opts.user.id),
|
|
name: opts.user.name,
|
|
displayName: opts.user.displayName
|
|
},
|
|
challenge: b64urlToArr(rawChallenge),
|
|
pubKeyCredParams: opts.pubKeyCredParams,
|
|
timeout: opts.timeout || 60000,
|
|
excludeCredentials: (opts.excludeCredentials || []).map(c => ({ type: c.type, id: b64urlToArr(c.id) })),
|
|
authenticatorSelection: opts.authenticatorSelection,
|
|
attestation: opts.attestation || 'none'
|
|
};
|
|
|
|
const cred = await navigator.credentials.create({ publicKey: pkOpts });
|
|
|
|
// 2. Finish
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
const finishRes = await fetch('/api/v2/settings/passkeys/register/finish', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrfToken },
|
|
body: JSON.stringify({
|
|
challenge: rawChallenge,
|
|
credentialId: arrToB64url(cred.rawId),
|
|
clientDataJSON: arrToB64url(cred.response.clientDataJSON),
|
|
attestationObject: arrToB64url(cred.response.attestationObject),
|
|
name: name || 'Passkey'
|
|
})
|
|
});
|
|
const finishData = await finishRes.json();
|
|
if (!finishData.success) throw new Error(finishData.msg || 'Registration failed');
|
|
return finishData;
|
|
}
|
|
|
|
async deletePasskey(credentialId) {
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
const res = await fetch('/api/v2/settings/passkeys/delete', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrfToken },
|
|
body: JSON.stringify({ credential_id: credentialId })
|
|
});
|
|
const data = await res.json();
|
|
if (!data.success) throw new Error(data.msg || 'Delete failed');
|
|
return data;
|
|
}
|
|
}
|
|
|
|
// ─── Bootstrap ────────────────────────────────────────────────────────────
|
|
|
|
window.f0ckAnonPasskey = new AnonPasskey();
|
|
window.f0ckPasskeyManager = new PasskeyManager();
|
|
|
|
// Backwards compat alias (so any code that checks window.f0ckAnonSSH still works for guards)
|
|
window.f0ckAnonSSH = window.f0ckAnonPasskey;
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', () => window.f0ckAnonPasskey.init());
|
|
} else {
|
|
window.f0ckAnonPasskey.init();
|
|
}
|
|
})();
|