Einmalpasswort-Flow für Portal-Credentials

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 18:48:13 +02:00
parent f0c97cd46d
commit 8534be22d0
12 changed files with 330 additions and 18 deletions
+4
View File
@@ -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?
@@ -385,3 +385,42 @@ export async function register(req: Request, res: Response): Promise<void> {
} 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<void> {
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);
}
}
@@ -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,
+3
View File
@@ -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;
+60 -8
View File
@@ -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<string | null> {
const customer = await prisma.customer.findUnique({
@@ -561,8 +607,14 @@ export async function sendPortalCredentialsEmail(params: {
<tr><td style="padding: 6px 12px; color: #6b7280;">Passwort:</td>
<td style="padding: 6px 12px; font-family: monospace;">${esc(params.password)}</td></tr>
</table>
<p style="color: #b91c1c; font-size: 14px; font-weight: 600;">
⚠️ Dieses Passwort ist ein <u>Einmalpasswort</u>.
</p>
<p style="color: #6b7280; font-size: 14px;">
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 <strong>nicht mehr gültig</strong> falls Sie den
Vorgang abbrechen, fordern Sie bitte neue Zugangsdaten an oder nutzen die
Passwort-vergessen-Funktion.
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
<p style="color: #9ca3af; font-size: 12px;">
+42
View File
@@ -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
+27 -1
View File
@@ -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 <Navigate to="/login" replace />;
}
// 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 <Navigate to="/change-initial-password" replace />;
}
return <>{children}</>;
}
function ChangeInitialPasswordGate() {
const { isAuthenticated, isLoading, user } = useAuth();
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-500">Laden...</div>
</div>
);
}
if (!isAuthenticated) return <Navigate to="/login" replace />;
// Wer nicht im Einmalpasswort-Flow ist, hat hier nichts zu suchen.
if (!user?.mustChangePassword) return <Navigate to="/" replace />;
return <ChangeInitialPassword />;
}
function PortalConsentGate({ children }: { children: React.ReactNode }) {
const { isCustomerPortal } = useAuth();
@@ -153,6 +176,9 @@ function App() {
<Route path="/password-reset/request" element={<PasswordResetRequest />} />
<Route path="/password-reset" element={<PasswordResetConfirm />} />
{/* Einmalpasswort → eigenes Passwort vergeben (eingeloggt, eigene Gate-Logik) */}
<Route path="/change-initial-password" element={<ChangeInitialPasswordGate />} />
<Route
path="/"
element={
+4 -4
View File
@@ -8,7 +8,7 @@ interface AuthContextType {
isLoading: boolean;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
customerLogin: (email: string, password: string) => Promise<void>;
customerLogin: (email: string, password: string) => Promise<User>;
logout: () => Promise<void>;
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<User> => {
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 () => {
@@ -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 (
<ul className="text-xs mt-2 space-y-0.5">
{items.map(([ok, label]) => (
<li key={label} className={ok ? 'text-green-700' : 'text-gray-500'}>
{ok ? '✓' : '○'} {label}
</li>
))}
</ul>
);
}
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 (
<div className="min-h-screen flex items-center justify-center bg-gray-100 p-4">
<Card className="w-full max-w-md">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-gray-900">Neues Passwort vergeben</h1>
<p className="text-gray-600 mt-2 text-sm">
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.
</p>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Input
label="Neues Passwort"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
autoComplete="new-password"
/>
{newPassword.length > 0 && <ComplexityHint pw={newPassword} />}
</div>
<div>
<Input
label="Passwort wiederholen"
type="password"
value={repeat}
onChange={(e) => setRepeat(e.target.value)}
required
autoComplete="new-password"
/>
{repeat.length > 0 && !matches && (
<p className="text-xs text-red-600 mt-1">Passwörter stimmen nicht überein</p>
)}
</div>
<Button type="submit" className="w-full" disabled={!canSubmit}>
{isSaving ? 'Speichere...' : 'Passwort setzen und ausloggen'}
</Button>
</form>
</Card>
</div>
);
}
+16 -3
View File
@@ -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() {
<p className="text-gray-600 mt-2">Melden Sie sich an</p>
</div>
{passwordChanged && !error && (
<div className="mb-4 p-4 bg-green-50 border border-green-200 text-green-700 rounded-lg text-sm">
Passwort wurde geändert. Bitte mit dem neuen Passwort anmelden.
</div>
)}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
{error}
+4
View File
@@ -127,6 +127,10 @@ export const authApi = {
const res = await api.post<ApiResponse<void>>('/auth/logout');
return res.data;
},
changeInitialPortalPassword: async (newPassword: string) => {
const res = await api.post<ApiResponse<void>>('/auth/change-initial-portal-password', { newPassword });
return res.data;
},
};
// Customers
+1
View File
@@ -7,6 +7,7 @@ export interface User {
customerId?: number;
roles?: Role[];
isCustomerPortal?: boolean;
mustChangePassword?: boolean;
representedCustomers?: CustomerSummary[];
whatsappNumber?: string;
telegramUsername?: string;