Portal: Mitarbeiter-Login gesperrt (Hinweis aufs Ticketsystem), alte Mitarbeiter-Sitzungen enden

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-02 03:44:10 +00:00
1 parent d0ca3c41cd
commit 3a4fa581c0
13 files changed
+145 -559

No files matched your search

+16 -140
View File
@@ -10,7 +10,7 @@ import {
updateDocument,
Query,
} from '../services/appwriteAdmin.js'
import { loginWithAppwrite, getLoginCooldownRemainingSec, sessionNeedsSecondFactor } from '../services/appwriteClient.js'
import { appwriteSessionFetch, loginWithAppwrite, getLoginCooldownRemainingSec } from '../services/appwriteClient.js'
import {
SESSION_REVOKED_MESSAGE,
clearPortalSession,
@@ -41,7 +41,6 @@ import {
} from '../services/kundenansicht.js'
import { logViewAction } from '../services/customerViewLog.js'
import { resolveStaffAccount } from '../services/staffAccount.js'
import { MFA_COOKIE, MFA_TTL_MS, resendStaffMfa, startStaffMfa, verifyStaffMfa } from '../services/staffMfa.js'
const router = Router()
@@ -59,14 +58,7 @@ function sanitizeCustomer(customer) {
}
}
/**
* Backoffice-Login: Jeder Mitarbeiter aus der employees-Collection darf ins
* Admin-Panel (/admin.html). Das Appwrite-Label "admin" ist ausschliesslich die
* WOMS-Adminrolle (Mitarbeiterverwaltung) und KEINE Voraussetzung fuer den
* Portalzugang - es wird nur als Flag in der Session mitgefuehrt.
* Gibt null zurueck, wenn der Login kein Mitarbeiter ist (dann greift der
* normale Kunden-Login).
*/
/** Kunden-Login: Kundenkonto mit freigeschaltetem, aktivem Portalzugang, sonst Fehler mit status 403. */
async function validatePortalAccess(appwriteUserId, email) {
let customer = await getCustomerByAppwriteUserId(appwriteUserId)
if (!customer && email) {
@@ -152,6 +144,8 @@ function safePreviewRedirectUrl(url) {
return null
}
const STAFF_LOGIN_BLOCKED = 'Mitarbeiter arbeiten im Ticketsystem.'
router.get('/login-status', (_req, res) => {
const retryAfterSeconds = getLoginCooldownRemainingSec()
res.json({
@@ -171,30 +165,14 @@ router.post('/login', async (req, res) => {
// Appwrite-Konto: Label "admin" und Passwortstand fuer die Kunden-Session (passwordVersion.js)
const account = await getUserById(user.$id).catch(() => null)
// Ticketsystem-Mitarbeiter (employees-Collection) bekommen die Admin-Ansicht
// Mitarbeiter (employees-Collection oder Label admin, mit oder ohne 2FA) arbeiten im Ticketsystem:
// keine Portal-Sitzung, die eben erzeugte Appwrite-Sitzung wieder beenden (best effort)
const staff = await resolveStaffAccount(user.$id, email.trim(), account)
if (staff) {
// Mitarbeiter mit Appwrite-2FA: erst der Code aus der Mail macht daraus eine Portal-Sitzung
// mfa-Flag ODER Appwrite verlangt einen zweiten Faktor: scheitert der Konto-Abruf, entscheidet Appwrite
if (account?.mfa === true || (await sessionNeedsSecondFactor(user.sessionCookie))) {
const { key, maskedEmail } = await startStaffMfa({ userId: user.$id, email: staff.email, sessionCookie: user.sessionCookie }).catch((err) => {
if (err.type === 'user_email_not_verified') {
err.status = 403
err.message = '2FA ist für dein Konto noch nicht eingerichtet – bitte Kenso Bescheid geben.'
} else if (err.status === 429) {
err.message = 'Zu viele Versuche. Bitte warte einige Minuten.'
}
throw err
})
res.cookie(MFA_COOKIE, key, { ...mfaCookieOptions(), maxAge: MFA_TTL_MS })
return res.json({ mfaRequired: true, email: maskedEmail })
}
await startStaffPortalSession(res, { userId: user.$id, staff })
return res.json({
success: true,
role: 'admin',
admin: { name: staff.name, email: staff.email, isAdmin: staff.isAdmin },
})
await appwriteSessionFetch('/account/sessions/current', { method: 'DELETE', sessionCookie: user.sessionCookie }).catch((err) =>
console.warn('[auth] Appwrite-Sitzung eines Mitarbeiters nicht beendet:', err.message)
)
return res.status(403).json({ error: STAFF_LOGIN_BLOCKED, ticketUrl: config.womsBaseUrl })
}
let { customer, portalAccess } = await validatePortalAccess(user.$id, email.trim())
@@ -351,19 +329,6 @@ router.get('/staff-preview', async (req, res) => {
return res.redirect(previewUrl)
})
// Wechsel aus dem Ticketsystem (POST /api/admin/switch-link): Einmal-Link -> Admin-Sitzung
router.get('/staff-switch', async (req, res) => {
const tokenData = consumeStaffPreviewToken(String(req.query.token || ''), 'switch')
if (!tokenData) {
return res.redirect(`/login.html?error=${encodeURIComponent('Link abgelaufen, bitte anmelden')}`)
}
await startStaffPortalSession(res, {
userId: tokenData.userId,
staff: { isAdmin: Boolean(tokenData.isAdmin), name: tokenData.name, email: tokenData.email },
})
return res.redirect('/admin.html')
})
// Kundenansicht aus dem Ticketsystem (POST /api/team/customers/:id/kundenansicht): Einmal-Link -> Portal des Kunden
router.get('/kundenansicht', async (req, res) => {
const gone = (text) => res.status(410).type('text/plain; charset=utf-8').send(text)
@@ -442,36 +407,12 @@ router.get('/me', async (req, res) => {
})
}
// Admin-Session (Ticketsystem-Mitarbeiter), optional im "Als Kunde ansehen"-Modus
if (session.role === 'admin' && session.appwriteUserId) {
// Sessions von vor der Mitarbeiter-Freigabe kennen das Flag nicht - die
// konnten nur Admins bekommen, also als Admin werten.
const isAdmin = session.isAdmin === undefined ? true : Boolean(session.isAdmin)
let customer = null
if (session.customerId) {
const doc = await getDocument(config.collections.customers, session.customerId).catch(() => null)
if (doc) customer = sanitizeCustomer(doc)
}
await refreshPreviewCookie(req, res, {
userId: session.appwriteUserId,
customerId: '',
email: session.adminEmail || session.email || '',
name: session.adminName || session.name || 'Admin',
role: 'admin',
labels: isAdmin ? ['admin'] : [],
authSource: 'kundenbereich-admin',
})
return res.json({
authenticated: true,
role: 'admin',
viewAs: Boolean(session.viewAs && customer),
admin: {
name: session.adminName || session.name || 'Admin',
email: session.adminEmail || session.email || '',
isAdmin,
},
customer,
})
// Mitarbeiter-Sitzung von frueher (Login im Portal oder "Als Kunde ansehen" im frueheren Admin-Panel):
// beenden, Mitarbeiter arbeiten im Ticketsystem
if (session.role === 'admin') {
clearPortalSession(res)
clearPreviewSession(res)
return res.json({ authenticated: false, reason: 'staff', redirect: config.womsBaseUrl })
}
if (!session.customerId || !session.appwriteUserId) {
@@ -524,69 +465,4 @@ router.get('/me', async (req, res) => {
}
})
function mfaCookieOptions() {
return { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', signed: true }
}
/** Portal-Sitzung + Preview-Cookie fuer einen Mitarbeiter (Login, Code-Bestaetigung, Wechsel aus dem Ticketsystem). */
async function startStaffPortalSession(res, { userId, staff }) {
setPortalSession(res, {
role: 'admin',
isAdmin: staff.isAdmin,
appwriteUserId: userId,
name: staff.name,
email: staff.email,
})
const staffPreviewToken = await createPreviewSessionToken({
userId,
customerId: '',
email: staff.email,
name: staff.name,
role: 'admin',
labels: staff.isAdmin ? ['admin'] : [],
authSource: 'kundenbereich-admin',
})
res.cookie(PREVIEW_COOKIE_NAME, staffPreviewToken, previewSessionCookieOptions())
}
const MFA_GONE = 'Bitte melde dich erneut an.'
router.post('/login/mfa', async (req, res) => {
const key = req.signedCookies?.[MFA_COOKIE]
try {
const result = await verifyStaffMfa(key, req.body?.otp)
if (!result.ok) {
if (result.reason === 'invalid') return res.status(401).json({ error: 'Code falsch oder abgelaufen.' })
res.clearCookie(MFA_COOKIE, mfaCookieOptions())
return res.status(410).json({ error: MFA_GONE })
}
res.clearCookie(MFA_COOKIE, mfaCookieOptions())
const account = await getUserById(result.userId).catch(() => null)
const staff = await resolveStaffAccount(result.userId, result.email, account)
if (!staff) return res.status(403).json({ error: 'Kein Mitarbeiterkonto.' })
await startStaffPortalSession(res, { userId: result.userId, staff })
return res.json({
success: true,
role: 'admin',
admin: { name: staff.name, email: staff.email, isAdmin: staff.isAdmin },
})
} catch (err) {
if (err.status === 429) return res.status(429).json({ error: 'Zu viele Versuche. Bitte warte einige Minuten.' })
return res.status(err.status || 500).json({ error: err.message || 'Anmeldung fehlgeschlagen' })
}
})
router.post('/login/mfa/resend', async (req, res) => {
try {
if (!(await resendStaffMfa(req.signedCookies?.[MFA_COOKIE]))) {
res.clearCookie(MFA_COOKIE, mfaCookieOptions())
return res.status(410).json({ error: MFA_GONE })
}
return res.json({ success: true })
} catch (err) {
if (err.status === 429) return res.status(429).json({ error: 'Zu viele Versuche. Bitte warte einige Minuten.' })
return res.status(err.status || 500).json({ error: err.message || 'Code konnte nicht gesendet werden' })
}
})
export default router
+3 -18
View File
@@ -50,8 +50,8 @@ function readSessionCookie(response) {
}
/**
* Appwrite-Aufruf im Namen einer Sitzung aus loginWithAppwrite (z. B. MFA-Challenge). Deutsche Sprache,
* damit Appwrite die deutsche Mailvorlage nimmt.
* Appwrite-Aufruf im Namen einer Sitzung aus loginWithAppwrite (z. B. Sitzung eines abgelehnten
* Mitarbeiter-Logins beenden). Deutsche Sprache, damit Appwrite deutsche Texte liefert.
*/
export function appwriteSessionFetch(path, { method = 'GET', body, sessionCookie } = {}) {
return appwriteFetch(path, {
@@ -64,21 +64,6 @@ export function appwriteSessionFetch(path, { method = 'GET', body, sessionCookie
})
}
/**
* Braucht die Sitzung noch einen zweiten Faktor (Appwrite-2FA)? Fragt Appwrite selbst statt des
* mfa-Flags aus dem Admin-Abruf, damit ein gestoerter Abruf nicht ohne Code durchlaesst. Andere
* Fehler werden geworfen (fail closed).
*/
export async function sessionNeedsSecondFactor(sessionCookie) {
try {
await appwriteSessionFetch('/account', { sessionCookie })
return false
} catch (err) {
if (err.type === 'user_more_factors_required') return true
throw err
}
}
/**
* Login via Appwrite Auth REST. userId kommt aus der Session – kein users.read nötig.
*/
@@ -131,7 +116,7 @@ export async function loginWithAppwrite(email, password) {
throw error
}
// sessionCookie: fuer den zweiten Faktor (Mitarbeiter-2FA) arbeitet der Server im Namen dieser Sitzung weiter
// sessionCookie: lehnt der Login einen Mitarbeiter ab, beendet der Server damit diese Sitzung wieder
const user = { $id: session.userId, email, name: '', sessionCookie }
clearLoginCooldown()
-80
View File
@@ -1,80 +0,0 @@
import { randomBytes } from 'node:crypto'
import { appwriteSessionFetch } from './appwriteClient.js'
/**
* Offene Mitarbeiter-Anmeldungen mit E-Mail-Code (Appwrite-2FA). Das Passwort ist geprueft, die
* Appwrite-Sitzung hat aber erst einen Faktor; erst der richtige Code aus der Mail macht daraus eine
* Portal-Sitzung. Nur im Speicher: nach einem Neustart meldet man sich neu an.
*/
export const MFA_COOKIE = 'webklar_portal_mfa'
export const MFA_TTL_MS = 10 * 60 * 1000
const MAX_ATTEMPTS = 5
const pending = new Map()
function purgeExpired() {
const now = Date.now()
for (const [key, entry] of pending) {
if (entry.expiresAt <= now) pending.delete(key)
}
}
function liveEntry(key) {
purgeExpired()
return (key && pending.get(key)) || null
}
async function createChallenge(sessionCookie) {
const challenge = await appwriteSessionFetch('/account/mfa/challenges', {
method: 'POST',
body: { factor: 'email' },
sessionCookie,
})
return challenge.$id
}
/** kenso@webklar.com -> k…@webklar.com */
export function maskEmail(email) {
const [local, domain] = String(email || '').split('@')
if (!local || !domain) return ''
return `${local[0]}…@${domain}`
}
export async function startStaffMfa({ userId, email, sessionCookie }) {
const challengeId = await createChallenge(sessionCookie)
purgeExpired()
const key = randomBytes(32).toString('hex')
pending.set(key, { userId, email, sessionCookie, challengeId, expiresAt: Date.now() + MFA_TTL_MS, attempts: 0 })
return { key, maskedEmail: maskEmail(email) }
}
/** @returns {Promise<{ok: true, userId: string, email: string} | {ok: false, reason: 'invalid' | 'gone'}>} */
export async function verifyStaffMfa(key, otp) {
const entry = liveEntry(key)
if (!entry || entry.attempts >= MAX_ATTEMPTS) return { ok: false, reason: 'gone' }
// Versuch vor dem Appwrite-Aufruf reservieren, sonst zaehlen parallele Anfragen alle als erster Versuch
entry.attempts += 1
try {
await appwriteSessionFetch('/account/mfa/challenges', {
method: 'PUT',
body: { challengeId: entry.challengeId, otp: String(otp || '').trim() },
sessionCookie: entry.sessionCookie,
})
} catch (err) {
if (err.type !== 'user_invalid_token') {
entry.attempts -= 1
throw err
}
if (entry.attempts >= MAX_ATTEMPTS) pending.delete(key)
return { ok: false, reason: 'invalid' }
}
pending.delete(key)
return { ok: true, userId: entry.userId, email: entry.email }
}
export async function resendStaffMfa(key) {
const entry = liveEntry(key)
if (!entry) return false
entry.challengeId = await createChallenge(entry.sessionCookie)
return true
}