|
36 | 36 |
|
37 | 37 | from src.interfaces.arbitrage_engine import ArbitrageEngine |
38 | 38 | from src.interfaces.cmg_predictor import CMgPredictor |
| 39 | +from src.interfaces.totp_auth import TOTPAuth |
39 | 40 |
|
40 | 41 | # Optional: bessai_arbitrage data-flywheel pipeline (Parquet-backed, cached) |
41 | 42 | try: |
@@ -194,23 +195,46 @@ def __init__( |
194 | 195 | state: DashboardState | None = None, |
195 | 196 | port: int = 8080, |
196 | 197 | api_key: str = "", |
| 198 | + site_id: str = "edge-001", |
197 | 199 | ) -> None: |
198 | 200 | self.state = state or DashboardState() |
199 | 201 | self.port = port |
200 | 202 | self.api_key = api_key or os.getenv("DASHBOARD_API_KEY", "") |
201 | 203 | self._app: Any | None = None |
202 | 204 | self._runner: Any | None = None |
| 205 | + # IEC 62443 SR 1.3 — TOTP MFA (optional, configured via DASHBOARD_MFA_SECRET) |
| 206 | + self._totp = TOTPAuth(site_id=site_id) |
203 | 207 |
|
204 | 208 | # ------------------------------------------------------------------ |
205 | 209 | # Route handlers |
206 | 210 | # ------------------------------------------------------------------ |
207 | 211 |
|
208 | 212 | async def _check_auth(self, request: Any) -> bool: |
209 | | - """Return True if auth is satisfied (no-op in dev mode).""" |
210 | | - if not self.api_key: |
211 | | - return True |
212 | | - auth = request.headers.get("Authorization", "") |
213 | | - return bool(auth == f"Bearer {self.api_key}") |
| 213 | + """Return True if auth is satisfied. |
| 214 | +
|
| 215 | + Auth flow (IEC 62443 SR 1.3): |
| 216 | + 1. Bearer token check (existing) → if configured and wrong, deny. |
| 217 | + 2. TOTP check (new) → if MFA enabled and token wrong/missing, deny. |
| 218 | + """ |
| 219 | + # Step 1 — Bearer token |
| 220 | + if self.api_key: |
| 221 | + auth = request.headers.get("Authorization", "") |
| 222 | + if auth != f"Bearer {self.api_key}": |
| 223 | + log.warning("dashboard_api.auth_bearer_failed", remote=str(request.remote)) |
| 224 | + return False |
| 225 | + |
| 226 | + # Step 2 — TOTP MFA (IEC 62443 SR 1.3) |
| 227 | + if self._totp.is_enabled: |
| 228 | + totp_token = request.headers.get("X-TOTP-Token", "") |
| 229 | + if not self._totp.verify(totp_token): |
| 230 | + log.warning( |
| 231 | + "dashboard_api.auth_totp_failed", |
| 232 | + remote=str(request.remote), |
| 233 | + token_received=bool(totp_token), |
| 234 | + ) |
| 235 | + return False |
| 236 | + |
| 237 | + return True |
214 | 238 |
|
215 | 239 | def _json_response(self, data: dict) -> Any: |
216 | 240 | return web.Response( |
@@ -254,6 +278,14 @@ async def handle_version(self, request: Any) -> Any: |
254 | 278 | } |
255 | 279 | ) |
256 | 280 |
|
| 281 | + async def handle_totp_info(self, request: Any) -> Any: |
| 282 | + """Report TOTP MFA configuration status (IEC 62443-3-3 SR 1.3). |
| 283 | +
|
| 284 | + This endpoint is *unauthenticated* intentionally — it only reports |
| 285 | + whether MFA is enabled, not the secret itself. |
| 286 | + """ |
| 287 | + return self._json_response(self._totp.info().to_dict()) |
| 288 | + |
257 | 289 | async def handle_health(self, request: Any) -> Any: |
258 | 290 | return self._json_response( |
259 | 291 | { |
@@ -453,6 +485,7 @@ async def cors_middleware(request: Any, handler: Any) -> Any: |
453 | 485 | self._app.router.add_get("/api/v1/ids", self.handle_ids) |
454 | 486 | self._app.router.add_get("/api/v1/version", self.handle_version) |
455 | 487 | self._app.router.add_get("/api/v1/health", self.handle_health) |
| 488 | + self._app.router.add_get("/api/v1/auth/totp-info", self.handle_totp_info) |
456 | 489 | self._app.router.add_options("/{path_info:.*}", self._cors_preflight) |
457 | 490 |
|
458 | 491 | self._runner = web.AppRunner(self._app) |
|
0 commit comments