1/* 2 ================================================== 3 DynamicBoard / Xobor - Passkey (WebAuthn) Frontend 4 -------------------------------------------------- 5 Vanilla JS, keine Abhaengigkeiten. Spricht /passkey.php (?action=...). 6 Bietet: Passkey.login() - usernameless Anmeldung 7 Passkey.register() - neuen Passkey im Profil anlegen 8 Passkey.list/rename/remove - Verwaltung im Profil 9 Passkey.supported() - Feature-Detection 10 ================================================== 11*/ 12(function (w) { 13 'use strict'; 14 if (w.Passkey) return; // bereits geladen (z. B. zweites <script>-Tag auf der Seite) 15 16 17 var ENDPOINT = '/passkey.php'; 18 var conditionalAbort = null; // AbortController fuer die Autofill-/Conditional-UI 19 20 /* ---- base64url <-> ArrayBuffer ---------------------------------- */ 21 function b64urlToBuf(b64url) { 22 var b64 = b64url.replace(/-/g, '+').replace(/_/g, '/'); 23 var pad = b64.length % 4; 24 if (pad) b64 += '===='.slice(pad); 25 var bin = atob(b64); 26 var buf = new Uint8Array(bin.length); 27 for (var i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i); 28 return buf.buffer; 29 } 30 function bufToB64url(buf) { 31 var bytes = new Uint8Array(buf); 32 var bin = ''; 33 for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); 34 return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); 35 } 36 37 /* ---- Feature-Detection ------------------------------------------ */ 38 // Stufe 1: Unterstuetzt der Browser die WebAuthn-API ueberhaupt? 39 function supported() { 40 return !!(w.PublicKeyCredential && w.navigator && navigator.credentials && 41 typeof navigator.credentials.create === 'function'); 42 } 43 // Stufe 2: Hat das Geraet einen eingebauten Authenticator (Face ID / 44 // Touch ID / Windows Hello / Android-Biometrie)? Async -> Promise<bool>. 45 // false heisst NICHT "unmoeglich": Sicherheitsschluessel/Smartphone gehen 46 // trotzdem. Liefert bei fehlender API/Fehler robust false. 47 function platformAuthenticatorAvailable() { 48 if (!w.PublicKeyCredential || 49 typeof PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable !== 'function') { 50 return Promise.resolve(false); 51 } 52 return PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() 53 .then(function (ok) { return !!ok; }) 54 .catch(function () { return false; }); 55 } 56 57 /* ---- Transport zum Server --------------------------------------- */ 58 function api(action, payload) { 59 return fetch(ENDPOINT + '?action=' + encodeURIComponent(action), { 60 method: 'POST', 61 credentials: 'same-origin', 62 headers: { 'Content-Type': 'application/json', 'X-Passkey-Request': '1' }, 63 body: JSON.stringify(payload || {}) 64 }).then(function (r) { 65 return r.json().then(function (j) { 66 if (!r.ok || (j && j.status === 'error')) { 67 var err = new Error((j && j.error) || ('HTTP ' + r.status)); 68 err.serverError = (j && j.error) || ('HTTP ' + r.status); 69 throw err; 70 } 71 return j; 72 }); 73 }); 74 } 75 76 /* ---- Fehler klassifizieren (Texte kommen aus dem Template) ------- */ 77 // Liefert einen Schluessel; die sichtbaren Texte werden NICHT hier 78 // gehalten, sondern vom Template per data-*-Attribut bereitgestellt. 79 function classifyError(e) { 80 if (e && (e.name === 'NotAllowedError' || e.name === 'AbortError')) return 'cancelled'; 81 if (e && e.name === 'InvalidStateError') return 'already'; 82 if (e && e.serverError === 'already_registered') return 'already'; 83 if (e && e.serverError === 'unknown_credential') return 'nocred'; 84 return 'failed'; 85 } 86 // msgs: Map mit Texten aus dem Template (cancelled/already/nocred/failed). 87 function humanError(e, msgs) { 88 msgs = msgs || {}; 89 var k = classifyError(e); 90 return msgs[k] || msgs.failed || ''; 91 } 92 93 /* ================================================================ * 94 * LOGIN 95 * opts: { username, next, mediation, signal } (String => next) 96 * - username gesetzt -> Server liefert allowCredentials (zuverlaessig, 97 * v.a. auf Android); leer -> usernameless / discoverable 98 * - mediation:'conditional' -> Autofill-UI 99 * ================================================================ */ 100 function login(opts) { 101 if (typeof opts === 'string') opts = { next: opts }; 102 opts = opts || {}; 103 if (!supported()) return Promise.reject(new Error('nicht unterstuetzt')); 104 return api('login_begin', { username: opts.username || '' }).then(function (res) { 105 var pub = res.publicKey; 106 pub.challenge = b64urlToBuf(pub.challenge); 107 if (pub.allowCredentials) { 108 pub.allowCredentials = pub.allowCredentials.map(function (c) { 109 return { type: c.type, id: b64urlToBuf(c.id), transports: c.transports }; 110 }); 111 } 112 var getOpts = { publicKey: pub }; 113 if (opts.mediation) getOpts.mediation = opts.mediation; 114 if (opts.signal) getOpts.signal = opts.signal; 115 return navigator.credentials.get(getOpts).then(function (cred) { 116 var resp = cred.response; 117 return api('login_finish', { 118 challengeToken: res.challengeToken, 119 id: cred.id, // bereits base64url 120 clientDataJSON: bufToB64url(resp.clientDataJSON), 121 authenticatorData: bufToB64url(resp.authenticatorData), 122 signature: bufToB64url(resp.signature), 123 userHandle: resp.userHandle ? bufToB64url(resp.userHandle) : null, 124 next_location: opts.next || '' 125 }); 126 }); 127 }).then(function (res) { 128 if (res && res.redirect) w.location.href = res.redirect; 129 return res; 130 }); 131 } 132 133 /* ================================================================ * 134 * REGISTRIERUNG (eingeloggt, im Profil) 135 * ================================================================ */ 136 function register(label) { 137 if (!supported()) return Promise.reject(new Error('nicht unterstuetzt')); 138 return api('register_begin', {}).then(function (opts) { 139 var pub = opts.publicKey; 140 pub.challenge = b64urlToBuf(pub.challenge); 141 pub.user.id = b64urlToBuf(pub.user.id); 142 if (pub.excludeCredentials) { 143 pub.excludeCredentials = pub.excludeCredentials.map(function (c) { 144 return { type: c.type, id: b64urlToBuf(c.id), transports: c.transports }; 145 }); 146 } 147 return navigator.credentials.create({ publicKey: pub }).then(function (cred) { 148 var resp = cred.response; 149 var transports = []; 150 if (resp.getTransports) { try { transports = resp.getTransports() || []; } catch (e) { } } 151 return api('register_finish', { 152 challengeToken: opts.challengeToken, 153 clientDataJSON: bufToB64url(resp.clientDataJSON), 154 attestationObject: bufToB64url(resp.attestationObject), 155 transports: transports, 156 label: label || '' 157 }); 158 }); 159 }); 160 } 161 162 /* ================================================================ * 163 * VERWALTUNG 164 * ================================================================ */ 165 function list() { return api('list', {}); } 166 function rename(id, label) { return api('rename', { id: id, label: label }); } 167 function remove(id) { return api('delete', { id: id }); } 168 169 /* ================================================================ * 170 * Login-Button initialisieren (Markup kommt aus dem Template) 171 * ================================================================ */ 172 function initLoginButton() { 173 // Es kann mehrere Login-Boxen auf einer Seite geben (z. B. Header-Box 174 // 'box_userinfo' UND das login-Element auf login.php) -> per Klasse alle. 175 var boxes = document.querySelectorAll('.passkeyLoginBox'); 176 if (!boxes.length || !supported()) return; // sonst bleiben sie versteckt 177 var p = new URLSearchParams(w.location.search); 178 var next = p.get('next_location') || p.get('goto') || ''; 179 Array.prototype.forEach.call(boxes, function (box) { 180 var btn = box.querySelector('.passkeyLoginBtn'); 181 var msg = box.querySelector('.passkeyLoginMsg'); 182 if (!btn) return; 183 // Texte ausschliesslich aus dem Template (Sprachvariablen) 184 var msgs = { 185 cancelled: box.getAttribute('data-pk-cancelled') || '', 186 nocred: box.getAttribute('data-pk-nocred') || '', 187 already: box.getAttribute('data-pk-failed') || '', 188 failed: box.getAttribute('data-pk-failed') || '' 189 }; 190 var confirmMsg = box.getAttribute('data-pk-confirm') || ''; 191 box.style.display = ''; // jetzt anzeigen 192 btn.addEventListener('click', function () {
193 if (conditionalAbort) { try { conditionalAbort.abort(); } catch (e) { } } // Autofill-Get stoppen 194 btn.disabled = true; 195 if (msg) { msg.style.color = ''; msg.textContent = confirmMsg; } 196 // Benutzername aus dem Formular (falls eingegeben) -> allowCredentials. 197 // Wichtig fuer non-discoverable Credentials (z. B. manche Android-Geraete): 198 // ohne Namen findet das Geraet sie nicht. 199 var uname = readUsername(box); 200 login({ username: uname, next: next }).catch(function (e) { 201 btn.disabled = false; 202 if (msg) { msg.style.color = '#c0392b'; msg.textContent = humanError(e, msgs); } 203 // Kein Name eingegeben und nichts gefunden -> Namensfeld fokussieren 204 if (!uname) { var inp = usernameInput(box); if (inp) { try { inp.focus(); } catch (er) { } } } 205 }); 206 }); 207 }); 208 } 209 210 // Findet das Benutzername-Feld im Login-Formular der Box (oder im Dokument) 211 function usernameInput(box) { 212 var form = (box && box.closest) ? box.closest('form') : null; 213 return (form || document).querySelector('input[name="name"]'); 214 } 215 function readUsername(box) { 216 var inp = usernameInput(box); 217 return (inp && inp.value) ? inp.value.trim() : ''; 218 } 219 220 /* ================================================================ * 221 * Passkey-Verwaltung initialisieren (Markup/Labels aus dem Template) 222 * ================================================================ */ 223 function initManage() { 224 var box = document.getElementById('passkeyManageBox'); 225 if (!box) return; 226 var listEl = document.getElementById('pkList'); 227 var addBtn = document.getElementById('pkAddBtn'); 228 var addMsg = document.getElementById('pkAddMsg'); 229 var d = function (k) { return box.getAttribute('data-pk-' + k) || ''; }; // Label aus Template 230 if (!supported()) { 231 var u = document.getElementById('pkUnsupported'); 232 if (u) u.style.display = ''; 233 if (addBtn) addBtn.disabled = true; 234 if (listEl) listEl.textContent = ''; 235 return; 236 } 237 // Kein eingebauter Authenticator? Hinweis einblenden, Anlegen bleibt aber 238 // moeglich (Sicherheitsschluessel/Smartphone). 239 platformAuthenticatorAvailable().then(function (ok) { 240 if (!ok) { var nd = document.getElementById('pkNoDevice'); if (nd) nd.style.display = ''; } 241 }); 242 var msgs = { cancelled: d('cancelled'), already: d('failed'), nocred: d('failed'), failed: d('failed') }; 243 function fmtDate(ts) { if (!ts) return 'â'; try { return new Date(ts * 1000).toLocaleDateString('de-DE'); } catch (e) { return 'â'; } } 244 function esc(s) { var t = document.createElement('span'); t.textContent = (s == null) ? '' : String(s); return t.innerHTML; } 245 function render(items) { 246 if (!items || !items.length) { listEl.innerHTML = '<i>' + esc(d('empty')) + '</i>'; return; } 247 var rows = items.map(function (p) { 248 return '<tr style="border-bottom:1px solid #eee;">' + 249 '<td style="padding:6px 4px;">🔑 ' + esc(p.label || 'Passkey') + '</td>' + 250 '<td style="padding:6px 4px;color:#777;white-space:nowrap;">' + esc(d('created')) + ' ' + fmtDate(p.created) + '</td>' + 251 '<td style="padding:6px 4px;color:#777;white-space:nowrap;">' + esc(d('lastused')) + ' ' + ((p.last_used > 0) ? fmtDate(p.last_used) : esc(d('never'))) + '</td>' + 252 '<td style="padding:6px 4px;text-align:right;white-space:nowrap;">' + 253 '<a href="#" data-del="' + p.id + '" style="color:#c0392b;">' + esc(d('remove')) + '</a>' + 254 '</td></tr>'; 255 }).join(''); 256 listEl.innerHTML = '<table style="width:100%;border-collapse:collapse;">' + rows + '</table>'; 257 listEl.querySelectorAll('[data-del]').forEach(function (a) { 258 a.addEventListener('click', function (e) { 259 e.preventDefault(); 260 if (!w.confirm(d('confirmdel'))) return; 261 remove(parseInt(a.getAttribute('data-del'), 10)).then(load).catch(function (err) { w.alert(humanError(err, msgs)); }); 262 }); 263 }); 264 } 265 function load() { 266 listEl.textContent = d('loading'); 267 list().then(function (r) { render(r.passkeys || []); }).catch(function (err) { listEl.textContent = humanError(err, msgs); }); 268 } 269 addBtn.addEventListener('click', function () { 270 // Name wird serverseitig automatisch vergeben -> keine Eingabe noetig 271 addBtn.disabled = true; addMsg.style.color = ''; addMsg.textContent = d('confirm'); 272 register('').then(function () { 273 addBtn.disabled = false; addMsg.style.color = 'green'; addMsg.textContent = d('saved'); load(); 274 }).catch(function (err) { 275 addBtn.disabled = false; addMsg.style.color = '#c0392b'; addMsg.textContent = humanError(err, msgs); 276 }); 277 }); 278 load(); 279 } 280 281 /* ================================================================ * 282 * Promo-Banner: bei fehlender WebAuthn-Unterstuetzung ausblenden
283 * (Markup kommt aus dem foben-Template; hier nur ausblenden) 284 * ================================================================ */ 285 function initPromo() { 286 var b = document.getElementById('pkPromo'); 287 if (b && !supported()) b.style.display = 'none'; 288 } 289 290 /* ================================================================ * 291 * Conditional UI / Autofill (zuverlaessigste Variante, v.a. Android) 292 * Markiert Benutzername-Felder mit autocomplete="...webauthn" und 293 * startet im Hintergrund einen Conditional-Get. Nur auf login.php, 294 * um nicht auf jeder Gastseite Requests auszuloesen. 295 * ================================================================ */ 296 function initConditional() { 297 if (!supported()) return; 298 if (w.location.pathname.indexOf('login.php') === -1) return; // nur auf der Login-Seite 299 if (typeof PublicKeyCredential.isConditionalMediationAvailable !== 'function') return; 300 var inputs = document.querySelectorAll('input[name="name"]'); 301 if (!inputs.length) return; 302 PublicKeyCredential.isConditionalMediationAvailable().then(function (ok) { 303 if (!ok) return; 304 Array.prototype.forEach.call(inputs, function (inp) { 305 var ac = inp.getAttribute('autocomplete') || ''; 306 if (ac.indexOf('webauthn') === -1) { 307 inp.setAttribute('autocomplete', (ac ? ac + ' ' : 'username ') + 'webauthn'); 308 } 309 }); 310 try { conditionalAbort = new AbortController(); } catch (e) { conditionalAbort = null; } 311 login({ mediation: 'conditional', signal: conditionalAbort ? conditionalAbort.signal : undefined }) 312 .catch(function () { /* abgebrochen / keine Auswahl -> egal, Button bleibt */ }); 313 }).catch(function () { }); 314 } 315 316 w.Passkey = { 317 supported: supported, 318 platformAuthenticatorAvailable: platformAuthenticatorAvailable, 319 login: login, 320 register: register, 321 list: list, 322 rename: rename, 323 remove: remove, 324 humanError: humanError, 325 initLoginButton: initLoginButton, 326 initManage: initManage, 327 initPromo: initPromo,
328 initConditional: initConditional 329 }; 330 331 // Auto-Initialisierung, sobald das DOM bereit ist 332 function autoInit() { initLoginButton(); initManage(); initPromo(); initConditional(); } 333 if (document.readyState === 'loading') { 334 document.addEventListener('DOMContentLoaded', autoInit); 335 } else { 336 autoInit(); 337 } 338})(window);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.