From 8534be22d0b3a0ab987471efbd8fa05dd559d819 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 16 May 2026 18:48:13 +0200 Subject: [PATCH] =?UTF-8?q?Einmalpasswort-Flow=20f=C3=BCr=20Portal-Credent?= =?UTF-8?q?ials?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wenn Admin "Zugangsdaten versenden" klickt, ist das Passwort jetzt ein echtes Einmalpasswort: beim ersten erfolgreichen Portal-Login werden Hash + Encrypted-Feld sofort genullt und der Kunde wird zwangsweise auf eine "Neues Passwort vergeben"-Seite geleitet. Erst nach eigenem Passwort kommt er ins Portal. Schema: - Customer.portalPasswordMustChange: Boolean @default(false) Backend: - sendPortalCredentials setzt Flag = true + erweitertes Mail-Template mit Einmalpasswort-Warnung - customerLogin: bei Flag=true wird OTP konsumiert (Hash+Encrypted=null, portalLastLogin aktualisiert), Response enthält mustChangePassword=true in token-payload + user-objekt - setCustomerPortalPassword (manuelles Setzen) räumt Flag wieder auf - changeInitialPortalPassword: neue Service-Funktion + Endpoint POST /api/auth/change-initial-portal-password (authenticated, nur Portal-User), validiert Komplexität, setzt neuen Hash, löscht Encrypted, invalidiert Session via portalTokenInvalidatedAt Frontend: - User-Type erweitert um mustChangePassword - AuthContext.customerLogin gibt User zurück (für sofortige Routing- Entscheidung) - Login.tsx: redirect zu /change-initial-password wenn mustChangePassword - ProtectedRoute: zwingt eingeloggte User mit Flag immer zur Change-Seite - ChangeInitialPasswordGate: blockt User OHNE Flag vom Zugriff - ChangeInitialPassword: eigene Seite mit Live-Komplexitäts-Hint, Passwort-Wiederholung, automatischer Logout + Redirect nach Erfolg Live-verifiziert (10 Schritte): - Setzen → Send → DB-Flag=true → OTP-Login gibt mustChange=true und consumed Hash → Re-Login mit OTP fehlschlägt → Change schwach=400, komplex=200 → neues Passwort funktioniert → Session invalidated. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/prisma/schema.prisma | 4 + backend/src/controllers/auth.controller.ts | 39 ++++++ .../src/controllers/customer.controller.ts | 8 +- backend/src/routes/auth.routes.ts | 3 + backend/src/services/auth.service.ts | 68 ++++++++-- docs/todo.md | 42 ++++++ frontend/src/App.tsx | 28 +++- frontend/src/context/AuthContext.tsx | 8 +- frontend/src/pages/ChangeInitialPassword.tsx | 124 ++++++++++++++++++ frontend/src/pages/Login.tsx | 19 ++- frontend/src/services/api.ts | 4 + frontend/src/types/index.ts | 1 + 12 files changed, 330 insertions(+), 18 deletions(-) create mode 100644 frontend/src/pages/ChangeInitialPassword.tsx diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index f2476567..b1103407 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -172,6 +172,10 @@ model Customer { portalPasswordResetExpiresAt DateTime? // Portal Session-Invalidation (nach Passwort-Reset / Rechte-Änderung) portalTokenInvalidatedAt DateTime? + // Einmalpasswort: gesetzt durch "Zugangsdaten versenden"-Button. Beim ersten + // erfolgreichen Login wird der Hash sofort gelöscht (OTP verbraucht) und + // Frontend in Force-Change-Password-Flow geleitet. + portalPasswordMustChange Boolean @default(false) // Geburtstagsmodal: Jahr in dem dem Kunden der Geburtstagsgruß gezeigt wurde (vermeidet mehrfaches Anzeigen) lastBirthdayGreetingYear Int? diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index ce2924df..64a57f95 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -385,3 +385,42 @@ export async function register(req: Request, res: Response): Promise { } as ApiResponse); } } + +// Vom Endkunden selbst nach Einmalpasswort-Login aufgerufen, um sein eigenes +// Passwort zu vergeben. Server invalidiert die laufende Session, Frontend +// loggt aus und schickt zurück zum Login. +export async function changeInitialPortalPassword(req: AuthRequest, res: Response): Promise { + try { + if (!req.user?.isCustomerPortal || !req.user?.customerId) { + res.status(403).json({ + success: false, + error: 'Nur für Kundenportal-Login', + } as ApiResponse); + return; + } + const { newPassword } = req.body || {}; + if (!newPassword || typeof newPassword !== 'string') { + res.status(400).json({ + success: false, + error: 'Neues Passwort erforderlich', + } as ApiResponse); + return; + } + const complexity = validatePasswordComplexity(newPassword); + if (!complexity.ok) { + res.status(400).json({ + success: false, + error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '), + } as ApiResponse); + return; + } + await authService.changeInitialPortalPassword(req.user.customerId, newPassword); + clearRefreshCookie(res); + res.json({ success: true, message: 'Passwort geändert' } as ApiResponse); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Passwort konnte nicht geändert werden', + } as ApiResponse); + } +} diff --git a/backend/src/controllers/customer.controller.ts b/backend/src/controllers/customer.controller.ts index 5633745a..8d469340 100644 --- a/backend/src/controllers/customer.controller.ts +++ b/backend/src/controllers/customer.controller.ts @@ -1036,16 +1036,20 @@ export async function sendPortalCredentials(req: AuthRequest, res: Response): Pr password: plaintextPassword, }); + // Versendetes Passwort ist ein Einmalpasswort → beim ersten Login muss + // der Kunde sich ein eigenes setzen. + await authService.markPortalPasswordForChange(customerId); + await logChange({ req, action: 'UPDATE', resourceType: 'PortalSettings', resourceId: customerId.toString(), - label: `Portal-Zugangsdaten per E-Mail versendet an ${targetEmail}`, + label: `Portal-Zugangsdaten per E-Mail versendet an ${targetEmail} (Einmalpasswort)`, customerId, }); - res.json({ success: true, message: `Zugangsdaten an ${targetEmail} versendet` } as ApiResponse); + res.json({ success: true, message: `Zugangsdaten an ${targetEmail} versendet (Einmalpasswort)` } as ApiResponse); } catch (error) { res.status(500).json({ success: false, diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index caa2f41f..2cb43b5c 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -16,4 +16,7 @@ router.post('/register', authenticate, requirePermission('users:create'), authCo router.post('/password-reset/request', passwordResetRateLimiter, authController.requestPasswordReset); router.post('/password-reset/confirm', passwordResetRateLimiter, authController.confirmPasswordReset); +// Force-Change-Password nach Einmalpasswort-Login (Kundenportal) +router.post('/change-initial-portal-password', authenticate, authController.changeInitialPortalPassword); + export default router; diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts index f285c209..c247be66 100644 --- a/backend/src/services/auth.service.ts +++ b/backend/src/services/auth.service.ts @@ -180,14 +180,30 @@ export async function customerLogin(email: string, password: string) { throw new Error('Ungültige Anmeldedaten'); } - // Lazy-Upgrade analog zu Mitarbeiter-Login - maybeUpgradePasswordHash('customer', customer.id, password, customer.portalPasswordHash).catch(() => {}); + // Einmalpasswort-Check: wurde es per "Zugangsdaten versenden" verschickt? + // Falls ja, jetzt sofort verbrauchen – Hash + Encrypted nullen, damit + // weder Re-Login noch Klartext-Abruf möglich ist. Customer landet im + // Force-Change-Password-Flow. + const mustChangePassword = customer.portalPasswordMustChange === true; + if (mustChangePassword) { + await prisma.customer.update({ + where: { id: customer.id }, + data: { + portalPasswordHash: null, + portalPasswordEncrypted: null, + portalLastLogin: new Date(), + }, + }); + } else { + // Lazy-Upgrade analog zu Mitarbeiter-Login + maybeUpgradePasswordHash('customer', customer.id, password, customer.portalPasswordHash).catch(() => {}); - // Letzte Anmeldung aktualisieren - await prisma.customer.update({ - where: { id: customer.id }, - data: { portalLastLogin: new Date() }, - }); + // Letzte Anmeldung aktualisieren + await prisma.customer.update({ + where: { id: customer.id }, + data: { portalLastLogin: new Date() }, + }); + } // IDs der Kunden sammeln, die dieser Kunde vertreten kann const representedCustomerIds = customer.representingFor.map( @@ -214,6 +230,7 @@ export async function customerLogin(email: string, password: string) { return { accessToken, refreshToken, + mustChangePassword, user: { id: customer.id, email: customer.portalEmail, @@ -222,6 +239,7 @@ export async function customerLogin(email: string, password: string) { permissions: customerPermissions, customerId: customer.id, isCustomerPortal: true, + mustChangePassword, representedCustomers: customer.representingFor.map((rep) => ({ id: rep.customer.id, customerNumber: rep.customer.customerNumber, @@ -331,17 +349,45 @@ export async function setCustomerPortalPassword(customerId: number, password: st console.log('[SetPortalPassword] Hash erstellt, Länge:', hashedPassword.length); + // Manuelles Setzen ist KEIN Einmalpasswort → Flag immer zurücksetzen, + // falls vorher ein OTP gesetzt war. await prisma.customer.update({ where: { id: customerId }, data: { portalPasswordHash: hashedPassword, portalPasswordEncrypted: encryptedPassword, + portalPasswordMustChange: false, }, }); console.log('[SetPortalPassword] Passwort gespeichert'); } +// Vom Endkunden selbst gesetztes Initial-Passwort nach OTP-Login. +// Speichert neuen Hash, löscht das verbrauchte Encrypted-Feld (Klartext- +// Speicherung soll bei OFF self-service nicht zurückkommen) und invalidiert +// sofort alle bestehenden Sessions, damit Login mit dem neuen Passwort +// gefordert wird. +export async function changeInitialPortalPassword(customerId: number, newPassword: string) { + const hashedPassword = await bcrypt.hash(newPassword, BCRYPT_COST); + await prisma.customer.update({ + where: { id: customerId }, + data: { + portalPasswordHash: hashedPassword, + portalPasswordEncrypted: null, + portalPasswordMustChange: false, + portalTokenInvalidatedAt: new Date(), + }, + }); +} + +export async function markPortalPasswordForChange(customerId: number) { + await prisma.customer.update({ + where: { id: customerId }, + data: { portalPasswordMustChange: true }, + }); +} + // Kundenportal-Passwort im Klartext abrufen export async function getCustomerPortalPassword(customerId: number): Promise { const customer = await prisma.customer.findUnique({ @@ -561,8 +607,14 @@ export async function sendPortalCredentialsEmail(params: { Passwort: ${esc(params.password)} +

+ ⚠️ Dieses Passwort ist ein Einmalpasswort. +

- Bitte ändern Sie Ihr Passwort nach dem ersten Login (im Portal unter „Mein Konto"). + Beim ersten Login werden Sie aufgefordert, ein eigenes Passwort zu vergeben. + Danach ist dieses Passwort hier nicht mehr gültig – falls Sie den + Vorgang abbrechen, fordern Sie bitte neue Zugangsdaten an oder nutzen die + Passwort-vergessen-Funktion.


diff --git a/docs/todo.md b/docs/todo.md index bbfbf5a4..fc97c14b 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,48 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🔐 Einmalpasswort-Flow für Portal-Credentials** + - **Intention**: Wenn wir Zugangsdaten per E-Mail an den Kunden + schicken, kennen wir das Passwort als Admin – das ist solange OK, + bis er sich einmal eingeloggt hat. Danach soll er gezwungen sein, + sich ein eigenes zu vergeben, und das per-Mail-Passwort ist tot. + - **Datenmodell**: neues Feld `portalPasswordMustChange: Boolean + @default(false)` am Customer. + - **Flow**: + 1. Admin klickt **Zugangsdaten versenden** → Flag wird gesetzt, + Mail-Template weist explizit auf „Einmalpasswort" hin. + 2. Kunde loggt sich mit dem OTP ein → Backend gibt + `mustChangePassword: true` im Login-Response zurück UND + **konsumiert das OTP sofort**: setzt `portalPasswordHash = + null` und `portalPasswordEncrypted = null`. Ein zweiter + Login mit demselben Passwort schlägt fehl (401). + 3. Frontend (`ProtectedRoute`) sieht `mustChangePassword=true` + und leitet auf `/change-initial-password` um – egal welche + Route der Kunde aufrufen will, er kommt nicht weiter. + 4. Auf der Seite gibt er ein neues, komplexes Passwort vor + (Live-Hint mit ✓/○, dieselben Regeln wie Backend). + 5. `POST /api/auth/change-initial-portal-password` speichert + neuen Hash, **löscht das Encrypted-Feld** (Admin kann das + eigene Passwort des Kunden nicht mehr im Klartext lesen), + setzt `portalTokenInvalidatedAt = now()` und + `portalPasswordMustChange = false`. + 6. Frontend loggt aus, leitet zu `/login?changed=1`, + Erfolgs-Banner: „Passwort wurde geändert. Bitte mit dem + neuen Passwort anmelden." + - **Edge case**: Tab geschlossen ohne Setzen → Kunde ist + ausgesperrt (OTP weg, eigenes Passwort nicht gesetzt). Lösung + aus seiner Sicht: Passwort-vergessen-Funktion oder Admin + versendet neue Zugangsdaten. + - **Edge case**: Admin macht zwischendurch nochmal manuelles + „Setzen" → `mustChange` wird automatisch wieder `false`. So + kann ein versehentlich versendetes OTP problemlos durch ein + direkt-gesetztes Passwort ersetzt werden. + - **Live-verifiziert (10 Schritte)**: Setzen → Send → Flag in + DB=true → Login mit OTP gibt mustChange=true zurück + Hash + in DB ist null → Re-Login mit OTP → 401 → Change-Endpoint + schwach → 400 → komplex → 200 → Login mit neuem PW → + mustChange=false + tokenInvalidatedAt gesetzt. + - [x] **🔐 Passwort-Komplexität + Portal-Credentials-UX** - **Problem**: Bisher reichten 6 Zeichen für gesetzte Passwörter (Portal-Login, User-Reset, Registrierung, User-Anlage). Das hat diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b29d2230..edfec450 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import Layout from './components/layout/Layout'; import Login from './pages/Login'; import PasswordResetRequest from './pages/PasswordResetRequest'; import PasswordResetConfirm from './pages/PasswordResetConfirm'; +import ChangeInitialPassword from './pages/ChangeInitialPassword'; import Dashboard from './pages/Dashboard'; import CustomerList from './pages/customers/CustomerList'; import CustomerDetail from './pages/customers/CustomerDetail'; @@ -49,7 +50,7 @@ import PortalProfile from './pages/portal/PortalProfile'; import PortalMeters from './pages/portal/PortalMeters'; function ProtectedRoute({ children }: { children: React.ReactNode }) { - const { isAuthenticated, isLoading } = useAuth(); + const { isAuthenticated, isLoading, user } = useAuth(); if (isLoading) { return ( @@ -63,9 +64,31 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { return ; } + // Force-Change-Password-Flow: nach Einmalpasswort-Login muss der Kunde + // zwingend ein eigenes Passwort vergeben, bevor er irgendwohin sonst + // navigieren darf. + if (user?.mustChangePassword) { + return ; + } + return <>{children}; } +function ChangeInitialPasswordGate() { + const { isAuthenticated, isLoading, user } = useAuth(); + if (isLoading) { + return ( +

+
Laden...
+
+ ); + } + if (!isAuthenticated) return ; + // Wer nicht im Einmalpasswort-Flow ist, hat hier nichts zu suchen. + if (!user?.mustChangePassword) return ; + return ; +} + function PortalConsentGate({ children }: { children: React.ReactNode }) { const { isCustomerPortal } = useAuth(); @@ -153,6 +176,9 @@ function App() { } /> } /> + {/* Einmalpasswort → eigenes Passwort vergeben (eingeloggt, eigene Gate-Logik) */} + } /> + Promise; - customerLogin: (email: string, password: string) => Promise; + customerLogin: (email: string, password: string) => Promise; logout: () => Promise; hasPermission: (permission: string) => boolean; isCustomer: boolean; @@ -72,14 +72,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }; - const customerLogin = async (email: string, password: string) => { + const customerLogin = async (email: string, password: string): Promise => { const res = await authApi.customerLogin(email, password); if (res.success && res.data) { setAccessToken(res.data.token); setUser(res.data.user); - } else { - throw new Error(res.error || 'Login fehlgeschlagen'); + return res.data.user; } + throw new Error(res.error || 'Login fehlgeschlagen'); }; const logout = async () => { diff --git a/frontend/src/pages/ChangeInitialPassword.tsx b/frontend/src/pages/ChangeInitialPassword.tsx new file mode 100644 index 00000000..7d760465 --- /dev/null +++ b/frontend/src/pages/ChangeInitialPassword.tsx @@ -0,0 +1,124 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; +import { authApi } from '../services/api'; +import Button from '../components/ui/Button'; +import Input from '../components/ui/Input'; +import Card from '../components/ui/Card'; + +const MIN_LENGTH = 12; + +function checkComplexity(pw: string) { + return { + length: pw.length >= MIN_LENGTH, + upper: /[A-Z]/.test(pw), + lower: /[a-z]/.test(pw), + digit: /[0-9]/.test(pw), + special: /[^A-Za-z0-9]/.test(pw), + }; +} + +function ComplexityHint({ pw }: { pw: string }) { + const c = checkComplexity(pw); + const items: [boolean, string][] = [ + [c.length, `Mindestens ${MIN_LENGTH} Zeichen`], + [c.upper, 'Großbuchstabe'], + [c.lower, 'Kleinbuchstabe'], + [c.digit, 'Ziffer'], + [c.special, 'Sonderzeichen'], + ]; + return ( +
    + {items.map(([ok, label]) => ( +
  • + {ok ? '✓' : '○'} {label} +
  • + ))} +
