From b2dbd31b410ac8c95961eeadeeebe9ecc528eed3 Mon Sep 17 00:00:00 2001 From: Kenearos Date: Tue, 7 Jul 2026 22:21:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(auth):=20Story=201.1=20=E2=80=94=20Auth-Sc?= =?UTF-8?q?hema=20(users/tokens/sessions/audit)=20+=20foreign=5Fkeys=20+?= =?UTF-8?q?=20audit-Helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fuegt users/login_tokens/sessions/audit_log Tabellen + FK-Indizes zu server/db.js hinzu (foreign_keys=ON fuer CASCADE), plus server/audit.js (append-only, keine PII). Bestehende documents/history-Tabellen und exports unveraendert. TDD: server/auth-schema.test.js zuerst rot, dann gruen; volle Suite (24 Tests) regressionsfrei. --- server/audit.js | 9 +++++ server/auth-schema.test.js | 77 ++++++++++++++++++++++++++++++++++++++ server/db.js | 29 ++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 server/audit.js create mode 100644 server/auth-schema.test.js diff --git a/server/audit.js b/server/audit.js new file mode 100644 index 0000000..4bd732c --- /dev/null +++ b/server/audit.js @@ -0,0 +1,9 @@ +const { db } = require('./db'); + +// Append-only Audit-Log. Keine PII (kein E-Mail/Name) — nur event, user_id, ip_hash. +function audit(event, userId = null, ipHash = null) { + db.prepare('INSERT INTO audit_log (ts, event, user_id, ip_hash) VALUES (?, ?, ?, ?)') + .run(new Date().toISOString(), event, userId, ipHash); +} + +module.exports = { audit }; diff --git a/server/auth-schema.test.js b/server/auth-schema.test.js new file mode 100644 index 0000000..0e33cf8 --- /dev/null +++ b/server/auth-schema.test.js @@ -0,0 +1,77 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const os = require('os'); +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-auth-schema-')); +const { db } = require('./db'); +const { audit } = require('./audit'); + +function tableNames() { + return db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r) => r.name); +} + +function indexNames() { + return db.prepare("SELECT name FROM sqlite_master WHERE type='index'").all().map((r) => r.name); +} + +test('Auth-Tabellen existieren', () => { + const names = tableNames(); + assert.ok(names.includes('users'), 'users fehlt'); + assert.ok(names.includes('login_tokens'), 'login_tokens fehlt'); + assert.ok(names.includes('sessions'), 'sessions fehlt'); + assert.ok(names.includes('audit_log'), 'audit_log fehlt'); +}); + +test('foreign_keys ist aktiv', () => { + assert.strictEqual(db.pragma('foreign_keys', { simple: true }), 1); +}); + +test('Indizes auf login_tokens.user_id und sessions.user_id existieren', () => { + const names = indexNames(); + assert.ok(names.includes('idx_login_tokens_user'), 'idx_login_tokens_user fehlt'); + assert.ok(names.includes('idx_sessions_user'), 'idx_sessions_user fehlt'); +}); + +test('foreign_keys CASCADE: Nutzer löschen räumt tokens+sessions', () => { + const now = '2026-07-07T10:00:00.000Z'; + const userId = db.prepare('INSERT INTO users (email, is_admin, created_at) VALUES (?, 0, ?)') + .run('cascade-test@example.com', now).lastInsertRowid; + db.prepare('INSERT INTO login_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)') + .run('hash-cascade', userId, now); + db.prepare('INSERT INTO sessions (id_hash, user_id, expires_at, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?)') + .run('sess-cascade', userId, now, now, now); + + db.prepare('DELETE FROM users WHERE id = ?').run(userId); + + assert.strictEqual(db.prepare('SELECT * FROM login_tokens WHERE user_id = ?').get(userId), undefined); + assert.strictEqual(db.prepare('SELECT * FROM sessions WHERE user_id = ?').get(userId), undefined); +}); + +test('audit() schreibt append-only Zeile ohne PII', () => { + const before = db.prepare('SELECT COUNT(*) AS n FROM audit_log').get().n; + audit('login_ok', 1, 'abc'); + const after = db.prepare('SELECT COUNT(*) AS n FROM audit_log').get().n; + assert.strictEqual(after, before + 1); + + const row = db.prepare('SELECT * FROM audit_log ORDER BY id DESC LIMIT 1').get(); + assert.strictEqual(row.event, 'login_ok'); + assert.strictEqual(row.user_id, 1); + assert.strictEqual(row.ip_hash, 'abc'); + assert.ok(row.ts, 'ts fehlt'); + // keine PII-Spalten (email/name) vorhanden + assert.deepStrictEqual( + Object.keys(row).sort(), + ['event', 'id', 'ip_hash', 'ts', 'user_id'].sort() + ); +}); + +test('audit() ohne userId/ipHash (defaults null)', () => { + audit('server_start'); + const row = db.prepare('SELECT * FROM audit_log ORDER BY id DESC LIMIT 1').get(); + assert.strictEqual(row.event, 'server_start'); + assert.strictEqual(row.user_id, null); + assert.strictEqual(row.ip_hash, null); +}); diff --git a/server/db.js b/server/db.js index c6e6382..174516c 100644 --- a/server/db.js +++ b/server/db.js @@ -9,6 +9,7 @@ const DB_PATH = path.join(DATA_DIR, 'dienstplan.db'); const db = new Database(DB_PATH); db.pragma('journal_mode = WAL'); db.pragma('synchronous = NORMAL'); +db.pragma('foreign_keys = ON'); db.exec(` CREATE TABLE IF NOT EXISTS documents ( @@ -22,6 +23,34 @@ db.exec(` value TEXT NOT NULL, replaced_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS login_tokens ( + token_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + used_at TEXT + ); + CREATE TABLE IF NOT EXISTS sessions ( + id_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, + event TEXT NOT NULL, + user_id INTEGER, + ip_hash TEXT + ); + CREATE INDEX IF NOT EXISTS idx_login_tokens_user ON login_tokens(user_id); + CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); `); function getDoc(key) {