feat(auth): Story 1.2 — normalizeEmail + Login-Token (SHA-256, einmalig, 30min)
This commit is contained in:
parent
b2dbd31b41
commit
fb708912d7
2 changed files with 97 additions and 0 deletions
45
server/auth.js
Normal file
45
server/auth.js
Normal 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
52
server/auth.test.js
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue