bulid 63
This commit is contained in:
275
hockey_data/auth_bridge.py
Normal file
275
hockey_data/auth_bridge.py
Normal file
@@ -0,0 +1,275 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user