diff --git a/gateway-proxy/gateway_proxy.py b/gateway-proxy/gateway_proxy.py index e712307..235a8cd 100644 --- a/gateway-proxy/gateway_proxy.py +++ b/gateway-proxy/gateway_proxy.py @@ -61,6 +61,72 @@ ACCESS_TOKENS: dict[str, dict] = {} REFRESH_TOKENS: dict[str, dict] = {} PENDING_AUTH: dict[str, dict] = {} +# Browser session store (cookie-based login for /admin and /dashboard) +GUI_SESSIONS: dict[str, float] = {} # token -> expires_at +GUI_SESSION_TTL = 8 * 3600 # 8 hours + + +def _create_gui_session() -> str: + token = secrets.token_hex(32) + GUI_SESSIONS[token] = time.time() + GUI_SESSION_TTL + return token + + +def _validate_gui_session(request: Request) -> bool: + token = request.cookies.get("mcp_session") + if not token: + return False + expires = GUI_SESSIONS.get(token, 0) + if expires < time.time(): + GUI_SESSIONS.pop(token, None) + return False + return True + + +GUI_LOGIN_HTML = """ + + + + + MCP Gateway — Login + + + +
+
🔒
+

MCP Gateway

+

Enter your gateway password to continue

+ {error_html} +
+ + + + +
+
+ +""" + def _hash(value: str) -> str: return hashlib.sha256(value.encode()).hexdigest() @@ -746,6 +812,53 @@ async def handle_mcp(request: Request) -> Response: }) +# --------------------------------------------------------------------------- +# GUI Login (password → session cookie for /admin and /dashboard) +# --------------------------------------------------------------------------- + +async def gui_login_get(request: Request) -> HTMLResponse: + """GET /gui-login — show login form (redirect here when session missing).""" + next_url = request.query_params.get("next", "/admin") + page = GUI_LOGIN_HTML.format(error_html="", next_url=html.escape(next_url)) + return HTMLResponse(page) + + +async def gui_login_post(request: Request) -> Response: + """POST /gui-login — validate password, set session cookie, redirect.""" + form = await request.form() + password = form.get("password", "") + next_url = form.get("next", "/admin") + # Sanitise redirect target — only allow relative paths on this host + if not next_url.startswith("/") or next_url.startswith("//"): + next_url = "/admin" + + if not GATEWAY_PASSWORD or password != GATEWAY_PASSWORD: + error_html = '
Incorrect password. Please try again.
' + page = GUI_LOGIN_HTML.format(error_html=error_html, next_url=html.escape(next_url)) + return HTMLResponse(page, status_code=401) + + session_token = _create_gui_session() + response = RedirectResponse(next_url, status_code=303) + response.set_cookie( + "mcp_session", session_token, + max_age=GUI_SESSION_TTL, + httponly=True, + samesite="lax", + secure=True, + ) + return response + + +async def gui_logout(request: Request) -> Response: + """GET /gui-logout — clear session cookie.""" + token = request.cookies.get("mcp_session") + if token: + GUI_SESSIONS.pop(token, None) + response = RedirectResponse("/gui-login?next=/admin", status_code=303) + response.delete_cookie("mcp_session") + return response + + # --------------------------------------------------------------------------- # Health / Status # --------------------------------------------------------------------------- @@ -827,8 +940,7 @@ async def probe_backend(name: str, url: str) -> dict: async def dashboard_status(request: Request) -> JSONResponse: """Auth-protected endpoint — live-probes all backends.""" - token_info = validate_bearer_token(request) - if not token_info: + if not _validate_gui_session(request) and not validate_bearer_token(request): return JSONResponse({"error": "unauthorized"}, status_code=401) probes = await asyncio.gather( *[probe_backend(name, url) for name, url in BACKENDS.items()], @@ -982,14 +1094,8 @@ DASHBOARD_HTML = """ async def dashboard(request: Request) -> Response: - token_info = validate_bearer_token(request) - if not token_info: - return Response( - content='{"error": "unauthorized"}', - status_code=401, - media_type="application/json", - headers={"WWW-Authenticate": f'Bearer resource_metadata="{ISSUER_URL}/.well-known/oauth-protected-resource"'}, - ) + if not _validate_gui_session(request): + return RedirectResponse(f"/gui-login?next=/dashboard", status_code=303) return Response(DASHBOARD_HTML, media_type="text/html") @@ -1080,6 +1186,9 @@ routes = [ Route("/status", status, methods=["GET"]), # Dashboard (no auth required) + Route("/gui-login", gui_login_get, methods=["GET"]), + Route("/gui-login", gui_login_post, methods=["POST"]), + Route("/gui-logout", gui_logout, methods=["GET"]), Route("/dashboard", dashboard, methods=["GET"]), Route("/dashboard/status", dashboard_status, methods=["GET"]), diff --git a/gateway-proxy/user_dashboard_ui.py b/gateway-proxy/user_dashboard_ui.py index c7f937e..b169d28 100644 --- a/gateway-proxy/user_dashboard_ui.py +++ b/gateway-proxy/user_dashboard_ui.py @@ -5,31 +5,8 @@ Provides a comprehensive web interface for managing users and API keys. Vue 3 compatible. """ -import os -import time -import hashlib from starlette.requests import Request -from starlette.responses import HTMLResponse, JSONResponse - -_STATIC_API_KEY = os.environ.get("GATEWAY_STATIC_API_KEY", "") - -def _admin_require_auth(request: Request): - """Returns None if authorized, or a 401 JSONResponse if not.""" - auth_header = request.headers.get("Authorization", "") - if not auth_header.startswith("Bearer "): - return JSONResponse({"error": "unauthorized"}, status_code=401) - token = auth_header[7:] - if _STATIC_API_KEY and token == _STATIC_API_KEY: - return None - try: - from gateway_proxy import ACCESS_TOKENS - token_hash = hashlib.sha256(token.encode()).hexdigest() - info = ACCESS_TOKENS.get(token_hash) - if info and info["expires_at"] >= time.time(): - return None - except ImportError: - pass - return JSONResponse({"error": "unauthorized"}, status_code=401) +from starlette.responses import HTMLResponse, RedirectResponse USER_DASHBOARD_HTML = """ @@ -422,6 +399,8 @@ createApp({ """ async def user_management_dashboard(request: Request): - """Serve the user management dashboard. Requires Bearer token auth.""" - if (err := _admin_require_auth(request)): return err + """Serve the user management dashboard. Requires GUI session cookie.""" + from gateway_proxy import _validate_gui_session + if not _validate_gui_session(request): + return RedirectResponse("/gui-login?next=/admin", status_code=303) return HTMLResponse(USER_DASHBOARD_HTML)