PageSourceSearch

https://www.andili.de/js/passkey.js

js andili.de collected 2026-10-04 06:58:12 UTC 15,927 bytes, 338 lines download raw bytes

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;">&#128273; ' + 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.