Added Additional Scaffolds for API Endpoint Domains

This commit is contained in:
2025-10-27 21:38:57 -06:00
parent 3772ea2e4a
commit 215a054979
20 changed files with 886 additions and 72 deletions

View File

@@ -1375,14 +1375,17 @@ switch ($choice) {
$py = Join-Path $scriptDir "Engine\Scripts\python.exe"
$previousEngineMode = $env:BOREALIS_ENGINE_MODE
$previousEnginePort = $env:BOREALIS_ENGINE_PORT
$previousProjectRoot = $env:BOREALIS_PROJECT_ROOT
$env:BOREALIS_ENGINE_MODE = $engineOperationMode
$env:BOREALIS_ENGINE_PORT = "5000"
$env:BOREALIS_PROJECT_ROOT = $scriptDir
Write-Host "`nLaunching Borealis Engine..." -ForegroundColor Green
Write-Host "===================================================================================="
Write-Host "$($symbols.Running) Engine Socket Server Started..."
& $py -m Data.Engine.bootstrapper
if ($previousEngineMode) { $env:BOREALIS_ENGINE_MODE = $previousEngineMode } else { Remove-Item Env:BOREALIS_ENGINE_MODE -ErrorAction SilentlyContinue }
if ($previousEnginePort) { $env:BOREALIS_ENGINE_PORT = $previousEnginePort } else { Remove-Item Env:BOREALIS_ENGINE_PORT -ErrorAction SilentlyContinue }
if ($previousProjectRoot) { $env:BOREALIS_PROJECT_ROOT = $previousProjectRoot } else { Remove-Item Env:BOREALIS_PROJECT_ROOT -ErrorAction SilentlyContinue }
Pop-Location
}
break
@@ -1493,14 +1496,17 @@ switch ($choice) {
$py = Join-Path $scriptDir "Engine\Scripts\python.exe"
$previousEngineMode = $env:BOREALIS_ENGINE_MODE
$previousEnginePort = $env:BOREALIS_ENGINE_PORT
$previousProjectRoot = $env:BOREALIS_PROJECT_ROOT
$env:BOREALIS_ENGINE_MODE = $engineOperationMode
$env:BOREALIS_ENGINE_PORT = "5000"
$env:BOREALIS_PROJECT_ROOT = $scriptDir
Write-Host "`nLaunching Borealis Engine..." -ForegroundColor Green
Write-Host "===================================================================================="
Write-Host "$($symbols.Running) Engine Socket Server Started..."
& $py -m Data.Engine.bootstrapper
if ($previousEngineMode) { $env:BOREALIS_ENGINE_MODE = $previousEngineMode } else { Remove-Item Env:BOREALIS_ENGINE_MODE -ErrorAction SilentlyContinue }
if ($previousEnginePort) { $env:BOREALIS_ENGINE_PORT = $previousEnginePort } else { Remove-Item Env:BOREALIS_ENGINE_PORT -ErrorAction SilentlyContinue }
if ($previousProjectRoot) { $env:BOREALIS_PROJECT_ROOT = $previousProjectRoot } else { Remove-Item Env:BOREALIS_PROJECT_ROOT -ErrorAction SilentlyContinue }
Pop-Location
}
}

View File

@@ -45,4 +45,4 @@ Lastly, everytime that you complete a stage, you will create a pull request name
## Current Status
- **Stage:** Stage 6 — Plan WebUI migration
- **Active Task:** Prepare legacy WebUI delegation switch (pending approval to touch legacy server).
- **Active Task:** Migrating authentication endpoints into the Engine API (legacy bridge removed).

View File

