Kundenansicht: zentraler Waechter (2 h, Verwechslungsschutz, Schreibsperre ausser News)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1cd776a2ee
commit
25f3c1df4d
5 files changed
+250
-12
No files matched your search
@@ -42,6 +42,7 @@ import adminSwitchLinkRoutes from './routes/admin/switchLink.js'
|
||||
import adminFinanzenRoutes from './routes/admin/finanzen.js'
|
||||
import teamRoutes from './routes/team/index.js'
|
||||
import { corsMiddleware } from './middleware/cors.js'
|
||||
import { kundenansichtGuard } from './middleware/kundenansichtGuard.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const publicDir = path.join(__dirname, '..', 'public')
|
||||
@@ -51,6 +52,9 @@ assertServerConfig()
|
||||
const app = express()
|
||||
app.set('trust proxy', 1)
|
||||
app.use(sessionMiddleware())
|
||||
// Kundenansicht (Mitarbeiter im Portal eines Kunden): Ablauf, Verwechslungsschutz und Schreibsperre vor allen
|
||||
// Kunden-Routen, auch vor dem 15-MB-Parser der Gutschriften
|
||||
app.use(kundenansichtGuard)
|
||||
// Uebernahmegutschrift: Nachweise kommen als Base64 im JSON (max. 10 MB Dateien) -> eigenes Limit,
|
||||
// muss vor dem globalen Parser stehen (der ueberspringt bereits geparste Bodies). Login-Pruefung
|
||||
// zuerst, damit niemand ohne Session grosse Bodies parsen laesst.
|
||||
|
||||
@@ -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.' })
|
||||
}
|
||||
+3
-11
@@ -34,13 +34,6 @@ function writeRateLimited(customerId) {
|
||||
return list.length > MAX_WRITES_PER_HOUR
|
||||
}
|
||||
|
||||
function rejectViewAs(req, res, next) {
|
||||
if (req.session.viewAs) {
|
||||
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nichts geändert werden.' })
|
||||
}
|
||||
return next()
|
||||
}
|
||||
|
||||
function limitWrites(req, res, next) {
|
||||
if (writeRateLimited(getSessionCustomerId(req))) {
|
||||
return res.status(429).json({ error: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.' })
|
||||
@@ -82,7 +75,7 @@ router.get('/', requireSession, withProject(), async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/', requireSession, rejectViewAs, withProject(), limitWrites, async (req, res) => {
|
||||
router.post('/', requireSession, withProject(), limitWrites, async (req, res) => {
|
||||
try {
|
||||
const post = await createPost({ customerId: getSessionCustomerId(req), projectId: req.projectId, input: req.body })
|
||||
res.status(201).json(post)
|
||||
@@ -95,7 +88,6 @@ router.post('/', requireSession, rejectViewAs, withProject(), limitWrites, async
|
||||
router.post(
|
||||
'/image',
|
||||
requireSession,
|
||||
rejectViewAs,
|
||||
withProject(),
|
||||
(req, res, next) => {
|
||||
if (newsImageRateLimited(getSessionCustomerId(req))) {
|
||||
@@ -131,7 +123,7 @@ router.get('/image/:projectId/:file', requireSession, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
router.patch('/:id', requireSession, rejectViewAs, withProject(), limitWrites, async (req, res) => {
|
||||
router.patch('/:id', requireSession, withProject(), limitWrites, async (req, res) => {
|
||||
try {
|
||||
const post = await updatePost({ customerId: getSessionCustomerId(req), projectId: req.projectId, id: req.params.id, input: req.body })
|
||||
res.json(post)
|
||||
@@ -140,7 +132,7 @@ router.patch('/:id', requireSession, rejectViewAs, withProject(), limitWrites, a
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/:id', requireSession, rejectViewAs, withProject(), limitWrites, async (req, res) => {
|
||||
router.delete('/:id', requireSession, withProject(), limitWrites, async (req, res) => {
|
||||
try {
|
||||
await deletePost({ customerId: getSessionCustomerId(req), projectId: req.projectId, id: req.params.id })
|
||||
res.status(204).end()
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { test, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
|
||||
// Waechter der Kundenansicht: Ablauf nach 2 h, Verwechslungsschutz (X-Kundenansicht), Schreibsperre ausser News
|
||||
process.env.NEWS_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'kundenansicht-guard-'))
|
||||
const aw = await startAppwriteMock({
|
||||
schema: {
|
||||
customerViewLog: schemaKeys('customerViewLog'),
|
||||
portalFeatures: schemaKeys('portalFeatures'),
|
||||
newsPosts: schemaKeys('newsPosts'),
|
||||
},
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
const { config } = await import('../server/config.js')
|
||||
const { startTestApp } = await import('./support/testApp.js')
|
||||
const { seedFixtures, customerCookie, kundenansichtCookie, viewAsCookie } = await import('./support/fixtures.js')
|
||||
const { kundenansichtGuard, KUNDENANSICHT_ALLOWED } = await import('../server/middleware/kundenansichtGuard.js')
|
||||
const { createStaffPreviewToken } = await import('../server/services/staffPreviewTokens.js')
|
||||
|
||||
// Kunden-Router mit denselben Mount-Pfaden wie server/index.js
|
||||
const MOUNTS = [
|
||||
['/api/auth', (await import('../server/routes/auth.js')).default],
|
||||
['/api/projects', (await import('../server/routes/projects.js')).default],
|
||||
['/api/features', (await import('../server/routes/features.js')).default],
|
||||
['/api/news', (await import('../server/routes/news.js')).default],
|
||||
['/api', (await import('../server/routes/customerData.js')).default],
|
||||
['/api/chat', (await import('../server/routes/chat.js')).default],
|
||||
['/api/abo/takeover-credits', (await import('../server/routes/takeoverCredits.js')).default],
|
||||
['/api/abo', (await import('../server/routes/abo.js')).default],
|
||||
['/api/funktionen', (await import('../server/routes/funktionen.js')).default],
|
||||
]
|
||||
const app = await startTestApp({ '/': kundenansichtGuard, ...Object.fromEntries(MOUNTS) })
|
||||
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
||||
|
||||
seedFixtures(aw)
|
||||
aw.seed('customerPortalAccess', { $id: 'pa1', customerId: 'c1', enabled: true })
|
||||
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', liveDomain: 'baeckerei.de' })
|
||||
aw.seed('portalFeatures', { $id: 'f_p1_news', customerId: 'c1', projectId: 'p1', featureKey: 'news', state: 'active', enabled: true, siteKey: 'abcdefghijklmnop' })
|
||||
|
||||
const VIEW = 'a'.repeat(32)
|
||||
const note = { projectId: 'p1', type: 'note', title: 'Hallo', text: 'Wir haben offen', paper: 'sun', duration: '1m' }
|
||||
const kv = (extra = {}) => ({ cookie: kundenansichtCookie(extra), headers: { 'x-kundenansicht': extra.viewId || VIEW } })
|
||||
|
||||
async function call(method, url, { cookie, headers = {}, body } = {}) {
|
||||
const res = await fetch(app.url + url, {
|
||||
method,
|
||||
redirect: 'manual',
|
||||
headers: { ...(cookie ? { cookie } : {}), ...(body !== undefined ? { 'content-type': 'application/json' } : {}), ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
const text = await res.text()
|
||||
let parsed = text
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null
|
||||
} catch {
|
||||
/* Text-Antwort */
|
||||
}
|
||||
return { status: res.status, body: parsed, headers: res.headers }
|
||||
}
|
||||
|
||||
/** Alle nicht-lesenden Routen der Router (router.stack), :param durch 'x' ersetzt. */
|
||||
function writeRoutes(mounts) {
|
||||
const routes = []
|
||||
for (const [prefix, router] of mounts) {
|
||||
for (const layer of router.stack) {
|
||||
if (!layer.route) continue
|
||||
for (const method of Object.keys(layer.route.methods)) {
|
||||
if (['get', 'head', 'options', '_all'].includes(method)) continue
|
||||
const sub = String(layer.route.path).replace(/:[A-Za-z]+/g, 'x')
|
||||
routes.push({ method: method.toUpperCase(), path: `${prefix === '/api' ? '/api' : prefix}${sub === '/' ? '' : sub}` || '/' })
|
||||
}
|
||||
}
|
||||
}
|
||||
return routes
|
||||
}
|
||||
const allowed = (method, p) => KUNDENANSICHT_ALLOWED.some(([m, re]) => m === method && re.test(p))
|
||||
const logEntries = () => [...aw.docs('customerViewLog').values()]
|
||||
// geloeschtes Cookie: Ablauf 1970 (ein signiertes behaelt dabei einen leeren signierten Wert "s:.<sig>")
|
||||
const clears = (res, name) => res.headers.getSetCookie().some((c) => c.startsWith(`${name}=`) && /Expires=Thu, 01 Jan 1970/.test(c))
|
||||
|
||||
test('jede Schreib-Route ausserhalb der Erlaubnisliste -> 403 view_blocked', async () => {
|
||||
const routes = writeRoutes(MOUNTS)
|
||||
assert.ok(routes.length >= 25, `nur ${routes.length} Schreib-Routen gefunden`)
|
||||
let checked = 0
|
||||
for (const { method, path: p } of routes) {
|
||||
if (allowed(method, p)) continue
|
||||
const res = await call(method, p, kv())
|
||||
assert.equal(res.status, 403, `${method} ${p}`)
|
||||
assert.equal(res.body.code, 'view_blocked', `${method} ${p}`)
|
||||
checked += 1
|
||||
}
|
||||
assert.ok(checked >= 20, `nur ${checked} gesperrte Routen geprueft`)
|
||||
})
|
||||
|
||||
test('Erlaubnisliste: genau News, Analyse-Link, Ende, Logout und Login', () => {
|
||||
const ok = [
|
||||
['POST', '/api/news'], ['POST', '/api/news/image'], ['PATCH', '/api/news/abc'], ['DELETE', '/api/news/abc'],
|
||||
['POST', '/api/funktionen/analyse/heatmap-link'], ['POST', '/api/auth/kundenansicht/ende'],
|
||||
['POST', '/api/auth/logout'], ['POST', '/api/auth/login'],
|
||||
]
|
||||
for (const [m, p] of ok) assert.ok(allowed(m, p), `${m} ${p}`)
|
||||
for (const [m, p] of [['POST', '/api/chat/read'], ['POST', '/api/news/abc'], ['POST', '/api/auth/password'], ['POST', '/api/abo/checkout'], ['POST', '/api/funktionen/news/checkout']]) {
|
||||
assert.ok(!allowed(m, p), `${m} ${p}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('POST /api/chat/read ist gesperrt (Lesestatus bleibt unberuehrt)', async () => {
|
||||
const res = await call('POST', '/api/chat/read', kv())
|
||||
assert.equal(res.status, 403)
|
||||
})
|
||||
|
||||
test('News anlegen in der Kundenansicht erlaubt', async () => {
|
||||
const res = await call('POST', '/api/news', { ...kv(), body: note })
|
||||
assert.equal(res.status, 201)
|
||||
})
|
||||
|
||||
test('fremde Kennung -> 409 view_replaced mit Kundennamen, auch beim Lesen', async () => {
|
||||
const res = await call('GET', '/api/projects', { cookie: kundenansichtCookie(), headers: { 'x-kundenansicht': 'b'.repeat(32) } })
|
||||
assert.equal(res.status, 409)
|
||||
assert.deepEqual([res.body.code, res.body.kunde], ['view_replaced', 'Kunde Eins'])
|
||||
})
|
||||
|
||||
test('Schreiben ohne Kennung -> 409, Lesen ohne Kennung -> erlaubt', async () => {
|
||||
const write = await call('POST', '/api/news', { cookie: kundenansichtCookie(), body: note })
|
||||
assert.equal(write.status, 409)
|
||||
assert.equal(write.body.code, 'view_replaced')
|
||||
const read = await call('GET', '/api/news?projectId=p1', { cookie: kundenansichtCookie() })
|
||||
assert.equal(read.status, 200)
|
||||
})
|
||||
|
||||
test('genau bei viewExpiresAt abgelaufen: 401, Cookies geloescht, Protokoll abgelaufen genau einmal', async (t) => {
|
||||
const at = Date.now()
|
||||
t.mock.method(Date, 'now', () => at)
|
||||
const viewId = 'e'.repeat(32)
|
||||
const opts = kv({ viewId, viewExpiresAt: at })
|
||||
const before = logEntries().filter((d) => d.action === 'abgelaufen').length
|
||||
const first = await call('GET', '/api/projects', opts)
|
||||
const second = await call('POST', '/api/news', { ...opts, body: note })
|
||||
for (const res of [first, second]) {
|
||||
assert.equal(res.status, 401)
|
||||
assert.equal(res.body.code, 'view_expired')
|
||||
assert.equal(res.body.error, 'Die Kundenansicht ist abgelaufen. Bitte im Ticketsystem erneut öffnen.')
|
||||
assert.ok(clears(res, config.cookieName), 'Portal-Cookie geloescht')
|
||||
assert.ok(clears(res, 'preview_session'), 'Preview-Cookie geloescht')
|
||||
}
|
||||
const expired = logEntries().filter((d) => d.action === 'abgelaufen')
|
||||
assert.equal(expired.length, before + 1)
|
||||
assert.deepEqual([expired.at(-1).customerId, expired.at(-1).staffName], ['c1', 'Andrej'])
|
||||
})
|
||||
|
||||
test('alte Ansicht aus admin.html (viewAsCookie, ohne viewId): Lesen ok, News anlegen 403, Logout ok', async () => {
|
||||
const cookie = viewAsCookie('c1')
|
||||
assert.equal((await call('GET', '/api/news?projectId=p1', { cookie })).status, 200)
|
||||
const write = await call('POST', '/api/news', { cookie, body: note })
|
||||
assert.equal(write.status, 403)
|
||||
assert.equal(write.body.code, 'view_blocked')
|
||||
assert.equal((await call('POST', '/api/auth/logout', { cookie })).status, 200)
|
||||
})
|
||||
|
||||
test('normale Kunden-Sitzung wird nicht beruehrt (News anlegen 201 ohne Header)', async () => {
|
||||
const res = await call('POST', '/api/news', { cookie: customerCookie('c1'), body: note })
|
||||
assert.equal(res.status, 201)
|
||||
})
|
||||
|
||||
test('abgelaufenes Cookie blockiert weder /api/auth/me noch das Einloesen eines neuen Links', async () => {
|
||||
const cookie = kundenansichtCookie({ viewExpiresAt: Date.now() - 1000 })
|
||||
assert.equal((await call('GET', '/api/auth/me', { cookie })).status, 200)
|
||||
const token = createStaffPreviewToken({
|
||||
purpose: 'kundenansicht',
|
||||
customerId: 'c1',
|
||||
staff: { role: 'admin', isAdmin: false, appwriteUserId: 'u_staff2', name: 'Andrej', email: 'andrej@webklar.com' },
|
||||
})
|
||||
const res = await call('GET', `/api/auth/kundenansicht?token=${token}`, { cookie })
|
||||
assert.equal(res.status, 302)
|
||||
assert.equal(res.headers.get('location'), '/dashboard.html')
|
||||
})
|
||||
@@ -20,7 +20,9 @@ const { customerCookie, seedFixtures } = await import('./support/fixtures.js')
|
||||
const { default: newsRoutes } = await import('../server/routes/news.js')
|
||||
const { default: publicNewsRoutes } = await import('../server/routes/publicNews.js')
|
||||
|
||||
const app = await startTestApp({ '/api/public/news': publicNewsRoutes, '/api/news': newsRoutes })
|
||||
// Waechter wie in server/index.js: er sperrt Schreibzugriffe der alten Ansicht "Als Kunde ansehen"
|
||||
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
|
||||
const app = await startTestApp({ '/': kundenansichtGuard, '/api/public/news': publicNewsRoutes, '/api/news': newsRoutes })
|
||||
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
||||
seedFixtures(aw)
|
||||
|
||||
|
||||
Reference in new issue
Block a user