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
+30 -12
View File
@@ -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' });
+8 -2
View File
@@ -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.',
},
],
},
];
+61 -11
View File
@@ -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: {