276 lines
9.1 KiB
Python
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()
|