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>
169 lines
7.8 KiB
JavaScript
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 }
|
|
}
|