diff --git a/docker-compose.yml b/docker-compose.yml index c4fdbd1..ee22c1b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,13 +28,10 @@ services: - ERP_DB_NAME=${ERP_DB_NAME:-smart_quotation_auto} # 对外地址(用于 OAuth 资源元数据),按实际部署机 IP 调整 - MCP_PUBLIC_URL=${MCP_PUBLIC_URL_ERP:-http://192.168.1.119:8001} - # 鉴权库(mcp_auth),DbTokenVerifier 查 mcp_token 表校验 - - MCP_AUTH_DB_HOST=${MCP_AUTH_DB_HOST:-192.168.1.206} - - MCP_AUTH_DB_PORT=${MCP_AUTH_DB_PORT:-5432} - - MCP_AUTH_DB_USER=${MCP_AUTH_DB_USER:-postgres} - - MCP_AUTH_DB_PASSWORD=${MCP_AUTH_DB_PASSWORD:-digiwin} - - MCP_AUTH_DB_NAME=${MCP_AUTH_DB_NAME:-mcp_auth} - - MCP_AUTH_SERVICE=erp + # 鉴权(调用 mcp-auth 后端 API 校验,不直连鉴权库) + # per-service API Key:由 mcp-auth 后端签发,各服务独立 + - MCP_AUTH_API_URL=${MCP_AUTH_API_URL:-http://192.168.1.119:8000} + - MCP_AUTH_API_KEY=${ERP_MCP_AUTH_API_KEY:-change-me-erp-key} restart: unless-stopped mcp-for-crm: @@ -54,13 +51,10 @@ services: - CRM_DB_NAME=${CRM_DB_NAME:-smart_quotation_auto} # 对外地址(用于 OAuth 资源元数据),按实际部署机 IP 调整 - MCP_PUBLIC_URL=${MCP_PUBLIC_URL_CRM:-http://192.168.1.119:8002} - # 鉴权库(mcp_auth),DbTokenVerifier 查 mcp_token 表校验 - - MCP_AUTH_DB_HOST=${MCP_AUTH_DB_HOST:-192.168.1.206} - - MCP_AUTH_DB_PORT=${MCP_AUTH_DB_PORT:-5432} - - MCP_AUTH_DB_USER=${MCP_AUTH_DB_USER:-postgres} - - MCP_AUTH_DB_PASSWORD=${MCP_AUTH_DB_PASSWORD:-digiwin} - - MCP_AUTH_DB_NAME=${MCP_AUTH_DB_NAME:-mcp_auth} - - MCP_AUTH_SERVICE=crm + # 鉴权(调用 mcp-auth 后端 API 校验,不直连鉴权库) + # per-service API Key:由 mcp-auth 后端签发,各服务独立 + - MCP_AUTH_API_URL=${MCP_AUTH_API_URL:-http://192.168.1.119:8000} + - MCP_AUTH_API_KEY=${CRM_MCP_AUTH_API_KEY:-change-me-crm-key} restart: unless-stopped # MCP Auth Admin — Bearer Token 动态鉴权管理后台(FastAPI + React) diff --git a/mcp-auth/backend/app/main.py b/mcp-auth/backend/app/main.py index b0ffec5..175f07a 100644 --- a/mcp-auth/backend/app/main.py +++ b/mcp-auth/backend/app/main.py @@ -15,7 +15,7 @@ from fastapi.staticfiles import StaticFiles from app.core import security from app.core.config import settings from app.core.db import close_pool, get_pool -from app.routers import auth, stats, tokens +from app.routers import auth, services, stats, tokens, verify async def _ensure_default_admin() -> None: @@ -57,7 +57,9 @@ app.add_middleware( # API 路由 app.include_router(auth.router) app.include_router(tokens.router) +app.include_router(services.router) app.include_router(stats.router) +app.include_router(verify.router) @app.get("/api/health") diff --git a/mcp-auth/backend/app/routers/services.py b/mcp-auth/backend/app/routers/services.py new file mode 100644 index 0000000..b9c3fb5 --- /dev/null +++ b/mcp-auth/backend/app/routers/services.py @@ -0,0 +1,119 @@ +"""services 路由:MCP 服务注册 + per-service API Key 管理""" + +import hashlib +import secrets + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel + +from ..core.db import get_pool +from ..core.deps import current_admin + +router = APIRouter(prefix="/api/services", tags=["services"]) + + +class ServiceCreate(BaseModel): + service_name: str # erp / crm / ... + description: str | None = None + + +def _row_to_dict(row) -> dict: + return { + "service_id": row["service_id"], + "service_name": row["service_name"], + "api_key_prefix": row["api_key_prefix"], + "description": row["description"], + "status": row["status"], + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + "created_by": row["created_by"], + "revoked_at": row["revoked_at"].isoformat() if row["revoked_at"] else None, + "last_used_at": row["last_used_at"].isoformat() if row["last_used_at"] else None, + } + + +@router.get("") +async def list_services( + status_filter: str | None = Query(None, alias="status"), + admin: dict = Depends(current_admin), +): + pool = await get_pool() + query = "SELECT * FROM mcp_service WHERE 1=1" + params: list = [] + if status_filter: + query += f" AND status = ${len(params)+1}" + params.append(status_filter) + query += " ORDER BY service_id DESC" + rows = await pool.fetch(query, *params) + return {"total": len(rows), "services": [_row_to_dict(r) for r in rows]} + + +@router.post("", status_code=status.HTTP_201_CREATED) +async def register_service(req: ServiceCreate, admin: dict = Depends(current_admin)): + pool = await get_pool() + + # 检查是否已存在同名 active 服务 + existing = await pool.fetchrow( + "SELECT service_id FROM mcp_service WHERE service_name = $1 AND status = 'active'", + req.service_name, + ) + if existing: + raise HTTPException(400, f"服务 {req.service_name} 已存在且处于 active 状态") + + # 生成 API Key(明文仅此一次返回) + plain = secrets.token_urlsafe(32) + api_key_hash = hashlib.sha256(plain.encode()).hexdigest() + api_key_prefix = plain[:12] + "…" + + row = await pool.fetchrow( + """INSERT INTO mcp_service (service_name, api_key_hash, api_key_prefix, description, created_by) + VALUES ($1, $2, $3, $4, $5) + RETURNING service_id, service_name, api_key_prefix, description, created_at, created_by""", + req.service_name, api_key_hash, api_key_prefix, req.description, admin.get("username", "admin"), + ) + + return { + "api_key": plain, # 明文仅此一次 + "service_id": row["service_id"], + "service_name": row["service_name"], + "api_key_prefix": row["api_key_prefix"], + "message": "请立即保存此 API Key,之后无法再次查看。配置到 MCP 服务的 MCP_AUTH_API_KEY 环境变量", + } + + +@router.patch("/{service_id}/revoke") +async def revoke_service( + service_id: int, + admin: dict = Depends(current_admin), +): + pool = await get_pool() + row = await pool.fetchrow( + "SELECT service_id, status FROM mcp_service WHERE service_id = $1", service_id + ) + if row is None: + raise HTTPException(404, "服务不存在") + if row["status"] == "revoked": + raise HTTPException(400, "服务已吊销") + + await pool.execute( + "UPDATE mcp_service SET status = 'revoked', revoked_at = NOW() WHERE service_id = $1", + service_id, + ) + return {"success": True, "service_id": service_id, "status": "revoked"} + + +@router.delete("/{service_id}") +async def delete_service( + service_id: int, + admin: dict = Depends(current_admin), +): + pool = await get_pool() + row = await pool.fetchrow( + "SELECT service_id, status FROM mcp_service WHERE service_id = $1", service_id + ) + if row is None: + raise HTTPException(404, "服务不存在") + if row["status"] != "revoked": + raise HTTPException(400, "仅允许删除已吊销的服务,请先调用吊销端点") + + await pool.execute("DELETE FROM mcp_service WHERE service_id = $1", service_id) + return {"success": True, "service_id": service_id} diff --git a/mcp-auth/backend/app/routers/tokens.py b/mcp-auth/backend/app/routers/tokens.py index 98e81e9..497b21c 100644 --- a/mcp-auth/backend/app/routers/tokens.py +++ b/mcp-auth/backend/app/routers/tokens.py @@ -13,8 +13,6 @@ from ..core.deps import current_admin router = APIRouter(prefix="/api/tokens", tags=["tokens"]) -VALID_SCOPES = ("erp", "crm", "both") - class TokenCreate(BaseModel): client_id: str @@ -76,15 +74,22 @@ async def list_tokens( @router.post("", status_code=status.HTTP_201_CREATED) async def create_token(req: TokenCreate, admin: dict = Depends(current_admin)): - if req.service_scope not in VALID_SCOPES: - raise HTTPException(400, f"service_scope 必须是 {VALID_SCOPES} 之一") + pool = await get_pool() + + # 动态校验 service_scope:both 始终有效,其他值必须是已注册的 active 服务 + if req.service_scope != "both": + exists = await pool.fetchval( + "SELECT 1 FROM mcp_service WHERE service_name = $1 AND status = 'active'", + req.service_scope, + ) + if not exists: + raise HTTPException(400, f"service_scope '{req.service_scope}' 不是已注册的活跃服务") # 生成明文 token:仅此一次返回 plain = secrets.token_urlsafe(32) token_hash = hashlib.sha256(plain.encode()).hexdigest() token_prefix = plain[:12] + "…" - pool = await get_pool() row = await pool.fetchrow( """INSERT INTO mcp_token (token_hash, token_prefix, client_id, service_scope, status, expires_at, description, created_by) @@ -141,12 +146,13 @@ async def update_token( return {"success": True, "token_id": token_id} -@router.delete("/{token_id}") +@router.put("/{token_id}/revoke") async def revoke_token( token_id: int, req: RevokeReq = RevokeReq(), admin: dict = Depends(current_admin), ): + """吊销 token:软删除,status 改为 revoked。吊销后才可删除。""" pool = await get_pool() row = await pool.fetchrow( "SELECT token_id, status FROM mcp_token WHERE token_id = $1", token_id @@ -167,6 +173,26 @@ async def revoke_token( return {"success": True, "token_id": token_id, "status": "revoked"} +@router.delete("/{token_id}") +async def delete_token( + token_id: int, + admin: dict = Depends(current_admin), +): + """删除 token:物理删除,仅允许删除已吊销的 token。""" + pool = await get_pool() + row = await pool.fetchrow( + "SELECT token_id, status FROM mcp_token WHERE token_id = $1", token_id + ) + if row is None: + raise HTTPException(404, "token 不存在") + if row["status"] != "revoked": + raise HTTPException(400, "仅可删除已吊销的 token,请先吊销") + + await pool.execute("DELETE FROM mcp_token_log WHERE token_id = $1", token_id) + await pool.execute("DELETE FROM mcp_token WHERE token_id = $1", token_id) + return {"success": True, "token_id": token_id} + + @router.get("/{token_id}/logs") async def token_logs(token_id: int, limit: int = 50, admin: dict = Depends(current_admin)): pool = await get_pool() diff --git a/mcp-auth/backend/app/routers/verify.py b/mcp-auth/backend/app/routers/verify.py new file mode 100644 index 0000000..f1f3569 --- /dev/null +++ b/mcp-auth/backend/app/routers/verify.py @@ -0,0 +1,104 @@ +"""内部 token 校验 API — 供 MCP 服务调用(不查库直连,走 HTTP)。 + +MCP 服务(ERP/CRM)通过此端点校验 Bearer Token,后端统一查 mcp_auth.mcp_token 表。 +用 per-service API Key(X-API-Key)鉴权,从 key 识别调用方服务,无需 MCP 服务自报 service。 +""" + +import asyncio +import hashlib +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, Header, HTTPException, status +from pydantic import BaseModel + +from ..core.db import get_pool + +router = APIRouter(prefix="/api/auth", tags=["verify"]) + + +class VerifyReq(BaseModel): + token: str # 明文 Bearer Token + + +class VerifyResp(BaseModel): + valid: bool + client_id: str | None = None + service_scope: str | None = None + + +async def _resolve_service(x_api_key: str | None = Header(None, alias="X-API-Key")) -> str: + """校验 per-service API Key,返回 service_name;不匹配则 401。 + + 用 sha256 比对 mcp_service 表,避免明文存储。同时更新 last_used_at。 + """ + if not x_api_key: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "missing X-API-Key") + api_key_hash = hashlib.sha256(x_api_key.encode()).hexdigest() + pool = await get_pool() + row = await pool.fetchrow( + "SELECT service_id, service_name, status FROM mcp_service WHERE api_key_hash = $1", + api_key_hash, + ) + if row is None or row["status"] != "active": + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid or revoked api key") + + # 异步更新 last_used_at,不阻塞响应 + asyncio.create_task(_touch_service(pool, row["service_id"])) + return row["service_name"] + + +@router.post("/verify-token", response_model=VerifyResp) +async def verify_token(req: VerifyReq, service: str = Depends(_resolve_service)): + """校验 Bearer Token:sha256 比对 + 状态/过期/服务范围检查 + 更新 last_used。 + + service 由 X-API-Key 自动识别,MCP 服务无需在 body 中传 service。 + 返回 valid=true 时附带 client_id 和 service_scope;校验失败返回 valid=false(非 401)。 + """ + token_hash = hashlib.sha256(req.token.encode()).hexdigest() + pool = await get_pool() + row = await pool.fetchrow( + """SELECT token_id, client_id, status, expires_at, service_scope + FROM mcp_token WHERE token_hash = $1""", + token_hash, + ) + + # 不存在 + if row is None: + return VerifyResp(valid=False) + + # 已吊销 + if row["status"] != "active": + return VerifyResp(valid=False) + + # 已过期 + if row["expires_at"] is not None and row["expires_at"].timestamp() < datetime.now(timezone.utc).timestamp(): + return VerifyResp(valid=False) + + # 服务范围校验:both 放行所有;否则要求精确匹配(由 API Key 识别的 service) + scope = row["service_scope"] + if scope != "both" and scope != service: + return VerifyResp(valid=False) + + # 异步更新 last_used_at / last_used_svc + asyncio.create_task(_touch_token(pool, row["token_id"], service)) + + return VerifyResp(valid=True, client_id=row["client_id"], service_scope=scope) + + +async def _touch_service(pool, service_id: int) -> None: + try: + await pool.execute( + "UPDATE mcp_service SET last_used_at = now() WHERE service_id = $1", service_id + ) + except Exception: + pass + + +async def _touch_token(pool, token_id: int, service: str) -> None: + try: + await pool.execute( + "UPDATE mcp_token SET last_used_at = now(), last_used_svc = $2 WHERE token_id = $1", + token_id, service, + ) + except Exception: + pass diff --git a/mcp-auth/frontend/src/App.tsx b/mcp-auth/frontend/src/App.tsx index eb2f78f..7acbaa6 100644 --- a/mcp-auth/frontend/src/App.tsx +++ b/mcp-auth/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { Layout, Menu, theme } from 'antd'; import { useState } from 'react'; import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; import Login from './pages/Login'; +import Services from './pages/Services'; import Stats from './pages/Stats'; import Tokens from './pages/Tokens'; const { Header, Content, Sider } = Layout; @@ -35,6 +36,7 @@ function AppLayout() { selectedKeys={[loc.pathname]} items={[ { key: '/tokens', label: 'Token 管理' }, + { key: '/services', label: '服务管理' }, { key: '/stats', label: '统计概览' }, ]} onClick={({ key }) => nav(key)} @@ -74,6 +76,7 @@ function AppLayout() { > } /> + } /> } /> } /> diff --git a/mcp-auth/frontend/src/api/index.ts b/mcp-auth/frontend/src/api/index.ts index 4cabe3b..62d865e 100644 --- a/mcp-auth/frontend/src/api/index.ts +++ b/mcp-auth/frontend/src/api/index.ts @@ -52,7 +52,12 @@ export async function createToken(payload: TokenCreate) { } export async function revokeToken(token_id: number, reason?: string) { - const { data } = await api.delete(`/tokens/${token_id}`, { data: { reason } }); + const { data } = await api.put(`/tokens/${token_id}/revoke`, { reason }); + return data; +} + +export async function deleteToken(token_id: number) { + const { data } = await api.delete(`/tokens/${token_id}`); return data; } @@ -96,3 +101,50 @@ export async function getStats() { }>; }; } + +// ==================== MCP 服务管理 ==================== + +export interface ServiceRow { + service_id: number; + service_name: string; + api_key_prefix: string; + description: string | null; + status: string; + created_at: string | null; + created_by: string; + revoked_at: string | null; + last_used_at: string | null; +} + +export interface ServiceCreate { + service_name: string; + description?: string; +} + +export interface ServiceCreateResult { + api_key: string; + service_id: number; + service_name: string; + api_key_prefix: string; + message: string; +} + +export async function listServices(params?: { status?: string }) { + const { data } = await api.get('/services', { params }); + return data as { total: number; services: ServiceRow[] }; +} + +export async function registerService(payload: ServiceCreate) { + const { data } = await api.post('/services', payload); + return data as ServiceCreateResult; +} + +export async function revokeService(service_id: number) { + const { data } = await api.patch(`/services/${service_id}/revoke`); + return data; +} + +export async function deleteService(service_id: number) { + const { data } = await api.delete(`/services/${service_id}`); + return data; +} diff --git a/mcp-auth/frontend/src/pages/Services/index.tsx b/mcp-auth/frontend/src/pages/Services/index.tsx new file mode 100644 index 0000000..b94f6ef --- /dev/null +++ b/mcp-auth/frontend/src/pages/Services/index.tsx @@ -0,0 +1,206 @@ +import { useEffect, useState } from 'react'; +import { + Button, + Form, + Input, + Modal, + Space, + Table, + Tag, + Typography, + message, +} from 'antd'; +import { PlusOutlined, ReloadOutlined } from '@ant-design/icons'; +import dayjs from 'dayjs'; +import type { ColumnsType } from 'antd/es/table'; +import { + listServices, + registerService, + revokeService, + deleteService, + type ServiceCreateResult, + type ServiceRow, +} from '../../api'; + +const STATUS_COLOR: Record = { + active: 'green', + revoked: 'red', +}; + +export default function Services() { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + const [created, setCreated] = useState(null); + const [form] = Form.useForm(); + + const load = async () => { + setLoading(true); + try { + const res = await listServices(); + setRows(res.services); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const onRevoke = async (id: number) => { + Modal.confirm({ + title: '吊销此服务的 API Key?', + content: '吊销后该 MCP 服务将无法调用鉴权 API。吊销后可执行删除操作。', + okText: '吊销', + okType: 'danger', + cancelText: '取消', + onOk: async () => { + await revokeService(id); + message.success('已吊销'); + load(); + }, + }); + }; + + const onDelete = async (id: number) => { + Modal.confirm({ + title: '删除此服务?', + content: '删除后记录将永久移除,无法恢复。', + okText: '删除', + okType: 'danger', + cancelText: '取消', + onOk: async () => { + await deleteService(id); + message.success('已删除'); + load(); + }, + }); + }; + + const onCreate = async () => { + const values = await form.validateFields(); + const res = await registerService({ + service_name: values.service_name, + description: values.description, + }); + setCreated(res); + setCreateOpen(false); + form.resetFields(); + load(); + }; + + const columns: ColumnsType = [ + { title: '服务名', dataIndex: 'service_name', key: 'service_name', width: 120 }, + { title: 'Key 前缀', dataIndex: 'api_key_prefix', key: 'api_key_prefix', width: 140 }, + { title: '说明', dataIndex: 'description', key: 'description' }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 90, + render: (s: string) => {s}, + }, + { + title: '创建时间', + dataIndex: 'created_at', + key: 'created_at', + width: 160, + render: (t: string) => (t ? dayjs(t).format('YYYY-MM-DD HH:mm') : '-'), + }, + { + title: '最近调用', + dataIndex: 'last_used_at', + key: 'last_used_at', + width: 160, + render: (t: string) => (t ? dayjs(t).format('MM-DD HH:mm') : '-'), + }, + { + title: '操作', + key: 'action', + width: 140, + render: (_, row: ServiceRow) => + row.status === 'active' ? ( + + + + + ) : row.status === 'revoked' ? ( + + ) : ( + - + ), + }, + ]; + + return ( +
+ + + + + + + + {/* 注册服务表单 */} + setCreateOpen(false)} + okText="注册" + cancelText="取消" + > +
+ + + + + + + +
+ + {/* 注册结果:明文 API Key 仅此一次 */} + setCreated(null)} + footer={} + > + + 请立即复制并保存,此 API Key 仅显示一次,之后无法再次查看。 + + + + 配置到 MCP 服务的 MCP_AUTH_API_KEY 环境变量。 + + + + ); +} diff --git a/mcp-auth/frontend/src/pages/Tokens/index.tsx b/mcp-auth/frontend/src/pages/Tokens/index.tsx index e74aab5..14d7bff 100644 --- a/mcp-auth/frontend/src/pages/Tokens/index.tsx +++ b/mcp-auth/frontend/src/pages/Tokens/index.tsx @@ -17,9 +17,12 @@ import type { ColumnsType } from 'antd/es/table'; import { createToken, listTokens, + listServices, revokeToken, + deleteToken, type CreateResult, type TokenRow, + type ServiceRow, } from '../../api'; const STATUS_COLOR: Record = { @@ -33,6 +36,7 @@ export default function Tokens() { const [loading, setLoading] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [created, setCreated] = useState(null); + const [services, setServices] = useState([]); const [form] = Form.useForm(); const load = async () => { @@ -45,14 +49,24 @@ export default function Tokens() { } }; + const loadServices = async () => { + try { + const res = await listServices({ status: 'active' }); + setServices(res.services); + } catch { + // 服务列表加载失败不影响 token 页面使用 + } + }; + useEffect(() => { load(); + loadServices(); }, []); const onRevoke = async (id: number) => { Modal.confirm({ title: '吊销此 Token?', - content: '吊销后最长 30s 缓存过期生效,且无法恢复。', + content: '吊销后该 Token 立即失效。吊销后可执行删除操作。', okText: '吊销', okType: 'danger', cancelText: '取消', @@ -64,6 +78,21 @@ export default function Tokens() { }); }; + const onDelete = async (id: number) => { + Modal.confirm({ + title: '删除此 Token?', + content: '删除后记录将永久移除,无法恢复。', + okText: '删除', + okType: 'danger', + cancelText: '取消', + onOk: async () => { + await deleteToken(id); + message.success('已删除'); + load(); + }, + }); + }; + const onCreate = async () => { const values = await form.validateFields(); const res = await createToken({ @@ -114,11 +143,20 @@ export default function Tokens() { { title: '操作', key: 'action', - width: 100, + width: 140, render: (_, row: TokenRow) => row.status === 'active' ? ( - + + + ) : row.status === 'revoked' ? ( + ) : ( - @@ -129,7 +167,7 @@ export default function Tokens() { return (
-