+ ); +} + +export default function ChangeInitialPassword() { + const { logout, user } = useAuth(); + const navigate = useNavigate(); + const [newPassword, setNewPassword] = useState(''); + const [repeat, setRepeat] = useState(''); + const [error, setError] = useState(''); + const [isSaving, setIsSaving] = useState(false); + + const c = checkComplexity(newPassword); + const meetsComplexity = c.length && c.upper && c.lower && c.digit && c.special; + const matches = newPassword.length > 0 && newPassword === repeat; + const canSubmit = meetsComplexity && matches && !isSaving; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + if (!canSubmit) return; + setIsSaving(true); + try { + const res = await authApi.changeInitialPortalPassword(newPassword); + if (!res.success) { + throw new Error(res.error || 'Passwort konnte nicht geändert werden'); + } + await logout(); + navigate('/login?changed=1', { replace: true }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Fehler beim Setzen des Passworts'); + setIsSaving(false); + } + }; + + return ( +
+ +
+

Neues Passwort vergeben

+

+ Hallo {user?.firstName || 'Kunde'}, Sie haben sich mit einem Einmalpasswort + angemeldet. Bitte vergeben Sie jetzt Ihr eigenes Passwort. Danach werden Sie + ausgeloggt und können sich mit dem neuen Passwort anmelden. +

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ setNewPassword(e.target.value)} + required + autoComplete="new-password" + /> + {newPassword.length > 0 && } +
+ +
+ setRepeat(e.target.value)} + required + autoComplete="new-password" + /> + {repeat.length > 0 && !matches && ( +

Passwörter stimmen nicht überein

+ )} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 79dce56f..2344fe1b 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,11 +1,13 @@ import { useState } from 'react'; -import { useNavigate, Link } from 'react-router-dom'; +import { useNavigate, useSearchParams, Link } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import Button from '../components/ui/Button'; import Input from '../components/ui/Input'; import Card from '../components/ui/Card'; export default function Login() { + const [searchParams] = useSearchParams(); + const passwordChanged = searchParams.get('changed') === '1'; const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); @@ -28,8 +30,13 @@ export default function Login() { } try { - await customerLogin(email, password); - navigate('/'); + const portalUser = await customerLogin(email, password); + // Einmalpasswort-Login → erzwungenes Passwort-Setzen vor Dashboard + if (portalUser?.mustChangePassword) { + navigate('/change-initial-password', { replace: true }); + } else { + navigate('/'); + } } catch { // Beide fehlgeschlagen setError('Ungültige Anmeldedaten'); @@ -45,6 +52,12 @@ export default function Login() {

Melden Sie sich an

+ {passwordChanged && !error && ( +
+ Passwort wurde geändert. Bitte mit dem neuen Passwort anmelden. +
+ )} + {error && (
{error} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index d3945408..3f071602 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -127,6 +127,10 @@ export const authApi = { const res = await api.post>('/auth/logout'); return res.data; }, + changeInitialPortalPassword: async (newPassword: string) => { + const res = await api.post>('/auth/change-initial-portal-password', { newPassword }); + return res.data; + }, }; // Customers diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 993f889b..f6b80681 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -7,6 +7,7 @@ export interface User { customerId?: number; roles?: Role[]; isCustomerPortal?: boolean; + mustChangePassword?: boolean; representedCustomers?: CustomerSummary[]; whatsappNumber?: string; telegramUsername?: string;