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>
This commit is contained in:
1 parent
63ddbf55cc
commit
81119c19bc
12 files changed
+585
-13
No files matched your search
@@ -94,3 +94,23 @@ export async function loginWithAppwrite(email, password) {
|
||||
clearLoginCooldown()
|
||||
return user
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft ein Passwort ueber eine Appwrite-Session (Passwort aendern im Kundenbereich). Anders als
|
||||
* loginWithAppwrite ohne die globale Login-Sperre: tippt ein Kunde sein aktuelles Passwort falsch,
|
||||
* darf das die Anmeldung der anderen nicht blockieren. Die Session loescht der Aufrufer wieder.
|
||||
* @returns {Promise<{ userId: string, sessionId: string } | null>} null = Passwort falsch
|
||||
*/
|
||||
export async function verifyAppwritePassword(email, password) {
|
||||
try {
|
||||
const session = await appwriteFetch('/account/sessions/email', {
|
||||
method: 'POST',
|
||||
body: { email, password },
|
||||
})
|
||||
return { userId: session?.userId || '', sessionId: session?.$id || '' }
|
||||
} catch (err) {
|
||||
// 400: kuerzer als 8 Zeichen (kann nie stimmen), 401: falsche Zugangsdaten; gesperrt u. a. weiterreichen
|
||||
if (err.status === 400 || (err.status === 401 && err.type === 'user_invalid_credentials')) return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
updateDocument,
|
||||
Query,
|
||||
} from './appwriteAdmin.js'
|
||||
import { forgetPasswordVersion } from './passwordVersion.js'
|
||||
|
||||
function readPortalPassword(customer) {
|
||||
if (customer?.portalPassword) return customer.portalPassword
|
||||
@@ -192,6 +193,8 @@ export async function updateCustomerWithPortalAccess(customerId, payload) {
|
||||
}
|
||||
if (normalizedPassword) {
|
||||
await updateAppwriteUserPassword(appwriteUserId, normalizedPassword)
|
||||
// Neues Passwort von uns: der Kunde ist auf allen Geraeten abgemeldet (passwordVersion.js)
|
||||
forgetPasswordVersion(appwriteUserId)
|
||||
portalPassword = normalizedPassword
|
||||
}
|
||||
} else if (normalizedPassword) {
|
||||
@@ -235,6 +238,24 @@ export async function updateCustomerWithPortalAccess(customerId, payload) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Kunde hat sein Passwort im Kundenbereich selbst geaendert: die Klartext-Kopie, die das
|
||||
* Ticketsystem zum Weitergeben anzeigt, stimmt nicht mehr. Sie wird geleert statt ueberschrieben,
|
||||
* denn ein selbst gewaehltes Passwort gehoert nirgends im Klartext hin. Das Ticketsystem zeigt dann "–".
|
||||
* @returns {Promise<boolean>} true, wenn eine Kopie entfernt wurde
|
||||
*/
|
||||
export async function forgetStoredPortalPassword(customerId) {
|
||||
const customer = await getDocument(config.collections.customers, customerId)
|
||||
const legacyInNotes = /^__portalPassword__:/.test(customer.notes || '')
|
||||
if (!customer.portalPassword && !legacyInNotes) return false
|
||||
await updateDocument(config.collections.customers, customerId, {
|
||||
portalPassword: '',
|
||||
...(legacyInNotes ? { notes: notesWithoutPortalPassword(customer.notes) } : {}),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
export async function listCustomersForAdmin() {
|
||||
// Ohne Limit liefert Appwrite nur 25 Dokumente - neue Kunden fielen dann aus der Liste.
|
||||
const customers = await listDocuments(config.collections.customers, [
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { getUserById } from './appwriteAdmin.js'
|
||||
|
||||
/**
|
||||
* Passwortstand je Appwrite-Konto (users.passwordUpdate, aendert sich nur mit dem Passwort).
|
||||
* Kunden-Sessions merken sich den Stand beim Login (pwv). Wird das Passwort geaendert - im
|
||||
* Kundenbereich oder durch uns im Ticketsystem -, gelten aeltere Sessions nicht mehr: der Kunde
|
||||
* ist auf allen anderen Geraeten abgemeldet. Sessions ohne pwv (vor dieser Pruefung angemeldet)
|
||||
* laufen normal aus. Kurz zwischengespeichert, damit nicht jede API-Anfrage Appwrite fragt;
|
||||
* Aenderungen ueber diesen Server gelten sofort (rememberPasswordVersion).
|
||||
*/
|
||||
const CACHE_MS = 60 * 1000
|
||||
const cache = new Map() // appwriteUserId -> { value, at }
|
||||
let lastWarnAt = 0
|
||||
|
||||
function remember(userId, value) {
|
||||
if (cache.size > 2000) {
|
||||
const now = Date.now()
|
||||
for (const [key, entry] of cache) if (now - entry.at > CACHE_MS) cache.delete(key)
|
||||
}
|
||||
cache.set(userId, { value, at: Date.now() })
|
||||
}
|
||||
|
||||
export function rememberPasswordVersion(userId, passwordUpdate) {
|
||||
if (userId) remember(userId, passwordUpdate || '')
|
||||
}
|
||||
|
||||
export function forgetPasswordVersion(userId) {
|
||||
cache.delete(userId)
|
||||
}
|
||||
|
||||
/** Aktueller Stand; '' = Konto ohne Passwort bzw. geloescht. Wirft bei Appwrite-Fehlern. */
|
||||
async function currentPasswordVersion(userId) {
|
||||
const hit = cache.get(userId)
|
||||
if (hit && Date.now() - hit.at < CACHE_MS) return hit.value
|
||||
let value
|
||||
try {
|
||||
const user = await getUserById(userId)
|
||||
value = user?.passwordUpdate || ''
|
||||
} catch (err) {
|
||||
if (err.status !== 404) throw err
|
||||
value = ''
|
||||
}
|
||||
remember(userId, value)
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* false, wenn die Kunden-Session von vor der letzten Passwortaenderung stammt. Admin-Sessions
|
||||
* ("Als Kunde ansehen") und Sessions ohne pwv werden nicht geprueft. Ist Appwrite nicht
|
||||
* erreichbar, gilt die Session weiter (sonst waeren bei jeder Stoerung alle abgemeldet).
|
||||
*/
|
||||
export async function isSessionPasswordCurrent(session) {
|
||||
if (!session?.pwv || session.role === 'admin' || !session.appwriteUserId) return true
|
||||
try {
|
||||
return (await currentPasswordVersion(session.appwriteUserId)) === session.pwv
|
||||
} catch (err) {
|
||||
if (Date.now() - lastWarnAt > 10 * 60 * 1000) {
|
||||
lastWarnAt = Date.now()
|
||||
console.warn('[auth] Passwortstand nicht prüfbar, Session gilt weiter:', err.message)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user