Kairo-Jinkendo/backend/auth.py
Lars 564008be13
Some checks failed
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Failing after 7s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
AP0.2: Auth, Tenant, Actor Foundation mit Sessions, TenantContext und Tests.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 20:09:54 +02:00

244 lines
7.4 KiB
Python

"""Authentication: bcrypt passwords, server-side sessions, FastAPI dependencies."""
from __future__ import annotations
import os
import secrets
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
import bcrypt
from fastapi import Depends, Header, HTTPException
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.audit import log_audit
AUTH_HEADER = "X-Auth-Token"
SESSION_DAYS = int(os.getenv("SESSION_DAYS", "30"))
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(password: str, stored_hash: str) -> bool:
if not stored_hash:
return False
try:
return bcrypt.checkpw(password.encode("utf-8"), stored_hash.encode("utf-8"))
except ValueError:
return False
def make_token() -> str:
return secrets.token_urlsafe(32)
def _session_expiry() -> datetime:
return datetime.now(timezone.utc) + timedelta(days=SESSION_DAYS)
def get_user_by_email(email: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, email, password_hash, display_name, portal_role, is_active
FROM users
WHERE LOWER(email) = LOWER(%s)
""",
(email.strip(),),
)
row = cur.fetchone()
return dict(row) if row else None
finally:
conn.close()
def get_user_by_id(user_id: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, email, display_name, portal_role, is_active, created_at
FROM users
WHERE id = %s
""",
(user_id,),
)
row = cur.fetchone()
return dict(row) if row else None
finally:
conn.close()
def get_session(token: str) -> Optional[dict[str, Any]]:
if not token:
return None
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT
s.id AS session_id,
s.token,
s.user_id,
s.active_tenant_id,
s.expires_at,
u.email,
u.display_name,
u.portal_role,
u.is_active AS user_is_active
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token = %s AND s.expires_at > NOW()
""",
(token,),
)
row = cur.fetchone()
if not row:
return None
session = dict(row)
if not session.get("user_is_active"):
return None
return session
finally:
conn.close()
def _default_active_tenant(user_id: str) -> Optional[str]:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT tm.tenant_id
FROM tenant_memberships tm
JOIN tenants t ON t.id = tm.tenant_id
WHERE tm.user_id = %s
AND tm.is_active = TRUE
AND t.is_active = TRUE
ORDER BY tm.created_at ASC
LIMIT 1
""",
(user_id,),
)
row = cur.fetchone()
return str(row[0]) if row else None
finally:
conn.close()
def create_session(user_id: str, active_tenant_id: Optional[str] = None) -> dict[str, Any]:
token = make_token()
tenant_id = active_tenant_id or _default_active_tenant(user_id)
expires_at = _session_expiry()
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
INSERT INTO sessions (token, user_id, active_tenant_id, expires_at)
VALUES (%s, %s, %s, %s)
RETURNING token, expires_at, active_tenant_id
""",
(token, user_id, tenant_id, expires_at),
)
row = dict(cur.fetchone())
conn.commit()
row["user_id"] = user_id
return row
finally:
conn.close()
def delete_session(token: str) -> None:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("DELETE FROM sessions WHERE token = %s", (token,))
conn.commit()
finally:
conn.close()
def set_session_active_tenant(token: str, tenant_id: str) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE sessions s
SET active_tenant_id = %s
FROM users u
WHERE s.token = %s
AND s.user_id = u.id
AND EXISTS (
SELECT 1 FROM tenant_memberships tm
JOIN tenants t ON t.id = tm.tenant_id
WHERE tm.user_id = s.user_id
AND tm.tenant_id = %s
AND tm.is_active = TRUE
AND t.is_active = TRUE
)
""",
(tenant_id, token, tenant_id),
)
updated = cur.rowcount > 0
conn.commit()
return updated
finally:
conn.close()
def login(email: str, password: str) -> dict[str, Any]:
user = get_user_by_email(email)
if not user or not user.get("is_active"):
log_audit("auth.login_failed", details={"email": email.strip().lower()})
raise HTTPException(status_code=401, detail="Ungültige Anmeldedaten")
if not verify_password(password, user["password_hash"]):
log_audit("auth.login_failed", user_id=str(user["id"]), details={"email": user["email"]})
raise HTTPException(status_code=401, detail="Ungültige Anmeldedaten")
session = create_session(str(user["id"]))
log_audit("auth.login", user_id=str(user["id"]), details={"email": user["email"]})
return {
"token": session["token"],
"expires_at": session["expires_at"].isoformat(),
"user": {
"id": str(user["id"]),
"email": user["email"],
"display_name": user["display_name"],
"portal_role": user["portal_role"],
},
}
def logout(token: str) -> None:
session = get_session(token)
if session:
log_audit(
"auth.logout",
user_id=str(session["user_id"]),
tenant_id=str(session["active_tenant_id"]) if session.get("active_tenant_id") else None,
)
delete_session(token)
def require_auth(x_auth_token: Optional[str] = Header(default=None, alias=AUTH_HEADER)) -> dict[str, Any]:
session = get_session(x_auth_token or "")
if not session:
raise HTTPException(status_code=401, detail="Nicht eingeloggt")
return session
def require_portal_admin(session: dict[str, Any] = Depends(require_auth)) -> dict[str, Any]:
if session.get("portal_role") != "admin":
raise HTTPException(status_code=403, detail="Nur für Portal-Admins")
return session