Kundenansicht: zentraler Waechter (2 h, Verwechslungsschutz, Schreibsperre ausser News)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-01 19:29:47 +00:00
1 parent 1cd776a2ee
commit 25f3c1df4d
5 files changed
+250 -12

No files matched your search

+56
View File
@@ -0,0 +1,56 @@
import { parsePortalSession } from './session.js'
import { endKundenansicht, isExpired, isKundenansicht, logOnce } from '../services/kundenansicht.js'
/**
* Waechter der Kundenansicht vor allen Kunden-Routen (Spec 2026-10-01, Abschnitt 3):
* 1. abgelaufen (2 h) -> 401 view_expired, Cookies weg
* 2. fremde Kennung im Header X-Kundenansicht -> 409 view_replaced (Tab einer anderen Kundenansicht)
* 3. Lesen ist erlaubt; Schreiben nur mit passender Kennung und nur, was auf der Erlaubnisliste steht.
* Neue Schreib-Routen sind damit in der Kundenansicht gesperrt, bis sie bewusst auf die Liste kommen.
* Alte Ansichten aus dem Admin-Panel (ohne viewId) duerfen nichts schreiben ausser Ab- und Anmelden.
*/
export const KUNDENANSICHT_ALLOWED = [
['POST', /^\/api\/news\/?$/],
['POST', /^\/api\/news\/image\/?$/],
['PATCH', /^\/api\/news\/[^/]+\/?$/],
['DELETE', /^\/api\/news\/[^/]+\/?$/],
['POST', /^\/api\/funktionen\/analyse\/heatmap-link\/?$/],
['POST', /^\/api\/auth\/kundenansicht\/ende\/?$/],
['POST', /^\/api\/auth\/logout\/?$/],
['POST', /^\/api\/auth\/login\/?$/],
]
const LEGACY_ALLOWED = [
['POST', /^\/api\/auth\/logout\/?$/],
['POST', /^\/api\/auth\/login\/?$/],
]
// Eigene Pruefung (Ticketsystem-JWT, Admin-Sitzung) oder Ablauf/Wechsel selbst behandelt (me, Einloesen)
const PASS_PREFIXES = ['/api/team/', '/api/admin/', '/api/portal-admin/']
const PASS_EXACT = ['/api/auth/me', '/api/auth/kundenansicht']
const READ_METHODS = ['GET', 'HEAD', 'OPTIONS']
const replaced = (res, session) =>
res.status(409).json({ code: 'view_replaced', kunde: session.name || '', error: 'Diese Ansicht wurde durch eine andere Kundenansicht ersetzt.' })
export async function kundenansichtGuard(req, res, next) {
const session = parsePortalSession(req)
if (!session?.viewAs) return next()
const pathname = req.originalUrl.split('?')[0]
if (!pathname.startsWith('/api/')) return next()
if (PASS_PREFIXES.some((p) => pathname.startsWith(p)) || PASS_EXACT.includes(pathname.replace(/\/$/, ''))) return next()
const current = isKundenansicht(session)
if (current && isExpired(session)) {
endKundenansicht(res)
await logOnce(session, 'abgelaufen')
return res.status(401).json({ code: 'view_expired', error: 'Die Kundenansicht ist abgelaufen. Bitte im Ticketsystem erneut öffnen.' })
}
const header = req.get('X-Kundenansicht')
if (current && header && header !== session.viewId) return replaced(res, session)
if (READ_METHODS.includes(req.method)) return next()
if (current && !header) return replaced(res, session)
const list = current ? KUNDENANSICHT_ALLOWED : LEGACY_ALLOWED
if (list.some(([method, re]) => method === req.method && re.test(pathname))) return next()
return res.status(403).json({ code: 'view_blocked', error: 'In der Kundenansicht nicht möglich.' })
}