Files
hockey_new/hockey_data/auth_bridge.py
2026-08-19 15:08:39 +03:00

276 lines
9.1 KiB
Python

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()