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:
+30
-12
@@ -64,18 +64,25 @@ function createExternalApi(deps) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Resolve the token to its owning user (the user whose cfg:API_TOKEN
|
// 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
|
// matches). An empty token is never a valid key — otherwise every user who
|
||||||
// first match, which is fine since the data the caller then sees is that
|
// has saved their settings once (which stores API_TOKEN as '') would be
|
||||||
// one user's only.
|
// matched by an empty header.
|
||||||
const user = await dbGet(
|
const matches = await dbAll(
|
||||||
`SELECT u.id, u.username, u.is_admin
|
`SELECT u.id, u.username, u.is_admin
|
||||||
FROM app_state a JOIN users u ON u.id = a.user_id
|
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]
|
[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).' });
|
return res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' });
|
||||||
}
|
}
|
||||||
|
const user = matches[0];
|
||||||
req.user = user;
|
req.user = user;
|
||||||
// Warm this user's cfg rows: config.get() is synchronous and reads from
|
// 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
|
// 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) => {
|
router.put('/settings', async (req, res) => {
|
||||||
try {
|
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
|
// Upsert, not UPDATE: a user without a settings row would otherwise match
|
||||||
// zero rows and the write would be silently dropped.
|
// zero rows and the write would be silently dropped.
|
||||||
|
const werte = keys.map((k) => sanitizeInput(String(b[k] ?? '')));
|
||||||
await dbRun(
|
await dbRun(
|
||||||
`INSERT INTO settings (user_id, name, adresse, kundennummer)
|
`INSERT INTO settings (user_id, ${keys.join(', ')})
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ${keys.map(() => '?').join(', ')})
|
||||||
ON CONFLICT(user_id) DO UPDATE SET
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
name = excluded.name, adresse = excluded.adresse, kundennummer = excluded.kundennummer`,
|
${keys.map((k) => `${k} = excluded.${k}`).join(', ')}`,
|
||||||
[uid(), sanitizeInput(name), sanitizeInput(adresse), sanitizeInput(kundennummer)]
|
[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) {
|
} catch (error) {
|
||||||
console.error('API save settings error:', error);
|
console.error('API save settings error:', error);
|
||||||
res.status(500).json({ error: 'Serverfehler' });
|
res.status(500).json({ error: 'Serverfehler' });
|
||||||
|
|||||||
+8
-2
@@ -83,9 +83,15 @@ const FIELDS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
titel: 'REST-API für Drittanbietersoftware',
|
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: [
|
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.',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+61
-11
@@ -43,8 +43,19 @@ function buildOpenApiSpec(baseUrl = '') {
|
|||||||
'Bewerbungen, Statusverlauf, generierten Unterlagen (PDFs), ' +
|
'Bewerbungen, Statusverlauf, generierten Unterlagen (PDFs), ' +
|
||||||
'E-Mail-Korrespondenz, Einstellungen und Statistiken.\n\n' +
|
'E-Mail-Korrespondenz, Einstellungen und Statistiken.\n\n' +
|
||||||
'Alle Endpunkte sind unter `/api/v1` gemountet und erfordern ' +
|
'Alle Endpunkte sind unter `/api/v1` gemountet und erfordern ' +
|
||||||
'Authentifizierung über einen API-Key im Header `X-API-Key` ' +
|
'Authentifizierung über einen API-Key im Header `X-API-Key`.\n\n' +
|
||||||
'(konfiguriert via Umgebungsvariable `API_TOKEN`).',
|
'### 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' }],
|
servers: baseUrl ? [{ url: `${baseUrl}/api/v1` }] : [{ url: '/api/v1' }],
|
||||||
tags: [
|
tags: [
|
||||||
@@ -409,22 +420,48 @@ function buildOpenApiSpec(baseUrl = '') {
|
|||||||
'/settings': {
|
'/settings': {
|
||||||
get: {
|
get: {
|
||||||
tags: ['Settings'],
|
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: {
|
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,
|
'401': errorResponse,
|
||||||
'500': errorResponse,
|
'500': errorResponse,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
put: {
|
put: {
|
||||||
tags: ['Settings'],
|
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: {
|
requestBody: {
|
||||||
required: true,
|
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: {
|
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,
|
'401': errorResponse,
|
||||||
'500': errorResponse,
|
'500': errorResponse,
|
||||||
},
|
},
|
||||||
@@ -654,7 +691,11 @@ function buildOpenApiSpec(baseUrl = '') {
|
|||||||
type: 'apiKey',
|
type: 'apiKey',
|
||||||
in: 'header',
|
in: 'header',
|
||||||
name: 'X-API-Key',
|
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: {
|
schemas: {
|
||||||
@@ -839,10 +880,19 @@ function buildOpenApiSpec(baseUrl = '') {
|
|||||||
},
|
},
|
||||||
Settings: {
|
Settings: {
|
||||||
type: 'object',
|
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: {
|
properties: {
|
||||||
name: { type: 'string' },
|
name: { type: 'string', example: 'Max Mustermann' },
|
||||||
adresse: { type: 'string' },
|
adresse: { type: 'string', example: 'Musterstraße 1, 12345 Musterstadt' },
|
||||||
kundennummer: { type: 'string' },
|
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: {
|
Statistics: {
|
||||||
|
|||||||
@@ -2888,6 +2888,22 @@ initializeDatabase().then(async () => {
|
|||||||
werte[f.key] = req.body[f.key] != null ? String(req.body[f.key]) : '';
|
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);
|
await config.saveAll(werte);
|
||||||
res.redirect('/einstellungen');
|
res.redirect('/einstellungen');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -68,6 +68,25 @@
|
|||||||
</button>
|
</button>
|
||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</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) { %>
|
<% if (f.help) { %>
|
||||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1"><%= f.help %></p>
|
<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';
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user