second commit

This commit is contained in:
2026-08-19 22:33:19 +02:00
parent 411812e954
commit 199f306993
107 changed files with 5984 additions and 0 deletions

0
app/auth/__init__.py Normal file
View File

122
app/auth/deps.py Normal file
View File

@ -0,0 +1,122 @@
"""FastAPI-Dependencies fuer Authentifizierung und Autorisierung."""
from __future__ import annotations
from dataclasses import dataclass
from fastapi import Cookie, Depends, HTTPException, Request, Response, WebSocket, status
from app.db import get_db
from app.rbac import user_has_role, user_has_role_for_host
from app.security.sessions import (
SESSION_COOKIE_NAME,
decode_session_token,
is_expired,
refresh_session_token,
)
@dataclass
class CurrentUser:
id: int
username: str
is_admin: bool
async def get_current_user(
response: Response,
jh_session: str | None = Cookie(default=None, alias=SESSION_COOKIE_NAME),
) -> CurrentUser:
if jh_session is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Nicht angemeldet")
payload = decode_session_token(jh_session)
if payload is None or is_expired(payload):
response.delete_cookie(SESSION_COOKIE_NAME)
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Session abgelaufen oder ungueltig")
conn = get_db()
cursor = await conn.execute(
"SELECT id, username, is_admin, is_active, session_version FROM users WHERE id = ?",
(payload.user_id,),
)
row = await cursor.fetchone()
if row is None or not row[3] or row[4] != payload.session_version:
response.delete_cookie(SESSION_COOKIE_NAME)
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Session ungueltig")
# Gleitenden Idle-Timeout verlaengern (gleiche session_version/login_ts).
new_token = refresh_session_token(payload)
response.set_cookie(
SESSION_COOKIE_NAME,
new_token,
httponly=True,
secure=True,
samesite="strict",
max_age=None, # Session-Cookie; Ablauf wird serverseitig durchgesetzt
path="/",
)
return CurrentUser(id=row[0], username=row[1], is_admin=bool(row[2]))
async def get_current_user_ws(websocket: WebSocket) -> CurrentUser | None:
"""Wie get_current_user(), aber fuer WebSocket-Handshakes: kein Cookie-Refresh
(WebSockets erlauben nach dem Handshake kein Set-Cookie mehr), stattdessen
wird der Idle-Timeout beim naechsten regulaeren HTTP-Request durchgesetzt."""
token = websocket.cookies.get(SESSION_COOKIE_NAME)
if token is None:
return None
payload = decode_session_token(token)
if payload is None or is_expired(payload):
return None
conn = get_db()
cursor = await conn.execute(
"SELECT id, username, is_admin, is_active, session_version FROM users WHERE id = ?",
(payload.user_id,),
)
row = await cursor.fetchone()
if row is None or not row[3] or row[4] != payload.session_version:
return None
return CurrentUser(id=row[0], username=row[1], is_admin=bool(row[2]))
async def require_global_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
if not user.is_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich")
return user
def require_host_group_role(role_name: str):
"""Dependency-Factory: prueft Rolle des Users fuer eine per Pfad-/Query-Param
uebergebene host_group_id. Globale Admins duerfen immer."""
async def _dep(host_group_id: int, user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
if user.is_admin:
return user
conn = get_db()
allowed = await user_has_role(
conn, user_id=user.id, host_group_id=host_group_id, role_name=role_name
)
if not allowed:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Keine Berechtigung fuer diese Hostgruppe")
return user
return _dep
def require_host_role(role_name: str):
"""Dependency-Factory: prueft Rolle des Users fuer einen konkreten host_id."""
async def _dep(host_id: int, user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
if user.is_admin:
return user
conn = get_db()
allowed = await user_has_role_for_host(
conn, user_id=user.id, host_id=host_id, role_name=role_name
)
if not allowed:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Keine Berechtigung fuer diesen Host")
return user
return _dep

330
app/auth/routes.py Normal file
View File

@ -0,0 +1,330 @@
"""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()
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()
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()
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}