From c1fc60cf02c76b2bb169ae8a3ed37625f9128656 Mon Sep 17 00:00:00 2001 From: Kenearos Date: Tue, 7 Jul 2026 22:30:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(auth):=20Story=201.3=20=E2=80=94=20Session?= =?UTF-8?q?s=20(idle+absolut,=20gedrosselter=20last=5Fseen,=20delete)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/auth.js | 59 ++++++++++++++++++++++++++++++++++- server/session.test.js | 71 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 server/session.test.js diff --git a/server/auth.js b/server/auth.js index a94fe13..05bc055 100644 --- a/server/auth.js +++ b/server/auth.js @@ -4,6 +4,11 @@ const { db } = require('./db'); // Einmal-Token-Lebensdauer (Minuten). Scanner-sicher lang genug, aber kurz. const TOKEN_TTL_MIN = parseInt(process.env.TOKEN_TTL_MIN, 10) || 30; +// Sitzung: absolute Frist + Inaktivitäts-Frist. last_seen wird gedrosselt geschrieben. +const SESSION_TTL_DAYS = parseInt(process.env.SESSION_TTL_DAYS, 10) || 30; +const SESSION_IDLE_HOURS = parseInt(process.env.SESSION_IDLE_HOURS, 10) || 8; +const LAST_SEEN_THROTTLE_MS = 5 * 60 * 1000; + /** Identische Normalisierung für Speicherung UND Abgleich (case-insensitive). */ function normalizeEmail(email) { return String(email).toLowerCase().trim(); @@ -42,4 +47,56 @@ function consumeLoginToken(raw) { return { userId: row.userId, email: row.email, isAdmin: !!row.isAdmin }; } -module.exports = { normalizeEmail, hashToken, createLoginToken, consumeLoginToken, TOKEN_TTL_MIN }; +/** + * Erzeugt eine Sitzung, speichert nur den Hash der ID; gibt den Rohwert zurück + * (kommt ins httpOnly-Cookie). + */ +function createSession(userId) { + const raw = crypto.randomBytes(32).toString('hex'); + const now = new Date().toISOString(); + const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 86400 * 1000).toISOString(); + db.prepare('INSERT INTO sessions (id_hash, user_id, expires_at, created_at, last_seen_at) VALUES (?,?,?,?,?)') + .run(hashToken(raw), userId, expiresAt, now, now); + return raw; +} + +/** + * Prüft eine Roh-Session-ID: existiert, nicht absolut abgelaufen, nicht idle abgelaufen. + * Bumpt last_seen nur, wenn älter als der Throttle (kein Write pro Request). + * @returns {{userId:number,email:string,isAdmin:boolean}|null} + */ +function validateSession(raw) { + const nowMs = Date.now(); + const nowIso = new Date(nowMs).toISOString(); + const idh = hashToken(raw); + const row = db.prepare(` + SELECT s.id_hash AS idHash, s.user_id AS userId, s.expires_at AS expiresAt, + s.last_seen_at AS lastSeenAt, u.email, u.is_admin AS isAdmin + FROM sessions s JOIN users u ON s.user_id = u.id + WHERE s.id_hash = ? + `).get(idh); + if (!row) return null; + if (nowIso >= row.expiresAt) return null; // absolut abgelaufen + const idleMs = nowMs - Date.parse(row.lastSeenAt); + if (idleMs >= SESSION_IDLE_HOURS * 3600 * 1000) return null; // idle abgelaufen + if (idleMs >= LAST_SEEN_THROTTLE_MS) { // gedrosselter Bump + db.prepare('UPDATE sessions SET last_seen_at = ? WHERE id_hash = ?').run(nowIso, idh); + } + return { userId: row.userId, email: row.email, isAdmin: !!row.isAdmin }; +} + +/** Beendet eine Sitzung (Logout) per Roh-ID. */ +function deleteSession(raw) { + db.prepare('DELETE FROM sessions WHERE id_hash = ?').run(hashToken(raw)); +} + +/** Löscht alle Sitzungen eines Nutzers (z.B. bei Nutzer-Löschung; CASCADE deckt es ohnehin ab). */ +function deleteUserSessions(userId) { + db.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId); +} + +module.exports = { + normalizeEmail, hashToken, createLoginToken, consumeLoginToken, + createSession, validateSession, deleteSession, deleteUserSessions, + TOKEN_TTL_MIN, SESSION_TTL_DAYS, SESSION_IDLE_HOURS, +}; diff --git a/server/session.test.js b/server/session.test.js new file mode 100644 index 0000000..a11a3b4 --- /dev/null +++ b/server/session.test.js @@ -0,0 +1,71 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +process.env.SESSION_IDLE_HOURS = '8'; +process.env.SESSION_TTL_DAYS = '30'; +process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dp-sess-')); +const { db } = require('./db'); +const { createSession, validateSession, deleteSession, deleteUserSessions, hashToken } = require('./auth'); + +let n = 0; +function makeUser(isAdmin = 0) { + const info = db.prepare('INSERT INTO users (email,is_admin,created_at) VALUES (?,?,?)') + .run(`u${n++}@x.de`, isAdmin, new Date().toISOString()); + return Number(info.lastInsertRowid); +} + +test('valide Session → Nutzer; last_seen wird gebumpt wenn > Throttle alt', () => { + const uid = makeUser(1); + const raw = createSession(uid); + const idh = hashToken(raw); + const old = new Date(Date.now() - 10 * 60 * 1000).toISOString(); // 10 Min (innerhalb 8h Idle) + db.prepare('UPDATE sessions SET last_seen_at = ? WHERE id_hash = ?').run(old, idh); + const res = validateSession(raw); + assert.deepStrictEqual(res, { userId: uid, email: db.prepare('SELECT email FROM users WHERE id=?').get(uid).email, isAdmin: true }); + const after = db.prepare('SELECT last_seen_at FROM sessions WHERE id_hash=?').get(idh).last_seen_at; + assert.ok(after > old, 'last_seen gebumpt (war > 5min alt)'); +}); + +test('frische Session: kein Bump innerhalb Throttle', () => { + const uid = makeUser(); + const raw = createSession(uid); + const idh = hashToken(raw); + const ls1 = db.prepare('SELECT last_seen_at FROM sessions WHERE id_hash=?').get(idh).last_seen_at; + validateSession(raw); + const ls2 = db.prepare('SELECT last_seen_at FROM sessions WHERE id_hash=?').get(idh).last_seen_at; + assert.strictEqual(ls1, ls2, 'kein Write innerhalb Throttle'); +}); + +test('absolut abgelaufen → null', () => { + const uid = makeUser(); + const raw = createSession(uid); + db.prepare('UPDATE sessions SET expires_at=? WHERE id_hash=?').run('2000-01-01T00:00:00.000Z', hashToken(raw)); + assert.strictEqual(validateSession(raw), null); +}); + +test('idle abgelaufen → null', () => { + const uid = makeUser(); + const raw = createSession(uid); + const old = new Date(Date.now() - 9 * 60 * 60 * 1000).toISOString(); // 9h > 8h Idle + db.prepare('UPDATE sessions SET last_seen_at=? WHERE id_hash=?').run(old, hashToken(raw)); + assert.strictEqual(validateSession(raw), null); +}); + +test('deleteSession und deleteUserSessions', () => { + const uid = makeUser(); + const raw = createSession(uid); + deleteSession(raw); + assert.strictEqual(validateSession(raw), null); + const r2 = createSession(uid); + const r3 = createSession(uid); + deleteUserSessions(uid); + assert.strictEqual(validateSession(r2), null); + assert.strictEqual(validateSession(r3), null); +}); + +test('unbekannte Session → null', () => { + assert.strictEqual(validateSession('nichtexistent'), null); +});