feat(auth): Story 1.2 — normalizeEmail + Login-Token (SHA-256, einmalig, 30min)

This commit is contained in:
Kenearos 2026-07-07 22:29:03 +02:00
parent b2dbd31b41
commit fb708912d7
2 changed files with 97 additions and 0 deletions

45
server/auth.js Normal file
View file

@ -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 };

52
server/auth.test.js Normal file
View file

@ -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);
});