Implement operator login service and fix static root

This commit is contained in:
2025-10-22 19:23:38 -06:00
parent 102e77f676
commit f361c51a5e
16 changed files with 947 additions and 2 deletions

View File

@@ -8,12 +8,22 @@ from .device_auth import (
RefreshTokenRequest, RefreshTokenRequest,
RefreshTokenRequestBuilder, RefreshTokenRequestBuilder,
) )
from .operator_auth import (
OperatorLoginRequest,
OperatorMFAVerificationRequest,
build_login_request,
build_mfa_request,
)
__all__ = [ __all__ = [
"DeviceAuthRequest", "DeviceAuthRequest",
"DeviceAuthRequestBuilder", "DeviceAuthRequestBuilder",
"RefreshTokenRequest", "RefreshTokenRequest",
"RefreshTokenRequestBuilder", "RefreshTokenRequestBuilder",
"OperatorLoginRequest",
"OperatorMFAVerificationRequest",
"build_login_request",
"build_mfa_request",
] ]
try: # pragma: no cover - optional dependency shim try: # pragma: no cover - optional dependency shim

View 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",
]

View File

@@ -91,7 +91,12 @@ def _resolve_project_root() -> Path:
candidate = os.getenv("BOREALIS_ROOT") candidate = os.getenv("BOREALIS_ROOT")
if candidate: if candidate:
return Path(candidate).expanduser().resolve() 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: def _resolve_database_path(project_root: Path) -> Path:

View File

@@ -26,6 +26,11 @@ from .github import ( # noqa: F401
GitHubTokenStatus, GitHubTokenStatus,
RepoHeadSnapshot, RepoHeadSnapshot,
) )
from .operator import ( # noqa: F401
OperatorAccount,
OperatorLoginSuccess,
OperatorMFAChallenge,
)
__all__ = [ __all__ = [
"AccessTokenClaims", "AccessTokenClaims",
@@ -45,5 +50,8 @@ __all__ = [
"GitHubRepoRef", "GitHubRepoRef",
"GitHubTokenStatus", "GitHubTokenStatus",
"RepoHeadSnapshot", "RepoHeadSnapshot",
"OperatorAccount",
"OperatorLoginSuccess",
"OperatorMFAChallenge",
"sanitize_service_context", "sanitize_service_context",
] ]

View 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",
]

View File

@@ -6,7 +6,7 @@ from flask import Flask
from Data.Engine.services.container import EngineServiceContainer 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 = ( _REGISTRARS = (
health.register, health.register,
@@ -15,6 +15,7 @@ _REGISTRARS = (
tokens.register, tokens.register,
job_management.register, job_management.register,
github.register, github.register,
auth.register,
admin.register, admin.register,
) )

View 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"]

View File

@@ -26,6 +26,7 @@ try: # pragma: no cover - optional dependency shim
from .github_repository import SQLiteGitHubRepository from .github_repository import SQLiteGitHubRepository
from .job_repository import SQLiteJobRepository from .job_repository import SQLiteJobRepository
from .token_repository import SQLiteRefreshTokenRepository from .token_repository import SQLiteRefreshTokenRepository
from .user_repository import SQLiteUserRepository
except ModuleNotFoundError as exc: # pragma: no cover - triggered when auth deps missing except ModuleNotFoundError as exc: # pragma: no cover - triggered when auth deps missing
def _missing_repo(*_args: object, **_kwargs: object) -> None: def _missing_repo(*_args: object, **_kwargs: object) -> None:
raise ModuleNotFoundError( raise ModuleNotFoundError(
@@ -44,4 +45,5 @@ else:
"SQLiteJobRepository", "SQLiteJobRepository",
"SQLiteEnrollmentRepository", "SQLiteEnrollmentRepository",
"SQLiteGitHubRepository", "SQLiteGitHubRepository",
"SQLiteUserRepository",
] ]

View 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"]

View File

@@ -9,3 +9,5 @@ requests
# Auth & security # Auth & security
PyJWT[crypto] PyJWT[crypto]
cryptography cryptography
pyotp
qrcode

View File

@@ -11,6 +11,14 @@ from .token_service import (
TokenRefreshErrorCode, TokenRefreshErrorCode,
TokenService, TokenService,
) )
from .operator_auth_service import (
InvalidCredentialsError,
InvalidMFACodeError,
MFAUnavailableError,
MFASessionError,
OperatorAuthError,
OperatorAuthService,
)
__all__ = [ __all__ = [
"DeviceAuthService", "DeviceAuthService",
@@ -24,4 +32,10 @@ __all__ = [
"TokenRefreshError", "TokenRefreshError",
"TokenRefreshErrorCode", "TokenRefreshErrorCode",
"TokenService", "TokenService",
"OperatorAuthService",
"OperatorAuthError",
"InvalidCredentialsError",
"InvalidMFACodeError",
"MFAUnavailableError",
"MFASessionError",
] ]

View 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",
]

