From 25f15c0ecb040aae475110be2075dcde4ab58f46 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 10:27:38 +0000 Subject: [PATCH] Portal: offene Mitarbeiter-Code-Anmeldungen (staffMfa) Co-Authored-By: Claude Opus 5.5 --- server/services/staffMfa.js | 76 +++++++++++++++++++++++++++++++++++++ test/staffMfa.test.js | 45 ++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 server/services/staffMfa.js diff --git a/server/services/staffMfa.js b/server/services/staffMfa.js new file mode 100644 index 0000000..e48bb3f --- /dev/null +++ b/server/services/staffMfa.js @@ -0,0 +1,76 @@ +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) return { ok: false, reason: 'gone' } + 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') throw err + entry.attempts += 1 + 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/staffMfa.test.js b/test/staffMfa.test.js index 5cee673..ab6dd40 100644 --- a/test/staffMfa.test.js +++ b/test/staffMfa.test.js @@ -34,3 +34,48 @@ test('appwriteSessionFetch schickt Sitzung und Sprache', async () => { (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) +})