382 lines
15 KiB
Python
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()
|