View File

@@ -17,10 +17,12 @@ from Data.Engine.repositories.sqlite import (
SQLiteGitHubRepository, SQLiteGitHubRepository,
SQLiteJobRepository, SQLiteJobRepository,
SQLiteRefreshTokenRepository, SQLiteRefreshTokenRepository,
SQLiteUserRepository,
) )
from Data.Engine.services.auth import ( from Data.Engine.services.auth import (
DeviceAuthService, DeviceAuthService,
DPoPValidator, DPoPValidator,
OperatorAuthService,
JWTService, JWTService,
TokenService, TokenService,
load_jwt_service, load_jwt_service,
@@ -46,6 +48,7 @@ class EngineServiceContainer:
agent_realtime: AgentRealtimeService agent_realtime: AgentRealtimeService
scheduler_service: SchedulerService scheduler_service: SchedulerService
github_service: GitHubService github_service: GitHubService
operator_auth_service: OperatorAuthService
def build_service_container( def build_service_container(
@@ -61,6 +64,7 @@ def build_service_container(
enrollment_repo = SQLiteEnrollmentRepository(db_factory, logger=log.getChild("enrollment")) enrollment_repo = SQLiteEnrollmentRepository(db_factory, logger=log.getChild("enrollment"))
job_repo = SQLiteJobRepository(db_factory, logger=log.getChild("jobs")) job_repo = SQLiteJobRepository(db_factory, logger=log.getChild("jobs"))
github_repo = SQLiteGitHubRepository(db_factory, logger=log.getChild("github_repo")) github_repo = SQLiteGitHubRepository(db_factory, logger=log.getChild("github_repo"))
user_repo = SQLiteUserRepository(db_factory, logger=log.getChild("users"))
jwt_service = load_jwt_service() jwt_service = load_jwt_service()
dpop_validator = DPoPValidator() dpop_validator = DPoPValidator()
@@ -106,6 +110,11 @@ def build_service_container(
logger=log.getChild("scheduler"), logger=log.getChild("scheduler"),
) )
operator_auth_service = OperatorAuthService(
repository=user_repo,
logger=log.getChild("operator_auth"),
)
github_provider = GitHubArtifactProvider( github_provider = GitHubArtifactProvider(
cache_file=settings.github.cache_file, cache_file=settings.github.cache_file,
default_repo=settings.github.default_repo, default_repo=settings.github.default_repo,
@@ -129,6 +138,7 @@ def build_service_container(
agent_realtime=agent_realtime, agent_realtime=agent_realtime,
scheduler_service=scheduler_service, scheduler_service=scheduler_service,
github_service=github_service, github_service=github_service,
operator_auth_service=operator_auth_service,
) )

View File

@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from Data.Engine.config.environment import load_environment 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() assert settings.flask.static_root == legacy_source.resolve()
monkeypatch.delenv("BOREALIS_ROOT", raising=False) 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

View 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"})

View 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)