diff --git a/public/app.js b/public/app.js index 4ad24ce..913760d 100644 --- a/public/app.js +++ b/public/app.js @@ -17,6 +17,8 @@ async function api(path, options = {}) { err.status = response.status if (data.code) err.code = data.code if (data.retryAfterSeconds) err.retryAfterSeconds = data.retryAfterSeconds + // Mitarbeiter-Login gesperrt: Adresse des Ticketsystems fuer den Link unter dem Hinweis + if (data.ticketUrl) err.ticketUrl = data.ticketUrl // Passwort auf einem anderen Gerät geändert: diese Sitzung gilt nicht mehr if (data.code === 'session_revoked' && document.body.classList.contains('dashboard-page') && !sessionEndRedirected) { sessionEndRedirected = true @@ -473,17 +475,12 @@ async function postLoginRedirect() { window.location.href = await resolvePostLoginTarget() } -// Links aus Admin-Mails (z. B. /admin.html?gutschrift=) nach dem Login wieder aufnehmen -function adminLoginTarget() { - const next = getQueryParam('next') - return /^\/admin\.html(\?[A-Za-z0-9=&_-]*)?$/.test(next) ? next : '/admin.html' -} - /* -------------------------------------------------------------- Login-Page --- */ async function initLoginPage() { const form = document.getElementById('login-form') const errorEl = document.getElementById('login-error') const btn = document.getElementById('login-btn') + const staffLink = document.getElementById('login-staff-link') const errorFromQuery = getQueryParam('error') if (errorFromQuery) { @@ -523,11 +520,8 @@ async function initLoginPage() { } try { + // Alte Mitarbeiter-Sitzung beendet /me selbst (reason staff): dann bleibt die Anmeldeseite stehen const me = await api('/api/auth/me') - if (me.authenticated && me.role === 'admin' && !me.viewAs) { - window.location.href = adminLoginTarget() - return - } if (me.authenticated && me.customer) { await postLoginRedirect() return @@ -540,6 +534,7 @@ async function initLoginPage() { e.preventDefault() if (btn.disabled) return errorEl.classList.add('hidden') + staffLink?.classList.add('hidden') btn.disabled = true btn.classList.add('is-loading') btn.setAttribute('aria-busy', 'true') @@ -552,18 +547,14 @@ async function initLoginPage() { password: document.getElementById('password').value, }), }) - if (result.mfaRequired) { - showMfaStep(result.email) - return - } - if (result.role === 'admin') { - window.location.href = adminLoginTarget() - return - } await postLoginRedirect() return } catch (err) { showError(errorEl, err.message) + if (err.ticketUrl && staffLink) { + staffLink.href = err.ticketUrl + staffLink.classList.remove('hidden') + } if (err.status === 429) { cooldownActive = true await applyCooldown(err.retryAfterSeconds || 900) @@ -576,59 +567,6 @@ async function initLoginPage() { }) } -/* Mitarbeiter-2FA: Code aus der E-Mail (POST /api/auth/login/mfa) */ -function showMfaStep(maskedEmail) { - const loginForm = document.getElementById('login-form') - const form = document.getElementById('mfa-form') - const input = document.getElementById('mfa-code') - const errorEl = document.getElementById('mfa-error') - const btn = document.getElementById('mfa-btn') - const resend = document.getElementById('mfa-resend') - loginForm?.classList.add('hidden') - form?.classList.remove('hidden') - document.getElementById('mfa-hint').textContent = `Wir haben dir einen Code an ${maskedEmail} geschickt.` - input?.focus() - - form?.addEventListener('submit', async (e) => { - e.preventDefault() - if (btn.disabled) return - hideError(errorEl) - btn.disabled = true - btn.classList.add('is-loading') - try { - await api('/api/auth/login/mfa', { method: 'POST', body: JSON.stringify({ otp: input.value.trim() }) }) - window.location.href = adminLoginTarget() - } catch (err) { - if (err.status === 410) { - window.location.href = `/login.html?error=${encodeURIComponent(err.message)}` - return - } - showError(errorEl, err.message) - input.select() - } finally { - btn.disabled = false - btn.classList.remove('is-loading') - } - }) - - resend?.addEventListener('click', async () => { - hideError(errorEl) - resend.disabled = true - try { - await api('/api/auth/login/mfa/resend', { method: 'POST' }) - document.getElementById('mfa-hint').textContent = `Neuer Code ist unterwegs an ${maskedEmail}.` - } catch (err) { - if (err.status === 410) { - window.location.href = `/login.html?error=${encodeURIComponent(err.message)}` - return - } - showError(errorEl, err.message) - } finally { - resend.disabled = false - } - }) -} - /* --------------------------------------------------------------- View-as --- */ function renderViewAsBanner(me) { const banner = document.getElementById('viewas-banner') @@ -2088,16 +2026,17 @@ async function initDashboardPage() { const me = await api('/api/auth/me') if (!me.authenticated) { + // Alte Mitarbeiter-Sitzung (Mitarbeiter arbeiten im Ticketsystem): dorthin weiter + if (me.reason === 'staff' && me.redirect) { + window.location.href = me.redirect + return + } const target = new URL(loginUrl, window.location.origin) // Passwort auf einem anderen Gerät geändert oder Kundenansicht abgelaufen: Hinweis auf der Anmeldeseite if ((me.reason === 'password_changed' || me.reason === 'view_expired') && me.message) target.searchParams.set('error', me.message) window.location.href = target.pathname + target.search return } - if (me.role === 'admin' && !me.customer) { - window.location.href = '/admin.html' - return - } if (!me.customer) { window.location.href = loginUrl return diff --git a/public/login.css b/public/login.css index 6478d32..fa6ab90 100644 --- a/public/login.css +++ b/public/login.css @@ -655,17 +655,12 @@ a.portal-btn-outline { } } -/* Mitarbeiter-2FA: Code-Schritt */ -#mfa-form.hidden { display: none; } -.mfa-hint { margin: 0 0 4px; text-align: center; } -#mfa-code { letter-spacing: 0.3em; font-variant-numeric: tabular-nums; } -.mfa-actions { margin-top: 12px; } -.mfa-link { - background: none; - border: 0; - padding: 0; - font: inherit; - color: inherit; - text-decoration: underline; - cursor: pointer; +/* Mitarbeiter-Login gesperrt: Link ins Ticketsystem unter dem Hinweis */ +.login-staff-link { + display: block; + margin: 8px 0 0; + text-align: center; + font-size: 0.875rem; + font-weight: 700; + color: #fff; } diff --git a/public/login.html b/public/login.html index bced9c3..cf14bab 100644 --- a/public/login.html +++ b/public/login.html @@ -62,6 +62,8 @@ enterkeyhint="go" > + + - -

Zugangsdaten erhältst du von Webklar. Dein Passwort kannst du nach der Anmeldung selbst ändern (Profilbild oben rechts, „Passwort ändern“). diff --git a/server/routes/auth.js b/server/routes/auth.js index d6c4489..16fede0 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -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 diff --git a/server/services/appwriteClient.js b/server/services/appwriteClient.js index aeb4f80..265b5dc 100644 --- a/server/services/appwriteClient.js +++ b/server/services/appwriteClient.js @@ -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() diff --git a/server/services/staffMfa.js b/server/services/staffMfa.js deleted file mode 100644 index 30290e7..0000000 --- a/server/services/staffMfa.js +++ /dev/null @@ -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 -} diff --git a/test/kundenansichtEnde.test.js b/test/kundenansichtEnde.test.js index 746b81d..4ce052c 100644 --- a/test/kundenansichtEnde.test.js +++ b/test/kundenansichtEnde.test.js @@ -10,7 +10,7 @@ const aw = await startAppwriteMock({ schema: { customerViewLog: schemaKeys('cust const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { config } = await import('../server/config.js') -const { startTestApp, get, post } = await import('./support/testApp.js') +const { startTestApp, post } = await import('./support/testApp.js') const { seedFixtures, kundenansichtCookie, viewAsCookie } = await import('./support/fixtures.js') const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js') const { default: authRoutes } = await import('../server/routes/auth.js') @@ -81,9 +81,8 @@ test('/me abgelaufen -> authenticated false, reason view_expired, Protokoll abge assert.equal(logOf('abgelaufen').length, before + 1) }) -test('/me alte Ansicht (viewAsCookie) unveraendert: viewAs true, keine viewId', async () => { - const body = await get('/api/auth/me', { cookie: viewAsCookie('c1') }) - assert.equal(body.viewAs, true) - assert.equal(body.viewId, undefined) - assert.equal(body.admin.name, 'Kenso') +test('/me alte Ansicht (viewAsCookie, ohne viewId): beendet, reason staff, Weiterleitung ins Ticketsystem', async () => { + const res = await me(viewAsCookie('c1')) + assert.deepEqual(res.body, { authenticated: false, reason: 'staff', redirect: 'https://ticket.webklar.com' }) + assert.equal(res.headers.getSetCookie().some((c) => c.startsWith(`${config.cookieName}=`) && /Expires=Thu, 01 Jan 1970/.test(c)), true) }) diff --git a/test/kundenansichtGuard.test.js b/test/kundenansichtGuard.test.js index fbe746b..af510b0 100644 --- a/test/kundenansichtGuard.test.js +++ b/test/kundenansichtGuard.test.js @@ -97,7 +97,7 @@ test('jede Schreib-Route ausserhalb der Erlaubnisliste -> 403 view_blocked', asy assert.equal(res.body.code, 'view_blocked', `${method} ${p}`) checked += 1 } - assert.ok(checked >= 20, `nur ${checked} gesperrte Routen geprueft`) + assert.ok(checked >= 18, `nur ${checked} gesperrte Routen geprueft`) }) test('Erlaubnisliste: genau News, Analyse-Link, Ende, Logout und Login', () => { diff --git a/test/staffLogin.test.js b/test/staffLogin.test.js index 8d07790..3e2a27e 100644 --- a/test/staffLogin.test.js +++ b/test/staffLogin.test.js @@ -4,12 +4,13 @@ import { startAppwriteMock } from './support/appwriteMock.js' import { startFakeStripe } from './support/fakeStripe.js' import { useTestEnv } from './support/testEnv.js' -// Portal-Login mit E-Mail-Code fuer Mitarbeiter mit Appwrite-2FA; Kunden und Mitarbeiter ohne 2FA wie bisher +// Portal-Login: Kunden wie bisher; Mitarbeiter (mit oder ohne 2FA) arbeiten im Ticketsystem (Etappe 4) const aw = await startAppwriteMock() const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { config } = await import('../server/config.js') -const { startTestApp, post, rawGet } = await import('./support/testApp.js') +const { startTestApp, post, rawGet, sessionCookie } = await import('./support/testApp.js') +const { viewAsCookie } = await import('./support/fixtures.js') const { default: authRoutes } = await import('../server/routes/auth.js') const app = await startTestApp({ '/api/auth': authRoutes }) after(() => Promise.all([app.close(), aw.close(), st.close()])) @@ -17,98 +18,85 @@ after(() => Promise.all([app.close(), aw.close(), st.close()])) aw.addUser({ $id: 'u_kunde', email: 'kunde@example.invalid', password: 'kunde-kunde' }) aw.addUser({ $id: 'u_ohne', email: 'justin@webklar.com', password: 'ohne-ohne-ohne' }) aw.addUser({ $id: 'u_mfa', email: 'kenso@webklar.com', password: 'mit-mit-mit', mfa: true, emailVerification: true, labels: ['admin'] }) +aw.addUser({ $id: 'u_label', email: 'label@webklar.com', password: 'label-label', labels: ['admin'] }) aw.seed('customers', { $id: 'c1', appwriteUserId: 'u_kunde', email: 'kunde@example.invalid', name: 'Kunde', customerStatus: 'active', portalAccessEnabled: true }) aw.seed('customerPortalAccess', { $id: 'pa1', customerId: 'c1', enabled: true }) aw.seed('employees', { $id: 'e1', userId: 'u_ohne', displayName: 'Justin', email: 'justin@webklar.com' }) aw.seed('employees', { $id: 'e2', userId: 'u_mfa', displayName: 'Kenso', email: 'kenso@webklar.com' }) const SESSION = config.cookieName +const STAFF_BLOCKED = { error: 'Mitarbeiter arbeiten im Ticketsystem.', ticketUrl: 'https://ticket.webklar.com' } const setCookies = (res) => res.headers.getSetCookie() -const cookieNamed = (res, name) => setCookies(res).find((c) => c.startsWith(`${name}=`)) || null -const jar = (res) => setCookies(res).map((c) => c.split(';')[0]).join('; ') +const cleared = (c) => /Expires=Thu, 01 Jan 1970/.test(c) +const liveCookies = (res, name) => setCookies(res).filter((c) => c.startsWith(`${name}=`) && !cleared(c)) +const clears = (res, name) => setCookies(res).some((c) => c.startsWith(`${name}=`) && cleared(c)) const login = (email, password) => post('/api/auth/login', { email, password }) -test('Kunde ohne 2FA: Anmeldung wie bisher', async () => { +test('Kunde meldet sich weiter normal an', async () => { + const before = aw.sessionDeletes() const res = await login('kunde@example.invalid', 'kunde-kunde') assert.equal(res.status, 200) - assert.equal(res.body.mfaRequired, undefined) - assert.ok(cookieNamed(res, SESSION)) + assert.equal(res.body.role, 'customer') + assert.equal(liveCookies(res, SESSION).length, 1) + assert.equal(aw.sessionDeletes(), before) }) -test('Mitarbeiter ohne 2FA: Admin-Anmeldung wie bisher', async () => { +test('Mitarbeiter ohne 2FA: 403 mit Link ins Ticketsystem, keine Sitzung, Appwrite-Sitzung beendet', async () => { + const before = aw.sessionDeletes() const res = await login('justin@webklar.com', 'ohne-ohne-ohne') - assert.equal(res.status, 200) - assert.equal(res.body.role, 'admin') - assert.ok(cookieNamed(res, SESSION)) -}) - -test('Mitarbeiter mit 2FA: vor dem Code keine Portal-Sitzung', async () => { - const res = await login('kenso@webklar.com', 'mit-mit-mit') - assert.equal(res.status, 200) - assert.deepEqual(res.body, { mfaRequired: true, email: 'k…@webklar.com' }) - assert.ok(cookieNamed(res, 'webklar_portal_mfa')) - assert.equal(cookieNamed(res, SESSION), null) - assert.equal(setCookies(res).length, 1) - const me = await rawGet('/api/auth/me', { cookie: jar(res) }) - assert.equal(me.body.authenticated, false) -}) - -test('Richtiger Code: Admin-Sitzung, Code-Cookie geloescht', async () => { - const first = await login('kenso@webklar.com', 'mit-mit-mit') - const res = await post('/api/auth/login/mfa', { otp: aw.lastOtp('u_mfa') }, { cookie: jar(first) }) - assert.equal(res.status, 200) - assert.equal(res.body.role, 'admin') - assert.equal(res.body.admin.isAdmin, true) - assert.ok(cookieNamed(res, SESSION)) - assert.match(cookieNamed(res, 'webklar_portal_mfa'), /Expires=Thu, 01 Jan 1970/) - const me = await rawGet('/api/auth/me', { cookie: jar(res) }) - assert.equal(me.body.role, 'admin') -}) - -test('Falscher Code: Hinweis, nach 5 Fehlversuchen neu anmelden', async () => { - const first = await login('kenso@webklar.com', 'mit-mit-mit') - for (let i = 0; i < 5; i++) { - const res = await post('/api/auth/login/mfa', { otp: '000000' }, { cookie: jar(first) }) - assert.equal(res.status, 401) - assert.equal(res.body.error, 'Code falsch oder abgelaufen.') - assert.equal(cookieNamed(res, SESSION), null) - } - const res = await post('/api/auth/login/mfa', { otp: aw.lastOtp('u_mfa') }, { cookie: jar(first) }) - assert.equal(res.status, 410) - assert.equal(res.body.error, 'Bitte melde dich erneut an.') -}) - -test('Ohne Code-Cookie: neu anmelden', async () => { - const res = await post('/api/auth/login/mfa', { otp: '123456' }) - assert.equal(res.status, 410) - const resend = await post('/api/auth/login/mfa/resend', {}) - assert.equal(resend.status, 410) -}) - -test('Code erneut senden: zweite Mail', async () => { - const first = await login('kenso@webklar.com', 'mit-mit-mit') - const before = aw.mailsSent('u_mfa') - const res = await post('/api/auth/login/mfa/resend', {}, { cookie: jar(first) }) - assert.equal(res.status, 200) - assert.equal(aw.mailsSent('u_mfa'), before + 1) -}) - -test('2FA an, E-Mail nicht bestaetigt: Hinweis statt Sitzung', async () => { - aw.addUser({ $id: 'u_unbest', email: 'andrej@webklar.com', password: 'unbest-unbest', mfa: true, emailVerification: false }) - aw.seed('employees', { $id: 'e3', userId: 'u_unbest', displayName: 'Andrej', email: 'andrej@webklar.com' }) - const res = await login('andrej@webklar.com', 'unbest-unbest') assert.equal(res.status, 403) - assert.equal(res.body.error, '2FA ist für dein Konto noch nicht eingerichtet – bitte Kenso Bescheid geben.') - assert.equal(cookieNamed(res, SESSION), null) + assert.deepEqual(res.body, STAFF_BLOCKED) + assert.equal(liveCookies(res, SESSION).length, 0) + assert.equal(liveCookies(res, 'preview_session').length, 0) + assert.equal(aw.sessionDeletes(), before + 1) }) -test('2FA-Pruefung schlaegt nicht offen fehl, wenn der Konto-Abruf scheitert', async () => { +test('Mitarbeiter mit 2FA: dieselbe Antwort, keine Code-Mail', async () => { + const before = aw.sessionDeletes() + const res = await login('kenso@webklar.com', 'mit-mit-mit') + assert.equal(res.status, 403) + assert.deepEqual(res.body, STAFF_BLOCKED) + assert.equal(setCookies(res).filter((c) => !cleared(c)).length, 0) + assert.equal(aw.mailsSent('u_mfa'), 0) + assert.equal(aw.sessionDeletes(), before + 1) +}) + +test('Nur Label admin (ohne employees-Eintrag): ebenfalls gesperrt', async () => { + const res = await login('label@webklar.com', 'label-label') + assert.equal(res.status, 403) + assert.deepEqual(res.body, STAFF_BLOCKED) + assert.equal(liveCookies(res, SESSION).length, 0) +}) + +test('Konto-Abruf gestoert: Mitarbeiter bleibt gesperrt (employees-Treffer)', async () => { aw.failUserLookups(true) try { const res = await login('kenso@webklar.com', 'mit-mit-mit') - assert.equal(cookieNamed(res, SESSION), null) - assert.equal(res.body.mfaRequired, true) + assert.equal(res.status, 403) + assert.deepEqual(res.body, STAFF_BLOCKED) + assert.equal(liveCookies(res, SESSION).length, 0) } finally { aw.failUserLookups(false) } }) + +test('/me mit alter Mitarbeiter-Sitzung: reason staff, Weiterleitung, Cookies geloescht', async () => { + const res = await rawGet('/api/auth/me', { cookie: sessionCookie({ role: 'admin', appwriteUserId: 'u_mfa', name: 'Kenso', email: 'kenso@webklar.com' }) }) + assert.equal(res.status, 200) + assert.deepEqual(res.body, { authenticated: false, reason: 'staff', redirect: 'https://ticket.webklar.com' }) + assert.ok(clears(res, SESSION)) + assert.ok(clears(res, 'preview_session')) + assert.equal(liveCookies(res, 'preview_session').length, 0) +}) + +test('/me mit Alt-Ansicht (viewAs ohne viewId): ebenso reason staff', async () => { + const res = await rawGet('/api/auth/me', { cookie: viewAsCookie('c1') }) + assert.deepEqual(res.body, { authenticated: false, reason: 'staff', redirect: 'https://ticket.webklar.com' }) + assert.ok(clears(res, SESSION)) +}) + +test('/login/mfa, /login/mfa/resend und /staff-switch gibt es nicht mehr (404)', async () => { + assert.equal((await post('/api/auth/login/mfa', { otp: '123456' })).status, 404) + assert.equal((await post('/api/auth/login/mfa/resend', {})).status, 404) + assert.equal((await rawGet('/api/auth/staff-switch?token=abc')).status, 404) +}) diff --git a/test/staffMfa.test.js b/test/staffMfa.test.js deleted file mode 100644 index 0a98267..0000000 --- a/test/staffMfa.test.js +++ /dev/null @@ -1,89 +0,0 @@ -import { test, after } from 'node:test' -import assert from 'node:assert/strict' -import { startAppwriteMock } from './support/appwriteMock.js' -import { startFakeStripe } from './support/fakeStripe.js' -import { useTestEnv } from './support/testEnv.js' - -// Mitarbeiter-2FA per E-Mail-Code: Appwrite-Sitzung aus dem Login und offene Code-Anmeldungen -const aw = await startAppwriteMock() -const st = await startFakeStripe() -useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) -const { loginWithAppwrite, appwriteSessionFetch } = await import('../server/services/appwriteClient.js') -after(() => Promise.all([aw.close(), st.close()])) - -aw.addUser({ $id: 'u_staff', email: 'kenso@webklar.com', password: 'geheim-geheim', mfa: true, emailVerification: true }) - -test('loginWithAppwrite liefert die Appwrite-Sitzung', async () => { - const user = await loginWithAppwrite('kenso@webklar.com', 'geheim-geheim') - assert.equal(user.$id, 'u_staff') - assert.equal(user.sessionCookie, 'sess-u_staff') -}) - -test('appwriteSessionFetch schickt Sitzung und Sprache', async () => { - const before = aw.mailsSent('u_staff') - const challenge = await appwriteSessionFetch('/account/mfa/challenges', { - method: 'POST', - body: { factor: 'email' }, - sessionCookie: 'sess-u_staff', - }) - assert.match(challenge.$id, /^ch/) - assert.equal(aw.mailsSent('u_staff'), before + 1) - assert.equal(aw.lastLocale(), 'de') - await assert.rejects( - appwriteSessionFetch('/account/mfa/challenges', { method: 'PUT', body: { challengeId: challenge.$id, otp: '000000' }, sessionCookie: 'sess-u_staff' }), - (err) => err.status === 401 && err.type === 'user_invalid_token' - ) -}) - -const { maskEmail, startStaffMfa, verifyStaffMfa, resendStaffMfa } = await import('../server/services/staffMfa.js') - -async function start() { - const { sessionCookie } = await loginWithAppwrite('kenso@webklar.com', 'geheim-geheim') - return startStaffMfa({ userId: 'u_staff', email: 'kenso@webklar.com', sessionCookie }) -} - -test('maskEmail: erstes Zeichen, Rest der Adresse ausgeblendet', () => { - assert.equal(maskEmail('kenso@webklar.com'), 'k…@webklar.com') - assert.equal(maskEmail('a@b.de'), 'a…@b.de') - assert.equal(maskEmail(''), '') -}) - -test('Richtiger Code meldet an, danach ist die Anmeldung verbraucht', async () => { - const { key, maskedEmail } = await start() - assert.equal(maskedEmail, 'k…@webklar.com') - assert.deepEqual(await verifyStaffMfa(key, aw.lastOtp('u_staff')), { ok: true, userId: 'u_staff', email: 'kenso@webklar.com' }) - assert.deepEqual(await verifyStaffMfa(key, aw.lastOtp('u_staff')), { ok: false, reason: 'gone' }) -}) - -test('Falscher Code: nach 5 Fehlversuchen ist die Anmeldung weg', async () => { - const { key } = await start() - for (let i = 0; i < 5; i++) assert.deepEqual(await verifyStaffMfa(key, '000000'), { ok: false, reason: 'invalid' }) - assert.deepEqual(await verifyStaffMfa(key, aw.lastOtp('u_staff')), { ok: false, reason: 'gone' }) -}) - -test('Nach 10 Minuten ist die Anmeldung abgelaufen', async (t) => { - const { key } = await start() - const now = Date.now() - t.mock.method(Date, 'now', () => now + 11 * 60 * 1000) - assert.deepEqual(await verifyStaffMfa(key, aw.lastOtp('u_staff')), { ok: false, reason: 'gone' }) -}) - -test('Code erneut senden: neue Mail, nur der neue Code gilt', async () => { - const { key } = await start() - const oldOtp = aw.lastOtp('u_staff') - const before = aw.mailsSent('u_staff') - assert.equal(await resendStaffMfa(key), true) - assert.equal(aw.mailsSent('u_staff'), before + 1) - const newOtp = aw.lastOtp('u_staff') - if (oldOtp !== newOtp) assert.deepEqual(await verifyStaffMfa(key, oldOtp), { ok: false, reason: 'invalid' }) - assert.equal((await verifyStaffMfa(key, newOtp)).ok, true) - assert.equal(await resendStaffMfa(key), false) -}) - -test('Parallele Fehlversuche: hoechstens 5 Versuche zaehlen', async () => { - const { key } = await start() - const results = await Promise.all(Array.from({ length: 10 }, () => verifyStaffMfa(key, '000000'))) - assert.equal(results.filter((r) => r.reason === 'invalid').length, 5) - assert.equal(results.filter((r) => r.reason === 'gone').length, 5) - assert.deepEqual(await verifyStaffMfa(key, aw.lastOtp('u_staff')), { ok: false, reason: 'gone' }) -}) diff --git a/test/staffSwitch.test.js b/test/staffSwitch.test.js index a0ee2e1..86d9ce7 100644 --- a/test/staffSwitch.test.js +++ b/test/staffSwitch.test.js @@ -45,39 +45,6 @@ test('switch-link: Mitarbeiter ohne Admin-Label bekommt einen Link', async () => assert.match(res.body.openUrl, /^https:\/\/project\.webklar\.com\/api\/auth\/staff-switch\?token=[0-9a-f]{64}$/) }) -test('staff-switch: gueltig -> Admin-Sitzung und /admin.html, zweites Mal -> Login', async () => { - const { body } = await switchLink('jwt-staff') - const path = `/api/auth/staff-switch?token=${tokenOf(body.openUrl)}` - const res = await rawGet(path) - assert.equal(res.status, 302) - assert.equal(res.headers.get('location'), '/admin.html') - const session = portalSession(res) - assert.equal(session.role, 'admin') - assert.equal(session.isAdmin, false) - assert.equal(session.appwriteUserId, 'u_staff2') - const again = await rawGet(path) - assert.equal(again.status, 302) - assert.equal(again.headers.get('location'), '/login.html?error=Link%20abgelaufen%2C%20bitte%20anmelden') - assert.equal(portalSession(again), null) -}) - -test('staff-switch: nach 61 Sekunden abgelaufen', async (t) => { - const { body } = await switchLink('jwt-staff') - const now = Date.now() - t.mock.method(Date, 'now', () => now + 61 * 1000) - const res = await rawGet(`/api/auth/staff-switch?token=${tokenOf(body.openUrl)}`) - assert.match(res.headers.get('location'), /^\/login\.html\?error=/) -}) - -test('Tokens nicht vertauschbar: Switch ueber staff-preview, Preview ueber staff-switch', async () => { - const { body } = await switchLink('jwt-staff') - assert.equal((await rawGet(`/api/auth/staff-preview?token=${tokenOf(body.openUrl)}`)).status, 410) - const previewToken = createStaffPreviewToken({ userId: 'u_admin', email: 'kenso@webklar.com', name: 'Kenso', previewUrl: 'https://x.project.webklar.com/' }) - const res = await rawGet(`/api/auth/staff-switch?token=${previewToken}`) - assert.match(res.headers.get('location'), /^\/login\.html\?error=/) - assert.equal(portalSession(res), null) -}) - test('admin.html: Rueckweg ins Ticketsystem in der Kopfzeile', () => { const html = readFileSync(new URL('../public/admin.html', import.meta.url), 'utf8') const actions = html.slice(html.indexOf('portal-header-actions'), html.indexOf('id="logout-btn"')) diff --git a/test/support/appwriteMock.js b/test/support/appwriteMock.js index 26b5f1d..391fbf3 100644 --- a/test/support/appwriteMock.js +++ b/test/support/appwriteMock.js @@ -21,6 +21,7 @@ export async function startAppwriteMock({ schema = {} } = {}) { const userCalls = [] let lastLocale = null let failUserLookups = false + let sessionDeletes = 0 // Wie Appwrite (Validator UID): 'unique()' ist erlaubt, sonst [A-Za-z0-9][A-Za-z0-9._-]{0,35} const validDocumentId = (id) => id === 'unique()' || (typeof id === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.test(id)) @@ -117,6 +118,13 @@ export async function startAppwriteMock({ schema = {} } = {}) { const project = req.headers['x-appwrite-project'] || 'x' return [201, { $id: `s-${user.$id}`, userId: user.$id }, { 'Set-Cookie': `a_session_${project}=${secret}; path=/; httponly` }] } + // Abmelden der eben erzeugten Sitzung (Portal-Login lehnt Mitarbeiter ab) + if (url.pathname === '/v1/account/sessions/current' && req.method === 'DELETE') { + const match = String(req.headers.cookie || '').match(/a_session_[^=]+=([^;]+)/) + if (!match || !sessions.delete(decodeURIComponent(match[1]))) return [401, { message: 'Unauthorized', code: 401, type: 'general_unauthorized_scope' }] + sessionDeletes += 1 + return [204, null] + } if (url.pathname === '/v1/account/mfa/challenges') { const session = sessionOf(req) if (!session) return [401, { message: 'Unauthorized', code: 401, type: 'general_unauthorized_scope' }] @@ -256,6 +264,8 @@ export async function startAppwriteMock({ schema = {} } = {}) { lastOtp: (userId) => [...challenges.values()].filter((c) => c.userId === userId).at(-1)?.otp || null, expireChallenges: () => challenges.forEach((c) => (c.expired = true)), mailsSent: (userId) => mails.get(userId) || 0, + /** Zahl der beendeten Appwrite-Sitzungen (DELETE /account/sessions/current) */ + sessionDeletes: () => sessionDeletes, lastLocale: () => lastLocale, /** GET /v1/users/:id scheitert (503), bis wieder false gesetzt wird */ failUserLookups: (on) => (failUserLookups = Boolean(on)), diff --git a/test/ui/kundenansicht.ui.mjs b/test/ui/kundenansicht.ui.mjs index 03d7a89..3d90bf5 100644 --- a/test/ui/kundenansicht.ui.mjs +++ b/test/ui/kundenansicht.ui.mjs @@ -31,8 +31,11 @@ const VIEW = 'a'.repeat(32) const customer = { id: 'c1', code: '1001', name: 'Berta Berg', email: 'b@example.test' } const ME = { neu: { authenticated: true, role: 'admin', viewAs: true, viewId: VIEW, viewExpiresAt: Date.now() + 3600000, admin: { name: 'Andrej', email: 'andrej@webklar.com', isAdmin: false }, customer }, - alt: { authenticated: true, role: 'admin', viewAs: true, admin: { name: 'Kenso', email: 'kenso@webklar.com', isAdmin: true }, customer }, + // alte Mitarbeiter-Sitzung (Login im Portal oder Alt-Ansicht ohne Kennung): /me beendet sie, weiter ins Ticketsystem + staff: { authenticated: false, reason: 'staff', redirect: `${base}/zurueck-im-ticketsystem.html?von=staff` }, + gast: { authenticated: false }, } +const STAFF_BLOCKED = { error: 'Mitarbeiter arbeiten im Ticketsystem.', ticketUrl: 'https://ticket.webklar.com' } let scenario = 'neu' let replaceAfterMe = false @@ -50,6 +53,7 @@ async function mockApi(page) { seen.ende += 1 return json({ redirect: `${base}/zurueck-im-ticketsystem.html` }) } + if (p === '/api/auth/login') return json(STAFF_BLOCKED, 403) if (p === '/api/chat/read') seen.chatRead += 1 if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Bäckerei Berg', status: 'live' }] }) if (p === '/api/contact') return json({ name: 'WEBklar-Team', responseTime: 'Antwort meist < 1 Werktag' }) @@ -104,11 +108,26 @@ try { await page.screenshot({ path: `${SHOTS}/${label}-ersetzt.png` }) replaceAfterMe = false - // 3) alte Ansicht aus dem Admin-Panel: altes Banner, keine Kennung - await open('alt') - const old = (await page.textContent('#viewas-banner')).replace(/\s+/g, ' ').trim() - check(`${label} altes_banner`, old.includes('Admin-Ansicht') && old.includes('Zurück zur Admin-Übersicht'), old) - check(`${label} alt_ohne_kennung`, seen.headers.every((h) => h === null), JSON.stringify(seen.headers)) + // 3) alte Mitarbeiter-Sitzung: Dashboard leitet ins Ticketsystem weiter + scenario = 'staff' + await page.goto('about:blank') + await Promise.all([page.waitForURL(/zurueck-im-ticketsystem\.html\?von=staff/), page.goto(`${base}/dashboard.html`)]) + check(`${label} staff_weiterleitung`, page.url().endsWith('/zurueck-im-ticketsystem.html?von=staff'), page.url()) + + // 4) Mitarbeiter meldet sich im Portal an: Hinweis und Link ins Ticketsystem + scenario = 'gast' + await page.goto(`${base}/login.html`) + await page.fill('#email', 'andrej@webklar.com') + await page.fill('#password', 'geheim-geheim') + await page.click('#login-btn') + await page.waitForSelector('#login-staff-link:not(.hidden)', { timeout: 15000 }).catch(() => null) + const hint = (await page.textContent('#login-error')).trim() + check(`${label} login_hinweis`, hint === 'Mitarbeiter arbeiten im Ticketsystem.', hint) + const link = await page.$eval('#login-staff-link', (a) => ({ text: a.textContent.trim(), href: a.getAttribute('href'), visible: a.offsetParent !== null })) + check(`${label} login_link_ticketsystem`, link.visible && link.text === 'Zum Ticketsystem' && link.href === 'https://ticket.webklar.com', JSON.stringify(link)) + check(`${label} kein_mfa_formular`, (await page.$('#mfa-form')) === null) + await page.waitForTimeout(1200) + await page.screenshot({ path: `${SHOTS}/${label}-login-mitarbeiter.png` }) check(`${label} keine_js_fehler`, errors.length === 0, errors.join(' | ')) await ctx.close()