feat(gateway): password login page with session cookie for /admin and /dashboard

Replaces Bearer-token-only auth on GUI routes with a proper browser
login flow. Visiting /admin or /dashboard now redirects to /gui-login
if no valid session exists. Submitting the OAUTH_PASSWORD sets a
secure httpOnly session cookie (8h TTL). /gui-logout clears it.

- /dashboard/status also accepts session cookie (for the dashboard JS
  to call back without needing a separate token)
- API routes (/users/*, /keys/*) still require Bearer token as before
- /gui-login, /gui-logout added as new public routes
This commit is contained in:
Zac Gaetano 2026-03-31 23:44:24 -04:00
parent 72add79a87
commit a4fa9e75a2
2 changed files with 124 additions and 36 deletions

View file

@ -61,6 +61,72 @@ ACCESS_TOKENS: dict[str, dict] = {}
REFRESH_TOKENS: dict[str, dict] = {} REFRESH_TOKENS: dict[str, dict] = {}
PENDING_AUTH: 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 = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCP Gateway Login</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f172a; color: #e2e8f0; min-height: 100vh;
display: flex; align-items: center; justify-content: center; }}
.card {{ background: #1e293b; border-radius: 16px; padding: 40px;
max-width: 380px; width: 90%; box-shadow: 0 25px 50px rgba(0,0,0,0.4); }}
h1 {{ font-size: 22px; margin-bottom: 6px; color: #f8fafc; }}
.subtitle {{ color: #94a3b8; margin-bottom: 28px; font-size: 14px; }}
label {{ display: block; font-size: 13px; color: #94a3b8; margin-bottom: 6px; }}
input[type=password] {{ width: 100%; padding: 12px 16px; border-radius: 8px;
border: 1px solid #475569; background: #0f172a; color: #f8fafc;
font-size: 16px; margin-bottom: 20px; outline: none; }}
input[type=password]:focus {{ border-color: #38bdf8; box-shadow: 0 0 0 3px rgba(56,189,248,0.15); }}
button {{ width: 100%; padding: 12px; border-radius: 8px; border: none; font-size: 15px;
font-weight: 600; cursor: pointer; background: #38bdf8; color: #0f172a; }}
button:hover {{ background: #7dd3fc; }}
.error {{ background: #7f1d1d; color: #fca5a5; padding: 10px 14px; border-radius: 8px;
margin-bottom: 16px; font-size: 13px; }}
.lock {{ font-size: 32px; margin-bottom: 16px; }}
</style>
</head>
<body>
<div class="card">
<div class="lock">&#128274;</div>
<h1>MCP Gateway</h1>
<p class="subtitle">Enter your gateway password to continue</p>
{error_html}
<form method="POST" action="/gui-login">
<input type="hidden" name="next" value="{next_url}" />
<label for="password">Password</label>
<input type="password" name="password" id="password" placeholder="Gateway password" autofocus />
<button type="submit">Sign In</button>
</form>
</div>
</body>
</html>"""
def _hash(value: str) -> str: def _hash(value: str) -> str:
return hashlib.sha256(value.encode()).hexdigest() 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 = '<div class="error">Incorrect password. Please try again.</div>'
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 # Health / Status
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -827,8 +940,7 @@ async def probe_backend(name: str, url: str) -> dict:
async def dashboard_status(request: Request) -> JSONResponse: async def dashboard_status(request: Request) -> JSONResponse:
"""Auth-protected endpoint — live-probes all backends.""" """Auth-protected endpoint — live-probes all backends."""
token_info = validate_bearer_token(request) if not _validate_gui_session(request) and not validate_bearer_token(request):
if not token_info:
return JSONResponse({"error": "unauthorized"}, status_code=401) return JSONResponse({"error": "unauthorized"}, status_code=401)
probes = await asyncio.gather( probes = await asyncio.gather(
*[probe_backend(name, url) for name, url in BACKENDS.items()], *[probe_backend(name, url) for name, url in BACKENDS.items()],
@ -982,14 +1094,8 @@ DASHBOARD_HTML = """<!DOCTYPE html>
async def dashboard(request: Request) -> Response: async def dashboard(request: Request) -> Response:
token_info = validate_bearer_token(request) if not _validate_gui_session(request):
if not token_info: return RedirectResponse(f"/gui-login?next=/dashboard", status_code=303)
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"'},
)
return Response(DASHBOARD_HTML, media_type="text/html") return Response(DASHBOARD_HTML, media_type="text/html")
@ -1080,6 +1186,9 @@ routes = [
Route("/status", status, methods=["GET"]), Route("/status", status, methods=["GET"]),
# Dashboard (no auth required) # 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", dashboard, methods=["GET"]),
Route("/dashboard/status", dashboard_status, methods=["GET"]), Route("/dashboard/status", dashboard_status, methods=["GET"]),

View file

@ -5,31 +5,8 @@ Provides a comprehensive web interface for managing users and API keys.
Vue 3 compatible. Vue 3 compatible.
""" """
import os
import time
import hashlib
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse from starlette.responses import HTMLResponse, RedirectResponse
_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)
USER_DASHBOARD_HTML = """ USER_DASHBOARD_HTML = """
@ -422,6 +399,8 @@ createApp({
""" """
async def user_management_dashboard(request: Request): async def user_management_dashboard(request: Request):
"""Serve the user management dashboard. Requires Bearer token auth.""" """Serve the user management dashboard. Requires GUI session cookie."""
if (err := _admin_require_auth(request)): return err 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) return HTMLResponse(USER_DASHBOARD_HTML)