mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-26 17:21: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,
|
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
|
||||||
|
|||||||
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")
|
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:
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
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 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
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 .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",
|
||||||
]
|
]
|
||||||
|
|||||||
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
|
# Auth & security
|
||||||
PyJWT[crypto]
|
PyJWT[crypto]
|
||||||
cryptography
|
cryptography
|
||||||
|
pyotp
|
||||||
|
qrcode
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
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,
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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