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 }