diff --git a/public/app.js b/public/app.js index cef369a..61b6cc6 100644 --- a/public/app.js +++ b/public/app.js @@ -548,6 +548,10 @@ async function initLoginPage() { password: document.getElementById('password').value, }), }) + if (result.mfaRequired) { + showMfaStep(result.email) + return + } if (result.role === 'admin') { window.location.href = adminLoginTarget() return @@ -568,6 +572,59 @@ 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') diff --git a/public/login.css b/public/login.css index 7729bfe..9f99b76 100644 --- a/public/login.css +++ b/public/login.css @@ -647,3 +647,18 @@ } } } + +/* 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; +} diff --git a/public/login.html b/public/login.html index 4aab939..bced9c3 100644 --- a/public/login.html +++ b/public/login.html @@ -72,6 +72,31 @@ + +

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 7ec4191..dc2ba87 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -4,8 +4,6 @@ import { getCustomerByAppwriteUserId, getCustomerByEmail, getDocument, - getEmployeeByEmail, - getEmployeeByUserId, getPortalAccessByCustomerId, getUserById, listDocuments, @@ -29,6 +27,8 @@ import { verifyPreviewSessionToken, } from '../services/previewSession.js' import { consumeStaffPreviewToken } from '../services/staffPreviewTokens.js' +import { resolveStaffAccount } from '../services/staffAccount.js' +import { MFA_COOKIE, MFA_TTL_MS, resendStaffMfa, startStaffMfa, verifyStaffMfa } from '../services/staffMfa.js' const router = Router() @@ -54,23 +54,6 @@ function sanitizeCustomer(customer) { * Gibt null zurueck, wenn der Login kein Mitarbeiter ist (dann greift der * normale Kunden-Login). */ -async function resolveStaffAccount(appwriteUserId, email, account) { - const isAdmin = Array.isArray(account?.labels) && account.labels.includes('admin') - - let employee = await getEmployeeByUserId(appwriteUserId).catch(() => null) - if (!employee) { - employee = await getEmployeeByEmail(account?.email || email).catch(() => null) - } - - if (!employee && !isAdmin) return null - - return { - isAdmin, - name: employee?.displayName || account?.name || 'Mitarbeiter', - email: account?.email || employee?.email || email, - } -} - async function validatePortalAccess(appwriteUserId, email) { let customer = await getCustomerByAppwriteUserId(appwriteUserId) if (!customer && email) { @@ -185,23 +168,21 @@ router.post('/login', async (req, res) => { // Ticketsystem-Mitarbeiter (employees-Collection) bekommen die Admin-Ansicht const staff = await resolveStaffAccount(user.$id, email.trim(), account) if (staff) { - setPortalSession(res, { - role: 'admin', - isAdmin: staff.isAdmin, - appwriteUserId: user.$id, - name: staff.name, - email: staff.email, - }) - const staffPreviewToken = await createPreviewSessionToken({ - userId: user.$id, - customerId: '', - email: staff.email, - name: staff.name, - role: 'admin', - labels: staff.isAdmin ? ['admin'] : [], - authSource: 'kundenbereich-admin', - }) - res.cookie(PREVIEW_COOKIE_NAME, staffPreviewToken, previewSessionCookieOptions()) + // Mitarbeiter mit Appwrite-2FA: erst der Code aus der Mail macht daraus eine Portal-Sitzung + if (account?.mfa === true) { + 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', @@ -461,4 +442,69 @@ 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/staffAccount.js b/server/services/staffAccount.js new file mode 100644 index 0000000..c9c3e7a --- /dev/null +++ b/server/services/staffAccount.js @@ -0,0 +1,22 @@ +import { getEmployeeByEmail, getEmployeeByUserId } from './appwriteAdmin.js' + +/** + * Ticketsystem-Mitarbeiter (employees-Collection) oder Appwrite-Label "admin" -> Admin-Ansicht im Portal. + * isAdmin = Label "admin"; null = kein Mitarbeiter. + */ +export async function resolveStaffAccount(appwriteUserId, email, account) { + const isAdmin = Array.isArray(account?.labels) && account.labels.includes('admin') + + let employee = await getEmployeeByUserId(appwriteUserId).catch(() => null) + if (!employee) { + employee = await getEmployeeByEmail(account?.email || email).catch(() => null) + } + + if (!employee && !isAdmin) return null + + return { + isAdmin, + name: employee?.displayName || account?.name || 'Mitarbeiter', + email: account?.email || employee?.email || email, + } +} diff --git a/test/staffLogin.test.js b/test/staffLogin.test.js new file mode 100644 index 0000000..e7af920 --- /dev/null +++ b/test/staffLogin.test.js @@ -0,0 +1,103 @@ +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' + +// Portal-Login mit E-Mail-Code fuer Mitarbeiter mit Appwrite-2FA; Kunden und Mitarbeiter ohne 2FA wie bisher +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 { default: authRoutes } = await import('../server/routes/auth.js') +const app = await startTestApp({ '/api/auth': authRoutes }) +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.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 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 login = (email, password) => post('/api/auth/login', { email, password }) + +test('Kunde ohne 2FA: Anmeldung wie bisher', async () => { + 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)) +}) + +test('Mitarbeiter ohne 2FA: Admin-Anmeldung wie bisher', async () => { + 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) +})