Files
ssh-jumphost/app/auth/routes.py
2026-09-02 20:30:44 +02:00

366 lines
15 KiB
Python

"""Login-Flow: Passwort -> Pflicht-TOTP -> Session-Cookie (siehe Konzept 4.5/6.2)."""
from __future__ import annotations
import base64
import io
import secrets
from datetime import datetime, timedelta, timezone
import qrcode
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from app.auth.deps import CurrentUser, get_current_user
from app.db import get_db
from app.models.schemas import (
ChangePasswordRequest,
LoginRequest,
TotpConfirmRequest,
TotpLoginRequest,
)
from app.security.audit import write_audit_event
from app.security.passwords import hash_password, needs_rehash, verify_password
from app.security.pending_totp import create_pending_token, decode_pending_token
from app.security.rate_limit import login_rate_limiter
from app.security.sessions import SESSION_COOKIE_NAME, create_session_token
from app.security.totp import (
decrypt_totp_secret,
encrypt_totp_secret,
generate_recovery_codes,
generate_totp_secret,
hash_recovery_code,
provisioning_uri,
verify_totp_code,
)
router = APIRouter(prefix="/auth", tags=["auth"])
# Konstante Dummy-Hash-Verifikation gegen Username-Enumeration per Timing-Seitenkanal.
_DUMMY_HASH = hash_password(secrets.token_hex(16))
def _client_ip(request: Request) -> str:
# Nur die direkte Peer-IP; X-Forwarded-For wird ausschliesslich vertrauenswuerdig
# ausgewertet, wenn nginx mit set_real_ip_from konfiguriert ist (Konzept 7.2a).
return request.client.host if request.client else "unknown"
@router.post("/login")
async def login(payload: LoginRequest, request: Request):
ip = _client_ip(request)
if not login_rate_limiter.allow(ip):
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Anmeldeversuche, bitte warten.")
conn = get_db()
cursor = await conn.execute(
"SELECT id, password_hash, is_active, failed_logins, locked_until, totp_enrolled "
"FROM users WHERE username = ?",
(payload.username,),
)
row = await cursor.fetchone()
if row is None:
verify_password(_DUMMY_HASH, payload.password) # Timing angleichen
await write_audit_event(
conn, event_type="login_failed", user_id=None, client_ip=ip,
details={"reason": "unknown_user", "username": payload.username},
)
await conn.commit()
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Benutzername oder Passwort falsch")
user_id, pw_hash, is_active, failed_logins, locked_until, totp_enrolled = row
if locked_until and locked_until > datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"):
await write_audit_event(
conn, event_type="login_failed", user_id=user_id, client_ip=ip,
details={"reason": "locked"},
)
await conn.commit()
raise HTTPException(status.HTTP_423_LOCKED, "Konto vorruebergehend gesperrt")
if not is_active or not verify_password(pw_hash, payload.password):
new_failed = failed_logins + 1
# Bewusst OHNE dynamisch zusammengesetztes SQL (kein f-String mit
# Query-Fragmenten, auch wenn hier nie Nutzereingaben einfliessen) --
# zwei feste, vollstaendig parametrisierte Statements statt eines
# "SQL-Query-Building"-Musters, das Scanner (z.B. bandit B608) und
# Reviewer sonst jedes Mal erneut pruefen muessten.
if new_failed >= 5:
delay_s = 30 * (2 ** min(new_failed - 5, 6)) # progressive Verzoegerung, gedeckelt
locked_until_ts = (datetime.now(timezone.utc) + timedelta(seconds=delay_s)).strftime(
"%Y-%m-%dT%H:%M:%S.%fZ"
)
await conn.execute(
"UPDATE users SET failed_logins = ?, locked_until = ? WHERE id = ?",
(new_failed, locked_until_ts, user_id),
)
else:
await conn.execute(
"UPDATE users SET failed_logins = ? WHERE id = ?", (new_failed, user_id)
)
await write_audit_event(
conn, event_type="login_failed", user_id=user_id, client_ip=ip,
details={"reason": "bad_password", "failed_logins": new_failed},
)
await conn.commit()
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Benutzername oder Passwort falsch")
await conn.execute(
"UPDATE users SET failed_logins = 0, locked_until = NULL WHERE id = ?", (user_id,)
)
if needs_rehash(pw_hash):
await conn.execute(
"UPDATE users SET password_hash = ? WHERE id = ?",
(hash_password(payload.password), user_id),
)
await write_audit_event(
conn, event_type="login_password_ok", user_id=user_id, client_ip=ip, details={}
)
await conn.commit()
pending_token = create_pending_token(user_id)
return {"pending_token": pending_token, "totp_enrolled": bool(totp_enrolled)}
@router.post("/totp/enroll/start")
async def totp_enroll_start(body: dict, request: Request):
"""Erster Schritt der TOTP-Pflicht-Einrichtung (nur wenn noch nicht enrolled)."""
pending_token = body.get("pending_token", "")
user_id = decode_pending_token(pending_token)
if user_id is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Ungueltiges oder abgelaufenes Token")
conn = get_db()
cursor = await conn.execute(
"SELECT username, totp_enrolled FROM users WHERE id = ?", (user_id,)
)
row = await cursor.fetchone()
if row is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Ungueltig")
username, totp_enrolled = row
if totp_enrolled:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "TOTP ist bereits eingerichtet")
secret = generate_totp_secret()
await conn.execute(
"UPDATE users SET totp_secret_enc = ? WHERE id = ?",
(encrypt_totp_secret(secret), user_id),
)
await write_audit_event(
conn, event_type="totp_enroll_started", user_id=user_id,
client_ip=_client_ip(request), details={},
)
await conn.commit()
uri = provisioning_uri(secret, username)
qr_img = qrcode.make(uri)
buf = io.BytesIO()
qr_img.save(buf, format="PNG")
qr_b64 = base64.b64encode(buf.getvalue()).decode()
return {
"provisioning_uri": uri,
"qr_png_base64": qr_b64,
"recovery_codes_hint": "Recovery-Codes werden erst nach erfolgreicher Bestaetigung angezeigt.",
}
@router.post("/totp/enroll/confirm")
async def totp_enroll_confirm(body: dict, request: Request, response: Response):
pending_token = body.get("pending_token", "")
code = str(body.get("code", ""))
user_id = decode_pending_token(pending_token)
if user_id is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Ungueltiges oder abgelaufenes Token")
conn = get_db()
cursor = await conn.execute(
"SELECT totp_secret_enc, session_version FROM users WHERE id = ?", (user_id,)
)
row = await cursor.fetchone()
if row is None or row[0] is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "TOTP-Einrichtung wurde nicht gestartet")
secret = decrypt_totp_secret(row[0])
if not verify_totp_code(secret, code):
await write_audit_event(
conn, event_type="totp_enroll_failed", user_id=user_id,
client_ip=_client_ip(request), details={},
)
await conn.commit()
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Code ungueltig")
recovery_codes = generate_recovery_codes()
await conn.execute("UPDATE users SET totp_enrolled = 1 WHERE id = ?", (user_id,))
for rc in recovery_codes:
await conn.execute(
"INSERT INTO recovery_codes (user_id, code_hash) VALUES (?, ?)",
(user_id, hash_recovery_code(rc)),
)
await write_audit_event(
conn, event_type="totp_enroll_confirmed", user_id=user_id,
client_ip=_client_ip(request), details={},
)
await conn.commit()
token = create_session_token(user_id, row[1])
response.set_cookie(
SESSION_COOKIE_NAME, token, httponly=True, secure=True, samesite="strict", path="/"
)
return {"recovery_codes": recovery_codes}
@router.post("/login/totp")
async def login_totp(payload: TotpLoginRequest, request: Request, response: Response):
user_id = decode_pending_token(payload.pending_token)
if user_id is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Ungueltiges oder abgelaufenes Token")
conn = get_db()
cursor = await conn.execute(
"SELECT totp_secret_enc, totp_enrolled, session_version FROM users WHERE id = ?",
(user_id,),
)
row = await cursor.fetchone()
if row is None or not row[1]:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "TOTP nicht eingerichtet")
secret_enc, _enrolled, session_version = row
ip = _client_ip(request)
ok = verify_totp_code(decrypt_totp_secret(secret_enc), payload.code)
if not ok:
# Recovery-Code als Fallback pruefen.
code_hash = hash_recovery_code(payload.code)
rc_cursor = await conn.execute(
"SELECT id FROM recovery_codes WHERE user_id = ? AND code_hash = ? AND used_at IS NULL",
(user_id, code_hash),
)
rc_row = await rc_cursor.fetchone()
if rc_row is not None:
await conn.execute(
"UPDATE recovery_codes SET used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
(rc_row[0],),
)
await write_audit_event(
conn, event_type="login_recovery_code_used", user_id=user_id, client_ip=ip, details={}
)
ok = True
if not ok:
await write_audit_event(
conn, event_type="login_totp_failed", user_id=user_id, client_ip=ip, details={}
)
await conn.commit()
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "TOTP-Code ungueltig")
await write_audit_event(
conn, event_type="login_success", user_id=user_id, client_ip=ip, details={}
)
await conn.commit()
token = create_session_token(user_id, session_version)
response.set_cookie(
SESSION_COOKIE_NAME, token, httponly=True, secure=True, samesite="strict", path="/"
)
return {"status": "ok"}
@router.post("/logout")
async def logout(request: Request, response: Response, user: CurrentUser = Depends(get_current_user)):
conn = get_db()
await write_audit_event(
conn, event_type="logout", user_id=user.id, client_ip=_client_ip(request), details={}
)
await conn.commit()
# Siehe change_password() unten fuer die ausfuehrliche Begruendung: die
# get_current_user()-Dependency hat bereits ein Refresh-Set-Cookie
# geschrieben, das hier entfernte werden muss, bevor delete_cookie()
# selbst noch ein Set-Cookie (mit Max-Age=0) anhaengt -- sonst zwei
# Set-Cookie-Kopfzeilen in einer Antwort.
if "set-cookie" in response.headers:
del response.headers["set-cookie"]
response.delete_cookie(SESSION_COOKIE_NAME, path="/")
return {"status": "ok"}
@router.post("/logout-everywhere")
async def logout_everywhere(
request: Request, response: Response, user: CurrentUser = Depends(get_current_user)
):
"""Invalidiert alle ausgestellten Session-Cookies dieses Users sofort."""
conn = get_db()
await conn.execute(
"UPDATE users SET session_version = session_version + 1 WHERE id = ?", (user.id,)
)
await write_audit_event(
conn, event_type="logout_everywhere", user_id=user.id, client_ip=_client_ip(request), details={}
)
await conn.commit()
# Siehe change_password() unten fuer die ausfuehrliche Begruendung.
if "set-cookie" in response.headers:
del response.headers["set-cookie"]
response.delete_cookie(SESSION_COOKIE_NAME, path="/")
return {"status": "ok"}
@router.post("/change-password")
async def change_password(
payload: ChangePasswordRequest,
request: Request,
response: Response,
user: CurrentUser = Depends(get_current_user),
):
conn = get_db()
cursor = await conn.execute("SELECT password_hash, session_version FROM users WHERE id = ?", (user.id,))
row = await cursor.fetchone()
if row is None or not verify_password(row[0], payload.current_password):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Aktuelles Passwort falsch")
new_hash = hash_password(payload.new_password)
new_version = row[1] + 1 # invalidiert alle anderen laufenden Sessions dieses Users
await conn.execute(
"UPDATE users SET password_hash = ?, session_version = ?, must_change_password = 0, "
"password_changed_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
(new_hash, new_version, user.id),
)
await write_audit_event(
conn, event_type="password_changed", user_id=user.id, client_ip=_client_ip(request), details={}
)
await conn.commit()
# Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 6): get_current_user()
# (siehe app/auth/deps.py) hat als Dependency VOR dieser Routenfunktion
# bereits ein eigenes Set-Cookie in `response` geschrieben (gleitender
# Idle-Timeout-Refresh) -- MIT DER ALTEN session_version, weil die DB zu
# dem Zeitpunkt noch nicht aktualisiert war. Ohne diese Zeile wuerden
# ZWEI "Set-Cookie: jh_session=..."-Header in derselben Antwort stehen
# (die alte Refresh-Cookie UND die neue unten) -- welche davon ein Client
# am Ende tatsaechlich speichert, ist zwischen Browsern/HTTP-Bibliotheken
# nicht einheitlich definiert (in der Praxis eher "die letzte gewinnt",
# aber verlassen sollte man sich darauf nicht). Ein per-Request frischer
# Client akzeptiert die alte Sitzung danach zwar bereits korrekt nicht
# mehr (session_version-Pruefung in deps.py greift unabhaengig davon),
# aber die doppelte Kopfzeile bleibt unsauber und macht Tests, die den
# Cookie-Jar wiederverwenden, unnoetig fragil (siehe der dadurch lange
# falsch als "Sicherheitsluecke" vermutete Testfehlschlag). Die alte
# Set-Cookie-Kopfzeile wird deshalb hier explizit entfernt, bevor die
# neue, autoritative gesetzt wird -- `del response.headers["set-cookie"]`
# entfernt ALLE bisherigen Vorkommen dieses Headers (Starlette
# MutableHeaders.__delitem__), nicht nur das erste.
if "set-cookie" in response.headers:
del response.headers["set-cookie"]
token = create_session_token(user.id, new_version)
response.set_cookie(
SESSION_COOKIE_NAME, token, httponly=True, secure=True, samesite="strict", path="/"
)
return {"status": "ok"}
@router.get("/me")
async def me(user: CurrentUser = Depends(get_current_user)):
return {
"id": user.id,
"username": user.username,
"is_admin": user.is_admin,
}