Erste Produktivversion
This commit is contained in:
396
backend/app/routers/auth.py
Normal file
396
backend/app/routers/auth.py
Normal 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.")
|
||||
Reference in New Issue
Block a user