From 1e147382584342f5d97643bfc31d3680ffb36497 Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Mon, 13 Jul 2026 22:56:21 +0200 Subject: [PATCH] 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 --- lib/api.js | 42 +++++++++++++++++++-------- lib/config.js | 10 +++++-- lib/openapi.js | 72 +++++++++++++++++++++++++++++++++++++++------- server.js | 16 +++++++++++ views/settings.ejs | 64 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 179 insertions(+), 25 deletions(-) diff --git a/lib/api.js b/lib/api.js index faedecb..b9f72e4 100644 --- a/lib/api.js +++ b/lib/api.js @@ -64,18 +64,25 @@ function createExternalApi(deps) { } try { // Resolve the token to its owning user (the user whose cfg:API_TOKEN - // matches). Two users could in principle share a value — we take the - // first match, which is fine since the data the caller then sees is that - // one user's only. - const user = await dbGet( + // matches). An empty token is never a valid key — otherwise every user who + // has saved their settings once (which stores API_TOKEN as '') would be + // matched by an empty header. + const matches = await dbAll( `SELECT u.id, u.username, u.is_admin FROM app_state a JOIN users u ON u.id = a.user_id - WHERE a.key = ? AND a.value = ? LIMIT 1`, + WHERE a.key = ? AND a.value = ? AND a.value != ''`, [CONFIG_PREFIX + 'API_TOKEN', provided] ); - if (!user) { + // Fail closed on an ambiguous token: if two users somehow share a value we + // must not silently pick one of them and hand the caller that user's data. + // (Saving a token that another user already uses is rejected in the UI.) + if (matches.length !== 1) { + if (matches.length > 1) { + console.error('API auth: Token ist mehreren Benutzern zugeordnet — Zugriff verweigert.'); + } return res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' }); } + const user = matches[0]; req.user = user; // Warm this user's cfg rows: config.get() is synchronous and reads from // the per-user cache, so without this an API request could see the user @@ -494,19 +501,30 @@ function createExternalApi(deps) { } }); + // Personal details, same set of fields the web UI writes (Persönliche Angaben). + // Only the keys present in the body are changed; omitted keys keep their value, + // so a client can patch a single field without wiping the rest. + const SETTINGS_FIELDS = ['name', 'adresse', 'kundennummer', 'email', 'telefon', 'ort', 'webseite', 'geburtsdatum']; + router.put('/settings', async (req, res) => { try { - const { name, adresse, kundennummer } = req.body || {}; + const b = req.body || {}; + const keys = SETTINGS_FIELDS.filter((k) => typeof b[k] !== 'undefined'); + if (!keys.length) { + return res.status(400).json({ error: `Mindestens eines dieser Felder erforderlich: ${SETTINGS_FIELDS.join(', ')}` }); + } // Upsert, not UPDATE: a user without a settings row would otherwise match // zero rows and the write would be silently dropped. + const werte = keys.map((k) => sanitizeInput(String(b[k] ?? ''))); await dbRun( - `INSERT INTO settings (user_id, name, adresse, kundennummer) - VALUES (?, ?, ?, ?) + `INSERT INTO settings (user_id, ${keys.join(', ')}) + VALUES (?, ${keys.map(() => '?').join(', ')}) ON CONFLICT(user_id) DO UPDATE SET - name = excluded.name, adresse = excluded.adresse, kundennummer = excluded.kundennummer`, - [uid(), sanitizeInput(name), sanitizeInput(adresse), sanitizeInput(kundennummer)] + ${keys.map((k) => `${k} = excluded.${k}`).join(', ')}`, + [uid(), ...werte] ); - res.json({ success: true }); + const row = await dbGet('SELECT * FROM settings WHERE user_id = ?', [uid()]); + res.json({ success: true, settings: row || {} }); } catch (error) { console.error('API save settings error:', error); res.status(500).json({ error: 'Serverfehler' }); diff --git a/lib/config.js b/lib/config.js index 2abf165..5cd1ca1 100644 --- a/lib/config.js +++ b/lib/config.js @@ -83,9 +83,15 @@ const FIELDS = [ }, { titel: 'REST-API für Drittanbietersoftware', - beschreibung: 'Ist ein Token gesetzt, ist /api/v1 für diesen Benutzer aktiv und erwartet den Wert im Header „X-API-Key“. Anfragen operieren auf den Daten dieses Benutzers. Ohne Token antwortet die API (bis auf /health) mit 503. Swagger unter /swagger.', + beschreibung: 'Ist ein Token gesetzt, ist /api/v1 für diesen Benutzer aktiv und erwartet den Wert im Header „X-API-Key“. Der Token identifiziert den Benutzer: Anfragen sehen und ändern ausschließlich dessen eigene Daten. Ohne Token antwortet die API (bis auf /health) mit 401. Swagger unter /swagger.', items: [ - { key: 'API_TOKEN', label: 'API-Token (X-API-Key)', secret: true, help: 'Leer = API deaktiviert' }, + { + key: 'API_TOKEN', + label: 'API-Token (X-API-Key)', + secret: true, + generate: true, + help: 'Leer = API deaktiviert. Über das Auge einblenden, „Neu generieren“ erzeugt einen zufälligen Token — danach speichern. Ein neuer Token macht den alten sofort ungültig.', + }, ], }, ]; diff --git a/lib/openapi.js b/lib/openapi.js index 1fb81d5..7f8179b 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -43,8 +43,19 @@ function buildOpenApiSpec(baseUrl = '') { 'Bewerbungen, Statusverlauf, generierten Unterlagen (PDFs), ' + 'E-Mail-Korrespondenz, Einstellungen und Statistiken.\n\n' + 'Alle Endpunkte sind unter `/api/v1` gemountet und erfordern ' + - 'Authentifizierung über einen API-Key im Header `X-API-Key` ' + - '(konfiguriert via Umgebungsvariable `API_TOKEN`).', + 'Authentifizierung über einen API-Key im Header `X-API-Key`.\n\n' + + '### Der API-Key gehört immer genau einem Benutzer\n\n' + + 'Der Tracker ist eine Multi-User-Plattform. Jeder Benutzer legt seinen ' + + 'eigenen Token unter **Einstellungen → REST-API** an („Neu generieren“) — ' + + 'es gibt keinen globalen oder serverweiten Key.\n\n' + + 'Der übergebene `X-API-Key` bestimmt, **wessen** Daten die Anfrage sieht: ' + + 'jeder Endpunkt liest und schreibt ausschließlich die Daten des Benutzers, ' + + 'dem der Token gehört. Fremde Bewerbungen, E-Mails, Anhänge, Jobangebote ' + + 'oder Einstellungen sind über die API nicht erreichbar — auch nicht durch ' + + 'Angabe einer fremden `id`. In dem Fall antwortet die API mit `404`, als ' + + 'gäbe es den Datensatz nicht. Ein unbekannter, leerer oder mehrdeutiger ' + + 'Token führt zu `401`.\n\n' + + 'Ein neu generierter Token macht den vorherigen sofort ungültig.', }, servers: baseUrl ? [{ url: `${baseUrl}/api/v1` }] : [{ url: '/api/v1' }], tags: [ @@ -409,22 +420,48 @@ function buildOpenApiSpec(baseUrl = '') { '/settings': { get: { tags: ['Settings'], - summary: 'Einstellungen abrufen', + summary: 'Persönliche Angaben abrufen', + description: + 'Liefert die persönlichen Angaben **des Benutzers, dem der API-Key gehört**. ' + + 'Hat er noch nichts gespeichert, ist die Antwort ein leeres Objekt `{}`.', responses: { - '200': { description: 'Einstellungen', content: { 'application/json': { schema: { $ref: '#/components/schemas/Settings' } } } }, + '200': { description: 'Persönliche Angaben (ggf. `{}`)', content: { 'application/json': { schema: { $ref: '#/components/schemas/Settings' } } } }, '401': errorResponse, '500': errorResponse, }, }, put: { tags: ['Settings'], - summary: 'Einstellungen speichern', + summary: 'Persönliche Angaben speichern', + description: + 'Speichert die persönlichen Angaben des eigenen Benutzers. Es werden nur die ' + + 'Felder geändert, die im Body vorkommen — weggelassene Felder behalten ihren ' + + 'Wert. Existiert noch keine Zeile, wird sie angelegt.', requestBody: { required: true, - content: { 'application/json': { schema: { $ref: '#/components/schemas/Settings' } } }, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Settings' }, + example: { name: 'Max Mustermann', ort: 'Gladbeck' }, + }, + }, }, responses: { - '200': { description: 'Gespeichert', content: { 'application/json': { schema: { $ref: '#/components/schemas/Ok' } } } }, + '200': { + description: 'Gespeichert; gibt den neuen Stand zurück', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + settings: { $ref: '#/components/schemas/Settings' }, + }, + }, + }, + }, + }, + '400': errorResponse, '401': errorResponse, '500': errorResponse, }, @@ -654,7 +691,11 @@ function buildOpenApiSpec(baseUrl = '') { type: 'apiKey', in: 'header', name: 'X-API-Key', - description: 'API-Token aus der Umgebungsvariable API_TOKEN.', + description: + 'Persönlicher API-Token des Benutzers, zu finden unter Einstellungen → ' + + 'REST-API (dort per „Neu generieren“ erzeugen und einblenden). Der Token ' + + 'identifiziert den Benutzer — die Anfrage sieht und ändert nur dessen ' + + 'eigene Daten.', }, }, schemas: { @@ -839,10 +880,19 @@ function buildOpenApiSpec(baseUrl = '') { }, Settings: { type: 'object', + description: + 'Persönliche Angaben des Benutzers, dem der API-Key gehört (fließen in ' + + 'die generierten Unterlagen ein). Hat der Benutzer noch nichts gespeichert, ' + + 'liefert GET ein leeres Objekt `{}` — das ist ein gültiger Zustand.', properties: { - name: { type: 'string' }, - adresse: { type: 'string' }, - kundennummer: { type: 'string' }, + name: { type: 'string', example: 'Max Mustermann' }, + adresse: { type: 'string', example: 'Musterstraße 1, 12345 Musterstadt' }, + kundennummer: { type: 'string', description: 'Kundennummer beim Jobcenter' }, + email: { type: 'string', example: 'max@example.com' }, + telefon: { type: 'string', example: '02043 123456' }, + ort: { type: 'string', example: 'Gladbeck' }, + webseite: { type: 'string', example: 'example.com' }, + geburtsdatum: { type: 'string', example: '01.01.1990' }, }, }, Statistics: { diff --git a/server.js b/server.js index ede5faf..32e014c 100644 --- a/server.js +++ b/server.js @@ -2888,6 +2888,22 @@ initializeDatabase().then(async () => { werte[f.key] = req.body[f.key] != null ? String(req.body[f.key]) : ''; } } + + // The API token identifies the user to /api/v1, so it must not collide with + // another user's: the auth lookup would otherwise be ambiguous (it now fails + // closed, which would lock *both* users out). Reject the save instead. + const token = (werte.API_TOKEN || '').trim(); + if (token) { + const fremd = await dbGet( + `SELECT u.username FROM app_state a JOIN users u ON u.id = a.user_id + WHERE a.key = ? AND a.value = ? AND a.user_id != ?`, + [config.PREFIX + 'API_TOKEN', token, uid()] + ); + if (fremd) { + return res.status(409).send('Dieser API-Token ist bereits von einem anderen Benutzer belegt. Bitte „Neu generieren“ verwenden.'); + } + } + await config.saveAll(werte); res.redirect('/einstellungen'); } catch (error) { diff --git a/views/settings.ejs b/views/settings.ejs index 96e03e8..6caefe1 100644 --- a/views/settings.ejs +++ b/views/settings.ejs @@ -68,6 +68,25 @@ <% } %> + <% if (f.generate) { %> +
+ + + +
+ <% } %> <% if (f.help) { %>

<%= f.help %>

<% } %> @@ -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.'); + } + }); + }); })();