Erste Produktivversion

This commit is contained in:
2026-08-08 20:31:58 +02:00
parent d8ded2816d
commit 7b21e2d1b4
110 changed files with 18170 additions and 644 deletions

396
backend/app/routers/auth.py Normal file
View File

@@ -0,0 +1,396 @@
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.")