from datetime import timedelta from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Response, status from fastapi.responses import RedirectResponse from sqlalchemy import select from app.config import settings from app.deps import ( CSRF_COOKIE, SESSION_COOKIE, CurrentUser, DbSession, client_ip, create_session, destroy_session, self_registration_enabled, ) from app.mail import send_password_reset, send_verification from app.models import EmailChange, EmailToken, User, UserSession from app.schemas_admin import WelcomeCompleteIn, WelcomePreviewOut from app.schemas import ( LoginIn, MessageOut, PasswordChangeIn, PasswordResetIn, PasswordResetRequestIn, ProfileUpdateIn, RegisterIn, UserOut, ) from app.users import apply_if_complete from app.security import ( check_rate_limit, hash_password, hash_token, needs_rehash, new_token, normalize_email, utcnow, verify_password, ) router = APIRouter(prefix="/api/auth", tags=["auth"]) # Bewusst identische Antwort fuer "Konto existiert" und "Konto existiert nicht". _NEUTRAL = "Falls die Adresse bei uns registriert ist, wurde eine E-Mail versendet." def _issue_token(db, user: User, purpose: str, hours: int) -> str: # Aeltere, noch offene Token desselben Zwecks entwerten. for old in db.scalars( select(EmailToken).where( EmailToken.user_id == user.id, EmailToken.purpose == purpose, EmailToken.used_at.is_(None), ) ).all(): old.used_at = utcnow() raw = new_token() db.add( EmailToken( token_hash=hash_token(raw), user_id=user.id, purpose=purpose, expires_at=utcnow() + timedelta(hours=hours), ) ) return raw def _consume_token(db, raw: str, purpose: str) -> User | None: row = db.scalar( select(EmailToken).where( EmailToken.token_hash == hash_token(raw), EmailToken.purpose == purpose, ) ) if row is None or row.used_at is not None or row.expires_at <= utcnow(): return None row.used_at = utcnow() return db.get(User, row.user_id) @router.post("/register", response_model=MessageOut, status_code=status.HTTP_202_ACCEPTED) def register( payload: RegisterIn, request: Request, db: DbSession, background: BackgroundTasks, ): if not self_registration_enabled(db): raise HTTPException( status.HTTP_403_FORBIDDEN, "Die Selbstregistrierung ist deaktiviert. Bitte lass dich einladen.", ) if not check_rate_limit( db, f"register:{client_ip(request)}", limit=5, window_minutes=60 ): db.commit() raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Versuche.") email = normalize_email(payload.email) existing = db.scalar(select(User).where(User.email == email)) if existing is None: user = User( email=email, display_name=(payload.display_name or None), password_hash=hash_password(payload.password), is_admin=(email == normalize_email(settings.admin_email)), ) db.add(user) db.flush() raw = _issue_token(db, user, "verify", hours=24) background.add_task(send_verification, email, raw) elif existing.verified_at is None: # Unbestaetigtes Konto: neuen Link schicken, statt zu verraten, # dass die Adresse schon vergeben ist. raw = _issue_token(db, existing, "verify", hours=24) background.add_task(send_verification, email, raw) # Bestaetigtes Konto: nichts tun, aber gleiche Antwort geben. db.commit() return MessageOut(detail=_NEUTRAL) @router.get("/verify", include_in_schema=True) def verify(token: str, db: DbSession): """Ziel des Links aus der Bestätigungsmail. Antwortet mit einer Weiterleitung auf die Oberfläche, nicht mit JSON: Der Nutzer klickt hier im Browser, nicht per API-Aufruf. """ user = _consume_token(db, token, "verify") if user is None: db.commit() return RedirectResponse("/?verified=invalid", status_code=303) if user.verified_at is None: user.verified_at = utcnow() db.commit() return RedirectResponse("/?verified=ok", status_code=303) @router.post("/login", response_model=UserOut) def login(payload: LoginIn, request: Request, response: Response, db: DbSession): email = normalize_email(payload.email) ip_ok = check_rate_limit(db, f"login-ip:{client_ip(request)}", limit=20, window_minutes=15) acct_ok = check_rate_limit(db, f"login-acct:{email}", limit=8, window_minutes=15) if not (ip_ok and acct_ok): db.commit() raise HTTPException( status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Anmeldeversuche. Bitte warte einen Moment.", ) user = db.scalar(select(User).where(User.email == email)) # Laeuft auch ohne Treffer gegen einen Dummy-Hash - gleiche Laufzeit. ok = verify_password(payload.password, user.password_hash if user else None) if not ok or user is None: db.commit() raise HTTPException( status.HTTP_401_UNAUTHORIZED, "E-Mail-Adresse oder Passwort ist falsch." ) # Erst NACH erfolgreicher Passwortprüfung über die Deaktivierung # informieren. Vorher wäre es ein Hinweis darauf, dass es das Konto # gibt - danach weiß die Person das ohnehin, weil sie das Passwort # kennt. Und wer nur "falsches Passwort" liest, obwohl es stimmt, # sucht den Fehler an der falschen Stelle. if not user.is_active: db.commit() raise HTTPException( status.HTTP_403_FORBIDDEN, "Dieses Konto ist deaktiviert. Deine Listen bleiben erhalten – " "wende dich an die Administration, wenn du den Zugang wieder " "brauchst.", ) if user.verified_at is None: db.commit() raise HTTPException( status.HTTP_403_FORBIDDEN, "Bitte bestätige zuerst deine E-Mail-Adresse.", ) if needs_rehash(user.password_hash): user.password_hash = hash_password(payload.password) # Grundlage der automatischen Deaktivierung nach langer Untätigkeit. user.last_seen_at = utcnow() create_session(db, user, response) db.commit() return UserOut.of(user) @router.post("/logout", response_model=MessageOut) def logout(request: Request, response: Response, db: DbSession, user: CurrentUser): destroy_session(db, request, response) db.commit() return MessageOut(detail="Abgemeldet.") @router.post("/logout-all", response_model=MessageOut) def logout_all(request: Request, response: Response, db: DbSession, user: CurrentUser): """Meldet alle Geräte ab - z.B. nach Verdacht auf Kompromittierung.""" for sess in db.scalars( select(UserSession).where(UserSession.user_id == user.id) ).all(): db.delete(sess) response.delete_cookie(SESSION_COOKIE, path="/") response.delete_cookie(CSRF_COOKIE, path="/") db.commit() return MessageOut(detail="Auf allen Geräten abgemeldet.") @router.get("/me", response_model=UserOut) def me(user: CurrentUser): return UserOut.of(user) @router.patch("/me", response_model=UserOut) def update_me(payload: ProfileUpdateIn, db: DbSession, user: CurrentUser): user.display_name = payload.display_name or None db.commit() return UserOut.of(user) @router.post("/password/change", response_model=MessageOut) def change_password( payload: PasswordChangeIn, request: Request, response: Response, db: DbSession, user: CurrentUser, ): if not verify_password(payload.current_password, user.password_hash): raise HTTPException(status.HTTP_400_BAD_REQUEST, "Aktuelles Passwort ist falsch.") if verify_password(payload.new_password, user.password_hash): raise HTTPException( status.HTTP_400_BAD_REQUEST, "Das neue Passwort muss sich vom bisherigen unterscheiden.", ) user.password_hash = hash_password(payload.new_password) user.must_change_password = False # Alle bestehenden Sessions verwerfen und eine neue ausstellen. for sess in db.scalars( select(UserSession).where(UserSession.user_id == user.id) ).all(): db.delete(sess) db.flush() create_session(db, user, response) db.commit() return MessageOut(detail="Passwort geändert. Andere Geräte wurden abgemeldet.") @router.post("/password/reset-request", response_model=MessageOut) def reset_request( payload: PasswordResetRequestIn, request: Request, db: DbSession, background: BackgroundTasks, ): if not check_rate_limit( db, f"reset:{client_ip(request)}", limit=5, window_minutes=60 ): db.commit() raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Versuche.") email = normalize_email(payload.email) user = db.scalar(select(User).where(User.email == email)) if user is not None and user.is_active and user.verified_at is not None: raw = _issue_token(db, user, "reset", hours=1) background.add_task(send_password_reset, email, raw) db.commit() return MessageOut(detail=_NEUTRAL) # ========================================================================== # Willkommensnachricht: Passwort setzen und Konto freischalten # ========================================================================== @router.get("/welcome/{token}", response_model=WelcomePreviewOut) def welcome_preview(token: str, db: DbSession): """Zeigt, für welche Adresse der Link gilt - damit die Person sieht, worauf sie sich einlässt, bevor sie ein Passwort vergibt.""" row = db.scalar( select(EmailToken).where( EmailToken.token_hash == hash_token(token), EmailToken.purpose == "welcome", ) ) if row is None or row.used_at is not None or row.expires_at <= utcnow(): raise HTTPException( status.HTTP_400_BAD_REQUEST, "Der Link ist ungültig oder abgelaufen. Bitte die Administration " "um eine neue Einladung.", ) user = db.get(User, row.user_id) if user is None or not user.is_active: raise HTTPException(status.HTTP_410_GONE, "Das Konto steht nicht mehr bereit.") return WelcomePreviewOut(email=user.email, display_name=user.display_name) @router.post("/welcome/complete", response_model=MessageOut) def welcome_complete(payload: WelcomeCompleteIn, db: DbSession): """Setzt das erste Passwort und bestätigt die Adresse in einem Schritt. Der Klick auf den Link aus der Willkommensnachricht beweist, dass das Postfach erreichbar ist - eine zusätzliche Verifikationsmail wäre nur ein weiterer Schritt ohne Erkenntnisgewinn. """ user = _consume_token(db, payload.token, "welcome") if user is None: db.commit() raise HTTPException( status.HTTP_400_BAD_REQUEST, "Der Link ist ungültig oder abgelaufen." ) if not user.is_active: db.commit() raise HTTPException(status.HTTP_410_GONE, "Das Konto steht nicht mehr bereit.") user.password_hash = hash_password(payload.password) user.must_change_password = False user.verified_at = user.verified_at or utcnow() user.last_seen_at = utcnow() if payload.display_name is not None: user.display_name = payload.display_name.strip() or None db.commit() return MessageOut(detail="Zugang eingerichtet. Du kannst dich jetzt anmelden.") # ========================================================================== # Bestätigung einer Adressänderung # ========================================================================== @router.get("/email-change/{token}", include_in_schema=False) def confirm_email_change(token: str, db: DbSession): """Ziel der Links aus den Bestätigungsmails. Antwortet mit einer Weiterleitung auf die Oberfläche - hier klickt jemand im Browser, nicht per API-Aufruf. """ digest = hash_token(token) change = db.scalar( select(EmailChange).where( (EmailChange.token_new_hash == digest) | (EmailChange.token_old_hash == digest) ) ) if change is None: return RedirectResponse("/?adresswechsel=unbekannt", status_code=303) if change.cancelled_at or change.applied_at: return RedirectResponse("/?adresswechsel=erledigt", status_code=303) if change.expires_at <= utcnow(): return RedirectResponse("/?adresswechsel=abgelaufen", status_code=303) if change.token_new_hash == digest: change.confirmed_new_at = change.confirmed_new_at or utcnow() else: change.confirmed_old_at = change.confirmed_old_at or utcnow() done = apply_if_complete(db, change) db.commit() if done: return RedirectResponse("/?adresswechsel=fertig", status_code=303) # Bei Administratorkonten fehlt jetzt noch die zweite Bestätigung. return RedirectResponse("/?adresswechsel=teilweise", status_code=303) @router.post("/password/reset", response_model=MessageOut) def reset_password(payload: PasswordResetIn, db: DbSession): user = _consume_token(db, payload.token, "reset") if user is None: db.commit() raise HTTPException( status.HTTP_400_BAD_REQUEST, "Der Link ist ungültig oder abgelaufen." ) user.password_hash = hash_password(payload.password) user.must_change_password = False for sess in db.scalars( select(UserSession).where(UserSession.user_id == user.id) ).all(): db.delete(sess) db.commit() return MessageOut(detail="Passwort gesetzt. Du kannst dich jetzt anmelden.")