mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-26 17:01:57 -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