Files
einkaufsapp/backend/app/routers/auth.py

408 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.runtime_settings import get_duration
from app.users import apply_if_complete
from app.security import (
bucket_key,
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, bucket_key(db, "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=get_duration(db, "verify_hours"))
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=get_duration(db, "verify_hours")
)
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, bucket_key(db, "login-ip", client_ip(request)),
limit=20, window_minutes=15,
)
acct_ok = check_rate_limit(
db, bucket_key(db, "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, bucket_key(db, "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=get_duration(db, "reset_hours"))
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.")