mirror of
				https://github.com/bunny-lab-io/Borealis.git
				synced 2025-10-26 15:41:58 -06:00 
			
		
		
		
	Implement operator login service and fix static root
This commit is contained in:
		| @@ -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 | ||||
|   | ||||
							
								
								
									
										72
									
								
								Data/Engine/builders/operator_auth.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								Data/Engine/builders/operator_auth.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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", | ||||
| ] | ||||
| @@ -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: | ||||
|   | ||||
| @@ -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", | ||||
| ] | ||||
|   | ||||
							
								
								
									
										51
									
								
								Data/Engine/domain/operator.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								Data/Engine/domain/operator.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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", | ||||
| ] | ||||
| @@ -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, | ||||
| ) | ||||
|  | ||||
|   | ||||
							
								
								
									
										165
									
								
								Data/Engine/interfaces/http/auth.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										165
									
								
								Data/Engine/interfaces/http/auth.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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"] | ||||
| @@ -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", | ||||
|     ] | ||||
|   | ||||
							
								
								
									
										123
									
								
								Data/Engine/repositories/sqlite/user_repository.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								Data/Engine/repositories/sqlite/user_repository.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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"] | ||||
| @@ -9,3 +9,5 @@ requests | ||||
| # Auth & security | ||||
| PyJWT[crypto] | ||||
| cryptography | ||||
| pyotp | ||||
| qrcode | ||||
|   | ||||
| @@ -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", | ||||
| ] | ||||
|   | ||||
							
								
								
									
										209
									
								
								Data/Engine/services/auth/operator_auth_service.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										209
									
								
								Data/Engine/services/auth/operator_auth_service.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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", | ||||
| ] | ||||
| @@ -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, | ||||
|     ) | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
							
								
								
									
										63
									
								
								Data/Engine/tests/test_operator_auth_builders.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								Data/Engine/tests/test_operator_auth_builders.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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"}) | ||||
							
								
								
									
										197
									
								
								Data/Engine/tests/test_operator_auth_service.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										197
									
								
								Data/Engine/tests/test_operator_auth_service.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
		Reference in New Issue
	
	Block a user