Files
Webklar-Kundenbereich/server/services/customerPassword.js
T
ClaudeandClaude Opus 5.5 81119c19bc Kundenbereich: Passwort selbst ändern
Im Konto-Menü (Profilbild) gibt es unter "Konto" jetzt "Passwort
ändern". Die Seite fragt aktuelles Passwort, neues Passwort und
Wiederholung ab (mindestens 8 Zeichen, "Passwörter anzeigen"). Am
Handy ist sie eine Unterseite des Tabs, aus dem man kam; Direktlink
/dashboard.html#passwort. Im Ansichtsmodus ist sie gesperrt.

Server (POST /api/auth/password):
- Das aktuelle Passwort wird über eine kurze Appwrite-Session geprüft,
  die gleich wieder gelöscht wird. Tippfehler lösen nicht die globale
  Login-Sperre aus. Es gibt höchstens 5 Prüfungen je Kunde und Stunde,
  das bleibt unter Appwrites Grenze (10 Anmeldungen je E-Mail und
  Stunde).
- Das neue Passwort wird über die Users-API gesetzt. Fehler von
  Appwrite (zuletzt benutzt, zu häufig, persönliche Daten) kommen als
  verständliche Meldung.
- Die Klartext-Kopie in customers.portalPassword und ein Alt-Eintrag
  in den Notizen werden geleert. Ein selbst gewähltes Passwort wird
  nie im Klartext gespeichert; das Ticketsystem zeigt dann "–".
- Der Kunde bekommt eine Bestätigungsmail (Kopie an die Admins wie
  bei allen Kundenmails).

Andere Geräte werden abgemeldet: Kunden-Sessions merken sich beim
Login den Passwortstand (Appwrite passwordUpdate, pwv).
requireSession und /api/auth/me prüfen ihn, 60 s zwischengespeichert.
Das gilt auch, wenn wir im Ticketsystem ein neues Passwort setzen. Ist
Appwrite nicht erreichbar, bleibt die Session gültig. Sessions von
vor diesem Update haben keinen Stand und laufen normal aus (höchstens
7 Tage).

Login-Seite: Hinweis auf "Passwort ändern" statt "über das
Ticketsystem", dazu "Passwort vergessen?" mit support@webklar.com.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:22:37 +00:00

169 lines
7.8 KiB
JavaScript

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 FOOTER = 'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · Telefon 0176 23726355 · support@webklar.com'
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, 'Ihr Passwort für den WEBklar-Kundenbereich wurde geändert', [
`Guten Tag ${name || ''}`.trim() + ',',
'',
`das Passwort für Ihren Zugang zum WEBklar-Kundenbereich (${email}) wurde am ${fmtDateTime(changedAt)} Uhr geändert.`,
'Auf allen anderen Geräten wurden Sie dabei abgemeldet.',
'',
'Waren Sie das? Dann ist nichts weiter zu tun.',
'Falls nicht, melden Sie sich bitte umgehend bei uns: support@webklar.com',
'',
'Viele Grüße',
'Ihr WEBklar-Team',
'',
FOOTER,
])
}
/**
* 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 }
}