408 lines
14 KiB
Python
408 lines
14 KiB
Python
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.")
|