From fb708912d7767abd0dae007f0baa30d139154230 Mon Sep 17 00:00:00 2001 From: Kenearos Date: Tue, 7 Jul 2026 22:29:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(auth):=20Story=201.2=20=E2=80=94=20normali?= =?UTF-8?q?zeEmail=20+=20Login-Token=20(SHA-256,=20einmalig,=2030min)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/auth.js | 45 +++++++++++++++++++++++++++++++++++++++ server/auth.test.js | 52 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 server/auth.js create mode 100644 server/auth.test.js diff --git a/server/auth.js b/server/auth.js new file mode 100644 index 0000000..a94fe13 --- /dev/null +++ b/server/auth.js @@ -0,0 +1,45 @@ +const crypto = require('crypto'); +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; + +/** Identische Normalisierung für Speicherung UND Abgleich (case-insensitive). */ +function normalizeEmail(email) { + return String(email).toLowerCase().trim(); +} + +/** SHA-256-Hex. Nur der Hash landet in der DB; der Rohwert lebt im Link/Cookie. */ +function hashToken(raw) { + return crypto.createHash('sha256').update(String(raw)).digest('hex'); +} + +/** + * Erzeugt einen Einmal-Login-Token für userId, speichert nur den Hash + Ablauf, + * gibt den ROHWERT zurück (kommt in den Magic-Link). + */ +function createLoginToken(userId) { + const raw = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + TOKEN_TTL_MIN * 60 * 1000).toISOString(); + db.prepare('INSERT INTO login_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)') + .run(hashToken(raw), userId, expiresAt); + return raw; +} + +/** + * Löst einen Roh-Token einmalig ein: prüft Hash + nicht abgelaufen + nicht verbraucht, + * markiert ihn als verbraucht und liefert { userId, email, isAdmin } — sonst null. + */ +function consumeLoginToken(raw) { + const now = new Date().toISOString(); + const row = db.prepare(` + SELECT t.token_hash AS tokenHash, t.user_id AS userId, u.email, u.is_admin AS isAdmin + FROM login_tokens t JOIN users u ON t.user_id = u.id + WHERE t.token_hash = ? AND t.used_at IS NULL AND t.expires_at > ? + `).get(hashToken(raw), now); + if (!row) return null; + db.prepare('UPDATE login_tokens SET used_at = ? WHERE token_hash = ?').run(now, row.tokenHash); + return { userId: row.userId, email: row.email, isAdmin: !!row.isAdmin }; +} + +module.exports = { normalizeEmail, hashToken, createLoginToken, consumeLoginToken, TOKEN_TTL_MIN }; diff --git a/server/auth.test.js b/server/auth.test.js new file mode 100644 index 0000000..ddfa35e --- /dev/null +++ b/server/auth.test.js @@ -0,0 +1,52 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dp-auth-')); +const { db } = require('./db'); +const { normalizeEmail, hashToken, createLoginToken, consumeLoginToken } = require('./auth'); + +function makeUser(email = 'a@b.de', isAdmin = 0) { + const info = db.prepare('INSERT INTO users (email,is_admin,created_at) VALUES (?,?,?)') + .run(email, isAdmin, new Date().toISOString()); + return Number(info.lastInsertRowid); +} + +test('normalizeEmail: lowercase + trim', () => { + assert.strictEqual(normalizeEmail(' Foo@Bar.DE '), 'foo@bar.de'); +}); + +test('createLoginToken speichert nur Hash; consume liefert Nutzer genau einmal', () => { + const uid = makeUser('roundtrip@x.de', 1); + const raw = createLoginToken(uid); + + // Rohwert darf NICHT als token_hash in der DB liegen + assert.strictEqual( + db.prepare('SELECT 1 FROM login_tokens WHERE token_hash = ?').get(raw), + undefined, + 'Rohtoken darf nicht als token_hash gespeichert sein' + ); + assert.ok( + db.prepare('SELECT 1 FROM login_tokens WHERE token_hash = ?').get(hashToken(raw)), + 'Hash muss gespeichert sein' + ); + + assert.deepStrictEqual(consumeLoginToken(raw), { userId: uid, email: 'roundtrip@x.de', isAdmin: true }); + assert.strictEqual(consumeLoginToken(raw), null, 'zweite Einlösung → null'); +}); + +test('abgelaufener Token → null', () => { + const uid = makeUser('expired@x.de'); + const raw = 'deadbeef'.repeat(8); + db.prepare('INSERT INTO login_tokens (token_hash, user_id, expires_at) VALUES (?,?,?)') + .run(hashToken(raw), uid, '2000-01-01T00:00:00.000Z'); + assert.strictEqual(consumeLoginToken(raw), null); +}); + +test('nicht-Admin: isAdmin === false', () => { + const uid = makeUser('plain@x.de', 0); + const raw = createLoginToken(uid); + assert.strictEqual(consumeLoginToken(raw).isAdmin, false); +});