From d5db84d93ffd3b23779ae4dd4dfbc89eb85faef5 Mon Sep 17 00:00:00 2001 From: CCS Admin Date: Sat, 28 Feb 2026 14:56:07 +0000 Subject: [PATCH] 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 --- .../versions/008_password_reset_tokens.py | 43 ++++++ backend/app/api/auth.py | 137 ++++++++++++++++- backend/app/config.py | 1 + backend/app/models/user.py | 24 +++ backend/app/schemas/auth.py | 13 ++ .../plans/2026-02-28-password-reset-design.md | 53 +++++++ docs/todo.md | 2 +- frontend/src/App.tsx | 2 + frontend/src/pages/LoginPage.tsx | 76 ++++++++- frontend/src/pages/ResetPasswordPage.tsx | 144 ++++++++++++++++++ 10 files changed, 490 insertions(+), 5 deletions(-) create mode 100644 backend/alembic/versions/008_password_reset_tokens.py create mode 100644 docs/plans/2026-02-28-password-reset-design.md create mode 100644 frontend/src/pages/ResetPasswordPage.tsx diff --git a/backend/alembic/versions/008_password_reset_tokens.py b/backend/alembic/versions/008_password_reset_tokens.py new file mode 100644 index 0000000..5361a25 --- /dev/null +++ b/backend/alembic/versions/008_password_reset_tokens.py @@ -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") diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index c48fea3..15be375 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -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 # --------------------------------------------------------------------------- diff --git a/backend/app/config.py b/backend/app/config.py index 7e872aa..d2f39d9 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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" diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 9f4bd59..dbd4a1c 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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" diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index cba9755..db0a825 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -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 diff --git a/docs/plans/2026-02-28-password-reset-design.md b/docs/plans/2026-02-28-password-reset-design.md new file mode 100644 index 0000000..0696ec0 --- /dev/null +++ b/docs/plans/2026-02-28-password-reset-design.md @@ -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` | diff --git a/docs/todo.md b/docs/todo.md index 33ee1a4..598f70a 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -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. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 89d92fe..63749a0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> + } /> }> } /> } /> diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 1bee1d4..2571fbb 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -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() { )} +
+ +
+ + {showForgot && ( +
+ {forgotSent ? ( +
+ +

+ Falls ein Konto mit dieser E-Mail existiert, wurde ein Link zum Zurücksetzen gesendet. Bitte prüfen Sie Ihr Postfach. +

+
+ ) : ( + <> +

+ Geben Sie Ihre E-Mail-Adresse ein. Sie erhalten einen Link zum Zurücksetzen Ihres Passworts. +

+ setForgotEmail(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleForgotPassword() } }} + /> + {forgotError &&

{forgotError}

} + + + )} +
+ )} + diff --git a/frontend/src/pages/ResetPasswordPage.tsx b/frontend/src/pages/ResetPasswordPage.tsx new file mode 100644 index 0000000..b214ded --- /dev/null +++ b/frontend/src/pages/ResetPasswordPage.tsx @@ -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 ( +
+ + + Ungültiger Link + + +

+ Der Reset-Link ist ungültig. Bitte fordern Sie einen neuen Link an. +

+ + + +
+
+
+ ) + } + + return ( +
+ + + Neues Passwort vergeben + + Geben Sie ein neues Passwort für Ihr Konto ein. + + + + {success ? ( +
+
+ +

+ Ihr Passwort wurde erfolgreich zurückgesetzt. Sie können sich jetzt mit Ihrem neuen Passwort anmelden. +

+
+ + + +
+ ) : ( +
+ {error && ( + + + {error} + + )} + +
+ + setPassword(e.target.value)} + placeholder="Mindestens 8 Zeichen" + /> + {password && !passwordValid && ( +

Das Passwort muss mindestens 8 Zeichen lang sein.

+ )} +
+ +
+ + setConfirm(e.target.value)} + placeholder="Passwort wiederholen" + /> + {confirm && !passwordsMatch && ( +

Die Passwörter stimmen nicht überein.

+ )} +
+ + + +
+ + Zurück zum Login + +
+
+ )} +
+
+
+ ) +}