diff --git a/Borealis.ps1 b/Borealis.ps1 index e3b48da0..282a9b12 100644 --- a/Borealis.ps1 +++ b/Borealis.ps1 @@ -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 } } diff --git a/Data/Engine/CODE_MIGRATION_TRACKER.md b/Data/Engine/CODE_MIGRATION_TRACKER.md index b0b332ca..53d2240c 100644 --- a/Data/Engine/CODE_MIGRATION_TRACKER.md +++ b/Data/Engine/CODE_MIGRATION_TRACKER.md @@ -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). diff --git a/Data/Engine/Unit_Tests/conftest.py b/Data/Engine/Unit_Tests/conftest.py index bd34bca1..4ddc44ef 100644 --- a/Data/Engine/Unit_Tests/conftest.py +++ b/Data/Engine/Unit_Tests/conftest.py @@ -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) diff --git a/Data/Engine/bootstrapper.py b/Data/Engine/bootstrapper.py index a62fa755..1aca2368 100644 --- a/Data/Engine/bootstrapper.py +++ b/Data/Engine/bootstrapper.py @@ -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) diff --git a/Data/Engine/engine-requirements.txt b/Data/Engine/engine-requirements.txt index a892616d..d093b670 100644 --- a/Data/Engine/engine-requirements.txt +++ b/Data/Engine/engine-requirements.txt @@ -7,3 +7,4 @@ cryptography PyJWT[crypto] pyotp qrcode +requests diff --git a/Data/Engine/services/API/__init__.py b/Data/Engine/services/API/__init__.py index a0f0c613..8fbaaa69 100644 --- a/Data/Engine/services/API/__init__.py +++ b/Data/Engine/services/API/__init__.py @@ -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/", 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) diff --git a/Data/Engine/services/API/access_management/credentials.py b/Data/Engine/services/API/access_management/credentials.py new file mode 100644 index 00000000..29845f47 --- /dev/null +++ b/Data/Engine/services/API/access_management/credentials.py @@ -0,0 +1 @@ +"""Placeholder for credentials API module.""" diff --git a/Data/Engine/services/API/access_management/login.py b/Data/Engine/services/API/access_management/login.py new file mode 100644 index 00000000..cd11e94e --- /dev/null +++ b/Data/Engine/services/API/access_management/login.py @@ -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'.") diff --git a/Data/Engine/services/API/access_management/users.py b/Data/Engine/services/API/access_management/users.py new file mode 100644 index 00000000..d54337b2 --- /dev/null +++ b/Data/Engine/services/API/access_management/users.py @@ -0,0 +1 @@ +"""Placeholder for users API module.""" diff --git a/Data/Engine/services/API/assemblies/management.py b/Data/Engine/services/API/assemblies/management.py new file mode 100644 index 00000000..441d841a --- /dev/null +++ b/Data/Engine/services/API/assemblies/management.py @@ -0,0 +1 @@ +"Placeholder for API module assemblies/management.py." diff --git a/Data/Engine/services/API/authentication.py b/Data/Engine/services/API/authentication.py new file mode 100644 index 00000000..5c58b81d --- /dev/null +++ b/Data/Engine/services/API/authentication.py @@ -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'.") diff --git a/Data/Engine/services/API/devices/approval.py b/Data/Engine/services/API/devices/approval.py new file mode 100644 index 00000000..2102559c --- /dev/null +++ b/Data/Engine/services/API/devices/approval.py @@ -0,0 +1 @@ +"Placeholder for API module devices/approval.py." diff --git a/Data/Engine/services/API/devices/enrollment.py b/Data/Engine/services/API/devices/enrollment.py new file mode 100644 index 00000000..931dffeb --- /dev/null +++ b/Data/Engine/services/API/devices/enrollment.py @@ -0,0 +1 @@ +"Placeholder for API module devices/enrollment.py." diff --git a/Data/Engine/services/API/devices/management.py b/Data/Engine/services/API/devices/management.py new file mode 100644 index 00000000..d8b2c1a1 --- /dev/null +++ b/Data/Engine/services/API/devices/management.py @@ -0,0 +1 @@ +"Placeholder for API module devices/management.py." diff --git a/Data/Engine/services/API/devices/remote_control.py b/Data/Engine/services/API/devices/remote_control.py new file mode 100644 index 00000000..f4db750c --- /dev/null +++ b/Data/Engine/services/API/devices/remote_control.py @@ -0,0 +1 @@ +"Placeholder for API module devices/remote_control.py." diff --git a/Data/Engine/services/API/filters/management.py b/Data/Engine/services/API/filters/management.py new file mode 100644 index 00000000..bc0941f1 --- /dev/null +++ b/Data/Engine/services/API/filters/management.py @@ -0,0 +1 @@ +"Placeholder for API module filters/management.py." diff --git a/Data/Engine/services/API/scheduled_jobs/management.py b/Data/Engine/services/API/scheduled_jobs/management.py new file mode 100644 index 00000000..a7521505 --- /dev/null +++ b/Data/Engine/services/API/scheduled_jobs/management.py @@ -0,0 +1 @@ +"Placeholder for API module scheduled_jobs/management.py." diff --git a/Data/Engine/services/API/scheduled_jobs/runner.py b/Data/Engine/services/API/scheduled_jobs/runner.py new file mode 100644 index 00000000..fa15d044 --- /dev/null +++ b/Data/Engine/services/API/scheduled_jobs/runner.py @@ -0,0 +1 @@ +"Placeholder for API module scheduled_jobs/runner.py." diff --git a/Data/Engine/services/API/server/info.py b/Data/Engine/services/API/server/info.py new file mode 100644 index 00000000..a83a48c7 --- /dev/null +++ b/Data/Engine/services/API/server/info.py @@ -0,0 +1 @@ +"Placeholder for API module server/info.py." diff --git a/Data/Engine/services/API/sites/management.py b/Data/Engine/services/API/sites/management.py new file mode 100644 index 00000000..acef9cdb --- /dev/null +++ b/Data/Engine/services/API/sites/management.py @@ -0,0 +1 @@ +"Placeholder for API module sites/management.py."