Kundenansicht: Waechter-Korrekturen aus dem Abschlussreview
- Pfade ohne Ruecksicht auf Gross-/Kleinschreibung pruefen (/API/news umging die Sperre) - Tab mit Kennung, Cookie ist keine Kundenansicht mehr -> 409 statt fremder Daten - Cookie lebt 1 h laenger als die Ansicht, damit 'abgelaufen' gemeldet und protokolliert wird Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0ccdde1c28
commit
a64a996291
4 files changed
+39
-6
No files matched your search
@@ -33,11 +33,12 @@ const replaced = (res, session) =>
|
|||||||
res.status(409).json({ code: 'view_replaced', kunde: session.name || '', error: 'Diese Ansicht wurde durch eine andere Kundenansicht ersetzt.' })
|
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) {
|
export async function kundenansichtGuard(req, res, next) {
|
||||||
const session = parsePortalSession(req)
|
// Express routet ohne Ruecksicht auf Gross-/Kleinschreibung (/API/news trifft /api/news): hier genauso pruefen
|
||||||
if (!session?.viewAs) return next()
|
const pathname = String(req.path || '').toLowerCase()
|
||||||
const pathname = req.originalUrl.split('?')[0]
|
|
||||||
if (!pathname.startsWith('/api/')) return next()
|
if (!pathname.startsWith('/api/')) return next()
|
||||||
if (PASS_PREFIXES.some((p) => pathname.startsWith(p)) || PASS_EXACT.includes(pathname.replace(/\/$/, ''))) return next()
|
if (PASS_PREFIXES.some((p) => pathname.startsWith(p)) || PASS_EXACT.includes(pathname.replace(/\/$/, ''))) return next()
|
||||||
|
const session = parsePortalSession(req)
|
||||||
|
if (!session) return next()
|
||||||
|
|
||||||
const current = isKundenansicht(session)
|
const current = isKundenansicht(session)
|
||||||
if (current && isExpired(session)) {
|
if (current && isExpired(session)) {
|
||||||
@@ -45,8 +46,11 @@ export async function kundenansichtGuard(req, res, next) {
|
|||||||
await logOnce(session, 'abgelaufen')
|
await logOnce(session, 'abgelaufen')
|
||||||
return res.status(401).json({ code: 'view_expired', error: VIEW_EXPIRED_MESSAGE })
|
return res.status(401).json({ code: 'view_expired', error: VIEW_EXPIRED_MESSAGE })
|
||||||
}
|
}
|
||||||
|
// Tab einer Kundenansicht, deren Cookie inzwischen zu etwas anderem gehoert (andere Kundenansicht, alte
|
||||||
|
// Admin-Ansicht, Kunden-Login): keine fremden Daten in diesen Tab
|
||||||
const header = req.get('X-Kundenansicht')
|
const header = req.get('X-Kundenansicht')
|
||||||
if (current && header && header !== session.viewId) return replaced(res, session)
|
if (header && header !== session.viewId) return replaced(res, session)
|
||||||
|
if (!session.viewAs) return next()
|
||||||
if (READ_METHODS.includes(req.method)) return next()
|
if (READ_METHODS.includes(req.method)) return next()
|
||||||
if (current && !header) return replaced(res, session)
|
if (current && !header) return replaced(res, session)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
* endet nach 2 Stunden. Sitzungen ohne viewId stammen aus "Als Kunde ansehen" im alten Admin-Panel.
|
* endet nach 2 Stunden. Sitzungen ohne viewId stammen aus "Als Kunde ansehen" im alten Admin-Panel.
|
||||||
*/
|
*/
|
||||||
export const KUNDENANSICHT_TTL_MS = 2 * 60 * 60 * 1000
|
export const KUNDENANSICHT_TTL_MS = 2 * 60 * 60 * 1000
|
||||||
|
const COOKIE_GRACE_MS = 60 * 60 * 1000
|
||||||
export const VIEW_EXPIRED_MESSAGE = 'Die Kundenansicht ist abgelaufen. Bitte im Ticketsystem erneut öffnen.'
|
export const VIEW_EXPIRED_MESSAGE = 'Die Kundenansicht ist abgelaufen. Bitte im Ticketsystem erneut öffnen.'
|
||||||
|
|
||||||
export const isKundenansicht = (session) => Boolean(session?.viewAs && session.viewId)
|
export const isKundenansicht = (session) => Boolean(session?.viewAs && session.viewId)
|
||||||
@@ -35,7 +36,9 @@ export async function startKundenansicht(res, { staff, customer, now = Date.now(
|
|||||||
name: customer.name || '',
|
name: customer.name || '',
|
||||||
email: customer.email || '',
|
email: customer.email || '',
|
||||||
}
|
}
|
||||||
setPortalSession(res, session, { maxAgeMs: KUNDENANSICHT_TTL_MS })
|
// Das Cookie lebt eine Stunde laenger als die Ansicht: so meldet der Server nach 2 Stunden "abgelaufen" (Hinweis im
|
||||||
|
// Tab, Protokolleintrag), statt dass der Browser das Cookie still verwirft
|
||||||
|
setPortalSession(res, session, { maxAgeMs: KUNDENANSICHT_TTL_MS + COOKIE_GRACE_MS })
|
||||||
await setKundenansichtPreview(res, session, KUNDENANSICHT_TTL_MS / 1000)
|
await setKundenansichtPreview(res, session, KUNDENANSICHT_TTL_MS / 1000)
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ test('Einloesen: Sitzung 2 h, Preview-Cookie nur fuer den Kunden, Protokoll geoe
|
|||||||
assert.match(s.viewId, /^[0-9a-f]{32}$/)
|
assert.match(s.viewId, /^[0-9a-f]{32}$/)
|
||||||
assert.ok(Math.abs(s.viewExpiresAt - (Date.now() + 7200000)) < 5000)
|
assert.ok(Math.abs(s.viewExpiresAt - (Date.now() + 7200000)) < 5000)
|
||||||
assert.deepEqual([s.customerId, s.adminName, s.appwriteUserId, s.name], ['c1', 'Andrej', 'u_staff2', 'Kunde Eins'])
|
assert.deepEqual([s.customerId, s.adminName, s.appwriteUserId, s.name], ['c1', 'Andrej', 'u_staff2', 'Kunde Eins'])
|
||||||
assert.match(setCookie(res, config.cookieName), /Max-Age=7200/)
|
// Cookie lebt laenger als die Ansicht (2 h + 1 h), damit der Server 'abgelaufen' melden und protokollieren kann
|
||||||
|
assert.match(setCookie(res, config.cookieName), /Max-Age=10800/)
|
||||||
const preview = await verifyPreviewSessionToken(cookieValue(res, 'preview_session'))
|
const preview = await verifyPreviewSessionToken(cookieValue(res, 'preview_session'))
|
||||||
assert.deepEqual([preview.role, preview.customerId, preview.userId], ['customer', 'c1', 'u_staff2'])
|
assert.deepEqual([preview.role, preview.customerId, preview.userId], ['customer', 'c1', 'u_staff2'])
|
||||||
assert.ok(preview.exp - preview.iat <= 7200)
|
assert.ok(preview.exp - preview.iat <= 7200)
|
||||||
|
|||||||
@@ -182,3 +182,28 @@ test('abgelaufenes Cookie blockiert weder /api/auth/me noch das Einloesen eines
|
|||||||
assert.equal(res.status, 302)
|
assert.equal(res.status, 302)
|
||||||
assert.equal(res.headers.get('location'), '/dashboard.html')
|
assert.equal(res.headers.get('location'), '/dashboard.html')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Abschluss-Review: Express routet ohne Ruecksicht auf Gross-/Kleinschreibung, der Waechter muss das auch
|
||||||
|
test('Grossschreibung umgeht den Waechter nicht: /API/... wie /api/...', async () => {
|
||||||
|
const routes = writeRoutes(MOUNTS).filter(({ method, path: p }) => !allowed(method, p))
|
||||||
|
for (const { method, path: p } of routes) {
|
||||||
|
const upper = p.replace(/^\/api\//, '/API/')
|
||||||
|
const res = await call(method, upper, kv())
|
||||||
|
assert.equal(res.status, 403, `${method} ${upper}`)
|
||||||
|
}
|
||||||
|
const legacy = await call('POST', '/API/news', { cookie: viewAsCookie('c1'), body: note })
|
||||||
|
assert.equal(legacy.status, 403)
|
||||||
|
const expired = await call('GET', '/Api/Projects', kv({ viewExpiresAt: Date.now() - 1000 }))
|
||||||
|
assert.equal(expired.status, 401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Tab mit Kennung, Cookie ist keine Kundenansicht mehr -> 409 statt fremder Daten', async () => {
|
||||||
|
const header = { 'x-kundenansicht': VIEW }
|
||||||
|
for (const cookie of [customerCookie('c2'), viewAsCookie('c2')]) {
|
||||||
|
const res = await call('GET', '/api/projects', { cookie, headers: header })
|
||||||
|
assert.equal(res.status, 409)
|
||||||
|
assert.deepEqual([res.body.code, res.body.kunde], ['view_replaced', 'Kunde Zwei'])
|
||||||
|
}
|
||||||
|
// ohne Cookie: kein fremder Kunde, die Route antwortet selbst mit 401
|
||||||
|
assert.equal((await call('GET', '/api/projects', { headers: header })).status, 401)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user