@@ -98,6 +98,7 @@ def engine_harness(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[
monkeypatch.setenv("BOREALIS_CERT_ROOT", str(cert_root))
monkeypatch.setenv("BOREALIS_SERVER_CERT_ROOT", str(cert_root / "Server"))
monkeypatch.setenv("BOREALIS_AGENT_CERT_ROOT", str(cert_root / "Agent"))
monkeypatch.setenv("BOREALIS_ENGINE_DISABLE_LEGACY_PROXY", "1")
db_path = tmp_path / "database" / "engine.sqlite3"
_initialise_legacy_schema(db_path)

View File

@@ -126,6 +126,27 @@ def _resolve_npm_executable() -> str:
return "npm"
def _resolve_npx_executable() -> str:
env_cmd = os.environ.get("BOREALIS_NPX_CMD")
if env_cmd:
candidate = Path(env_cmd).expanduser()
if candidate.is_file():
return str(candidate)
node_dir = os.environ.get("BOREALIS_NODE_DIR")
if node_dir:
candidate = Path(node_dir) / "npx.cmd"
if candidate.is_file():
return str(candidate)
candidate = Path(node_dir) / "npx"
if candidate.is_file():
return str(candidate)
if os.name == "nt":
return "npx.cmd"
return "npx"
def _run_npm(args: list[str], cwd: Path, logger: logging.Logger) -> None:
command = [_resolve_npm_executable()] + args
logger.info("Running npm command: %s", " ".join(command))
@@ -153,6 +174,20 @@ def _run_npm(args: list[str], cwd: Path, logger: logging.Logger) -> None:
logger.info("npm command completed in %.2fs", duration)
def _run_vite(args: list[str], cwd: Path, logger: logging.Logger) -> None:
command = [_resolve_npx_executable(), "vite"] + args
logger.info("Running Vite command: %s", " ".join(command))
start = time.time()
try:
completed = subprocess.run(command, cwd=str(cwd), check=False)
except FileNotFoundError as exc:
raise RuntimeError("npx executable not found; ensure Node.js dependencies are installed.") from exc
duration = time.time() - start
if completed.returncode != 0:
raise RuntimeError(f"Vite command {' '.join(command)} failed with exit code {completed.returncode}")
logger.info("Vite command completed in %.2fs", duration)
def _ensure_web_ui_build(staging_root: Path, logger: logging.Logger, *, mode: str) -> str:
package_json = staging_root / "package.json"
node_modules = staging_root / "node_modules"
@@ -179,7 +214,7 @@ def _ensure_web_ui_build(staging_root: Path, logger: logging.Logger, *, mode: st
_run_npm(["install", "--silent", "--no-fund", "--audit=false"], staging_root, logger)
if needs_build:
_run_npm(["run", "build"], staging_root, logger)
_run_vite(["build"], staging_root, logger)
else:
logger.info("Existing WebUI build found at %s; reuse.", build_dir)

View File

@@ -7,3 +7,4 @@ cryptography
PyJWT[crypto]
pyotp
qrcode
requests

View File

@@ -1,11 +1,4 @@
"""API service adapters for the Borealis Engine runtime.
Stage 3 of the migration introduces blueprint registration that mirrors the
behaviour of :mod:`Data.Server.server` by delegating to the existing domain
modules under ``Data/Server/Modules``. Each adapter wires the Engine context
into the legacy registration helpers so routes continue to function while
configuration toggles control which API groups are exposed.
"""
"""API service adapters for the Borealis Engine runtime."""
from __future__ import annotations
import datetime as _dt
@@ -15,10 +8,9 @@ import sqlite3
import time
from dataclasses import dataclass, field
from pathlib import Path
import os
from typing import Any, Callable, Iterable, Mapping, Optional, Sequence
from flask import Blueprint, Flask, jsonify, request
from flask import Blueprint, Flask, jsonify
from Modules.auth import jwt_service as jwt_service_module
from Modules.auth.dpop import DPoPValidator
@@ -29,11 +21,12 @@ from Modules.enrollment.nonce_store import NonceCache
from Modules.tokens import routes as token_routes
from ...server import EngineContext
from .authentication import register_auth
DEFAULT_API_GROUPS: Sequence[str] = ("tokens", "enrollment")
DEFAULT_API_GROUPS: Sequence[str] = ("auth", "tokens", "enrollment")
_SERVER_SCOPE_PATTERN = re.compile(r"\b(?:scope|context|agent_context)=([A-Za-z0-9_-]+)", re.IGNORECASE)
_SERVER_AGENT_ID_PATTERN = re.compile(r"\bagent_id=([^\s,]+)", re.IGNORECASE)
_SERVER_SCOPE_PATTERN = re.compile(r"\\b(?:scope|context|agent_context)=([A-Za-z0-9_-]+)", re.IGNORECASE)
_SERVER_AGENT_ID_PATTERN = re.compile(r"\\bagent_id=([^\\s,]+)", re.IGNORECASE)
def _canonical_server_scope(raw: Optional[str]) -> Optional[str]:
@@ -104,7 +97,7 @@ def _make_service_logger(base: Path, logger: logging.Logger) -> Callable[[str, s
prefix_parts.append(f"[CONTEXT-{resolved_scope}]")
prefix = "".join(prefix_parts)
with path.open("a", encoding="utf-8") as handle:
handle.write(f"[{timestamp}] {prefix} {msg}\n")
handle.write(f"[{timestamp}] {prefix} {msg}\\n")
except Exception:
logger.debug("Failed to write service log entry", exc_info=True)
@@ -187,64 +180,11 @@ def _register_enrollment(app: Flask, adapters: LegacyServiceAdapters) -> None:
_GROUP_REGISTRARS: Mapping[str, Callable[[Flask, LegacyServiceAdapters], None]] = {
"auth": register_auth,
"tokens": _register_tokens,
"enrollment": _register_enrollment,
}
LEGACY_APP_CACHE: Optional[Flask] = None
def _load_legacy_app(context: EngineContext) -> Flask:
global LEGACY_APP_CACHE
if LEGACY_APP_CACHE is not None:
return LEGACY_APP_CACHE
os.environ.setdefault("BOREALIS_DATABASE_PATH", context.database_path)
if context.tls_cert_path:
os.environ.setdefault("BOREALIS_TLS_CERT", context.tls_cert_path)
if context.tls_key_path:
os.environ.setdefault("BOREALIS_TLS_KEY", context.tls_key_path)
if context.tls_bundle_path:
os.environ.setdefault("BOREALIS_TLS_BUNDLE", context.tls_bundle_path)
try:
from Data.Server import server as legacy_server # Local import to avoid heavy import when unused
except ImportError as exc:
raise RuntimeError("Legacy server module is unavailable; cannot enable fallback proxy.") from exc
LEGACY_APP_CACHE = legacy_server.app
return LEGACY_APP_CACHE
def _register_legacy_proxy(app: Flask, context: EngineContext) -> None:
try:
legacy_app = _load_legacy_app(context)
except RuntimeError as exc:
context.logger.warning("Legacy API fallback disabled: %s", exc)
return
blueprint = Blueprint("legacy_api_bridge", __name__)
methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
@blueprint.route("/api", defaults={"path": ""}, methods=methods)
@blueprint.route("/api/<path:path>", methods=methods)
def _legacy_passthrough(path: str):
legacy_context = legacy_app.request_context(request.environ)
legacy_context.push()
try:
request_path = request.path or f"/api/{path or ''}"
context.logger.info(
"Engine API routed to legacy handler: %s %s",
request.method,
request_path,
)
response = legacy_app.full_dispatch_request()
finally:
legacy_context.pop()
return response
app.register_blueprint(blueprint)
context.logger.info("Engine registered legacy API fallback proxy.")
def _register_core(app: Flask, context: EngineContext) -> None:
"""Register core utility endpoints that do not require legacy adapters."""
@@ -279,5 +219,3 @@ def register_api(app: Flask, context: EngineContext) -> None:
continue
registrar(app, adapters)
context.logger.info("Engine registered API group '%s'.", group)
_register_legacy_proxy(app, context)

View File

@@ -0,0 +1 @@
"""Placeholder for credentials API module."""

View File

@@ -0,0 +1,408 @@
"""Authentication endpoints for the Borealis Engine API."""
from __future__ import annotations
import base64
import hashlib
import io
import os
import sqlite3
import time
import uuid
from typing import Any, Dict, Mapping, Optional, Sequence, TYPE_CHECKING
from flask import Blueprint, Flask, jsonify, request, session
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
try:
import pyotp # type: ignore
except Exception: # pragma: no cover - optional dependency
pyotp = None # type: ignore
try:
import qrcode # type: ignore
except Exception: # pragma: no cover - optional dependency
qrcode = None # type: ignore
if TYPE_CHECKING: # pragma: no cover - typing helper
from Data.Engine.services.API import LegacyServiceAdapters
def _now_ts() -> int:
return int(time.time())
def _sha512_hex(value: str) -> str:
return hashlib.sha512((value or "").encode("utf-8")).hexdigest()
def _generate_totp_secret() -> str:
if not pyotp:
raise RuntimeError("pyotp is not installed; MFA unavailable")
return pyotp.random_base32()
def _totp_for_secret(secret: str):
if not pyotp:
raise RuntimeError("pyotp is not installed; MFA unavailable")
normalized = (secret or "").replace(" ", "").strip().upper()
if not normalized:
raise ValueError("empty MFA secret")
return pyotp.TOTP(normalized, digits=6, interval=30)
def _totp_provisioning_uri(secret: str, username: str) -> Optional[str]:
try:
totp = _totp_for_secret(secret)
except Exception:
return None
issuer = os.environ.get("BOREALIS_MFA_ISSUER", "Borealis")
return totp.provisioning_uri(name=username or "user", issuer_name=issuer)
def _totp_qr_data_uri(payload: str) -> Optional[str]:
if not payload or qrcode is None:
return None
try:
image = qrcode.make(payload, box_size=6, border=4)
buffer = io.BytesIO()
image.save(buffer, format="PNG")
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
return f"data:image/png;base64,{encoded}"
except Exception:
return None
def _user_row_to_dict(row: Sequence[Any]) -> Mapping[str, Any]:
mfa_enabled = 0
if len(row) > 7:
try:
mfa_enabled = 1 if (row[7] or 0) else 0
except Exception:
mfa_enabled = 0
return {
"id": row[0],
"username": row[1],
"display_name": row[2] or row[1],
"role": row[3] or "User",
"last_login": row[4] or 0,
"created_at": row[5] or 0,
"updated_at": row[6] or 0,
"mfa_enabled": mfa_enabled,
}
class _AuthService:
def __init__(self, app: Flask, adapters: "LegacyServiceAdapters") -> None:
self.app = app
self.adapters = adapters
self.context = adapters.context
self.db_conn_factory = adapters.db_conn_factory
self.service_log = adapters.service_log
self.logger = adapters.context.logger
def _db_conn(self) -> sqlite3.Connection:
return self.db_conn_factory()
def _token_serializer(self) -> URLSafeTimedSerializer:
secret = self.app.secret_key or "borealis-dev-secret"
return URLSafeTimedSerializer(secret, salt="borealis-auth")
def _make_token(self, username: str, role: str) -> str:
serializer = self._token_serializer()
payload = {"u": username, "r": role or "User", "ts": _now_ts()}
return serializer.dumps(payload)
def _verify_token(self, token: str) -> Optional[Mapping[str, Any]]:
try:
serializer = self._token_serializer()
max_age = int(os.environ.get("BOREALIS_TOKEN_TTL_SECONDS", 60 * 60 * 24 * 30))
data = serializer.loads(token, max_age=max_age)
return {"username": data.get("u"), "role": data.get("r") or "User"}
except (BadSignature, SignatureExpired, Exception):
return None
def _current_user(self) -> Optional[Mapping[str, Any]]:
username = session.get("username")
role = session.get("role") or "User"
if username:
return {"username": username, "role": role}
token = None
auth_header = request.headers.get("Authorization") or ""
if auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1].strip()
if not token:
token = request.cookies.get("borealis_auth")
if token:
return self._verify_token(token)
return None
def _update_last_login(self, username: str) -> None:
if not username:
return
try:
conn = self._db_conn()
try:
cur = conn.cursor()
now_ts = _now_ts()
cur.execute(
"UPDATE users SET last_login=?, updated_at=? WHERE LOWER(username)=LOWER(?)",
(now_ts, now_ts, username),
)
conn.commit()
finally:
conn.close()
except Exception:
self.logger.debug("Failed to update last_login for %s", username, exc_info=True)
def _finalize_login(self, username: str, role: str):
session.pop("mfa_pending", None)
session["username"] = username
session["role"] = role
self._update_last_login(username)
token = self._make_token(username, role or "User")
response = jsonify({"status": "ok", "username": username, "role": role, "token": token})
samesite = self.app.config.get("SESSION_COOKIE_SAMESITE", "Lax")
secure = bool(self.app.config.get("SESSION_COOKIE_SECURE", False))
domain = self.app.config.get("SESSION_COOKIE_DOMAIN")
response.set_cookie(
"borealis_auth",
token,
httponly=False,
samesite=samesite,
secure=secure,
domain=domain,
path="/",
)
return response
def login(self):
payload = request.get_json(silent=True) or {}
username = (payload.get("username") or "").strip()
password = payload.get("password")
password_sha512 = (payload.get("password_sha512") or "").strip().lower()
if not username or (not password and not password_sha512):
return jsonify({"error": "missing credentials"}), 400
conn = self._db_conn()
try:
cur = conn.cursor()
cur.execute(
"""
SELECT
id,
username,
display_name,
password_sha512,
role,
last_login,
created_at,
updated_at,
COALESCE(mfa_enabled, 0) AS mfa_enabled,
COALESCE(mfa_secret, '') AS mfa_secret
FROM users WHERE LOWER(username)=LOWER(?)
""",
(username,),
)
row = cur.fetchone()
finally:
conn.close()
if not row:
return jsonify({"error": "invalid username or password"}), 401
stored_hash = (row[3] or "").lower()
check_hash = password_sha512 or _sha512_hex(password or "")
if stored_hash != (check_hash or "").lower():
return jsonify({"error": "invalid username or password"}), 401
role = row[4] or "User"
mfa_enabled = bool(row[8] or 0)
existing_secret = (row[9] or "").strip()
session.pop("username", None)
session.pop("role", None)
if not mfa_enabled:
session.pop("mfa_pending", None)
return self._finalize_login(row[1], role)
stage = "verify" if existing_secret else "setup"
pending_token = uuid.uuid4().hex
pending = {
"username": row[1],
"role": role,
"token": pending_token,
"stage": stage,
"expires": _now_ts() + 300,
}
secret = None
otpauth_url = None
qr_image = None
if stage == "setup":
try:
secret = _generate_totp_secret()
except Exception as exc:
return jsonify({"error": f"MFA setup unavailable: {exc}"}), 500
pending["secret"] = secret
otpauth_url = _totp_provisioning_uri(secret, row[1])
if otpauth_url:
qr_image = _totp_qr_data_uri(otpauth_url)
else:
pending["secret"] = None
session["mfa_pending"] = pending
session.modified = True
response_payload: Dict[str, Any] = {
"status": "mfa_required",
"stage": stage,
"pending_token": pending_token,
"username": row[1],
"role": role,
}
if stage == "setup":
response_payload.update(
{
"secret": secret,
"otpauth_url": otpauth_url,
"qr_image": qr_image,
}
)
return jsonify(response_payload)
def logout(self):
session.clear()
response = jsonify({"status": "ok"})
response.set_cookie("borealis_auth", "", expires=0, path="/")
return response
def mfa_verify(self):
pending = session.get("mfa_pending") or {}
if not pending or not isinstance(pending, dict):
return jsonify({"error": "mfa_pending"}), 401
payload = request.get_json(silent=True) or {}
token = (payload.get("pending_token") or "").strip()
code_raw = str(payload.get("code") or "").strip()
code = "".join(ch for ch in code_raw if ch.isdigit())
if not token or token != pending.get("token"):
return jsonify({"error": "invalid_session"}), 401
if pending.get("expires", 0) < _now_ts():
session.pop("mfa_pending", None)
return jsonify({"error": "expired"}), 401
if len(code) < 6:
return jsonify({"error": "invalid_code"}), 400
username = pending.get("username") or ""
role = pending.get("role") or "User"
stage = pending.get("stage") or "verify"
try:
if stage == "setup":
secret = pending.get("secret") or ""
totp = _totp_for_secret(secret)
if not totp.verify(code, valid_window=1):
return jsonify({"error": "invalid_code"}), 401
now_ts = _now_ts()
conn = self._db_conn()
try:
cur = conn.cursor()
cur.execute(
"UPDATE users SET mfa_secret=?, updated_at=? WHERE LOWER(username)=LOWER(?)",
(secret, now_ts, username),
)
conn.commit()
finally:
conn.close()
else:
conn = self._db_conn()
try:
cur = conn.cursor()
cur.execute(
"SELECT COALESCE(mfa_secret,'') FROM users WHERE LOWER(username)=LOWER(?)",
(username,),
)
row = cur.fetchone()
finally:
conn.close()
secret = (row[0] or "").strip() if row else ""
if not secret:
return jsonify({"error": "mfa_not_configured"}), 403
totp = _totp_for_secret(secret)
if not totp.verify(code, valid_window=1):
return jsonify({"error": "invalid_code"}), 401
except Exception as exc:
return jsonify({"error": str(exc)}), 500
return self._finalize_login(username, role)
def me(self):
user = self._current_user()
if not user:
return jsonify({"error": "unauthorized"}), 401
username = (user.get("username") or "").strip()
try:
conn = self._db_conn()
try:
cur = conn.cursor()
cur.execute(
"SELECT id, username, display_name, role, last_login, created_at, updated_at FROM users WHERE LOWER(username)=LOWER(?)",
(username,),
)
row = cur.fetchone()
finally:
conn.close()
if row:
info = _user_row_to_dict(row)
return jsonify(
{
"username": info["username"],
"display_name": info["display_name"],
"role": info["role"],
}
)
except Exception:
self.logger.debug("Failed to fetch user record for %s", username, exc_info=True)
return jsonify(
{
"username": username,
"display_name": username,
"role": user.get("role") or "User",
}
)
def register_auth(app: Flask, adapters: "LegacyServiceAdapters") -> None:
"""Register authentication endpoints for the Engine."""
service = _AuthService(app, adapters)
blueprint = Blueprint("auth", __name__)
@blueprint.route("/api/auth/login", methods=["POST"])
def _login():
return service.login()
@blueprint.route("/api/auth/logout", methods=["POST"])
def _logout():
return service.logout()
@blueprint.route("/api/auth/mfa/verify", methods=["POST"])
def _mfa_verify():
return service.mfa_verify()
@blueprint.route("/api/auth/me", methods=["GET"])
def _me():
return service.me()
app.register_blueprint(blueprint)
adapters.context.logger.info("Engine registered API group 'auth'.")

View File

@@ -0,0 +1 @@
"""Placeholder for users API module."""

View File

@@ -0,0 +1 @@
"Placeholder for API module assemblies/management.py."

View File

@@ -0,0 +1,413 @@
"""Authentication endpoints for the Borealis Engine API."""
from __future__ import annotations
import base64
import hashlib
import io
import os
import sqlite3
import time
import uuid
from typing import Any, Dict, Mapping, Optional, Sequence, TYPE_CHECKING
from flask import Blueprint, Flask, jsonify, request, session
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
try:
import pyotp # type: ignore
except Exception: # pragma: no cover - optional dependency
pyotp = None # type: ignore
try:
import qrcode # type: ignore
except Exception: # pragma: no cover - optional dependency
qrcode = None # type: ignore
if TYPE_CHECKING: # pragma: no cover - typing helper
from . import LegacyServiceAdapters
def _now_ts() -> int:
return int(time.time())
def _sha512_hex(value: str) -> str:
return hashlib.sha512((value or "").encode("utf-8")).hexdigest()
def _generate_totp_secret() -> str:
if not pyotp:
raise RuntimeError("pyotp is not installed; MFA unavailable")
return pyotp.random_base32()
def _totp_for_secret(secret: str):
if not pyotp:
raise RuntimeError("pyotp is not installed; MFA unavailable")
normalized = (secret or "").replace(" ", "").strip().upper()
if not normalized:
raise ValueError("empty MFA secret")
return pyotp.TOTP(normalized, digits=6, interval=30)
def _totp_provisioning_uri(secret: str, username: str) -> Optional[str]:
try:
totp = _totp_for_secret(secret)
except Exception:
return None
issuer = os.environ.get("BOREALIS_MFA_ISSUER", "Borealis")
return totp.provisioning_uri(name=username or "user", issuer_name=issuer)
def _totp_qr_data_uri(payload: str) -> Optional[str]:
if not payload or qrcode is None:
return None
try:
image = qrcode.make(payload, box_size=6, border=4)
buffer = io.BytesIO()
image.save(buffer, format="PNG")
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
return f"data:image/png;base64,{encoded}"
except Exception:
return None
def _user_row_to_dict(row: Sequence[Any]) -> Mapping[str, Any]:
mfa_enabled = 0
if len(row) > 7:
try:
mfa_enabled = 1 if (row[7] or 0) else 0
except Exception:
mfa_enabled = 0
return {
"id": row[0],
"username": row[1],
"display_name": row[2] or row[1],
"role": row[3] or "User",
"last_login": row[4] or 0,
"created_at": row[5] or 0,
"updated_at": row[6] or 0,
"mfa_enabled": mfa_enabled,
}
class _AuthService:
def __init__(self, app: Flask, adapters: "LegacyServiceAdapters") -> None:
self.app = app
self.adapters = adapters
self.context = adapters.context
self.db_conn_factory = adapters.db_conn_factory
self.service_log = adapters.service_log
self.logger = adapters.context.logger
# Database helpers -------------------------------------------------
def _db_conn(self) -> sqlite3.Connection:
return self.db_conn_factory()
# Token helpers ----------------------------------------------------
def _token_serializer(self) -> URLSafeTimedSerializer:
secret = self.app.secret_key or "borealis-dev-secret"
return URLSafeTimedSerializer(secret, salt="borealis-auth")
def _make_token(self, username: str, role: str) -> str:
serializer = self._token_serializer()
payload = {"u": username, "r": role or "User", "ts": _now_ts()}
return serializer.dumps(payload)
def _verify_token(self, token: str) -> Optional[Mapping[str, Any]]:
try:
serializer = self._token_serializer()
max_age = int(os.environ.get("BOREALIS_TOKEN_TTL_SECONDS", 60 * 60 * 24 * 30))
data = serializer.loads(token, max_age=max_age)
return {"username": data.get("u"), "role": data.get("r") or "User"}
except (BadSignature, SignatureExpired, Exception):
return None
# Session helpers --------------------------------------------------
def _current_user(self) -> Optional[Mapping[str, Any]]:
username = session.get("username")
role = session.get("role") or "User"
if username:
return {"username": username, "role": role}
token = None
auth_header = request.headers.get("Authorization") or ""
if auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1].strip()
if not token:
token = request.cookies.get("borealis_auth")
if token:
return self._verify_token(token)
return None
def _update_last_login(self, username: str) -> None:
if not username:
return
try:
conn = self._db_conn()
try:
cur = conn.cursor()
now_ts = _now_ts()
cur.execute(
"UPDATE users SET last_login=?, updated_at=? WHERE LOWER(username)=LOWER(?)",
(now_ts, now_ts, username),
)
conn.commit()
finally:
conn.close()
except Exception:
self.logger.debug("Failed to update last_login for %s", username, exc_info=True)
# Response helpers -------------------------------------------------
def _finalize_login(self, username: str, role: str):
session.pop("mfa_pending", None)
session["username"] = username
session["role"] = role
self._update_last_login(username)
token = self._make_token(username, role or "User")
response = jsonify({"status": "ok", "username": username, "role": role, "token": token})
samesite = self.app.config.get("SESSION_COOKIE_SAMESITE", "Lax")
secure = bool(self.app.config.get("SESSION_COOKIE_SECURE", False))
domain = self.app.config.get("SESSION_COOKIE_DOMAIN")
response.set_cookie(
"borealis_auth",
token,
httponly=False,
samesite=samesite,
secure=secure,
domain=domain,
path="/",
)
return response
# Route handlers ---------------------------------------------------
def login(self):
payload = request.get_json(silent=True) or {}
username = (payload.get("username") or "").strip()
password = payload.get("password")
password_sha512 = (payload.get("password_sha512") or "").strip().lower()
if not username or (not password and not password_sha512):
return jsonify({"error": "missing credentials"}), 400
conn = self._db_conn()
try:
cur = conn.cursor()
cur.execute(
"""
SELECT
id,
username,
display_name,
password_sha512,
role,
last_login,
created_at,
updated_at,
COALESCE(mfa_enabled, 0) AS mfa_enabled,
COALESCE(mfa_secret, '') AS mfa_secret
FROM users WHERE LOWER(username)=LOWER(?)
""",
(username,),
)
row = cur.fetchone()
finally:
conn.close()
if not row:
return jsonify({"error": "invalid username or password"}), 401
stored_hash = (row[3] or "").lower()
check_hash = password_sha512 or _sha512_hex(password or "")
if stored_hash != (check_hash or "").lower():
return jsonify({"error": "invalid username or password"}), 401
role = row[4] or "User"
mfa_enabled = bool(row[8] or 0)
existing_secret = (row[9] or "").strip()
session.pop("username", None)
session.pop("role", None)
if not mfa_enabled:
session.pop("mfa_pending", None)
return self._finalize_login(row[1], role)
stage = "verify" if existing_secret else "setup"
pending_token = uuid.uuid4().hex
pending = {
"username": row[1],
"role": role,
"token": pending_token,
"stage": stage,
"expires": _now_ts() + 300,
}
secret = None
otpauth_url = None
qr_image = None
if stage == "setup":
try:
secret = _generate_totp_secret()
except Exception as exc:
return jsonify({"error": f"MFA setup unavailable: {exc}"}), 500
pending["secret"] = secret
otpauth_url = _totp_provisioning_uri(secret, row[1])
if otpauth_url:
qr_image = _totp_qr_data_uri(otpauth_url)
else:
pending["secret"] = None
session["mfa_pending"] = pending
session.modified = True
response_payload: Dict[str, Any] = {
"status": "mfa_required",
"stage": stage,
"pending_token": pending_token,
"username": row[1],
"role": role,
}
if stage == "setup":
response_payload.update(
{
"secret": secret,
"otpauth_url": otpauth_url,
"qr_image": qr_image,
}
)
return jsonify(response_payload)
def logout(self):
session.clear()
response = jsonify({"status": "ok"})
response.set_cookie("borealis_auth", "", expires=0, path="/")
return response
def mfa_verify(self):
pending = session.get("mfa_pending") or {}
if not pending or not isinstance(pending, dict):
return jsonify({"error": "mfa_pending"}), 401
payload = request.get_json(silent=True) or {}
token = (payload.get("pending_token") or "").strip()
code_raw = str(payload.get("code") or "").strip()
code = "".join(ch for ch in code_raw if ch.isdigit())
if not token or token != pending.get("token"):
return jsonify({"error": "invalid_session"}), 401
if pending.get("expires", 0) < _now_ts():
session.pop("mfa_pending", None)
return jsonify({"error": "expired"}), 401
if len(code) < 6:
return jsonify({"error": "invalid_code"}), 400
username = pending.get("username") or ""
role = pending.get("role") or "User"
stage = pending.get("stage") or "verify"
try:
if stage == "setup":
secret = pending.get("secret") or ""
totp = _totp_for_secret(secret)
if not totp.verify(code, valid_window=1):
return jsonify({"error": "invalid_code"}), 401
now_ts = _now_ts()
conn = self._db_conn()
try:
cur = conn.cursor()
cur.execute(
"UPDATE users SET mfa_secret=?, updated_at=? WHERE LOWER(username)=LOWER(?)",
(secret, now_ts, username),
)
conn.commit()
finally:
conn.close()
else:
conn = self._db_conn()
try:
cur = conn.cursor()
cur.execute(
"SELECT COALESCE(mfa_secret,'') FROM users WHERE LOWER(username)=LOWER(?)",
(username,),
)
row = cur.fetchone()
finally:
conn.close()
secret = (row[0] or "").strip() if row else ""
if not secret:
return jsonify({"error": "mfa_not_configured"}), 403
totp = _totp_for_secret(secret)
if not totp.verify(code, valid_window=1):
return jsonify({"error": "invalid_code"}), 401
except Exception as exc:
return jsonify({"error": str(exc)}), 500
return self._finalize_login(username, role)
def me(self):
user = self._current_user()
if not user:
return jsonify({"error": "unauthorized"}), 401
username = (user.get("username") or "").strip()
try:
conn = self._db_conn()
try:
cur = conn.cursor()
cur.execute(
"SELECT id, username, display_name, role, last_login, created_at, updated_at FROM users WHERE LOWER(username)=LOWER(?)",
(username,),
)
row = cur.fetchone()
finally:
conn.close()
if row:
info = _user_row_to_dict(row)
return jsonify(
{
"username": info["username"],
"display_name": info["display_name"],
"role": info["role"],
}
)
except Exception:
self.logger.debug("Failed to fetch user record for %s", username, exc_info=True)
return jsonify(
{
"username": username,
"display_name": username,
"role": user.get("role") or "User",
}
)
def register_auth(app: Flask, adapters: "LegacyServiceAdapters") -> None:
"""Register authentication endpoints for the Engine."""
service = _AuthService(app, adapters)
blueprint = Blueprint("auth", __name__)
@blueprint.route("/api/auth/login", methods=["POST"])
def _login():
return service.login()
@blueprint.route("/api/auth/logout", methods=["POST"])
def _logout():
return service.logout()
@blueprint.route("/api/auth/mfa/verify", methods=["POST"])
def _mfa_verify():
return service.mfa_verify()
@blueprint.route("/api/auth/me", methods=["GET"])
def _me():
return service.me()
app.register_blueprint(blueprint)
adapters.context.logger.info("Engine registered API group 'auth'.")

View File

@@ -0,0 +1 @@
"Placeholder for API module devices/approval.py."

View File

@@ -0,0 +1 @@
"Placeholder for API module devices/enrollment.py."

View File

@@ -0,0 +1 @@
"Placeholder for API module devices/management.py."

View File

@@ -0,0 +1 @@
"Placeholder for API module devices/remote_control.py."

View File

@@ -0,0 +1 @@
"Placeholder for API module filters/management.py."

View File

@@ -0,0 +1 @@
"Placeholder for API module scheduled_jobs/management.py."

View File

@@ -0,0 +1 @@
"Placeholder for API module scheduled_jobs/runner.py."

View File

@@ -0,0 +1 @@
"Placeholder for API module server/info.py."

View File

@@ -0,0 +1 @@
"Placeholder for API module sites/management.py."