From f361c51a5e8a9e303d9f1707cfcc3dfc86ec36c3 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Wed, 22 Oct 2025 19:23:38 -0600 Subject: [PATCH] Implement operator login service and fix static root --- Data/Engine/builders/__init__.py | 10 + Data/Engine/builders/operator_auth.py | 72 ++++++ Data/Engine/config/environment.py | 7 +- Data/Engine/domain/__init__.py | 8 + Data/Engine/domain/operator.py | 51 +++++ Data/Engine/interfaces/http/__init__.py | 3 +- Data/Engine/interfaces/http/auth.py | 165 ++++++++++++++ Data/Engine/repositories/sqlite/__init__.py | 2 + .../repositories/sqlite/user_repository.py | 123 +++++++++++ Data/Engine/requirements.txt | 2 + Data/Engine/services/auth/__init__.py | 14 ++ .../services/auth/operator_auth_service.py | 209 ++++++++++++++++++ Data/Engine/services/container.py | 10 + Data/Engine/tests/test_config_environment.py | 13 ++ .../tests/test_operator_auth_builders.py | 63 ++++++ .../tests/test_operator_auth_service.py | 197 +++++++++++++++++ 16 files changed, 947 insertions(+), 2 deletions(-) create mode 100644 Data/Engine/builders/operator_auth.py create mode 100644 Data/Engine/domain/operator.py create mode 100644 Data/Engine/interfaces/http/auth.py create mode 100644 Data/Engine/repositories/sqlite/user_repository.py create mode 100644 Data/Engine/services/auth/operator_auth_service.py create mode 100644 Data/Engine/tests/test_operator_auth_builders.py create mode 100644 Data/Engine/tests/test_operator_auth_service.py diff --git a/Data/Engine/builders/__init__.py b/Data/Engine/builders/__init__.py index 0f9b02a..6dd2fc9 100644 --- a/Data/Engine/builders/__init__.py +++ b/Data/Engine/builders/__init__.py @@ -8,12 +8,22 @@ from .device_auth import ( RefreshTokenRequest, RefreshTokenRequestBuilder, ) +from .operator_auth import ( + OperatorLoginRequest, + OperatorMFAVerificationRequest, + build_login_request, + build_mfa_request, +) __all__ = [ "DeviceAuthRequest", "DeviceAuthRequestBuilder", "RefreshTokenRequest", "RefreshTokenRequestBuilder", + "OperatorLoginRequest", + "OperatorMFAVerificationRequest", + "build_login_request", + "build_mfa_request", ] try: # pragma: no cover - optional dependency shim diff --git a/Data/Engine/builders/operator_auth.py b/Data/Engine/builders/operator_auth.py new file mode 100644 index 0000000..9153897 --- /dev/null +++ b/Data/Engine/builders/operator_auth.py @@ -0,0 +1,72 @@ +"""Builders for operator authentication payloads.""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from typing import Mapping + + +@dataclass(frozen=True, slots=True) +class OperatorLoginRequest: + """Normalized operator login credentials.""" + + username: str + password_sha512: str + + +@dataclass(frozen=True, slots=True) +class OperatorMFAVerificationRequest: + """Normalized MFA verification payload.""" + + pending_token: str + code: str + + +def _sha512_hex(raw: str) -> str: + digest = hashlib.sha512() + digest.update(raw.encode("utf-8")) + return digest.hexdigest() + + +def build_login_request(payload: Mapping[str, object]) -> OperatorLoginRequest: + """Validate and normalize the login *payload*.""" + + username = str(payload.get("username") or "").strip() + password_sha512 = str(payload.get("password_sha512") or "").strip().lower() + password = payload.get("password") + + if not username: + raise ValueError("username is required") + + if password_sha512: + normalized_hash = password_sha512 + else: + if not isinstance(password, str) or not password: + raise ValueError("password is required") + normalized_hash = _sha512_hex(password) + + return OperatorLoginRequest(username=username, password_sha512=normalized_hash) + + +def build_mfa_request(payload: Mapping[str, object]) -> OperatorMFAVerificationRequest: + """Validate and normalize the MFA verification *payload*.""" + + pending_token = str(payload.get("pending_token") or "").strip() + raw_code = str(payload.get("code") or "").strip() + digits = "".join(ch for ch in raw_code if ch.isdigit()) + + if not pending_token: + raise ValueError("pending_token is required") + if len(digits) < 6: + raise ValueError("code must contain 6 digits") + + return OperatorMFAVerificationRequest(pending_token=pending_token, code=digits) + + +__all__ = [ + "OperatorLoginRequest", + "OperatorMFAVerificationRequest", + "build_login_request", + "build_mfa_request", +] diff --git a/Data/Engine/config/environment.py b/Data/Engine/config/environment.py index 460b4ea..14cde00 100644 --- a/Data/Engine/config/environment.py +++ b/Data/Engine/config/environment.py @@ -91,7 +91,12 @@ def _resolve_project_root() -> Path: candidate = os.getenv("BOREALIS_ROOT") if candidate: return Path(candidate).expanduser().resolve() - return Path(__file__).resolve().parents[2] + # ``environment.py`` lives under ``Data/Engine/config``. The project + # root is three levels above this module (the repository checkout). The + # previous implementation only walked up two levels which incorrectly + # treated ``Data/`` as the root, breaking all filesystem discovery logic + # that expects peers such as ``Data/Server`` to be available. + return Path(__file__).resolve().parents[3] def _resolve_database_path(project_root: Path) -> Path: diff --git a/Data/Engine/domain/__init__.py b/Data/Engine/domain/__init__.py index 077ce2f..8f36e8e 100644 --- a/Data/Engine/domain/__init__.py +++ b/Data/Engine/domain/__init__.py @@ -26,6 +26,11 @@ from .github import ( # noqa: F401 GitHubTokenStatus, RepoHeadSnapshot, ) +from .operator import ( # noqa: F401 + OperatorAccount, + OperatorLoginSuccess, + OperatorMFAChallenge, +) __all__ = [ "AccessTokenClaims", @@ -45,5 +50,8 @@ __all__ = [ "GitHubRepoRef", "GitHubTokenStatus", "RepoHeadSnapshot", + "OperatorAccount", + "OperatorLoginSuccess", + "OperatorMFAChallenge", "sanitize_service_context", ] diff --git a/Data/Engine/domain/operator.py b/Data/Engine/domain/operator.py new file mode 100644 index 0000000..6e0211c --- /dev/null +++ b/Data/Engine/domain/operator.py @@ -0,0 +1,51 @@ +"""Domain models for operator authentication.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal, Optional + + +@dataclass(frozen=True, slots=True) +class OperatorAccount: + """Snapshot of an operator account stored in SQLite.""" + + username: str + display_name: str + password_sha512: str + role: str + last_login: int + created_at: int + updated_at: int + mfa_enabled: bool + mfa_secret: Optional[str] + + +@dataclass(frozen=True, slots=True) +class OperatorLoginSuccess: + """Successful login payload for the caller.""" + + username: str + role: str + token: str + + +@dataclass(frozen=True, slots=True) +class OperatorMFAChallenge: + """Details describing an in-progress MFA challenge.""" + + username: str + role: str + stage: Literal["setup", "verify"] + pending_token: str + expires_at: int + secret: Optional[str] = None + otpauth_url: Optional[str] = None + qr_image: Optional[str] = None + + +__all__ = [ + "OperatorAccount", + "OperatorLoginSuccess", + "OperatorMFAChallenge", +] diff --git a/Data/Engine/interfaces/http/__init__.py b/Data/Engine/interfaces/http/__init__.py index ce80a82..e388b81 100644 --- a/Data/Engine/interfaces/http/__init__.py +++ b/Data/Engine/interfaces/http/__init__.py @@ -6,7 +6,7 @@ from flask import Flask from Data.Engine.services.container import EngineServiceContainer -from . import admin, agents, enrollment, github, health, job_management, tokens +from . import admin, agents, auth, enrollment, github, health, job_management, tokens _REGISTRARS = ( health.register, @@ -15,6 +15,7 @@ _REGISTRARS = ( tokens.register, job_management.register, github.register, + auth.register, admin.register, ) diff --git a/Data/Engine/interfaces/http/auth.py b/Data/Engine/interfaces/http/auth.py new file mode 100644 index 0000000..d91d9c7 --- /dev/null +++ b/Data/Engine/interfaces/http/auth.py @@ -0,0 +1,165 @@ +"""Operator authentication HTTP endpoints.""" + +from __future__ import annotations + +from typing import Any, Dict + +from flask import Blueprint, Flask, current_app, jsonify, request, session + +from Data.Engine.builders import build_login_request, build_mfa_request +from Data.Engine.domain import OperatorLoginSuccess, OperatorMFAChallenge +from Data.Engine.services.auth import ( + InvalidCredentialsError, + InvalidMFACodeError, + MFAUnavailableError, + MFASessionError, + OperatorAuthService, +) +from Data.Engine.services.container import EngineServiceContainer + + +def _service(container: EngineServiceContainer) -> OperatorAuthService: + return container.operator_auth_service + + +def register(app: Flask, services: EngineServiceContainer) -> None: + bp = Blueprint("auth", __name__) + + @bp.route("/api/auth/login", methods=["POST"]) + def login() -> Any: + payload = request.get_json(silent=True) or {} + try: + login_request = build_login_request(payload) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + + service = _service(services) + + try: + result = service.authenticate(login_request) + except InvalidCredentialsError: + return jsonify({"error": "invalid username or password"}), 401 + except MFAUnavailableError as exc: + current_app.logger.error("mfa unavailable: %s", exc) + return jsonify({"error": str(exc)}), 500 + + session.pop("username", None) + session.pop("role", None) + + if isinstance(result, OperatorLoginSuccess): + session.pop("mfa_pending", None) + session["username"] = result.username + session["role"] = result.role or "User" + response = jsonify( + {"status": "ok", "username": result.username, "role": result.role, "token": result.token} + ) + _set_auth_cookie(response, result.token) + return response + + challenge = result + session["mfa_pending"] = { + "username": challenge.username, + "role": challenge.role, + "stage": challenge.stage, + "token": challenge.pending_token, + "expires": challenge.expires_at, + "secret": challenge.secret, + } + session.modified = True + + payload: Dict[str, Any] = { + "status": "mfa_required", + "stage": challenge.stage, + "pending_token": challenge.pending_token, + "username": challenge.username, + "role": challenge.role, + } + if challenge.stage == "setup": + if challenge.secret: + payload["secret"] = challenge.secret + if challenge.otpauth_url: + payload["otpauth_url"] = challenge.otpauth_url + if challenge.qr_image: + payload["qr_image"] = challenge.qr_image + return jsonify(payload) + + @bp.route("/api/auth/logout", methods=["POST"]) + def logout() -> Any: + session.clear() + response = jsonify({"status": "ok"}) + _set_auth_cookie(response, "", expires=0) + return response + + @bp.route("/api/auth/mfa/verify", methods=["POST"]) + def verify_mfa() -> Any: + pending = session.get("mfa_pending") + if not isinstance(pending, dict): + return jsonify({"error": "mfa_pending"}), 401 + + try: + request_payload = build_mfa_request(request.get_json(silent=True) or {}) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + + challenge = OperatorMFAChallenge( + username=str(pending.get("username") or ""), + role=str(pending.get("role") or "User"), + stage=str(pending.get("stage") or "verify"), + pending_token=str(pending.get("token") or ""), + expires_at=int(pending.get("expires") or 0), + secret=str(pending.get("secret") or "") or None, + ) + + service = _service(services) + + try: + result = service.verify_mfa(challenge, request_payload) + except MFASessionError as exc: + error_key = str(exc) + status = 401 if error_key != "mfa_not_configured" else 403 + if error_key not in {"expired", "invalid_session", "mfa_not_configured"}: + error_key = "invalid_session" + session.pop("mfa_pending", None) + return jsonify({"error": error_key}), status + except InvalidMFACodeError as exc: + return jsonify({"error": str(exc) or "invalid_code"}), 401 + except MFAUnavailableError as exc: + current_app.logger.error("mfa unavailable: %s", exc) + return jsonify({"error": str(exc)}), 500 + except InvalidCredentialsError: + session.pop("mfa_pending", None) + return jsonify({"error": "invalid username or password"}), 401 + + session.pop("mfa_pending", None) + session["username"] = result.username + session["role"] = result.role or "User" + payload = { + "status": "ok", + "username": result.username, + "role": result.role, + "token": result.token, + } + response = jsonify(payload) + _set_auth_cookie(response, result.token) + return response + + app.register_blueprint(bp) + + +def _set_auth_cookie(response, value: str, *, expires: int | None = None) -> None: + same_site = current_app.config.get("SESSION_COOKIE_SAMESITE", "Lax") + secure = bool(current_app.config.get("SESSION_COOKIE_SECURE", False)) + domain = current_app.config.get("SESSION_COOKIE_DOMAIN", None) + response.set_cookie( + "borealis_auth", + value, + httponly=False, + samesite=same_site, + secure=secure, + domain=domain, + path="/", + expires=expires, + ) + + +__all__ = ["register"] diff --git a/Data/Engine/repositories/sqlite/__init__.py b/Data/Engine/repositories/sqlite/__init__.py index ceef224..869829f 100644 --- a/Data/Engine/repositories/sqlite/__init__.py +++ b/Data/Engine/repositories/sqlite/__init__.py @@ -26,6 +26,7 @@ try: # pragma: no cover - optional dependency shim from .github_repository import SQLiteGitHubRepository from .job_repository import SQLiteJobRepository from .token_repository import SQLiteRefreshTokenRepository + from .user_repository import SQLiteUserRepository except ModuleNotFoundError as exc: # pragma: no cover - triggered when auth deps missing def _missing_repo(*_args: object, **_kwargs: object) -> None: raise ModuleNotFoundError( @@ -44,4 +45,5 @@ else: "SQLiteJobRepository", "SQLiteEnrollmentRepository", "SQLiteGitHubRepository", + "SQLiteUserRepository", ] diff --git a/Data/Engine/repositories/sqlite/user_repository.py b/Data/Engine/repositories/sqlite/user_repository.py new file mode 100644 index 0000000..14708e5 --- /dev/null +++ b/Data/Engine/repositories/sqlite/user_repository.py @@ -0,0 +1,123 @@ +"""SQLite repository for operator accounts.""" + +from __future__ import annotations + +import logging +import sqlite3 +from dataclasses import dataclass +from typing import Optional + +from Data.Engine.domain import OperatorAccount + +from .connection import SQLiteConnectionFactory + + +@dataclass(frozen=True, slots=True) +class _UserRow: + id: str + username: str + display_name: str + password_sha512: str + role: str + last_login: int + created_at: int + updated_at: int + mfa_enabled: int + mfa_secret: str + + +class SQLiteUserRepository: + """Expose CRUD helpers for operator accounts stored in SQLite.""" + + def __init__( + self, + connection_factory: SQLiteConnectionFactory, + *, + logger: Optional[logging.Logger] = None, + ) -> None: + self._connection_factory = connection_factory + self._log = logger or logging.getLogger("borealis.engine.repositories.users") + + def fetch_by_username(self, username: str) -> Optional[OperatorAccount]: + conn = self._connection_factory() + try: + cur = conn.cursor() + cur.execute( + """ + SELECT + id, + username, + display_name, + COALESCE(password_sha512, '') as password_sha512, + COALESCE(role, 'User') as role, + COALESCE(last_login, 0) as last_login, + COALESCE(created_at, 0) as created_at, + COALESCE(updated_at, 0) as 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() + if not row: + return None + record = _UserRow(*row) + return OperatorAccount( + username=record.username, + display_name=record.display_name or record.username, + password_sha512=(record.password_sha512 or "").lower(), + role=record.role or "User", + last_login=int(record.last_login or 0), + created_at=int(record.created_at or 0), + updated_at=int(record.updated_at or 0), + mfa_enabled=bool(record.mfa_enabled), + mfa_secret=(record.mfa_secret or "") or None, + ) + except sqlite3.Error as exc: # pragma: no cover - defensive + self._log.error("failed to load user %s: %s", username, exc) + return None + finally: + conn.close() + + def update_last_login(self, username: str, timestamp: int) -> None: + conn = self._connection_factory() + try: + cur = conn.cursor() + cur.execute( + """ + UPDATE users + SET last_login = ?, + updated_at = ? + WHERE LOWER(username) = LOWER(?) + """, + (timestamp, timestamp, username), + ) + conn.commit() + except sqlite3.Error as exc: # pragma: no cover - defensive + self._log.warning("failed to update last_login for %s: %s", username, exc) + finally: + conn.close() + + def store_mfa_secret(self, username: str, secret: str, *, timestamp: int) -> None: + conn = self._connection_factory() + try: + cur = conn.cursor() + cur.execute( + """ + UPDATE users + SET mfa_secret = ?, + updated_at = ? + WHERE LOWER(username) = LOWER(?) + """, + (secret, timestamp, username), + ) + conn.commit() + except sqlite3.Error as exc: # pragma: no cover - defensive + self._log.warning("failed to persist MFA secret for %s: %s", username, exc) + finally: + conn.close() + + +__all__ = ["SQLiteUserRepository"] diff --git a/Data/Engine/requirements.txt b/Data/Engine/requirements.txt index 6f7693c..b1f10c7 100644 --- a/Data/Engine/requirements.txt +++ b/Data/Engine/requirements.txt @@ -9,3 +9,5 @@ requests # Auth & security PyJWT[crypto] cryptography +pyotp +qrcode diff --git a/Data/Engine/services/auth/__init__.py b/Data/Engine/services/auth/__init__.py index f24d072..98e66cd 100644 --- a/Data/Engine/services/auth/__init__.py +++ b/Data/Engine/services/auth/__init__.py @@ -11,6 +11,14 @@ from .token_service import ( TokenRefreshErrorCode, TokenService, ) +from .operator_auth_service import ( + InvalidCredentialsError, + InvalidMFACodeError, + MFAUnavailableError, + MFASessionError, + OperatorAuthError, + OperatorAuthService, +) __all__ = [ "DeviceAuthService", @@ -24,4 +32,10 @@ __all__ = [ "TokenRefreshError", "TokenRefreshErrorCode", "TokenService", + "OperatorAuthService", + "OperatorAuthError", + "InvalidCredentialsError", + "InvalidMFACodeError", + "MFAUnavailableError", + "MFASessionError", ] diff --git a/Data/Engine/services/auth/operator_auth_service.py b/Data/Engine/services/auth/operator_auth_service.py new file mode 100644 index 0000000..d3c1163 --- /dev/null +++ b/Data/Engine/services/auth/operator_auth_service.py @@ -0,0 +1,209 @@ +"""Operator authentication service.""" + +from __future__ import annotations + +import base64 +import io +import logging +import os +import time +import uuid +from typing import Optional + +try: # pragma: no cover - optional dependencies mirror legacy server behaviour + import pyotp # type: ignore +except Exception: # pragma: no cover - gracefully degrade when unavailable + pyotp = None # type: ignore + +try: # pragma: no cover - optional dependency + import qrcode # type: ignore +except Exception: # pragma: no cover - gracefully degrade when unavailable + qrcode = None # type: ignore + +from itsdangerous import URLSafeTimedSerializer + +from Data.Engine.builders.operator_auth import ( + OperatorLoginRequest, + OperatorMFAVerificationRequest, +) +from Data.Engine.domain import ( + OperatorAccount, + OperatorLoginSuccess, + OperatorMFAChallenge, +) +from Data.Engine.repositories.sqlite.user_repository import SQLiteUserRepository + + +class OperatorAuthError(Exception): + """Base class for operator authentication errors.""" + + +class InvalidCredentialsError(OperatorAuthError): + """Raised when username/password verification fails.""" + + +class MFAUnavailableError(OperatorAuthError): + """Raised when MFA functionality is requested but dependencies are missing.""" + + +class InvalidMFACodeError(OperatorAuthError): + """Raised when the submitted MFA code is invalid.""" + + +class MFASessionError(OperatorAuthError): + """Raised when the MFA session state cannot be validated.""" + + +class OperatorAuthService: + """Authenticate operator accounts and manage MFA challenges.""" + + def __init__( + self, + repository: SQLiteUserRepository, + *, + logger: Optional[logging.Logger] = None, + ) -> None: + self._repository = repository + self._log = logger or logging.getLogger("borealis.engine.services.operator_auth") + + def authenticate( + self, request: OperatorLoginRequest + ) -> OperatorLoginSuccess | OperatorMFAChallenge: + account = self._repository.fetch_by_username(request.username) + if not account: + raise InvalidCredentialsError("invalid username or password") + + if not self._password_matches(account, request.password_sha512): + raise InvalidCredentialsError("invalid username or password") + + if not account.mfa_enabled: + return self._finalize_login(account) + + stage = "verify" if account.mfa_secret else "setup" + return self._build_mfa_challenge(account, stage) + + def verify_mfa( + self, + challenge: OperatorMFAChallenge, + request: OperatorMFAVerificationRequest, + ) -> OperatorLoginSuccess: + now = int(time.time()) + if challenge.pending_token != request.pending_token: + raise MFASessionError("invalid_session") + if challenge.expires_at < now: + raise MFASessionError("expired") + + if challenge.stage == "setup": + secret = (challenge.secret or "").strip() + if not secret: + raise MFASessionError("mfa_not_configured") + totp = self._totp_for_secret(secret) + if not totp.verify(request.code, valid_window=1): + raise InvalidMFACodeError("invalid_code") + self._repository.store_mfa_secret(challenge.username, secret, timestamp=now) + else: + account = self._repository.fetch_by_username(challenge.username) + if not account or not account.mfa_secret: + raise MFASessionError("mfa_not_configured") + totp = self._totp_for_secret(account.mfa_secret) + if not totp.verify(request.code, valid_window=1): + raise InvalidMFACodeError("invalid_code") + + account = self._repository.fetch_by_username(challenge.username) + if not account: + raise InvalidCredentialsError("invalid username or password") + return self._finalize_login(account) + + def issue_token(self, username: str, role: str) -> str: + serializer = self._token_serializer() + payload = {"u": username, "r": role or "User", "ts": int(time.time())} + return serializer.dumps(payload) + + def _finalize_login(self, account: OperatorAccount) -> OperatorLoginSuccess: + now = int(time.time()) + self._repository.update_last_login(account.username, now) + token = self.issue_token(account.username, account.role) + return OperatorLoginSuccess(username=account.username, role=account.role, token=token) + + def _password_matches(self, account: OperatorAccount, provided_hash: str) -> bool: + expected = (account.password_sha512 or "").strip().lower() + candidate = (provided_hash or "").strip().lower() + return bool(expected and candidate and expected == candidate) + + def _build_mfa_challenge( + self, + account: OperatorAccount, + stage: str, + ) -> OperatorMFAChallenge: + now = int(time.time()) + pending_token = uuid.uuid4().hex + secret = None + otpauth_url = None + qr_image = None + + if stage == "setup": + secret = self._generate_totp_secret() + otpauth_url = self._totp_provisioning_uri(secret, account.username) + qr_image = self._totp_qr_data_uri(otpauth_url) if otpauth_url else None + + return OperatorMFAChallenge( + username=account.username, + role=account.role, + stage="verify" if stage == "verify" else "setup", + pending_token=pending_token, + expires_at=now + 300, + secret=secret, + otpauth_url=otpauth_url, + qr_image=qr_image, + ) + + def _token_serializer(self) -> URLSafeTimedSerializer: + secret = os.getenv("BOREALIS_FLASK_SECRET_KEY") or "change-me" + return URLSafeTimedSerializer(secret, salt="borealis-auth") + + def _generate_totp_secret(self) -> str: + if not pyotp: + raise MFAUnavailableError("pyotp is not installed; MFA unavailable") + return pyotp.random_base32() # type: ignore[no-any-return] + + def _totp_for_secret(self, secret: str): + if not pyotp: + raise MFAUnavailableError("pyotp is not installed; MFA unavailable") + normalized = secret.replace(" ", "").strip().upper() + if not normalized: + raise MFASessionError("mfa_not_configured") + return pyotp.TOTP(normalized, digits=6, interval=30) + + def _totp_provisioning_uri(self, secret: str, username: str) -> Optional[str]: + try: + totp = self._totp_for_secret(secret) + except OperatorAuthError: + return None + issuer = os.getenv("BOREALIS_MFA_ISSUER", "Borealis") + try: + return totp.provisioning_uri(name=username, issuer_name=issuer) + except Exception: # pragma: no cover - defensive + return None + + def _totp_qr_data_uri(self, payload: str) -> Optional[str]: + if not payload or qrcode is None: + return None + try: + img = qrcode.make(payload, box_size=6, border=4) + buf = io.BytesIO() + img.save(buf, format="PNG") + encoded = base64.b64encode(buf.getvalue()).decode("ascii") + return f"data:image/png;base64,{encoded}" + except Exception: # pragma: no cover - defensive + self._log.warning("failed to generate MFA QR code", exc_info=True) + return None + + +__all__ = [ + "OperatorAuthService", + "OperatorAuthError", + "InvalidCredentialsError", + "MFAUnavailableError", + "InvalidMFACodeError", + "MFASessionError", +] diff --git a/Data/Engine/services/container.py b/Data/Engine/services/container.py index 756c44f..714b686 100644 --- a/Data/Engine/services/container.py +++ b/Data/Engine/services/container.py @@ -17,10 +17,12 @@ from Data.Engine.repositories.sqlite import ( SQLiteGitHubRepository, SQLiteJobRepository, SQLiteRefreshTokenRepository, + SQLiteUserRepository, ) from Data.Engine.services.auth import ( DeviceAuthService, DPoPValidator, + OperatorAuthService, JWTService, TokenService, load_jwt_service, @@ -46,6 +48,7 @@ class EngineServiceContainer: agent_realtime: AgentRealtimeService scheduler_service: SchedulerService github_service: GitHubService + operator_auth_service: OperatorAuthService def build_service_container( @@ -61,6 +64,7 @@ def build_service_container( enrollment_repo = SQLiteEnrollmentRepository(db_factory, logger=log.getChild("enrollment")) job_repo = SQLiteJobRepository(db_factory, logger=log.getChild("jobs")) github_repo = SQLiteGitHubRepository(db_factory, logger=log.getChild("github_repo")) + user_repo = SQLiteUserRepository(db_factory, logger=log.getChild("users")) jwt_service = load_jwt_service() dpop_validator = DPoPValidator() @@ -106,6 +110,11 @@ def build_service_container( logger=log.getChild("scheduler"), ) + operator_auth_service = OperatorAuthService( + repository=user_repo, + logger=log.getChild("operator_auth"), + ) + github_provider = GitHubArtifactProvider( cache_file=settings.github.cache_file, default_repo=settings.github.default_repo, @@ -129,6 +138,7 @@ def build_service_container( agent_realtime=agent_realtime, scheduler_service=scheduler_service, github_service=github_service, + operator_auth_service=operator_auth_service, ) diff --git a/Data/Engine/tests/test_config_environment.py b/Data/Engine/tests/test_config_environment.py index c89ef01..03ff2ba 100644 --- a/Data/Engine/tests/test_config_environment.py +++ b/Data/Engine/tests/test_config_environment.py @@ -2,6 +2,8 @@ from __future__ import annotations +from pathlib import Path + from Data.Engine.config.environment import load_environment @@ -59,3 +61,14 @@ def test_static_root_falls_back_to_legacy_source(tmp_path, monkeypatch): assert settings.flask.static_root == legacy_source.resolve() monkeypatch.delenv("BOREALIS_ROOT", raising=False) + + +def test_resolve_project_root_defaults_to_repository(monkeypatch): + """The project root should resolve to the repository checkout.""" + + monkeypatch.delenv("BOREALIS_ROOT", raising=False) + from Data.Engine.config import environment as env_module + + expected = Path(env_module.__file__).resolve().parents[3] + + assert env_module._resolve_project_root() == expected diff --git a/Data/Engine/tests/test_operator_auth_builders.py b/Data/Engine/tests/test_operator_auth_builders.py new file mode 100644 index 0000000..80deb14 --- /dev/null +++ b/Data/Engine/tests/test_operator_auth_builders.py @@ -0,0 +1,63 @@ +"""Tests for operator authentication builders.""" + +from __future__ import annotations + +import pytest + +from Data.Engine.builders import ( + OperatorLoginRequest, + OperatorMFAVerificationRequest, + build_login_request, + build_mfa_request, +) + + +def test_build_login_request_uses_explicit_hash(): + payload = {"username": "Admin", "password_sha512": "abc123"} + + result = build_login_request(payload) + + assert isinstance(result, OperatorLoginRequest) + assert result.username == "Admin" + assert result.password_sha512 == "abc123" + + +def test_build_login_request_hashes_plain_password(): + payload = {"username": "user", "password": "secret"} + + result = build_login_request(payload) + + assert isinstance(result, OperatorLoginRequest) + assert result.username == "user" + assert result.password_sha512 + assert result.password_sha512 != "secret" + + +@pytest.mark.parametrize( + "payload", + [ + {"password": "secret"}, + {"username": ""}, + {"username": "user"}, + ], +) +def test_build_login_request_validation(payload): + with pytest.raises(ValueError): + build_login_request(payload) + + +def test_build_mfa_request_normalizes_code(): + payload = {"pending_token": "token", "code": "12 34-56"} + + result = build_mfa_request(payload) + + assert isinstance(result, OperatorMFAVerificationRequest) + assert result.pending_token == "token" + assert result.code == "123456" + + +def test_build_mfa_request_requires_token_and_code(): + with pytest.raises(ValueError): + build_mfa_request({"code": "123"}) + with pytest.raises(ValueError): + build_mfa_request({"pending_token": "token", "code": "12"}) diff --git a/Data/Engine/tests/test_operator_auth_service.py b/Data/Engine/tests/test_operator_auth_service.py new file mode 100644 index 0000000..921441e --- /dev/null +++ b/Data/Engine/tests/test_operator_auth_service.py @@ -0,0 +1,197 @@ +"""Tests for the operator authentication service.""" + +from __future__ import annotations + +import hashlib +import sqlite3 +from pathlib import Path +from typing import Callable + +import pytest + +pyotp = pytest.importorskip("pyotp") + +from Data.Engine.builders import ( + OperatorLoginRequest, + OperatorMFAVerificationRequest, +) +from Data.Engine.repositories.sqlite.connection import connection_factory +from Data.Engine.repositories.sqlite.user_repository import SQLiteUserRepository +from Data.Engine.services.auth.operator_auth_service import ( + InvalidCredentialsError, + InvalidMFACodeError, + OperatorAuthService, +) + + +def _prepare_db(path: Path) -> Callable[[], sqlite3.Connection]: + conn = sqlite3.connect(path) + conn.execute( + """ + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT, + display_name TEXT, + password_sha512 TEXT, + role TEXT, + last_login INTEGER, + created_at INTEGER, + updated_at INTEGER, + mfa_enabled INTEGER, + mfa_secret TEXT + ) + """ + ) + conn.commit() + conn.close() + return connection_factory(path) + + +def _insert_user( + factory: Callable[[], sqlite3.Connection], + *, + user_id: str, + username: str, + password_hash: str, + role: str = "Admin", + mfa_enabled: int = 0, + mfa_secret: str = "", +) -> None: + conn = factory() + conn.execute( + """ + INSERT INTO users ( + id, username, display_name, password_sha512, role, + last_login, created_at, updated_at, mfa_enabled, mfa_secret + ) VALUES (?, ?, ?, ?, ?, 0, 0, 0, ?, ?) + """, + (user_id, username, username, password_hash, role, mfa_enabled, mfa_secret), + ) + conn.commit() + conn.close() + + +def test_authenticate_success_updates_last_login(tmp_path): + db_path = tmp_path / "auth.db" + factory = _prepare_db(db_path) + password_hash = hashlib.sha512(b"password").hexdigest() + _insert_user(factory, user_id="1", username="admin", password_hash=password_hash) + + repo = SQLiteUserRepository(factory) + service = OperatorAuthService(repo) + + request = OperatorLoginRequest(username="admin", password_sha512=password_hash) + result = service.authenticate(request) + + assert result.username == "admin" + + conn = factory() + row = conn.execute("SELECT last_login FROM users WHERE username=?", ("admin",)).fetchone() + conn.close() + assert row[0] > 0 + + +def test_authenticate_invalid_credentials(tmp_path): + db_path = tmp_path / "auth.db" + factory = _prepare_db(db_path) + repo = SQLiteUserRepository(factory) + service = OperatorAuthService(repo) + + request = OperatorLoginRequest(username="missing", password_sha512="abc") + with pytest.raises(InvalidCredentialsError): + service.authenticate(request) + + +def test_mfa_verify_flow(tmp_path): + db_path = tmp_path / "auth.db" + factory = _prepare_db(db_path) + secret = pyotp.random_base32() + password_hash = hashlib.sha512(b"password").hexdigest() + _insert_user( + factory, + user_id="1", + username="admin", + password_hash=password_hash, + mfa_enabled=1, + mfa_secret=secret, + ) + + repo = SQLiteUserRepository(factory) + service = OperatorAuthService(repo) + login_request = OperatorLoginRequest(username="admin", password_sha512=password_hash) + + challenge = service.authenticate(login_request) + assert challenge.stage == "verify" + + totp = pyotp.TOTP(secret) + verify_request = OperatorMFAVerificationRequest( + pending_token=challenge.pending_token, + code=totp.now(), + ) + + result = service.verify_mfa(challenge, verify_request) + assert result.username == "admin" + + +def test_mfa_setup_flow_persists_secret(tmp_path): + db_path = tmp_path / "auth.db" + factory = _prepare_db(db_path) + password_hash = hashlib.sha512(b"password").hexdigest() + _insert_user( + factory, + user_id="1", + username="admin", + password_hash=password_hash, + mfa_enabled=1, + mfa_secret="", + ) + + repo = SQLiteUserRepository(factory) + service = OperatorAuthService(repo) + + challenge = service.authenticate(OperatorLoginRequest(username="admin", password_sha512=password_hash)) + assert challenge.stage == "setup" + assert challenge.secret + + totp = pyotp.TOTP(challenge.secret) + verify_request = OperatorMFAVerificationRequest( + pending_token=challenge.pending_token, + code=totp.now(), + ) + + result = service.verify_mfa(challenge, verify_request) + assert result.username == "admin" + + conn = factory() + stored_secret = conn.execute( + "SELECT mfa_secret FROM users WHERE username=?", ("admin",) + ).fetchone()[0] + conn.close() + assert stored_secret + + +def test_mfa_invalid_code_raises(tmp_path): + db_path = tmp_path / "auth.db" + factory = _prepare_db(db_path) + secret = pyotp.random_base32() + password_hash = hashlib.sha512(b"password").hexdigest() + _insert_user( + factory, + user_id="1", + username="admin", + password_hash=password_hash, + mfa_enabled=1, + mfa_secret=secret, + ) + + repo = SQLiteUserRepository(factory) + service = OperatorAuthService(repo) + challenge = service.authenticate(OperatorLoginRequest(username="admin", password_sha512=password_hash)) + + verify_request = OperatorMFAVerificationRequest( + pending_token=challenge.pending_token, + code="000000", + ) + + with pytest.raises(InvalidMFACodeError): + service.verify_mfa(challenge, verify_request)