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 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: return await self.adapter.current_user(request) 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()