API: Zugriff strikt auf den Besitzer des API-Keys, Key generieren/anzeigen

Der X-API-Key identifiziert den Benutzer; alle Endpunkte waren bereits auf
dessen user_id gescoped (27 Operationen geprueft). Zwei Luecken blieben:

- Die Aufloesung Token -> Benutzer nahm bei mehrdeutigem Token per LIMIT 1
  einfach den ersten Treffer. Haetten zwei Benutzer denselben Token, saehe
  der eine die Daten des anderen. Jetzt: fail closed (401 + Logeintrag),
  und /einstellungen weist einen bereits vergebenen Token mit 409 ab.
- Ein leerer Token galt als Wert: wer seine Einstellungen einmal gespeichert
  hatte, besass eine API_TOKEN-Zeile mit ''. Leere Werte matchen jetzt nie.

Neu in den Einstellungen (Abschnitt REST-API):
- "Neu generieren" erzeugt einen zufaelligen Token (32 Byte, crypto.get-
  RandomValues); er wird nur ins Feld gefuellt und erst beim Speichern
  aktiv, ein Fehlklick laesst sich also verwerfen.
- "Kopieren" legt den Token in die Zwischenablage; das Auge blendet ihn ein
  (bestand bereits fuer Secret-Felder).

API aktualisiert:
- PUT /settings kannte nur name/adresse/kundennummer, GET lieferte aber alle
  acht Felder. Jetzt schreibt PUT alle (email, telefon, ort, webseite,
  geburtsdatum) und aendert nur die im Body uebergebenen Felder; die Antwort
  enthaelt den neuen Stand.

Swagger:
- Beschreibung sagte "konfiguriert via Umgebungsvariable API_TOKEN" - das
  gilt seit der Multi-User-Umstellung nicht mehr. Jetzt dokumentiert:
  Token pro Benutzer aus den Einstellungen, Zugriff nur auf eigene Daten,
  fremde id -> 404, unbekannter/leerer/mehrdeutiger Token -> 401.
- Settings-Schema um die fehlenden fuenf Felder ergaenzt.

Verifiziert mit zwei Benutzern und je eigenem Key: Lesen, Aendern, Loeschen,
Timeline, Anhaenge, E-Mails, Generierung, Jobangebote und Blacklist des
jeweils anderen liefern durchgaengig 404; Listen, Export und Statistik
zeigen nur eigene Daten; kollidierender Token -> 409; mehrdeutiger Token in
der DB -> 401 fuer beide.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 22:56:21 +02:00
co-authored by Claude Opus 4.8
parent b6cb94fdb5
commit 1e14738258
5 changed files with 179 additions and 25 deletions
+64
View File
@@ -68,6 +68,25 @@
</button>
<% } %>
</div>
<% if (f.generate) { %>
<div class="flex items-center gap-2 mt-2">
<button type="button" data-generate="cfg-<%= f.key %>"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
Neu generieren
</button>
<button type="button" data-copy="cfg-<%= f.key %>"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
Kopieren
</button>
<span data-hint="cfg-<%= f.key %>" class="text-xs text-amber-600 dark:text-amber-400 hidden"></span>
</div>
<% } %>
<% if (f.help) { %>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1"><%= f.help %></p>
<% } %>
@@ -106,6 +125,51 @@
if (input) input.type = input.type === 'password' ? 'text' : 'password';
});
});
function hint(id, text, tone) {
const el = document.querySelector('[data-hint="' + id + '"]');
if (!el) return;
el.textContent = text;
el.classList.remove('hidden');
el.classList.toggle('text-amber-600', tone !== 'ok');
el.classList.toggle('dark:text-amber-400', tone !== 'ok');
el.classList.toggle('text-green-600', tone === 'ok');
el.classList.toggle('dark:text-green-400', tone === 'ok');
}
// Generate a fresh random token (32 bytes hex, from the browser CSPRNG).
// It only fills the field — nothing changes until the form is saved,
// so an accidental click can still be discarded.
document.querySelectorAll('[data-generate]').forEach(function (btn) {
btn.addEventListener('click', function () {
const id = btn.getAttribute('data-generate');
const input = document.getElementById(id);
if (!input) return;
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
input.value = Array.from(bytes).map(function (b) {
return b.toString(16).padStart(2, '0');
}).join('');
input.type = 'text'; // reveal, so it can be copied down
hint(id, 'Neuer Token erzeugt — zum Aktivieren speichern. Der alte wird damit ungültig.');
});
});
document.querySelectorAll('[data-copy]').forEach(function (btn) {
btn.addEventListener('click', async function () {
const id = btn.getAttribute('data-copy');
const input = document.getElementById(id);
if (!input || !input.value) return;
try {
await navigator.clipboard.writeText(input.value);
hint(id, 'In die Zwischenablage kopiert.', 'ok');
} catch (e) {
input.type = 'text';
input.select();
hint(id, 'Kopieren nicht möglich — Wert ist markiert, bitte manuell kopieren.');
}
});
});
})();
</script>
</body>