Files
Webklar-Kundenbereich/server/services/staffMfa.js
T

81 lines
2.6 KiB
JavaScript

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
}