Files
hockey_new/hockey_data/auth_bridge.py

382 lines
15 KiB
Python

from __future__ import annotations
import base64
import hashlib
import hmac
import os
import secrets
import time
from collections import OrderedDict
from threading import Lock, RLock
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Protocol
from fastapi import HTTPException, Request, status
from sqlalchemy import create_engine, select, text
from sqlalchemy.engine import Engine, make_url
from sqlalchemy.exc import SQLAlchemyError
from .database import HockeyDatabase
from .async_work import run_db
from .models import HockeyWebSession
from .account_activity import ACCOUNT_IDLE_SECONDS, lock_account
<<<<<<< HEAD
from .program_log import set_log_context
=======
>>>>>>> e4bedb1264fca8a6bc8a41f0ca1ff67b176c6651
HOCKEY_COOKIE_NAME = "hockey_auth_token"
HOCKEY_SESSION_SECONDS = ACCOUNT_IDLE_SECONDS
@dataclass(frozen=True, slots=True)
class HockeyUser:
id: str
login: str
display_name: str
is_active: bool = True
is_admin: bool = False
role: str = "operator"
class SharedAuthAdapter(Protocol):
async def current_user(self, request: Request) -> HockeyUser | None:
...
def login(self, username: str, password: str) -> tuple[str, HockeyUser] | None:
...
def logout(self, token: str) -> None:
...
def session_status(self, token: str, *, idle_ms: int | None = None) -> dict | None:
...
def _session_deadline(web_session: HockeyWebSession) -> datetime:
# Apply the idle limit to sessions created before this release as well.
return min(web_session.expires_at,
web_session.last_activity_at + timedelta(seconds=HOCKEY_SESSION_SECONDS))
def _session_status_payload(deadline: datetime, now: datetime) -> dict:
return {"expires_at": deadline.isoformat() + "Z",
"expires_in_seconds": max(0, (deadline - now).total_seconds()),
"idle_timeout_seconds": HOCKEY_SESSION_SECONDS}
class WFLAuthAdapter:
"""Authenticate against WFL accounts and keep web sessions in hockey DB.
The WFL engine is strictly read-only: no WFL accounts, password hashes or
auth sessions are copied or modified. Only the WFL user id and snapshots
needed for audit/display are stored in the hockey database.
"""
def __init__(self, database_url: str | None, hockey_database: HockeyDatabase) -> None:
self.hockey_database = hockey_database
self.engine: Engine | None = None
self._read_cache: OrderedDict[str, tuple[float, datetime, HockeyUser]] = OrderedDict()
self._cache_lock = RLock()
self._token_locks = [Lock() for _ in range(64)]
self._read_cache_seconds = 2.0
if database_url:
url = make_url(database_url)
kwargs = {"future": True, "pool_pre_ping": True, "hide_parameters": True,
"pool_size": 5, "max_overflow": 2, "pool_timeout": 5, "pool_recycle": 1800}
if url.get_backend_name() == "postgresql":
url = url.set(drivername="postgresql+psycopg")
kwargs["connect_args"] = {
"connect_timeout": 5, "application_name": "hockey-wfl-auth",
"options": "-c statement_timeout=5000 -c lock_timeout=1000 -c default_transaction_read_only=on",
}
self.engine = create_engine(url, **kwargs)
def _remember_user(self, token_hash: str, user: HockeyUser, expires_at: datetime) -> None:
with self._cache_lock:
self._read_cache[token_hash] = (time.monotonic() + self._read_cache_seconds, expires_at, user)
self._read_cache.move_to_end(token_hash)
while len(self._read_cache) > 1024:
self._read_cache.popitem(last=False)
def login(self, username: str, password: str) -> tuple[str, HockeyUser] | None:
username = username.strip()
if not username or not password:
return None
row = self._wfl_account(username=username)
if row is None or not row["is_active"]:
return None
if not _verify_wfl_password(password, str(row["password_hash"] or "")):
return None
user = _user_from_row(row)
token = secrets.token_urlsafe(48)
now = datetime.utcnow()
with self.hockey_database.session() as session:
lock_account(session, user.id)
session.add(
HockeyWebSession(
token_hash=_token_hash(token),
wfl_user_id=user.id,
login_snapshot=user.login,
role_snapshot=user.role,
created_at=now,
last_activity_at=now,
expires_at=now + timedelta(seconds=HOCKEY_SESSION_SECONDS),
)
)
return token, user
async def current_user(self, request: Request) -> HockeyUser | None:
token = request.cookies.get(HOCKEY_COOKIE_NAME, "").strip()
if not token:
return None
# Mutations always revalidate the session AND the WFL account. The small
# read cache never grants write authority after a role/account revocation.
return await run_db(self._current_user_sync, _token_hash(token),
allow_cache=request.method in {"GET", "HEAD", "OPTIONS"})
def _current_user_sync(self, token_hash: str, *, allow_cache: bool) -> HockeyUser | None:
with self._token_locks[int(token_hash[:4], 16) % len(self._token_locks)]:
now = datetime.utcnow()
if allow_cache:
with self._cache_lock:
cached = self._read_cache.get(token_hash)
if cached and cached[0] > time.monotonic() and cached[1] > now:
self._read_cache.move_to_end(token_hash)
return cached[2]
with self.hockey_database.session() as session:
web_session = session.scalar(select(HockeyWebSession).where(HockeyWebSession.token_hash == token_hash))
if web_session is None or web_session.revoked_at is not None or _session_deadline(web_session) <= now:
with self._cache_lock:
self._read_cache.pop(token_hash, None)
return None
row = self._wfl_account(user_id=web_session.wfl_user_id)
if row is None or not row["is_active"]:
web_session.revoked_at = now
with self._cache_lock:
self._read_cache.pop(token_hash, None)
return None
user = _user_from_row(row)
expires_at = _session_deadline(web_session)
self._remember_user(token_hash, user, expires_at)
return user
def session_status(self, token: str, *, idle_ms: int | None = None) -> dict | None:
"""Only explicit UI activity extends login; polling never does."""
if not token:
return None
token_hash = _token_hash(token)
with self._token_locks[int(token_hash[:4], 16) % len(self._token_locks)]:
now = datetime.utcnow()
with self.hockey_database.session() as session:
query = select(HockeyWebSession).where(HockeyWebSession.token_hash == token_hash)
if idle_ms is not None:
user_id = session.scalar(select(HockeyWebSession.wfl_user_id).where(
HockeyWebSession.token_hash == token_hash))
if user_id is None:
return None
# Same lock order as cleanup: account, then session row.
lock_account(session, user_id)
query = query.with_for_update()
web_session = session.scalar(query)
if web_session is None or web_session.revoked_at is not None or _session_deadline(web_session) <= now:
with self._cache_lock:
self._read_cache.pop(token_hash, None)
return None
row = self._wfl_account(user_id=web_session.wfl_user_id)
if row is None or not row["is_active"]:
web_session.revoked_at = now
with self._cache_lock:
self._read_cache.pop(token_hash, None)
return None
if idle_ms is not None:
activity_at = now - timedelta(milliseconds=max(0, idle_ms))
web_session.last_activity_at = max(web_session.last_activity_at, activity_at)
web_session.expires_at = web_session.last_activity_at + timedelta(seconds=HOCKEY_SESSION_SECONDS)
deadline = _session_deadline(web_session)
user = _user_from_row(row)
self._remember_user(token_hash, user, deadline)
return _session_status_payload(deadline, now)
def logout(self, token: str) -> None:
if not token:
return
token_hash = _token_hash(token)
with self._token_locks[int(token_hash[:4], 16) % len(self._token_locks)]:
with self._cache_lock:
self._read_cache.pop(token_hash, None)
with self.hockey_database.session() as session:
web_session = session.scalar(select(HockeyWebSession).where(HockeyWebSession.token_hash == token_hash))
if web_session is not None and web_session.revoked_at is None:
web_session.revoked_at = datetime.utcnow()
def _wfl_account(
self,
*,
username: str | None = None,
user_id: str | None = None,
):
if self.engine is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="WFL_DATABASE_URL is not configured in the hockey .env",
)
where = "u.username = :value" if username is not None else "u.id = :value"
value = username if username is not None else user_id
fields = "u.id, u.username, u.is_active, u.role"
if username is not None:
fields += ", u.password_hash"
try:
with self.engine.connect() as connection:
return connection.execute(
text(
f"""
SELECT {fields}
FROM admin_users AS u
WHERE {where}
LIMIT 1
"""
),
{"value": value},
).mappings().first()
except SQLAlchemyError:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="WFL authentication database is unavailable",
)
class LocalBrowserUserAdapter:
"""Explicit development fallback; never enabled in WFL mode."""
def __init__(self) -> None:
self._tokens: dict[str, HockeyUser] = {}
self._deadlines: dict[str, datetime] = {}
async def current_user(self, request: Request) -> HockeyUser | None:
user_id = request.headers.get("X-Hockey-User", "").strip()
if user_id:
role = request.headers.get("X-Hockey-Role", "operator").strip().lower()
return HockeyUser(
id=user_id,
login=user_id,
display_name=user_id,
is_admin=role == "admin",
role=role,
)
token = request.cookies.get(HOCKEY_COOKIE_NAME, "")
if self._deadlines.get(token, datetime.min) <= datetime.utcnow():
self.logout(token)
return None
return self._tokens.get(token)
def login(self, username: str, password: str) -> tuple[str, HockeyUser] | None:
if not username.strip() or not password:
return None
user = HockeyUser(
id=username.strip(),
login=username.strip(),
display_name=username.strip(),
)
token = secrets.token_urlsafe(32)
self._tokens[token] = user
self._deadlines[token] = datetime.utcnow() + timedelta(seconds=HOCKEY_SESSION_SECONDS)
return token, user
def logout(self, token: str) -> None:
self._tokens.pop(token, None)
self._deadlines.pop(token, None)
def session_status(self, token: str, *, idle_ms: int | None = None) -> dict | None:
now = datetime.utcnow()
deadline = self._deadlines.get(token, datetime.min)
if token not in self._tokens or deadline <= now:
self.logout(token)
return None
if idle_ms is not None:
deadline = max(deadline, now + timedelta(seconds=HOCKEY_SESSION_SECONDS, milliseconds=-max(0, idle_ms)))
self._deadlines[token] = deadline
return _session_status_payload(deadline, now)
def create_auth_adapter(hockey_database: HockeyDatabase) -> SharedAuthAdapter:
mode = os.getenv("HOCKEY_AUTH_MODE", "wfl").strip().lower()
if mode == "dev":
return LocalBrowserUserAdapter()
if mode != "wfl":
raise RuntimeError("HOCKEY_AUTH_MODE must be 'wfl' or 'dev'")
database_url = os.getenv("WFL_DATABASE_URL", "").strip() or None
return WFLAuthAdapter(database_url, hockey_database)
class HockeyAuthDependencies:
def __init__(self, adapter: SharedAuthAdapter) -> None:
self.adapter = adapter
async def optional_user(self, request: Request) -> HockeyUser | None:
<<<<<<< HEAD
user = getattr(request.state, "wfl_user", None)
if user is None:
user = await self.adapter.current_user(request)
if user is not None:
request.state.wfl_user = user
set_log_context(actor_id=user.id, actor_login=user.login)
return user
=======
return await self.adapter.current_user(request)
>>>>>>> e4bedb1264fca8a6bc8a41f0ca1ff67b176c6651
async def require_user(self, request: Request) -> HockeyUser:
user = await self.optional_user(request)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Hockey authentication required",
)
return user
async def require_admin(self, request: Request) -> HockeyUser:
user = await self.require_user(request)
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="WFL administrator role required",
)
return user
def _user_from_row(row) -> HockeyUser:
role = str(row["role"] or "operator").strip().lower()
username = str(row["username"] or "").strip()
return HockeyUser(
id=str(row["id"]),
login=username,
display_name=username,
is_active=bool(row["is_active"]),
is_admin=role == "admin",
role=role,
)
def _verify_wfl_password(password: str, stored_hash: str) -> bool:
try:
algorithm, iterations_raw, salt_b64, hash_b64 = stored_hash.split("$", 3)
if algorithm != "pbkdf2_sha256":
return False
iterations = int(iterations_raw)
salt = base64.b64decode(salt_b64)
expected = base64.b64decode(hash_b64)
except Exception:
return False
actual = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt, iterations
)
return hmac.compare_digest(actual, expected)
def _token_hash(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()