import { deleteUserSession, getUserById, updateAppwriteUserPassword } from './appwriteAdmin.js' import { verifyAppwritePassword } from './appwriteClient.js' import { forgetStoredPortalPassword } from './customerAdmin.js' import { sendCustomerMail } from './mailer.js' import { rememberPasswordVersion } from './passwordVersion.js' const fmtDateTime = (d) => new Date(d).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'medium', timeStyle: 'short' }) // Appwrite verlangt mindestens 8 Zeichen und nimmt hoechstens 256 Byte export const PASSWORD_MIN_LENGTH = 8 const PASSWORD_MAX_BYTES = 256 // Hoechstens 5 Pruefungen des aktuellen Passworts je Kunde und Stunde. Appwrite erlaubt 10 Sessions // je E-Mail und Stunde (Anmeldungen zaehlen mit), die normale Anmeldung bleibt also moeglich. const MAX_ATTEMPTS = 5 const ATTEMPT_WINDOW_MS = 60 * 60 * 1000 const attempts = new Map() // customerId -> Zeitpunkte der Pruefungen function httpError(status, message, extra = {}) { return Object.assign(new Error(message), { status }, extra) } /** Was am neuen Passwort nicht passt, oder ''. Dieselben Regeln prueft das Formular (app.js). */ export function newPasswordProblem(currentPassword, newPassword, email = '') { if (!newPassword) return 'Bitte gib ein neues Passwort ein.' if ([...newPassword].length < PASSWORD_MIN_LENGTH) return 'Das neue Passwort muss mindestens 8 Zeichen haben.' if (Buffer.byteLength(newPassword, 'utf8') > PASSWORD_MAX_BYTES) return 'Das neue Passwort ist zu lang (höchstens 256 Zeichen).' if (!newPassword.trim()) return 'Das neue Passwort darf nicht nur aus Leerzeichen bestehen.' if (newPassword === currentPassword) return 'Das neue Passwort muss sich vom aktuellen unterscheiden.' if (email && newPassword.trim().toLowerCase() === email.trim().toLowerCase()) { return 'Bitte nimm nicht deine E-Mail-Adresse als Passwort.' } return '' } /** Pruefungen des Kunden in der letzten Stunde; blockedFor = Sekunden bis zum naechsten Versuch. */ export function attemptState(customerId, now = Date.now()) { const recent = (attempts.get(customerId) || []).filter((t) => now - t < ATTEMPT_WINDOW_MS) if (recent.length) attempts.set(customerId, recent) else attempts.delete(customerId) const blockedFor = recent.length >= MAX_ATTEMPTS ? Math.ceil((recent[recent.length - MAX_ATTEMPTS] + ATTEMPT_WINDOW_MS - now) / 1000) : 0 return { left: Math.max(0, MAX_ATTEMPTS - recent.length), blockedFor } } export function recordAttempt(customerId, now = Date.now()) { attempts.set(customerId, [...(attempts.get(customerId) || []), now]) } function minutesText(seconds) { const minutes = Math.max(1, Math.ceil(seconds / 60)) return `${minutes} ${minutes === 1 ? 'Minute' : 'Minuten'}` } function tooManyAttempts(seconds) { return httpError(429, `Zu viele Versuche. Bitte versuche es in ${minutesText(seconds)} noch einmal.`, { retryAfterSeconds: seconds, }) } function updateError(err) { switch (err.type) { case 'password_recently_used': return httpError(400, 'Dieses Passwort hast du vor Kurzem schon benutzt. Bitte wähle ein anderes.', { code: 'new_invalid' }) case 'password_personal_data': return httpError(400, 'Das Passwort darf nicht deinen Namen, deine E-Mail-Adresse oder Telefonnummer enthalten.', { code: 'new_invalid' }) case 'general_argument_invalid': // Die Laenge ist schon geprueft; bleibt Appwrites Liste haeufiger Passwoerter (falls eingeschaltet) return httpError(400, 'Dieses Passwort ist zu leicht zu erraten. Bitte wähle ein anderes.', { code: 'new_invalid' }) default: console.error('[auth] Neues Passwort nicht gesetzt:', err.status, err.type || '', err.message) return httpError(502, 'Das Passwort konnte gerade nicht geändert werden. Bitte versuche es später noch einmal.') } } function sendPasswordChangedMail(email, name, changedAt) { return sendCustomerMail(email, { template: 'K11', values: { name: name || '', email, geaendert_am: fmtDateTime(changedAt) }, }) } /** * Kunde aendert sein Passwort im Kundenbereich: aktuelles Passwort pruefen, neues setzen, * Klartext-Kopie des alten entfernen, Bestaetigung per Mail. * @returns {Promise<{ passwordVersion: string }>} neuer Passwortstand fuer die eigene Session */ export async function changeCustomerPassword(session, body) { const { customerId, appwriteUserId: userId } = session const currentPassword = typeof body?.currentPassword === 'string' ? body.currentPassword : '' const newPassword = typeof body?.newPassword === 'string' ? body.newPassword : '' const before = attemptState(customerId) if (before.blockedFor > 0) throw tooManyAttempts(before.blockedFor) if (!currentPassword) throw httpError(400, 'Bitte gib dein aktuelles Passwort ein.', { code: 'current_missing' }) const problem = newPasswordProblem(currentPassword, newPassword, session.email) if (problem) throw httpError(400, problem, { code: 'new_invalid' }) // Angemeldet wird mit der E-Mail des Appwrite-Kontos (kann von customers.email abweichen) const account = await getUserById(userId).catch(() => null) if (!account?.email) { throw httpError(502, 'Dein Zugang konnte gerade nicht geladen werden. Bitte versuche es später noch einmal.') } recordAttempt(customerId) let check try { check = await verifyAppwritePassword(account.email, currentPassword) } catch (err) { if (err.status === 429) { throw httpError(429, 'Zu viele Anmeldeversuche für deinen Zugang. Bitte versuche es in einer Stunde noch einmal.') } if (err.type === 'user_blocked') { throw httpError(403, 'Dein Zugang ist gesperrt. Bitte wende dich an support@webklar.com.') } console.error('[auth] Aktuelles Passwort nicht prüfbar:', err.status, err.type || '', err.message) throw httpError(502, 'Das Passwort konnte gerade nicht geprüft werden. Bitte versuche es später noch einmal.') } if (!check) { const { left, blockedFor } = attemptState(customerId) const hint = blockedFor > 0 ? ` Weitere Versuche sind in ${minutesText(blockedFor)} wieder möglich.` : left <= 2 ? ` Noch ${left} ${left === 1 ? 'Versuch' : 'Versuche'}.` : '' throw httpError(400, `Das aktuelle Passwort stimmt nicht.${hint}`, { code: 'wrong_password' }) } // Die Session diente nur der Pruefung if (check.sessionId) { await deleteUserSession(check.userId || userId, check.sessionId).catch((err) => console.warn('[auth] Prüf-Session nicht gelöscht:', err.message)) } if (check.userId && check.userId !== userId) { throw httpError(409, 'Dein Zugang passt nicht zu dieser Anmeldung. Bitte melde dich ab und neu an.') } let updated try { updated = await updateAppwriteUserPassword(userId, newPassword) } catch (err) { throw updateError(err) } const passwordVersion = updated?.passwordUpdate || '' rememberPasswordVersion(userId, passwordVersion) console.log(`[auth] Kunde ${customerId} hat sein Passwort geändert`) try { await forgetStoredPortalPassword(customerId) } catch (err) { console.error(`[auth] Alte Passwort-Kopie bei Kunde ${customerId} nicht entfernt:`, err.message) } sendPasswordChangedMail(account.email, session.name || account.name, passwordVersion || new Date()).catch((err) => console.error('[auth] Mail "Passwort geändert" fehlgeschlagen:', err.message)) return { passwordVersion } }