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
Co-authored-by: Cursor <cursoragent@cursor.com>
244 lines
7.4 KiB
Python
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
|