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()
|
||||
|
||||
Reference in new issue
Block a user