security: require auth on all admin/dashboard/user routes
/admin, /dashboard, /dashboard/status, and all /users/* and /keys/* endpoints were publicly accessible with no authentication, exposing user management, API key generation, and backend topology to anyone. - /dashboard and /dashboard/status now require Bearer token - /admin (user management UI) now requires Bearer token - All /users/* and /keys/revoke routes now require Bearer token - /health scrubbed of sensitive fields (token counts, client counts) - /linkedin/* left public (required for OAuth callback flow) Auth checks use GATEWAY_STATIC_API_KEY or valid OAuth access tokens, consistent with the existing /mcp and /status endpoints.
This commit is contained in:
parent
c387d80d1b
commit
a1a6ef137a
3 changed files with 76 additions and 8 deletions
|
|
@ -753,11 +753,7 @@ async def handle_mcp(request: Request) -> Response:
|
||||||
async def health(request: Request) -> JSONResponse:
|
async def health(request: Request) -> JSONResponse:
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"backends": len(BACKENDS),
|
|
||||||
"tools": len(TOOL_DEFINITIONS),
|
|
||||||
"oauth": "enabled",
|
"oauth": "enabled",
|
||||||
"active_tokens": len(ACCESS_TOKENS),
|
|
||||||
"registered_clients": len(REGISTERED_CLIENTS),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -830,7 +826,10 @@ async def probe_backend(name: str, url: str) -> dict:
|
||||||
|
|
||||||
|
|
||||||
async def dashboard_status(request: Request) -> JSONResponse:
|
async def dashboard_status(request: Request) -> JSONResponse:
|
||||||
"""Public endpoint — no auth required — live-probes all backends."""
|
"""Auth-protected endpoint — live-probes all backends."""
|
||||||
|
token_info = validate_bearer_token(request)
|
||||||
|
if not token_info:
|
||||||
|
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()],
|
||||||
return_exceptions=True
|
return_exceptions=True
|
||||||
|
|
@ -983,6 +982,14 @@ DASHBOARD_HTML = """<!DOCTYPE html>
|
||||||
|
|
||||||
|
|
||||||
async def dashboard(request: Request) -> Response:
|
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"'},
|
||||||
|
)
|
||||||
return Response(DASHBOARD_HTML, media_type="text/html")
|
return Response(DASHBOARD_HTML, media_type="text/html")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,31 @@ Provides a comprehensive web interface for managing users and API keys.
|
||||||
Vue 3 compatible.
|
Vue 3 compatible.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from starlette.responses import HTMLResponse
|
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)
|
||||||
|
|
||||||
|
|
||||||
USER_DASHBOARD_HTML = """
|
USER_DASHBOARD_HTML = """
|
||||||
|
|
@ -397,6 +421,7 @@ createApp({
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async def user_management_dashboard(request):
|
async def user_management_dashboard(request: Request):
|
||||||
"""Serve the user management dashboard."""
|
"""Serve the user management dashboard. Requires Bearer token auth."""
|
||||||
|
if (err := _admin_require_auth(request)): return err
|
||||||
return HTMLResponse(USER_DASHBOARD_HTML)
|
return HTMLResponse(USER_DASHBOARD_HTML)
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,45 @@
|
||||||
User Management Routes for MCP Gateway
|
User Management Routes for MCP Gateway
|
||||||
======================================
|
======================================
|
||||||
REST endpoints for user, API key, and MCP access control management.
|
REST endpoints for user, API key, and MCP access control management.
|
||||||
|
All routes require a valid Bearer token (static API key or OAuth token).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse
|
||||||
from user_management import user_manager
|
from user_management import user_manager
|
||||||
|
|
||||||
|
STATIC_API_KEY = os.environ.get("GATEWAY_STATIC_API_KEY", "")
|
||||||
|
|
||||||
|
def _hash(token: str) -> str:
|
||||||
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
def _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
|
||||||
|
# Import live token store from gateway_proxy to validate OAuth tokens
|
||||||
|
try:
|
||||||
|
from gateway_proxy import ACCESS_TOKENS
|
||||||
|
token_hash = _hash(token)
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
async def create_user(request: Request) -> JSONResponse:
|
async def create_user(request: Request) -> JSONResponse:
|
||||||
"""POST /users — Create a new user."""
|
"""POST /users — Create a new user."""
|
||||||
|
if (err := _require_auth(request)): return err
|
||||||
try:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
username = body.get('username', '').strip()
|
username = body.get('username', '').strip()
|
||||||
|
|
@ -40,6 +69,7 @@ async def create_user(request: Request) -> JSONResponse:
|
||||||
|
|
||||||
async def list_users(request: Request) -> JSONResponse:
|
async def list_users(request: Request) -> JSONResponse:
|
||||||
"""GET /users — List all users."""
|
"""GET /users — List all users."""
|
||||||
|
if (err := _require_auth(request)): return err
|
||||||
try:
|
try:
|
||||||
users = user_manager.list_users()
|
users = user_manager.list_users()
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
|
|
@ -53,6 +83,7 @@ async def list_users(request: Request) -> JSONResponse:
|
||||||
|
|
||||||
async def get_user(request: Request) -> JSONResponse:
|
async def get_user(request: Request) -> JSONResponse:
|
||||||
"""GET /users/{username} — Get user details."""
|
"""GET /users/{username} — Get user details."""
|
||||||
|
if (err := _require_auth(request)): return err
|
||||||
try:
|
try:
|
||||||
username = request.path_params.get('username')
|
username = request.path_params.get('username')
|
||||||
users = {u['username']: u for u in user_manager.list_users()}
|
users = {u['username']: u for u in user_manager.list_users()}
|
||||||
|
|
@ -75,6 +106,7 @@ async def get_user(request: Request) -> JSONResponse:
|
||||||
|
|
||||||
async def delete_user(request: Request) -> JSONResponse:
|
async def delete_user(request: Request) -> JSONResponse:
|
||||||
"""DELETE /users/{username} — Delete a user."""
|
"""DELETE /users/{username} — Delete a user."""
|
||||||
|
if (err := _require_auth(request)): return err
|
||||||
try:
|
try:
|
||||||
username = request.path_params.get('username')
|
username = request.path_params.get('username')
|
||||||
user_manager.delete_user(username)
|
user_manager.delete_user(username)
|
||||||
|
|
@ -88,6 +120,7 @@ async def delete_user(request: Request) -> JSONResponse:
|
||||||
|
|
||||||
async def toggle_user(request: Request) -> JSONResponse:
|
async def toggle_user(request: Request) -> JSONResponse:
|
||||||
"""PATCH /users/{username}/enable — Enable/disable a user."""
|
"""PATCH /users/{username}/enable — Enable/disable a user."""
|
||||||
|
if (err := _require_auth(request)): return err
|
||||||
try:
|
try:
|
||||||
username = request.path_params.get('username')
|
username = request.path_params.get('username')
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
|
|
@ -109,6 +142,7 @@ async def toggle_user(request: Request) -> JSONResponse:
|
||||||
|
|
||||||
async def generate_api_key(request: Request) -> JSONResponse:
|
async def generate_api_key(request: Request) -> JSONResponse:
|
||||||
"""POST /users/{username}/keys — Generate a new API key."""
|
"""POST /users/{username}/keys — Generate a new API key."""
|
||||||
|
if (err := _require_auth(request)): return err
|
||||||
try:
|
try:
|
||||||
username = request.path_params.get('username')
|
username = request.path_params.get('username')
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
|
|
@ -132,6 +166,7 @@ async def generate_api_key(request: Request) -> JSONResponse:
|
||||||
|
|
||||||
async def revoke_api_key(request: Request) -> JSONResponse:
|
async def revoke_api_key(request: Request) -> JSONResponse:
|
||||||
"""DELETE /keys/{key_hash} — Revoke an API key."""
|
"""DELETE /keys/{key_hash} — Revoke an API key."""
|
||||||
|
if (err := _require_auth(request)): return err
|
||||||
try:
|
try:
|
||||||
key_hash = request.path_params.get('key_hash')
|
key_hash = request.path_params.get('key_hash')
|
||||||
# In practice, we'd need to reconstruct the hash or pass the full key
|
# In practice, we'd need to reconstruct the hash or pass the full key
|
||||||
|
|
@ -155,6 +190,7 @@ async def revoke_api_key(request: Request) -> JSONResponse:
|
||||||
|
|
||||||
async def set_mcp_access(request: Request) -> JSONResponse:
|
async def set_mcp_access(request: Request) -> JSONResponse:
|
||||||
"""PUT /users/{username}/mcp-access — Configure MCP access for a user."""
|
"""PUT /users/{username}/mcp-access — Configure MCP access for a user."""
|
||||||
|
if (err := _require_auth(request)): return err
|
||||||
try:
|
try:
|
||||||
username = request.path_params.get('username')
|
username = request.path_params.get('username')
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue