feat(auth): Story 3.1 — /api/state pro Nutzer hinter Auth (Anti-IDOR), documents (user_id,key), Startup seed+migrate

This commit is contained in:
Kenearos 2026-07-07 22:46:25 +02:00
parent 0b25c7b493
commit 846ca4f83a
5 changed files with 91 additions and 57 deletions

View file

@ -9,7 +9,7 @@ const { putDoc } = require('./db');
const { runBackup, BACKUP_DIR } = require('./backup'); const { runBackup, BACKUP_DIR } = require('./backup');
test('runBackup erzeugt eine nicht-leere DB-Datei', async () => { test('runBackup erzeugt eine nicht-leere DB-Datei', async () => {
putDoc('employees', ['Max'], '2026-07-07T10:00:00.000Z'); putDoc(1, 'employees', ['Max'], '2026-07-07T10:00:00.000Z');
const dest = await runBackup('2026-07-07T10:00:00.000Z'); const dest = await runBackup('2026-07-07T10:00:00.000Z');
assert.ok(fs.existsSync(dest), 'Backup-Datei existiert'); assert.ok(fs.existsSync(dest), 'Backup-Datei existiert');
assert.ok(fs.statSync(dest).size > 0, 'Backup ist nicht leer'); assert.ok(fs.statSync(dest).size > 0, 'Backup ist nicht leer');

View file

@ -13,15 +13,18 @@ db.pragma('foreign_keys = ON');
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS documents ( CREATE TABLE IF NOT EXISTS documents (
key TEXT PRIMARY KEY, user_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL, value TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, key)
); );
CREATE TABLE IF NOT EXISTS history ( CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL, key TEXT NOT NULL,
value TEXT NOT NULL, value TEXT NOT NULL,
replaced_at TEXT NOT NULL replaced_at TEXT NOT NULL,
user_id INTEGER
); );
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -53,26 +56,26 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
`); `);
function getDoc(key) { function getDoc(userId, key) {
const row = db.prepare('SELECT value, updated_at FROM documents WHERE key = ?').get(key); const row = db.prepare('SELECT value, updated_at FROM documents WHERE user_id = ? AND key = ?').get(userId, key);
if (!row) return null; if (!row) return null;
return { value: JSON.parse(row.value), updatedAt: row.updated_at }; return { value: JSON.parse(row.value), updatedAt: row.updated_at };
} }
// ponytail: history wächst unbegrenzt. Bei Single-User/kleinen Docs jahrelang egal. // ponytail: history wächst unbegrenzt. Bei kleinen Docs jahrelang egal.
// Pruning (z.B. > 500 Einträge pro key löschen) nachrüsten, falls es je wächst. // Pruning (z.B. > 500 Einträge pro user/key löschen) nachrüsten, falls es je wächst.
function putDoc(key, value, now) { function putDoc(userId, key, value, now) {
const json = JSON.stringify(value); const json = JSON.stringify(value);
const tx = db.transaction(() => { const tx = db.transaction(() => {
const existing = db.prepare('SELECT value FROM documents WHERE key = ?').get(key); const existing = db.prepare('SELECT value FROM documents WHERE user_id = ? AND key = ?').get(userId, key);
if (existing) { if (existing) {
db.prepare('INSERT INTO history (key, value, replaced_at) VALUES (?, ?, ?)') db.prepare('INSERT INTO history (key, value, replaced_at, user_id) VALUES (?, ?, ?, ?)')
.run(key, existing.value, now); .run(key, existing.value, now, userId);
} }
db.prepare(` db.prepare(`
INSERT INTO documents (key, value, updated_at) VALUES (?, ?, ?) INSERT INTO documents (user_id, key, value, updated_at) VALUES (?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`).run(key, json, now); `).run(userId, key, json, now);
}); });
tx(); tx();
} }

View file

@ -4,26 +4,34 @@ const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
// DATA_DIR auf Temp umbiegen, BEVOR db.js geladen wird
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dp-db-')); process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dp-db-'));
const { getDoc, putDoc } = require('./db'); const { getDoc, putDoc, db } = require('./db');
test('putDoc/getDoc Roundtrip', () => { const UID = 1;
putDoc('employees', ['Max', 'Anna'], '2026-07-07T10:00:00.000Z');
const doc = getDoc('employees'); test('putDoc/getDoc Roundtrip (pro Nutzer)', () => {
putDoc(UID, 'employees', ['Max', 'Anna'], '2026-07-07T10:00:00.000Z');
const doc = getDoc(UID, 'employees');
assert.deepStrictEqual(doc.value, ['Max', 'Anna']); assert.deepStrictEqual(doc.value, ['Max', 'Anna']);
assert.strictEqual(doc.updatedAt, '2026-07-07T10:00:00.000Z'); assert.strictEqual(doc.updatedAt, '2026-07-07T10:00:00.000Z');
}); });
test('getDoc für unbekannten Key liefert null', () => { test('getDoc für unbekannten Key liefert null', () => {
assert.strictEqual(getDoc('gibtsnicht'), null); assert.strictEqual(getDoc(UID, 'gibtsnicht'), null);
}); });
test('putDoc snapshottet den alten Wert in history', () => { test('putDoc snapshottet den alten Wert in history (mit user_id)', () => {
const { db } = require('./db'); putDoc(UID, 'duties', { v: 1 }, '2026-07-07T10:00:00.000Z');
putDoc('duties', { v: 1 }, '2026-07-07T10:00:00.000Z'); putDoc(UID, 'duties', { v: 2 }, '2026-07-07T11:00:00.000Z');
putDoc('duties', { v: 2 }, '2026-07-07T11:00:00.000Z'); const rows = db.prepare("SELECT value, user_id FROM history WHERE key='duties' AND user_id=? ORDER BY id").all(UID);
const rows = db.prepare("SELECT value FROM history WHERE key = 'duties' ORDER BY id").all();
assert.strictEqual(rows.length, 1); assert.strictEqual(rows.length, 1);
assert.deepStrictEqual(JSON.parse(rows[0].value), { v: 1 }); assert.deepStrictEqual(JSON.parse(rows[0].value), { v: 1 });
assert.strictEqual(rows[0].user_id, UID);
});
test('Datentrennung: verschiedene user_id → disjunkte Daten', () => {
putDoc(10, 'employees', ['A'], '2026-07-07T10:00:00.000Z');
putDoc(20, 'employees', ['B'], '2026-07-07T10:00:00.000Z');
assert.deepStrictEqual(getDoc(10, 'employees').value, ['A']);
assert.deepStrictEqual(getDoc(20, 'employees').value, ['B']);
}); });

View file

@ -9,6 +9,7 @@ const { sendMagicLink } = require('./mailer');
const { const {
normalizeEmail, hashToken, createLoginToken, consumeLoginToken, normalizeEmail, hashToken, createLoginToken, consumeLoginToken,
createSession, validateSession, deleteSession, SESSION_TTL_DAYS, createSession, validateSession, deleteSession, SESSION_TTL_DAYS,
seedAdmin, migrateToMultiUser,
} = require('./auth'); } = require('./auth');
const app = express(); const app = express();
@ -43,7 +44,7 @@ function authMiddleware(req, res, next) {
const raw = req.cookies && req.cookies[SESSION_COOKIE]; const raw = req.cookies && req.cookies[SESSION_COOKIE];
const u = raw ? validateSession(raw) : null; const u = raw ? validateSession(raw) : null;
if (!u) { clearSessionCookie(res); return res.status(401).json({ error: 'nicht angemeldet' }); } if (!u) { clearSessionCookie(res); return res.status(401).json({ error: 'nicht angemeldet' }); }
req.user = u; req.user = { id: u.userId, email: u.email, isAdmin: u.isAdmin };
next(); next();
} }
function adminMiddleware(req, res, next) { function adminMiddleware(req, res, next) {
@ -118,25 +119,28 @@ app.post('/api/admin/login-link', authMiddleware, adminMiddleware, (req, res) =>
res.json({ url: `${baseUrl(req)}/auth?token=${raw}` }); res.json({ url: `${baseUrl(req)}/auth?token=${raw}` });
}); });
// ── Daten (global — wird in Epic 3.1 pro Nutzer + hinter Auth gestellt) ── // ── Daten pro Nutzer (hinter Auth; user_id NUR aus der Session, nie aus dem Client) ──
app.get('/api/state', (req, res) => { app.get('/api/state', authMiddleware, (req, res) => {
const state = { ...EMPTY, updatedAt: null }; const state = { ...EMPTY, updatedAt: null };
for (const key of KEYS) { for (const key of KEYS) {
const doc = getDoc(key); const doc = getDoc(req.user.id, key);
if (doc) { state[key] = doc.value; if (!state.updatedAt || doc.updatedAt > state.updatedAt) state.updatedAt = doc.updatedAt; } if (doc) { state[key] = doc.value; if (!state.updatedAt || doc.updatedAt > state.updatedAt) state.updatedAt = doc.updatedAt; }
} }
res.json(state); res.json(state);
}); });
app.put('/api/state', (req, res) => { app.put('/api/state', authMiddleware, (req, res) => {
const body = req.body || {}; const body = req.body || {};
const now = new Date().toISOString(); const now = new Date().toISOString();
for (const key of KEYS) { if (body[key] !== undefined) putDoc(key, body[key], now); } for (const key of KEYS) { if (body[key] !== undefined) putDoc(req.user.id, key, body[key], now); }
res.json({ status: 'ok', updatedAt: now }); res.json({ status: 'ok', updatedAt: now });
}); });
app.use(express.static(path.join(__dirname, '..'))); app.use(express.static(path.join(__dirname, '..')));
if (require.main === module) { if (require.main === module) {
// Startup-Reihenfolge: Fail-Fast auf ADMIN_EMAIL → Seed → Migration → listen.
const adminId = seedAdmin(process.env.ADMIN_EMAIL);
migrateToMultiUser(adminId);
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
app.listen(PORT, '0.0.0.0', () => { console.log(`Dienstplan-Pro auf :${PORT}`); scheduleBackups(); }); app.listen(PORT, '0.0.0.0', () => { console.log(`Dienstplan-Pro auf :${PORT}`); scheduleBackups(); });
} }

View file

@ -6,37 +6,56 @@ const path = require('path');
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dp-state-')); process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dp-state-'));
const app = require('./index'); const app = require('./index');
const { db } = require('./db');
const { createSession } = require('./auth');
async function req(port, method, body) { function seedUser(email) {
return Number(db.prepare('INSERT INTO users (email,is_admin,created_at) VALUES (?,0,?)')
.run(email, new Date().toISOString()).lastInsertRowid);
}
async function withServer(fn) {
const s = app.listen(0);
try { return await fn(s.address().port); } finally { s.close(); }
}
async function state(port, method, cookie, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } }; const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (cookie) opts.headers.Cookie = cookie;
if (body) opts.body = JSON.stringify(body); if (body) opts.body = JSON.stringify(body);
const res = await fetch(`http://127.0.0.1:${port}/api/state`, opts); const res = await fetch(`http://127.0.0.1:${port}/api/state`, opts);
return { status: res.status, body: await res.json() }; let b = null; try { b = await res.json(); } catch { /* leer */ }
return { status: res.status, body: b };
} }
test('leerer Server liefert leeren State', async () => { test('ohne Session → 401', async () => {
const server = app.listen(0); await withServer(async (port) => {
const { port } = server.address(); assert.strictEqual((await state(port, 'GET', null)).status, 401);
try { });
const { status, body } = await req(port, 'GET');
assert.strictEqual(status, 200);
assert.deepStrictEqual(body.employees, []);
assert.deepStrictEqual(body.duties, {});
assert.deepStrictEqual(body.vacation, {});
assert.strictEqual(body.updatedAt, null);
} finally { server.close(); }
}); });
test('PUT dann GET Roundtrip', async () => { test('eigener leerer State; PUT→GET Roundtrip', async () => {
const server = app.listen(0); const cookie = 'session=' + createSession(seedUser('a@x.de'));
const { port } = server.address(); await withServer(async (port) => {
try { let r = await state(port, 'GET', cookie);
const put = await req(port, 'PUT', { employees: ['Max'], duties: { Max: { '2026-07': [] } } }); assert.strictEqual(r.status, 200);
assert.strictEqual(put.status, 200); assert.deepStrictEqual(r.body.employees, []);
assert.strictEqual(put.body.status, 'ok'); r = await state(port, 'PUT', cookie, { employees: ['Max'], duties: { Max: { '2026-07': [] } } });
const get = await req(port, 'GET'); assert.strictEqual(r.status, 200);
assert.deepStrictEqual(get.body.employees, ['Max']); r = await state(port, 'GET', cookie);
assert.deepStrictEqual(get.body.duties, { Max: { '2026-07': [] } }); assert.deepStrictEqual(r.body.employees, ['Max']);
assert.strictEqual(typeof get.body.updatedAt, 'string'); assert.deepStrictEqual(r.body.duties, { Max: { '2026-07': [] } });
} finally { server.close(); } });
});
test('Datentrennung + Anti-IDOR: zwei Nutzer disjunkt, Client-user_id wird ignoriert', async () => {
const a = seedUser('user-a@x.de');
const b = seedUser('user-b@x.de');
const ca = 'session=' + createSession(a);
const cb = 'session=' + createSession(b);
await withServer(async (port) => {
// A schreibt und schmuggelt fremde user_id in den Body → muss wirkungslos sein
await state(port, 'PUT', ca, { employees: ['A'], user_id: b });
await state(port, 'PUT', cb, { employees: ['B'] });
assert.deepStrictEqual((await state(port, 'GET', ca)).body.employees, ['A']);
assert.deepStrictEqual((await state(port, 'GET', cb)).body.employees, ['B'], 'B unberührt vom user_id-Schmuggel');
});
}); });