Version 1.0.0: Passwort-Reset + Rate-Limiting + Auto-Geburtstagsgrüße
Die drei letzten wichtigen Features für ein produktionsreifes 1.0.0: ## 1. Passwort vergessen-Flow Der klassische Selfservice-Reset per Email – sowohl für Mitarbeiter als auch für Portal-Kunden. User können sich nicht mehr aussperren, Admin muss nicht mehr manuell eingreifen. - Neues Link "Passwort vergessen?" auf Login-Seite - PasswordResetRequest: Email + Typ-Auswahl (Mitarbeiter / Portal) - PasswordResetConfirm: Token-basierte Bestätigung + neues Passwort (min 6 Zeichen) - Token ist 2 Stunden gültig, dann muss neu angefordert werden - Token ist kryptografisch sicher (crypto.randomBytes(32)) - User-Enumeration-Schutz: Backend gibt immer 200 zurück, egal ob Email existiert - Nach erfolgreichem Reset werden ALLE bestehenden Sessions gekickt (tokenInvalidatedAt gesetzt) – falls jemand parallel eingeloggt war DB: - User.passwordResetToken + passwordResetExpiresAt - Customer.portalPasswordResetToken + portalPasswordResetExpiresAt ## 2. Rate-Limiting gegen Brute-Force Mit express-rate-limit: - Login: 10 Versuche pro 15 Minuten pro IP. Erfolgreiche zählen nicht mit. - Passwort-Reset-Request: 5 Versuche pro Stunde pro IP (Mail-Flut verhindern) Sowohl Mitarbeiter-Login als auch Portal-Login geschützt. ## 3. Auto-Geburtstagsgrüße per Cron Das autoBirthdayGreeting-Flag hatten wir schon, aber kein Scheduler der ihn wirklich abschickt. Jetzt: - Läuft täglich um 08:00 Uhr - Findet Kunden mit heutigem Geburtstag + autoBirthdayGreeting=true - Nur Email-Kanal (Messenger brauchen Browser-Klick) - Catch-up 30s nach Server-Start: wenn Server am Geburtstag down war, wird beim nächsten Boot nachgeholt - lastBirthdayGreetingYear verhindert Doppelversand Dependencies: node-cron, @types/node-cron, express-rate-limit Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,8 @@ import { Shield } from 'lucide-react';
|
||||
import ScrollToTop from './components/ScrollToTop';
|
||||
import Layout from './components/layout/Layout';
|
||||
import Login from './pages/Login';
|
||||
import PasswordResetRequest from './pages/PasswordResetRequest';
|
||||
import PasswordResetConfirm from './pages/PasswordResetConfirm';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import CustomerList from './pages/customers/CustomerList';
|
||||
import CustomerDetail from './pages/customers/CustomerDetail';
|
||||
@@ -146,6 +148,10 @@ function App() {
|
||||
element={isAuthenticated ? <Navigate to="/" replace /> : <Login />}
|
||||
/>
|
||||
|
||||
{/* Passwort-Reset (öffentlich, kein Auth-Check) */}
|
||||
<Route path="/password-reset/request" element={<PasswordResetRequest />} />
|
||||
<Route path="/password-reset" element={<PasswordResetConfirm />} />
|
||||
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import Button from '../components/ui/Button';
|
||||
import Input from '../components/ui/Input';
|
||||
@@ -73,6 +73,15 @@ export default function Login() {
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Anmeldung...' : 'Anmelden'}
|
||||
</Button>
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
to="/password-reset/request"
|
||||
className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
|
||||
>
|
||||
Passwort vergessen?
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Lock, CheckCircle, AlertCircle, Eye, EyeOff } from 'lucide-react';
|
||||
import Button from '../components/ui/Button';
|
||||
import Input from '../components/ui/Input';
|
||||
import Card from '../components/ui/Card';
|
||||
import axios from 'axios';
|
||||
|
||||
export default function PasswordResetConfirm() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const token = searchParams.get('token') || '';
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!token) {
|
||||
setError('Ungültiger Link: Kein Token enthalten.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError('Das Passwort muss mindestens 6 Zeichen lang sein.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== passwordConfirm) {
|
||||
setError('Die Passwörter stimmen nicht überein.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await axios.post('/api/auth/password-reset/confirm', { token, password });
|
||||
setSuccess(true);
|
||||
setTimeout(() => navigate('/login'), 3000);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Fehler beim Zurücksetzen. Bitte versuche es erneut.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Ungültiger Link</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Dieser Reset-Link ist unvollständig. Bitte fordere einen neuen an.
|
||||
</p>
|
||||
<Link to="/password-reset/request">
|
||||
<Button className="w-full">Neuen Link anfordern</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="text-center">
|
||||
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-4" />
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Passwort geändert</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Dein Passwort wurde erfolgreich zurückgesetzt. Du wirst in Kürze zum Login weitergeleitet.
|
||||
</p>
|
||||
<Link to="/login">
|
||||
<Button className="w-full">Jetzt einloggen</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="text-center mb-6">
|
||||
<Lock className="w-10 h-10 text-blue-500 mx-auto mb-3" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">Neues Passwort</h1>
|
||||
<p className="text-gray-600 mt-2 text-sm">Vergib ein neues Passwort für deinen Account.</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>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Neues Passwort *</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
autoComplete="new-password"
|
||||
className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">Mindestens 6 Zeichen</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Passwort bestätigen *"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={passwordConfirm}
|
||||
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Wird gespeichert…' : 'Passwort festlegen'}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Mail, ArrowLeft, CheckCircle } from 'lucide-react';
|
||||
import Button from '../components/ui/Button';
|
||||
import Input from '../components/ui/Input';
|
||||
import Card from '../components/ui/Card';
|
||||
import axios from 'axios';
|
||||
|
||||
export default function PasswordResetRequest() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [userType, setUserType] = useState<'admin' | 'portal'>('admin');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await axios.post('/api/auth/password-reset/request', { email, userType });
|
||||
setSent(true);
|
||||
} catch (err: any) {
|
||||
// Backend sendet absichtlich immer 200, aber Rate-Limit kann 429 senden
|
||||
if (err.response?.status === 429) {
|
||||
setError(err.response.data?.error || 'Zu viele Anfragen. Bitte später erneut versuchen.');
|
||||
} else {
|
||||
setSent(true); // Auch bei anderen Fehlern Erfolg anzeigen (Email-Enumeration-Schutz)
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="text-center">
|
||||
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-4" />
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">E-Mail gesendet</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Wenn ein Konto mit der E-Mail <strong>{email}</strong> existiert, haben wir dir einen
|
||||
Link zum Zurücksetzen des Passworts gesendet. Der Link ist 2 Stunden gültig.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
Nichts erhalten? Schau in den Spam-Ordner oder versuche es in ein paar Minuten erneut.
|
||||
</p>
|
||||
<Link to="/login">
|
||||
<Button variant="secondary" className="w-full">
|
||||
Zurück zum Login
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="text-center mb-6">
|
||||
<Mail className="w-10 h-10 text-blue-500 mx-auto mb-3" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">Passwort vergessen?</h1>
|
||||
<p className="text-gray-600 mt-2 text-sm">
|
||||
Gib deine E-Mail-Adresse ein. Wir senden dir einen Link zum Zurücksetzen.
|
||||
</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>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Konto-Typ</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="userType"
|
||||
checked={userType === 'admin'}
|
||||
onChange={() => setUserType('admin')}
|
||||
/>
|
||||
<span className="text-sm">Mitarbeiter</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="userType"
|
||||
checked={userType === 'portal'}
|
||||
onChange={() => setUserType('portal')}
|
||||
/>
|
||||
<span className="text-sm">Kunde (Portal)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="E-Mail"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
placeholder="deine@email.de"
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Wird gesendet…' : 'Link zum Zurücksetzen senden'}
|
||||
</Button>
|
||||
|
||||
<Link
|
||||
to="/login"
|
||||
className="flex items-center justify-center gap-1 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Zurück zum Login
|
||||
</Link>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user