Terminplaner: Zellen gegen Doppelbuchung, Tokens, Buchungsspeicher
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4def5c735d
commit
e281db1b2b
5 files changed
+1315
-1
No files matched your search
@@ -0,0 +1,97 @@
|
||||
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
||||
import { config } from '../../config.js'
|
||||
|
||||
/**
|
||||
* Terminplaner-Tokens (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitte 4 und 8).
|
||||
*
|
||||
* - Bestaetigungstoken: 32 Zufallsbytes (base64url), gespeichert wird nur der SHA-256-Hex (verifyHash).
|
||||
* - Verwaltungstoken: base64url(HMAC-SHA256(Schluessel, bookingId)); jederzeit neu berechenbar, damit spaetere Mails
|
||||
* (Erinnerung) denselben Link tragen, gespeichert wird nur sein Hash (manageHash). Wie lange er gilt (Terminende
|
||||
* + 1 Tag), entscheidet der Aufrufer anhand der Buchung.
|
||||
* - Buchungsnummer: 8 Zeichen A-Z0-9, nur aus der Buchungs-ID berechnet (kein eigenes Feld); steht in der Bestaetigung
|
||||
* und gehoert beim Widerruf ohne Link zusammen mit der E-Mail-Adresse.
|
||||
* - Formular-Token: "<Ausstellzeit base36>.<HMAC>" je siteKey, beweist nur Alter und Herkunft (kein Geheimnis fuer
|
||||
* den Besucher). Zu schnell abgeschickt (config.termine.formMinSekunden) = Bot, aelter als 2 Stunden = neu laden.
|
||||
*
|
||||
* Die HMAC-Schluessel werden je Zweck aus config.sessionSecret abgeleitet (Beschriftung 'termine-manage' bzw.
|
||||
* 'termine-form'), damit ein Token nie fuer einen anderen Zweck gilt. config wird bei jedem Aufruf gelesen.
|
||||
*/
|
||||
|
||||
const FORM_MAX_ALTER_MS = 2 * 60 * 60 * 1000
|
||||
const FORM_TOKEN_MAX_LAENGE = 100
|
||||
const MAC_RE = /^[A-Za-z0-9_-]{43}$/
|
||||
const ZEIT_RE = /^[0-9a-z]{1,13}$/
|
||||
|
||||
const sha256Hex = (text) => createHash('sha256').update(text).digest('hex')
|
||||
|
||||
/** Schluessel fuer genau einen Zweck; ohne (ausreichend langes) Sitzungsgeheimnis gibt es keine Tokens. */
|
||||
function schluessel(zweck) {
|
||||
const geheimnis = config.sessionSecret
|
||||
if (typeof geheimnis !== 'string' || geheimnis.length < 32) {
|
||||
throw new Error('Terminplaner-Token: SESSION_SECRET fehlt oder ist zu kurz (mindestens 32 Zeichen).')
|
||||
}
|
||||
return createHmac('sha256', geheimnis).update(zweck).digest()
|
||||
}
|
||||
|
||||
function pruefeText(wert, name) {
|
||||
if (typeof wert !== 'string' || wert === '') throw new TypeError(`${name} fehlt`)
|
||||
}
|
||||
|
||||
function zeitMs(now, name) {
|
||||
const ms = now instanceof Date ? now.getTime() : now
|
||||
if (typeof ms !== 'number' || !Number.isFinite(ms)) throw new TypeError(`${name}: now fehlt`)
|
||||
return ms
|
||||
}
|
||||
|
||||
/** Neues Bestaetigungstoken: token geht in den Link, hash (SHA-256-Hex) in die Buchung. */
|
||||
export function neuesToken() {
|
||||
const token = randomBytes(32).toString('base64url')
|
||||
return { token, hash: hashToken(token) }
|
||||
}
|
||||
|
||||
/** SHA-256-Hex eines Tokens. Fremde Eingaben (kein Text) ergeben den Hash des leeren Textes und treffen nichts. */
|
||||
export function hashToken(token) {
|
||||
return sha256Hex(typeof token === 'string' ? token : '')
|
||||
}
|
||||
|
||||
/** Verwaltungstoken einer Buchung (stabil; bei geaendertem Sitzungsgeheimnis ein anderer Wert). */
|
||||
export function verwaltungsToken(bookingId) {
|
||||
pruefeText(bookingId, 'bookingId')
|
||||
return createHmac('sha256', schluessel('termine-manage')).update(bookingId).digest('base64url')
|
||||
}
|
||||
|
||||
/** Buchungsnummer aus der Buchungs-ID: 8 Zeichen A-Z0-9 (die ersten 64 Bit des SHA-256 zur Basis 36). */
|
||||
export function buchungsNummer(bookingId) {
|
||||
pruefeText(bookingId, 'bookingId')
|
||||
const zahl = createHash('sha256').update(`termine-nummer|${bookingId}`).digest().readBigUInt64BE(0)
|
||||
return (zahl % 36n ** 8n).toString(36).toUpperCase().padStart(8, '0')
|
||||
}
|
||||
|
||||
function formMac(siteKey, zeit) {
|
||||
return createHmac('sha256', schluessel('termine-form')).update(`${siteKey}|${zeit}`).digest('base64url')
|
||||
}
|
||||
|
||||
/** Formular-Token fuer ein Widget dieses siteKeys, ausgestellt zum Zeitpunkt now. */
|
||||
export function formToken(siteKey, now = new Date()) {
|
||||
pruefeText(siteKey, 'siteKey')
|
||||
const zeit = zeitMs(now, 'formToken').toString(36)
|
||||
return `${zeit}.${formMac(siteKey, zeit)}`
|
||||
}
|
||||
|
||||
/** 'ok' | 'zu_schnell' (juenger als formMinSekunden) | 'abgelaufen' (aelter als 2 h) | 'ungueltig' (fremd/gefaelscht). */
|
||||
export function pruefeFormToken(siteKey, token, now = new Date()) {
|
||||
if (typeof siteKey !== 'string' || siteKey === '') return 'ungueltig'
|
||||
if (typeof token !== 'string' || token.length > FORM_TOKEN_MAX_LAENGE) return 'ungueltig'
|
||||
const teile = token.split('.')
|
||||
if (teile.length !== 2) return 'ungueltig'
|
||||
const [zeit, mac] = teile
|
||||
if (!ZEIT_RE.test(zeit) || !MAC_RE.test(mac)) return 'ungueltig'
|
||||
const erwartet = Buffer.from(formMac(siteKey, zeit))
|
||||
const gegeben = Buffer.from(mac)
|
||||
if (gegeben.length !== erwartet.length || !timingSafeEqual(gegeben, erwartet)) return 'ungueltig'
|
||||
|
||||
const alter = zeitMs(now, 'pruefeFormToken') - parseInt(zeit, 36)
|
||||
if (alter > FORM_MAX_ALTER_MS) return 'abgelaufen'
|
||||
if (alter < config.termine.formMinSekunden * 1000) return 'zu_schnell'
|
||||
return 'ok'
|
||||
}
|
||||
Reference in new issue
Block a user