mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-26 15:21:57 -06:00
210 lines
7.4 KiB
Python
210 lines
7.4 KiB
Python
"""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",
|
|
]
|