From cf3e0fd52ae0e78edb2001e4abdbfdf8c266c1ae Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:11:43 +0000 Subject: [PATCH] =?UTF-8?q?Auftr=C3=A4ge=20L5:=20Best=C3=A4tigungs-Token?= =?UTF-8?q?=20(32=20Zufallsbytes,=20nur=20als=20Hash=20gespeichert,=2060?= =?UTF-8?q?=20Tage=20g=C3=BCltig)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- server/services/auftraege/token.js | 23 ++++++++++++++++++++ test/auftraegeToken.test.js | 35 ++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 server/services/auftraege/token.js create mode 100644 test/auftraegeToken.test.js diff --git a/server/services/auftraege/token.js b/server/services/auftraege/token.js new file mode 100644 index 0000000..aec22e4 --- /dev/null +++ b/server/services/auftraege/token.js @@ -0,0 +1,23 @@ +/** + * Bestätigungs-Token einer Auftragsbestätigung (Übersicht V4 „L5“): 32 Zufallsbytes als base64url (43 Zeichen), + * gespeichert wird nur der SHA-256-Hash in Hex. Gültig 60 Tage ab dem letzten Versand. + * Importiert bewusst nur node:crypto und konstanten.js (nie server/config.js). + */ +import { createHash, randomBytes } from 'node:crypto' +import { TOKEN_GUELTIG_TAGE } from './konstanten.js' + +const TAG_MS = 86_400_000 +const TOKEN_FORMAT = /^[A-Za-z0-9_-]{43}$/ + +const sha256Hex = (text) => createHash('sha256').update(text).digest('hex') + +export function neuerToken(jetzt = new Date()) { + if (!(jetzt instanceof Date) || !Number.isFinite(jetzt.getTime())) throw new TypeError('neuerToken: jetzt muss ein gültiges Date sein') + const token = randomBytes(32).toString('base64url') + return { token, hash: sha256Hex(token), gueltigBis: new Date(jetzt.getTime() + TOKEN_GUELTIG_TAGE * TAG_MS) } +} + +/** Hash eines Tokens aus einem Link; `null`, wenn der Text nicht das Format eines Tokens hat. */ +export function hashToken(token) { + return typeof token === 'string' && TOKEN_FORMAT.test(token) ? sha256Hex(token) : null +} diff --git a/test/auftraegeToken.test.js b/test/auftraegeToken.test.js new file mode 100644 index 0000000..757035b --- /dev/null +++ b/test/auftraegeToken.test.js @@ -0,0 +1,35 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { hashToken, neuerToken } from '../server/services/auftraege/token.js' + +const TAG_MS = 24 * 60 * 60 * 1000 + +test('neuerToken: 32 Zufallsbytes als base64url (43 Zeichen), Hash = SHA-256 in Hex, gültig 60 Tage ab jetzt', () => { + const jetzt = new Date('2026-10-06T10:00:00.000Z') + const { token, hash, gueltigBis } = neuerToken(jetzt) + assert.match(token, /^[A-Za-z0-9_-]{43}$/) + assert.equal(hash, createHash('sha256').update(token).digest('hex')) + assert.ok(gueltigBis instanceof Date) + assert.equal(gueltigBis.toISOString(), '2026-12-05T10:00:00.000Z') + assert.equal(jetzt.toISOString(), '2026-10-06T10:00:00.000Z', 'das übergebene Datum bleibt unverändert') +}) + +test('neuerToken: jedes Token ist neu; ohne Argument zählt die aktuelle Zeit; ein ungültiger Zeitpunkt wirft', () => { + const vorher = Date.now() + const a = neuerToken() + const b = neuerToken() + const nachher = Date.now() + assert.notEqual(a.token, b.token) + assert.notEqual(a.hash, b.hash) + assert.ok(a.gueltigBis.getTime() >= vorher + 60 * TAG_MS && a.gueltigBis.getTime() <= nachher + 60 * TAG_MS) + assert.throws(() => neuerToken(new Date('kein Datum')), TypeError) + assert.throws(() => neuerToken('2026-10-06'), TypeError) +}) + +test('hashToken: gleicher Hash wie neuerToken, null bei falschem Format', () => { + const { token, hash } = neuerToken() + assert.equal(hashToken(token), hash) + const falsch = [undefined, null, 42, {}, [], '', 'kurz', `${token}x`, token.slice(1), ` ${token.slice(0, 42)}`, `${token.slice(0, 42)}+`, `${token.slice(0, 42)}=`, `../${token.slice(3)}`, `${token.slice(0, 42)}\n`] + for (const wert of falsch) assert.equal(hashToken(wert), null, JSON.stringify(wert)) +})