feat: add self-service password reset via email

Adds "Passwort vergessen?" to login page with email-based password
reset flow. Backend generates secure token (SHA-256 hashed, 1h expiry),
sends reset link via SMTP, and validates on submission. Includes rate
limiting (3 requests/hour/email), audit logging, and account unlock
on successful reset. New ResetPasswordPage with password confirmation.

New DB table: password_reset_tokens (migration 008).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
CCS Admin 2026-02-28 14:56:07 +00:00
parent 002021d7c7
commit d5db84d93f
10 changed files with 490 additions and 5 deletions

View file

@ -0,0 +1,43 @@
"""Add password_reset_tokens table.
Revision ID: 008_password_reset_tokens
Revises: 007_add_report_type
"""
from alembic import op
import sqlalchemy as sa
revision = "008_password_reset_tokens"
down_revision = "007_add_report_type"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"password_reset_tokens",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column(
"user_id",
sa.Integer,
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("token_hash", sa.String(255), nullable=False),
sa.Column("expires_at", sa.DateTime, nullable=False),
sa.Column("used_at", sa.DateTime, nullable=True),
sa.Column(
"created_at",
sa.DateTime,
nullable=False,
server_default=sa.func.now(),
),
)
op.create_index("idx_prt_token", "password_reset_tokens", ["token_hash"])
op.create_index("idx_prt_user", "password_reset_tokens", ["user_id"])
def downgrade() -> None:
op.drop_index("idx_prt_user", table_name="password_reset_tokens")
op.drop_index("idx_prt_token", table_name="password_reset_tokens")
op.drop_table("password_reset_tokens")

View file

