mirror of
				https://github.com/bunny-lab-io/Borealis.git
				synced 2025-10-27 03:41: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",
 | |
| ]
 |