mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-26 19:21:58 -06:00
Remove legacy bridge and expose auth session endpoint
This commit is contained in:
@@ -18,7 +18,7 @@ from .interfaces import (
|
|||||||
from .interfaces.eventlet_compat import apply_eventlet_patches
|
from .interfaces.eventlet_compat import apply_eventlet_patches
|
||||||
from .repositories.sqlite import connection as sqlite_connection
|
from .repositories.sqlite import connection as sqlite_connection
|
||||||
from .repositories.sqlite import migrations as sqlite_migrations
|
from .repositories.sqlite import migrations as sqlite_migrations
|
||||||
from .server import attach_legacy_bridge, create_app
|
from .server import create_app
|
||||||
from .services.container import build_service_container
|
from .services.container import build_service_container
|
||||||
from .services.crypto.certificates import ensure_certificate
|
from .services.crypto.certificates import ensure_certificate
|
||||||
|
|
||||||
@@ -71,19 +71,13 @@ def bootstrap() -> EngineRuntime:
|
|||||||
logger.info("default-admin-ensured")
|
logger.info("default-admin-ensured")
|
||||||
|
|
||||||
app = create_app(settings, db_factory=db_factory)
|
app = create_app(settings, db_factory=db_factory)
|
||||||
attach_legacy_bridge(app, settings, logger=logger)
|
|
||||||
services = build_service_container(settings, db_factory=db_factory, logger=logger.getChild("services"))
|
services = build_service_container(settings, db_factory=db_factory, logger=logger.getChild("services"))
|
||||||
app.extensions["engine_services"] = services
|
app.extensions["engine_services"] = services
|
||||||
register_http_interfaces(app, services)
|
register_http_interfaces(app, services)
|
||||||
|
|
||||||
legacy_active = bool(app.config.get("ENGINE_LEGACY_BRIDGE_ACTIVE"))
|
socketio = create_socket_server(app, settings.socketio)
|
||||||
if legacy_active:
|
register_ws_interfaces(socketio, services)
|
||||||
socketio = None
|
services.scheduler_service.start(socketio)
|
||||||
logger.info("legacy-ws-deferred")
|
|
||||||
else:
|
|
||||||
socketio = create_socket_server(app, settings.socketio)
|
|
||||||
register_ws_interfaces(socketio, services)
|
|
||||||
services.scheduler_service.start(socketio)
|
|
||||||
logger.info("bootstrap-complete")
|
logger.info("bootstrap-complete")
|
||||||
return EngineRuntime(
|
return EngineRuntime(
|
||||||
app=app,
|
app=app,
|
||||||
|
|||||||
@@ -26,11 +26,7 @@ def register_http_interfaces(app: Flask, services: EngineServiceContainer) -> No
|
|||||||
The implementation is intentionally minimal for the initial scaffolding.
|
The implementation is intentionally minimal for the initial scaffolding.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
registrars = list(_REGISTRARS)
|
for registrar in _REGISTRARS:
|
||||||
if app.config.get("ENGINE_LEGACY_BRIDGE_ACTIVE"):
|
|
||||||
registrars = [r for r in registrars if r is not job_management.register]
|
|
||||||
|
|
||||||
for registrar in registrars:
|
|
||||||
registrar(app, services)
|
registrar(app, services)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,36 @@ def register(app: Flask, services: EngineServiceContainer) -> None:
|
|||||||
_set_auth_cookie(response, "", expires=0)
|
_set_auth_cookie(response, "", expires=0)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@bp.route("/api/auth/me", methods=["GET"])
|
||||||
|
def me() -> Any:
|
||||||
|
service = _service(services)
|
||||||
|
|
||||||
|
account = None
|
||||||
|
username = session.get("username")
|
||||||
|
if isinstance(username, str) and username:
|
||||||
|
account = service.fetch_account(username)
|
||||||
|
|
||||||
|
if account is None:
|
||||||
|
token = request.cookies.get("borealis_auth", "")
|
||||||
|
if not token:
|
||||||
|
auth_header = request.headers.get("Authorization", "")
|
||||||
|
if auth_header.lower().startswith("bearer "):
|
||||||
|
token = auth_header.split(None, 1)[1]
|
||||||
|
account = service.resolve_token(token)
|
||||||
|
if account is not None:
|
||||||
|
session["username"] = account.username
|
||||||
|
session["role"] = account.role or "User"
|
||||||
|
|
||||||
|
if account is None:
|
||||||
|
return jsonify({"error": "not_authenticated"}), 401
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"username": account.username,
|
||||||
|
"display_name": account.display_name or account.username,
|
||||||
|
"role": account.role,
|
||||||
|
}
|
||||||
|
return jsonify(payload)
|
||||||
|
|
||||||
@bp.route("/api/auth/mfa/verify", methods=["POST"])
|
@bp.route("/api/auth/mfa/verify", methods=["POST"])
|
||||||
def verify_mfa() -> Any:
|
def verify_mfa() -> Any:
|
||||||
pending = session.get("mfa_pending")
|
pending = session.get("mfa_pending")
|
||||||
|
|||||||
@@ -2,11 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterable, Optional
|
from typing import Optional
|
||||||
|
|
||||||
from flask import Flask, request, send_from_directory
|
from flask import Flask, request, send_from_directory
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
@@ -103,135 +100,4 @@ def create_app(
|
|||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def attach_legacy_bridge(
|
__all__ = ["create_app"]
|
||||||
app: Flask,
|
|
||||||
settings: EngineSettings,
|
|
||||||
*,
|
|
||||||
logger: Optional[logging.Logger] = None,
|
|
||||||
) -> None:
|
|
||||||
"""Attach the legacy Flask application as a fallback dispatcher.
|
|
||||||
|
|
||||||
Borealis ships a mature API surface inside ``Data/Server/server.py``. The
|
|
||||||
Engine will eventually supersede it, but during the migration the React
|
|
||||||
frontend still expects the historical endpoints to exist. This helper
|
|
||||||
attempts to load the legacy application and wires it as a fallback WSGI
|
|
||||||
dispatcher so any route the Engine does not yet implement transparently
|
|
||||||
defers to the proven implementation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
log = logger or logging.getLogger("borealis.engine.legacy")
|
|
||||||
|
|
||||||
if not _legacy_bridge_enabled():
|
|
||||||
log.info("legacy-bridge-disabled")
|
|
||||||
return
|
|
||||||
|
|
||||||
legacy = _load_legacy_app(settings, app, log)
|
|
||||||
if legacy is None:
|
|
||||||
log.warning("legacy-bridge-unavailable")
|
|
||||||
return
|
|
||||||
|
|
||||||
app.config["ENGINE_LEGACY_BRIDGE_ACTIVE"] = True
|
|
||||||
app.wsgi_app = _FallbackDispatcher(app.wsgi_app, legacy.wsgi_app) # type: ignore[assignment]
|
|
||||||
app.extensions["legacy_flask_app"] = legacy
|
|
||||||
log.info("legacy-bridge-active")
|
|
||||||
|
|
||||||
|
|
||||||
def _legacy_bridge_enabled() -> bool:
|
|
||||||
raw = os.getenv("BOREALIS_ENGINE_ENABLE_LEGACY_BRIDGE", "1")
|
|
||||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
||||||
|
|
||||||
|
|
||||||
def _load_legacy_app(
|
|
||||||
settings: EngineSettings,
|
|
||||||
engine_app: Flask,
|
|
||||||
logger: logging.Logger,
|
|
||||||
) -> Optional[Flask]:
|
|
||||||
try:
|
|
||||||
legacy_module = importlib.import_module("Data.Server.server")
|
|
||||||
except Exception as exc: # pragma: no cover - defensive
|
|
||||||
logger.exception("legacy-import-failed", exc_info=exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
legacy_app = getattr(legacy_module, "app", None)
|
|
||||||
if not isinstance(legacy_app, Flask):
|
|
||||||
logger.error("legacy-app-missing")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Align runtime configuration so both applications share database and
|
|
||||||
# session state.
|
|
||||||
try:
|
|
||||||
setattr(legacy_module, "DB_PATH", str(settings.database_path))
|
|
||||||
except Exception: # pragma: no cover - defensive
|
|
||||||
logger.warning("legacy-db-path-sync-failed", extra={"path": str(settings.database_path)})
|
|
||||||
|
|
||||||
_synchronise_session_config(engine_app, legacy_app)
|
|
||||||
|
|
||||||
return legacy_app
|
|
||||||
|
|
||||||
|
|
||||||
def _synchronise_session_config(engine_app: Flask, legacy_app: Flask) -> None:
|
|
||||||
legacy_app.secret_key = engine_app.config.get("SECRET_KEY", legacy_app.secret_key)
|
|
||||||
for key in (
|
|
||||||
"SESSION_COOKIE_HTTPONLY",
|
|
||||||
"SESSION_COOKIE_SECURE",
|
|
||||||
"SESSION_COOKIE_SAMESITE",
|
|
||||||
"SESSION_COOKIE_DOMAIN",
|
|
||||||
):
|
|
||||||
value = engine_app.config.get(key)
|
|
||||||
if value is not None:
|
|
||||||
legacy_app.config[key] = value
|
|
||||||
|
|
||||||
|
|
||||||
class _FallbackDispatcher:
|
|
||||||
"""WSGI dispatcher that retries a secondary app when the primary 404s."""
|
|
||||||
|
|
||||||
__slots__ = ("_primary", "_fallback", "_retry_statuses")
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
primary: Any,
|
|
||||||
fallback: Any,
|
|
||||||
*,
|
|
||||||
retry_statuses: Iterable[int] = (404,),
|
|
||||||
) -> None:
|
|
||||||
self._primary = primary
|
|
||||||
self._fallback = fallback
|
|
||||||
self._retry_statuses = {int(status) for status in retry_statuses}
|
|
||||||
|
|
||||||
def __call__(self, environ: dict[str, Any], start_response: Any) -> Iterable[bytes]:
|
|
||||||
captured_body: list[bytes] = []
|
|
||||||
captured_status: dict[str, Any] = {}
|
|
||||||
|
|
||||||
def _capture_start_response(status: str, headers: list[tuple[str, str]], exc_info: Any = None):
|
|
||||||
captured_status["status"] = status
|
|
||||||
captured_status["headers"] = headers
|
|
||||||
captured_status["exc_info"] = exc_info
|
|
||||||
|
|
||||||
def _write(data: bytes) -> None:
|
|
||||||
captured_body.append(data)
|
|
||||||
|
|
||||||
return _write
|
|
||||||
|
|
||||||
primary_iterable = self._primary(environ, _capture_start_response)
|
|
||||||
try:
|
|
||||||
for chunk in primary_iterable:
|
|
||||||
captured_body.append(chunk)
|
|
||||||
finally:
|
|
||||||
close = getattr(primary_iterable, "close", None)
|
|
||||||
if callable(close):
|
|
||||||
close()
|
|
||||||
|
|
||||||
status_line = str(captured_status.get("status") or "500 Internal Server Error")
|
|
||||||
try:
|
|
||||||
status_code = int(status_line.split()[0])
|
|
||||||
except Exception: # pragma: no cover - defensive
|
|
||||||
status_code = 500
|
|
||||||
|
|
||||||
if status_code not in self._retry_statuses:
|
|
||||||
start_response(status_line, captured_status.get("headers", []), captured_status.get("exc_info"))
|
|
||||||
return captured_body
|
|
||||||
|
|
||||||
return self._fallback(environ, start_response)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["attach_legacy_bridge", "create_app"]
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ try: # pragma: no cover - optional dependency
|
|||||||
except Exception: # pragma: no cover - gracefully degrade when unavailable
|
except Exception: # pragma: no cover - gracefully degrade when unavailable
|
||||||
qrcode = None # type: ignore
|
qrcode = None # type: ignore
|
||||||
|
|
||||||
from itsdangerous import URLSafeTimedSerializer
|
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||||
|
|
||||||
from Data.Engine.builders.operator_auth import (
|
from Data.Engine.builders.operator_auth import (
|
||||||
OperatorLoginRequest,
|
OperatorLoginRequest,
|
||||||
@@ -119,6 +119,33 @@ class OperatorAuthService:
|
|||||||
payload = {"u": username, "r": role or "User", "ts": int(time.time())}
|
payload = {"u": username, "r": role or "User", "ts": int(time.time())}
|
||||||
return serializer.dumps(payload)
|
return serializer.dumps(payload)
|
||||||
|
|
||||||
|
def resolve_token(self, token: str, *, max_age: int = 30 * 24 * 3600) -> Optional[OperatorAccount]:
|
||||||
|
"""Return the account associated with *token* if it is valid."""
|
||||||
|
|
||||||
|
token = (token or "").strip()
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
serializer = self._token_serializer()
|
||||||
|
try:
|
||||||
|
payload = serializer.loads(token, max_age=max_age)
|
||||||
|
except (BadSignature, SignatureExpired):
|
||||||
|
return None
|
||||||
|
|
||||||
|
username = str(payload.get("u") or "").strip()
|
||||||
|
if not username:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self._repository.fetch_by_username(username)
|
||||||
|
|
||||||
|
def fetch_account(self, username: str) -> Optional[OperatorAccount]:
|
||||||
|
"""Return the operator account for *username* if it exists."""
|
||||||
|
|
||||||
|
username = (username or "").strip()
|
||||||
|
if not username:
|
||||||
|
return None
|
||||||
|
return self._repository.fetch_by_username(username)
|
||||||
|
|
||||||
def _finalize_login(self, account: OperatorAccount) -> OperatorLoginSuccess:
|
def _finalize_login(self, account: OperatorAccount) -> OperatorLoginSuccess:
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
self._repository.update_last_login(account.username, now)
|
self._repository.update_last_login(account.username, now)
|
||||||
|
|||||||
120
Data/Engine/tests/test_http_auth.py
Normal file
120
Data/Engine/tests/test_http_auth.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("flask")
|
||||||
|
|
||||||
|
from Data.Engine.config.environment import (
|
||||||
|
DatabaseSettings,
|
||||||
|
EngineSettings,
|
||||||
|
FlaskSettings,
|
||||||
|
GitHubSettings,
|
||||||
|
ServerSettings,
|
||||||
|
SocketIOSettings,
|
||||||
|
)
|
||||||
|
from Data.Engine.interfaces.http import register_http_interfaces
|
||||||
|
from Data.Engine.repositories.sqlite import connection as sqlite_connection
|
||||||
|
from Data.Engine.repositories.sqlite import migrations as sqlite_migrations
|
||||||
|
from Data.Engine.server import create_app
|
||||||
|
from Data.Engine.services.container import build_service_container
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def engine_settings(tmp_path: Path) -> EngineSettings:
|
||||||
|
project_root = tmp_path
|
||||||
|
static_root = project_root / "static"
|
||||||
|
static_root.mkdir()
|
||||||
|
(static_root / "index.html").write_text("<html></html>", encoding="utf-8")
|
||||||
|
|
||||||
|
database_path = project_root / "database.db"
|
||||||
|
|
||||||
|
return EngineSettings(
|
||||||
|
project_root=project_root,
|
||||||
|
debug=False,
|
||||||
|
database=DatabaseSettings(path=database_path, apply_migrations=False),
|
||||||
|
flask=FlaskSettings(
|
||||||
|
secret_key="test-key",
|
||||||
|
static_root=static_root,
|
||||||
|
cors_allowed_origins=("https://localhost",),
|
||||||
|
),
|
||||||
|
socketio=SocketIOSettings(cors_allowed_origins=("https://localhost",)),
|
||||||
|
server=ServerSettings(host="127.0.0.1", port=5000),
|
||||||
|
github=GitHubSettings(
|
||||||
|
default_repo="owner/repo",
|
||||||
|
default_branch="main",
|
||||||
|
refresh_interval_seconds=60,
|
||||||
|
cache_root=project_root / "cache",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def prepared_app(engine_settings: EngineSettings):
|
||||||
|
settings = engine_settings
|
||||||
|
settings.github.cache_root.mkdir(exist_ok=True, parents=True)
|
||||||
|
|
||||||
|
db_factory = sqlite_connection.connection_factory(settings.database.path)
|
||||||
|
with sqlite_connection.connection_scope(settings.database.path) as conn:
|
||||||
|
sqlite_migrations.apply_all(conn)
|
||||||
|
|
||||||
|
app = create_app(settings, db_factory=db_factory)
|
||||||
|
services = build_service_container(settings, db_factory=db_factory)
|
||||||
|
app.extensions["engine_services"] = services
|
||||||
|
register_http_interfaces(app, services)
|
||||||
|
app.config.update(TESTING=True)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client) -> dict:
|
||||||
|
payload = {
|
||||||
|
"username": "admin",
|
||||||
|
"password_sha512": hashlib.sha512("Password".encode()).hexdigest(),
|
||||||
|
}
|
||||||
|
resp = client.post("/api/auth/login", json=payload)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_me_returns_session_user(prepared_app):
|
||||||
|
client = prepared_app.test_client()
|
||||||
|
|
||||||
|
_login(client)
|
||||||
|
resp = client.get("/api/auth/me")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body == {
|
||||||
|
"username": "admin",
|
||||||
|
"display_name": "admin",
|
||||||
|
"role": "Admin",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_me_uses_token_when_session_missing(prepared_app):
|
||||||
|
client = prepared_app.test_client()
|
||||||
|
login_data = _login(client)
|
||||||
|
token = login_data.get("token")
|
||||||
|
assert token
|
||||||
|
|
||||||
|
# New client without session
|
||||||
|
other_client = prepared_app.test_client()
|
||||||
|
other_client.set_cookie(server_name="localhost", key="borealis_auth", value=token)
|
||||||
|
|
||||||
|
resp = other_client.get("/api/auth/me")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body == {
|
||||||
|
"username": "admin",
|
||||||
|
"display_name": "admin",
|
||||||
|
"role": "Admin",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_me_requires_authentication(prepared_app):
|
||||||
|
client = prepared_app.test_client()
|
||||||
|
resp = client.get("/api/auth/me")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body == {"error": "not_authenticated"}
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Callable, Iterable
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
pytest.importorskip("flask")
|
|
||||||
|
|
||||||
from Data.Engine.server import _FallbackDispatcher
|
|
||||||
|
|
||||||
|
|
||||||
def _wsgi_app(status: str, body: bytes) -> Callable:
|
|
||||||
def _app(environ, start_response): # type: ignore[override]
|
|
||||||
start_response(status, [("Content-Type", "text/plain"), ("Content-Length", str(len(body)))])
|
|
||||||
return [body]
|
|
||||||
|
|
||||||
return _app
|
|
||||||
|
|
||||||
|
|
||||||
def _invoke(app: Callable, path: str = "/") -> tuple[str, bytes]:
|
|
||||||
status_holder: dict[str, str] = {}
|
|
||||||
body_parts: list[bytes] = []
|
|
||||||
|
|
||||||
def _start_response(status: str, headers: Iterable[tuple[str, str]], exc_info=None): # type: ignore[override]
|
|
||||||
status_holder["status"] = status
|
|
||||||
return body_parts.append
|
|
||||||
|
|
||||||
environ = {"PATH_INFO": path, "REQUEST_METHOD": "GET", "wsgi.input": None}
|
|
||||||
result = app(environ, _start_response)
|
|
||||||
try:
|
|
||||||
for chunk in result:
|
|
||||||
body_parts.append(chunk)
|
|
||||||
finally:
|
|
||||||
close = getattr(result, "close", None)
|
|
||||||
if callable(close):
|
|
||||||
close()
|
|
||||||
|
|
||||||
return status_holder.get("status", ""), b"".join(body_parts)
|
|
||||||
|
|
||||||
|
|
||||||
def test_fallback_dispatcher_primary_wins() -> None:
|
|
||||||
primary = _wsgi_app("200 OK", b"engine")
|
|
||||||
fallback = _wsgi_app("200 OK", b"legacy")
|
|
||||||
dispatcher = _FallbackDispatcher(primary, fallback)
|
|
||||||
|
|
||||||
status, body = _invoke(dispatcher)
|
|
||||||
|
|
||||||
assert status == "200 OK"
|
|
||||||
assert body == b"engine"
|
|
||||||
|
|
||||||
|
|
||||||
def test_fallback_dispatcher_uses_fallback_on_404() -> None:
|
|
||||||
primary = _wsgi_app("404 Not Found", b"missing")
|
|
||||||
fallback = _wsgi_app("200 OK", b"legacy")
|
|
||||||
dispatcher = _FallbackDispatcher(primary, fallback)
|
|
||||||
|
|
||||||
status, body = _invoke(dispatcher)
|
|
||||||
|
|
||||||
assert status == "200 OK"
|
|
||||||
assert body == b"legacy"
|
|
||||||
Reference in New Issue
Block a user