@ -1,26 +1,33 @@
"""Auth API router: login, register, refresh, logout, MFA, password change."""
"""Auth API router: login, register, refresh, logout, MFA, password change, reset."""
import logging
import os
import secrets
import time
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from sqlalchemy.orm import Session
from app.config import get_settings
from app.core.dependencies import get_current_user
from app.core.security import hash_password, hash_token
from app.database import get_db
from app.services.audit_service import log_action
from app.models.user import User
from app.models.user import PasswordResetToken, User
from app.schemas.auth import (
ChangePasswordRequest,
ForgotPasswordRequest,
LoginRequest,
MFADisableRequest,
MFASetupResponse,
MFAVerifyRequest,
RefreshRequest,
RegisterRequest,
ResetPasswordRequest,
TokenResponse,
)
from app.schemas.user import ProfileUpdate, UserResponse
from app.services.audit_service import log_action
from app.services.auth_service import (
activate_mfa,
authenticate_user,
@ -33,6 +40,10 @@ from app.services.auth_service import (
setup_mfa,
update_profile,
)
from app.services.notification_service import send_email
logger = logging.getLogger(__name__)
settings = get_settings()
router = APIRouter()
@ -87,6 +98,126 @@ def refresh(data: RefreshRequest, db: Session = Depends(get_db)):
)
# ---------------------------------------------------------------------------
# Password reset (public, no auth required)
# ---------------------------------------------------------------------------
RESET_TOKEN_EXPIRE_HOURS = 1
RESET_MAX_REQUESTS_PER_HOUR = 3
@router.post("/forgot-password")
def forgot_password(data: ForgotPasswordRequest, request: Request, db: Session = Depends(get_db)):
"""Request a password reset link via email.
Always returns 200 to prevent user enumeration.
"""
user = (
db.query(User)
.filter(User.email == data.email, User.is_active == True) # noqa: E712
.first()
)
if user:
# Rate limiting: max N requests per email per hour
one_hour_ago = datetime.now(timezone.utc) - timedelta(hours=1)
recent_count = (
db.query(PasswordResetToken)
.filter(
PasswordResetToken.user_id == user.id,
PasswordResetToken.created_at >= one_hour_ago,
)
.count()
)
if recent_count < RESET_MAX_REQUESTS_PER_HOUR:
# Generate token
raw_token = secrets.token_urlsafe(32)
token_h = hash_token(raw_token)
prt = PasswordResetToken(
user_id=user.id,
token_hash=token_h,
expires_at=datetime.now(timezone.utc) + timedelta(hours=RESET_TOKEN_EXPIRE_HOURS),
)
db.add(prt)
db.commit()
# Send email
reset_url = f"{settings.FRONTEND_BASE_URL}/reset-password?token={raw_token}"
send_email(
to=user.email,
subject="[DAK Portal] Passwort zurücksetzen",
body=(
f"Hallo {user.first_name or user.username},\n\n"
f"Sie haben eine Passwort-Zurücksetzung angefordert.\n\n"
f"Klicken Sie auf folgenden Link, um ein neues Passwort zu vergeben:\n"
f"{reset_url}\n\n"
f"Der Link ist {RESET_TOKEN_EXPIRE_HOURS} Stunde gültig.\n\n"
f"Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.\n\n"
f"Mit freundlichen Grüßen\n"
f"DAK Zweitmeinungs-Portal"
),
)
log_action(
db, user_id=user.id, action="password_reset_requested",
entity_type="user", entity_id=user.id,
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
return {"detail": "Falls ein Konto mit dieser E-Mail existiert, wurde ein Reset-Link gesendet."}
@router.post("/reset-password")
def reset_password(data: ResetPasswordRequest, request: Request, db: Session = Depends(get_db)):
"""Reset password using a valid reset token."""
if len(data.new_password) < 8:
raise HTTPException(status_code=422, detail="Das Passwort muss mindestens 8 Zeichen lang sein.")
token_h = hash_token(data.token)
prt = (
db.query(PasswordResetToken)
.filter(
PasswordResetToken.token_hash == token_h,
PasswordResetToken.used_at == None, # noqa: E711
PasswordResetToken.expires_at >= datetime.now(timezone.utc),
)
.first()
)
if not prt:
raise HTTPException(status_code=400, detail="Ungültiger oder abgelaufener Reset-Link.")
user = db.query(User).filter(User.id == prt.user_id).first()
if not user:
raise HTTPException(status_code=400, detail="Benutzer nicht gefunden.")
# Update password
user.password_hash = hash_password(data.new_password)
user.must_change_password = False
user.failed_login_attempts = 0
user.locked_until = None
# Invalidate all reset tokens for this user
db.query(PasswordResetToken).filter(
PasswordResetToken.user_id == user.id,
PasswordResetToken.used_at == None, # noqa: E711
).update({"used_at": datetime.now(timezone.utc)})
db.commit()
log_action(
db, user_id=user.id, action="password_reset_completed",
entity_type="user", entity_id=user.id,
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
return {"detail": "Passwort erfolgreich zurückgesetzt. Sie können sich jetzt anmelden."}
# ---------------------------------------------------------------------------
# Authenticated endpoints
# ---------------------------------------------------------------------------

View file

@ -28,6 +28,7 @@ class Settings(BaseSettings):
# App
APP_NAME: str = "DAK Zweitmeinungs-Portal"
FRONTEND_BASE_URL: str = "https://dak.complexcaresolutions.de"
CORS_ORIGINS: str = "http://localhost:5173,https://dak.complexcaresolutions.de"
MAX_UPLOAD_SIZE: int = 20971520 # 20MB
VERSICHERUNG_FILTER: str = "DAK"

View file

@ -146,6 +146,30 @@ class InvitationLink(Base):
)
class PasswordResetToken(Base):
__tablename__ = "password_reset_tokens"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
token_hash: Mapped[str] = mapped_column(String(255), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
used_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now()
)
user: Mapped[User] = relationship()
__table_args__ = (
Index("idx_prt_token", "token_hash"),
Index("idx_prt_user", "user_id"),
)
class AllowedDomain(Base):
__tablename__ = "allowed_domains"

View file

@ -68,3 +68,16 @@ class MFADisableRequest(BaseModel):
"""Password confirmation required to disable MFA."""
password: str
class ForgotPasswordRequest(BaseModel):
"""Request body for POST /forgot-password."""
email: EmailStr
class ResetPasswordRequest(BaseModel):
"""Request body for POST /reset-password."""
token: str
new_password: str

View file

@ -0,0 +1,53 @@
# Passwort-Reset per E-Mail — Design
## Flow
1. Nutzer klickt "Passwort vergessen?" auf der Login-Seite
2. Gibt E-Mail-Adresse ein → `POST /api/auth/forgot-password`
3. Backend generiert Token, speichert Hash in DB, sendet E-Mail mit Link
4. E-Mail enthält: `{FRONTEND_BASE_URL}/reset-password?token=XXX` (1h gültig)
5. Nutzer klickt Link → Frontend zeigt "Neues Passwort"-Formular
6. Nutzer gibt neues Passwort ein → `POST /api/auth/reset-password`
7. Backend validiert Token, setzt Passwort, invalidiert Token
## Backend
### Neues Modell: `PasswordResetToken`
Felder: `id`, `user_id` (FK), `token_hash` (SHA-256), `expires_at` (1h), `used_at`, `created_at`.
### Endpoints
- `POST /api/auth/forgot-password` — Immer 200 (verhindert User-Enumeration). Bei gültiger E-Mail: Token generieren, E-Mail senden.
- `POST /api/auth/reset-password` — Token + neues Passwort. Validiert Token, setzt Passwort, invalidiert alle Reset-Tokens des Users.
### Config
`FRONTEND_BASE_URL` in Settings für Reset-Link-Generierung.
### Sicherheit
- Token: `secrets.token_urlsafe(32)`, gespeichert als SHA-256 Hash
- 1h gültig, einmalig verwendbar
- Rate-Limiting: Max 3 Requests pro E-Mail pro Stunde
- Funktioniert auch bei gesperrten Accounts
- Audit-Log: `password_reset_requested`, `password_reset_completed`
## Frontend
- "Passwort vergessen?"-Link auf LoginPage
- Inline E-Mail-Eingabe unter dem Login-Formular
- Neue Route `/reset-password``ResetPasswordPage.tsx`
- Formular: Neues Passwort + Bestätigung (min. 8 Zeichen)
## Dateien
| Datei | Änderung |
|-------|----------|
| `backend/app/models/user.py` | `PasswordResetToken` Modell |
| `backend/alembic/versions/007_password_reset_tokens.py` | Migration |
| `backend/app/config.py` | `FRONTEND_BASE_URL` Setting |
| `backend/app/api/auth.py` | Zwei neue Endpoints |
| `frontend/src/pages/LoginPage.tsx` | "Passwort vergessen?" UI |
| `frontend/src/pages/ResetPasswordPage.tsx` | Neue Seite |
| `frontend/src/App.tsx` | Route `/reset-password` |

View file

@ -17,4 +17,4 @@
- [ ] **Erweiterte Suche mit Filterspeicherung** — Häufig genutzte Filter als Presets speichern (z.B. "Onko ohne ICD 2026").
- [ ] **Dashboard: Durchlaufzeiten** — Durchschnittliche Dauer von Fallerfassung bis Gutachten visualisieren.
- [ ] **Passwort-Reset per E-Mail** — Self-Service "Passwort vergessen" auf der Login-Seite. Aktuell nur Admin-seitig möglich.
- [x] **Passwort-Reset per E-Mail** — ✅ Implementiert: "Passwort vergessen?" auf Login-Seite, Reset-Link per E-Mail (1h gültig), ResetPasswordPage mit Passwort-Formular. Rate-Limiting (3/h), Audit-Log, Token als SHA-256 Hash gespeichert.

View file

@ -21,6 +21,7 @@ import { GutachtenStatistikPage } from '@/pages/GutachtenStatistikPage'
import { MyDisclosuresPage } from '@/pages/MyDisclosuresPage'
import { WochenuebersichtPage } from '@/pages/WochenuebersichtPage'
import { AnleitungPage } from '@/pages/AnleitungPage'
import { ResetPasswordPage } from '@/pages/ResetPasswordPage'
const queryClient = new QueryClient({
defaultOptions: {
@ -41,6 +42,7 @@ function App() {
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/" element={<ProtectedRoute><AppLayout /></ProtectedRoute>}>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />

View file

@ -12,8 +12,9 @@ import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { AlertCircle, Loader2, Moon, Sun } from 'lucide-react'
import { AlertCircle, CheckCircle, Loader2, Moon, Sun } from 'lucide-react'
import { useTheme } from '@/hooks/useTheme'
import api from '@/services/api'
const loginSchema = z.object({
email: z.string().email('Bitte geben Sie eine gueltige E-Mail-Adresse ein'),
@ -31,6 +32,25 @@ export function LoginPage() {
const [showMfa, setShowMfa] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [legalDialog, setLegalDialog] = useState<{ title: string; url: string } | null>(null)
const [showForgot, setShowForgot] = useState(false)
const [forgotEmail, setForgotEmail] = useState('')
const [forgotLoading, setForgotLoading] = useState(false)
const [forgotSent, setForgotSent] = useState(false)
const [forgotError, setForgotError] = useState('')
const handleForgotPassword = async () => {
if (!forgotEmail.trim()) return
setForgotLoading(true)
setForgotError('')
try {
await api.post('/auth/forgot-password', { email: forgotEmail.trim() })
setForgotSent(true)
} catch {
setForgotError('Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.')
} finally {
setForgotLoading(false)
}
}
const {
register,
@ -151,6 +171,60 @@ export function LoginPage() {
)}
</Button>
<div className="text-center">
<button
type="button"
onClick={() => { setShowForgot(!showForgot); setForgotSent(false); setForgotError('') }}
className="text-sm text-muted-foreground hover:text-foreground underline underline-offset-2 transition-colors"
>
Passwort vergessen?
</button>
</div>
{showForgot && (
<div className="space-y-3 rounded-lg border p-3">
{forgotSent ? (
<div className="flex items-start gap-2">
<CheckCircle className="h-4 w-4 text-green-600 mt-0.5 shrink-0" />
<p className="text-sm text-muted-foreground">
Falls ein Konto mit dieser E-Mail existiert, wurde ein Link zum Zurücksetzen gesendet. Bitte prüfen Sie Ihr Postfach.
</p>
</div>
) : (
<>
<p className="text-sm text-muted-foreground">
Geben Sie Ihre E-Mail-Adresse ein. Sie erhalten einen Link zum Zurücksetzen Ihres Passworts.
</p>
<Input
type="email"
placeholder="name@dak.de"
value={forgotEmail}
onChange={(e) => setForgotEmail(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleForgotPassword() } }}
/>
{forgotError && <p className="text-sm text-destructive">{forgotError}</p>}
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={handleForgotPassword}
disabled={forgotLoading || !forgotEmail.trim()}
>
{forgotLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Wird gesendet...
</>
) : (
'Reset-Link senden'
)}
</Button>
</>
)}
</div>
)}
</form>
</CardContent>
</Card>

View file

@ -0,0 +1,144 @@
import { useState } from 'react'
import { useSearchParams, Link } from 'react-router-dom'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { AlertCircle, CheckCircle, Loader2 } from 'lucide-react'
import api from '@/services/api'
export function ResetPasswordPage() {
const [searchParams] = useSearchParams()
const token = searchParams.get('token') || ''
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const passwordValid = password.length >= 8
const passwordsMatch = password === confirm
const canSubmit = passwordValid && passwordsMatch && !loading
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!canSubmit) return
setLoading(true)
setError('')
try {
await api.post('/auth/reset-password', { token, new_password: password })
setSuccess(true)
} catch (err: any) {
const detail = err?.response?.data?.detail
setError(typeof detail === 'string' ? detail : 'Ein Fehler ist aufgetreten.')
} finally {
setLoading(false)
}
}
if (!token) {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-background px-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Ungültiger Link</CardTitle>
</CardHeader>
<CardContent className="text-center space-y-4">
<p className="text-muted-foreground">
Der Reset-Link ist ungültig. Bitte fordern Sie einen neuen Link an.
</p>
<Link to="/login">
<Button variant="outline">Zurück zum Login</Button>
</Link>
</CardContent>
</Card>
</div>
)
}
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-background px-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Neues Passwort vergeben</CardTitle>
<CardDescription>
Geben Sie ein neues Passwort für Ihr Konto ein.
</CardDescription>
</CardHeader>
<CardContent>
{success ? (
<div className="space-y-4">
<div className="flex items-start gap-2">
<CheckCircle className="h-5 w-5 text-green-600 mt-0.5 shrink-0" />
<p className="text-sm">
Ihr Passwort wurde erfolgreich zurückgesetzt. Sie können sich jetzt mit Ihrem neuen Passwort anmelden.
</p>
</div>
<Link to="/login">
<Button className="w-full">Zum Login</Button>
</Link>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="password">Neues Passwort</Label>
<Input
id="password"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Mindestens 8 Zeichen"
/>
{password && !passwordValid && (
<p className="text-sm text-destructive">Das Passwort muss mindestens 8 Zeichen lang sein.</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirm">Passwort bestätigen</Label>
<Input
id="confirm"
type="password"
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="Passwort wiederholen"
/>
{confirm && !passwordsMatch && (
<p className="text-sm text-destructive">Die Passwörter stimmen nicht überein.</p>
)}
</div>
<Button type="submit" className="w-full" disabled={!canSubmit}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Wird gespeichert...
</>
) : (
'Passwort zurücksetzen'
)}
</Button>
<div className="text-center">
<Link to="/login" className="text-sm text-muted-foreground hover:text-foreground underline underline-offset-2 transition-colors">
Zurück zum Login
</Link>
</div>
</form>
)}
</CardContent>
</Card>
</div>
)
}