Push-Zeitmail: signierter Token für Zeit-Links

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-30 13:33:39 +00:00
1 parent cdc814ae09
commit 5b7365b99e
3 files changed
+96

No files matched your search

+11
View File
@@ -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.')
}
+40
View File
@@ -0,0 +1,40 @@
import { createHmac, timingSafeEqual } from 'node:crypto'
import { config } from '../config.js'
/**
* Signierte Links fuer die Push-Zeitmail: <nutzlast>.<signatur>, 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 }
}
+45
View File
@@ -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' })
})