from __future__ import annotations import base64 import hashlib import hmac import os import secrets 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 from sqlalchemy.exc import SQLAlchemyError from .database import HockeyDatabase from .models import HockeyWebSession HOCKEY_COOKIE_NAME = "hockey_auth_token" HOCKEY_SESSION_SECONDS = 4 * 60 * 60 HOCKEY_SESSION_TOUCH_SECONDS = 60 @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: ... 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 if database_url: self.engine = create_engine( database_url, future=True, pool_pre_ping=True, ) 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: 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 now = datetime.utcnow() with self.hockey_database.session() as session: web_session = session.scalar( select(HockeyWebSession).where( HockeyWebSession.token_hash == _token_hash(token) ) ) if ( web_session is None or web_session.revoked_at is not None or web_session.expires_at < now ): return None wfl_user_id = web_session.wfl_user_id row = self._wfl_account(user_id=wfl_user_id) if row is None or not row["is_active"]: web_session.revoked_at = now return None if ( now - web_session.last_activity_at ).total_seconds() >= HOCKEY_SESSION_TOUCH_SECONDS: web_session.last_activity_at = now web_session.expires_at = now + timedelta( seconds=HOCKEY_SESSION_SECONDS ) return _user_from_row(row) def logout(self, token: str) -> None: if not token: return with self.hockey_database.session() as session: web_session = session.scalar( select(HockeyWebSession).where( HockeyWebSession.token_hash == _token_hash(token) ) ) 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] = {} 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, ) return self._tokens.get(request.cookies.get(HOCKEY_COOKIE_NAME, "")) 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 return token, user def logout(self, token: str) -> None: self._tokens.pop(token, None) 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()