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:
parent
72add79a87
commit
a4fa9e75a2
2 changed files with 124 additions and 36 deletions
|
|
@ -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 = """<!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">🔒</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:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -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 = """<!DOCTYPE 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"]),
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue