diff --git a/server/config.js b/server/config.js index 7f117ee..6973e3a 100644 --- a/server/config.js +++ b/server/config.js @@ -121,6 +121,14 @@ export const config = { }, publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''), + // Ticketsystem (WOMS), z. B. fuer den Link "Ticket oeffnen" in der Push-Zeitmail + womsBaseUrl: (process.env.WOMS_PUBLIC_URL || 'https://ticket.webklar.com').replace(/\/$/, ''), + + // Push-Zeitmail: signierte Links zum Eintragen der Zeit eines Git-Pushes (ohne Login) + pushTime: { + secret: process.env.PUSH_TIME_SECRET || process.env.SESSION_SECRET || '', + ttlDays: 14, + }, products, mailStorage, @@ -281,6 +289,9 @@ export function assertServerConfig() { if (!config.sessionSecret || config.sessionSecret.length < 32) { console.warn('[config] SESSION_SECRET fehlt oder ist zu kurz (min. 32 Zeichen).') } + if (!config.pushTime.secret || config.pushTime.secret.length < 32) { + console.warn('[config] PUSH_TIME_SECRET (bzw. SESSION_SECRET) fehlt oder ist zu kurz – Zeit-Links aus Push-Mails funktionieren nicht.') + } if (!config.appwrite.apiKey) { console.warn('[config] APPWRITE_API_KEY fehlt – DB-Zugriff und Webhook schlagen fehl.') } diff --git a/server/services/pushTimeToken.js b/server/services/pushTimeToken.js new file mode 100644 index 0000000..a6b9e45 --- /dev/null +++ b/server/services/pushTimeToken.js @@ -0,0 +1,40 @@ +import { createHmac, timingSafeEqual } from 'node:crypto' +import { config } from '../config.js' + +/** + * Signierte Links fuer die Push-Zeitmail: ., beides base64url. + * Nutzlast { w: Arbeitsblatt-ID, e: Mitarbeiter-Dokument-ID, x: Ablauf in Unix-Sekunden }. + * Zustandslos, damit Links einen Neustart ueberleben; der Link darf nur die Zeit genau dieses + * Arbeitsblatts eintragen (siehe routes/pushTime.js). + */ + +const sign = (payload) => createHmac('sha256', config.pushTime.secret).update(payload).digest('base64url') + +export function createPushTimeToken({ worksheetId, employeeId }, now = Date.now()) { + if (!config.pushTime.secret) throw new Error('PUSH_TIME_SECRET fehlt') + const expires = Math.floor(now / 1000) + config.pushTime.ttlDays * 86400 + const payload = Buffer.from(JSON.stringify({ w: worksheetId, e: employeeId, x: expires })).toString('base64url') + return `${payload}.${sign(payload)}` +} + +export function verifyPushTimeToken(token, now = Date.now()) { + const invalid = { error: 'invalid' } + if (!config.pushTime.secret || typeof token !== 'string') return invalid + const parts = token.split('.') + if (parts.length !== 2 || !parts[0] || !parts[1]) return invalid + const [payload, signature] = parts + + const expected = Buffer.from(sign(payload)) + const given = Buffer.from(signature) + if (given.length !== expected.length || !timingSafeEqual(given, expected)) return invalid + + let data + try { + data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) + } catch { + return invalid + } + if (typeof data?.w !== 'string' || typeof data?.e !== 'string' || !Number.isFinite(data?.x)) return invalid + if (data.x * 1000 <= now) return { error: 'expired' } + return { worksheetId: data.w, employeeId: data.e } +} diff --git a/test/pushTimeToken.test.js b/test/pushTimeToken.test.js new file mode 100644 index 0000000..d72a730 --- /dev/null +++ b/test/pushTimeToken.test.js @@ -0,0 +1,45 @@ +import { test, before } from 'node:test' +import assert from 'node:assert/strict' +import { createHmac } from 'node:crypto' + +let createPushTimeToken +let verifyPushTimeToken + +before(async () => { + process.env.PUSH_TIME_SECRET = 'x'.repeat(40) + // config.js liest die Umgebung beim Import + ;({ createPushTimeToken, verifyPushTimeToken } = await import('../server/services/pushTimeToken.js')) +}) + +const DAY = 86400 * 1000 +const b64 = (value) => Buffer.from(value).toString('base64url') + +test('Token rundet sauber', () => { + const token = createPushTimeToken({ worksheetId: 'ws1', employeeId: 'e1' }) + assert.deepEqual(verifyPushTimeToken(token), { worksheetId: 'ws1', employeeId: 'e1' }) +}) + +test('nach 14 Tagen abgelaufen', () => { + const token = createPushTimeToken({ worksheetId: 'ws1', employeeId: 'e1' }, 0) + assert.deepEqual(verifyPushTimeToken(token, 14 * DAY + 1000), { error: 'expired' }) + assert.deepEqual(verifyPushTimeToken(token, 14 * DAY - 1000), { worksheetId: 'ws1', employeeId: 'e1' }) +}) + +test('manipulierte Nutzlast oder Signatur', () => { + const token = createPushTimeToken({ worksheetId: 'ws1', employeeId: 'e1' }) + const [payload, sig] = token.split('.') + const data = JSON.parse(Buffer.from(payload, 'base64url').toString()) + const forged = `${b64(JSON.stringify({ ...data, w: 'ws2' }))}.${sig}` + assert.deepEqual(verifyPushTimeToken(forged), { error: 'invalid' }) + const lastChar = sig.at(-1) === 'A' ? 'B' : 'A' + assert.deepEqual(verifyPushTimeToken(`${payload}.${sig.slice(0, -1)}${lastChar}`), { error: 'invalid' }) + for (const bad of ['', 'abc', 'a.b.c', undefined, null]) { + assert.deepEqual(verifyPushTimeToken(bad), { error: 'invalid' }) + } +}) + +test('anderes Secret', () => { + const payload = b64(JSON.stringify({ w: 'ws1', e: 'e1', x: Math.floor(Date.now() / 1000) + 3600 })) + const sig = createHmac('sha256', 'y'.repeat(40)).update(payload).digest('base64url') + assert.deepEqual(verifyPushTimeToken(`${payload}.${sig}`), { error: 'invalid' }) +})