346 lines
13 KiB
Python
346 lines
13 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()
|
|
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)):
|
|
tenant_admin_of = []
|
|
if user.tenant_admin_ids:
|
|
conn = get_db()
|
|
placeholders = ",".join("?" for _ in user.tenant_admin_ids)
|
|
cursor = await conn.execute(
|
|
f"SELECT id, name FROM tenants WHERE id IN ({placeholders}) ORDER BY name",
|
|
tuple(user.tenant_admin_ids),
|
|
)
|
|
tenant_admin_of = [{"id": r[0], "name": r[1]} for r in await cursor.fetchall()]
|
|
return {
|
|
"id": user.id,
|
|
"username": user.username,
|
|
"is_admin": user.is_admin,
|
|
"is_any_admin": user.is_any_admin,
|
|
"tenant_admin_of": tenant_admin_of,
|
|
}
|