新增mcp_auth项目

This commit is contained in:
2026-08-31 11:02:07 +08:00
parent efe6fbe770
commit cbff2283e2
33 changed files with 4723 additions and 73 deletions
+17
View File
@@ -30,6 +30,23 @@ env/
ENV/ ENV/
.python-version .python-version
# ===== Node / 前端(React + Vite)=====
# 依赖(npm install 生成,不入库)
node_modules/
# 构建产物(Vite build 输出,dist/ 已被上方 Python 段覆盖,此处显式声明更清晰)
dist/
# Vite 缓存
.vite/
# npm/pnpm/yarn 锁文件按需保留;如需忽略锁文件取消下行注释
# package-lock.json
# pnpm-lock.yaml
# yarn.lock
# 前端环境变量(含 API 地址等,不入库)
.env.local
.env.*.local
# 编辑器本地配置
.eslintcache
# ===== 环境变量与密钥(含数据库密码等,切勿入库)===== # ===== 环境变量与密钥(含数据库密码等,切勿入库)=====
.env .env
.env.* .env.*
+35 -7
View File
@@ -1,9 +1,10 @@
# 汽车零部件智能报价 — 两个 MCP 服务编排 # 汽车零部件智能报价 — 两个 MCP 服务 + 鉴权管理后台 编排
# 生产环境:docker compose up -d # 生产环境:docker compose up -d
# 默认连生产库 192.168.1.206:5432,对外 192.168.1.119:8001/8002 # 默认连生产库 192.168.1.206:5432,对外 192.168.1.119:8001/8002/8000
# 8000 = mcp-auth-admin 管理后台(FastAPI + React)
# 开发环境:本机直接 python server.py(不入容器) # 开发环境:本机直接 python server.py(不入容器)
# source .env.dev 后到 mcp-for-{erp,crm}/src 执行 python server.py # source .env.dev 后到 mcp-for-{erp,crm}/src 执行 python server.py
# 连开发库 47.101.220.40:5432,对外 localhost:8001/8002 # 连开发库 47.101.220.40:5432,对外 localhost:8001/8002/8000
# 也可用 docker 跑开发:docker compose --env-file .env.dev up -d # 也可用 docker 跑开发:docker compose --env-file .env.dev up -d
# 停止:docker compose down # 停止:docker compose down
# 日志:docker compose logs -f # 日志:docker compose logs -f
@@ -27,8 +28,13 @@ services:
- ERP_DB_NAME=${ERP_DB_NAME:-smart_quotation_auto} - ERP_DB_NAME=${ERP_DB_NAME:-smart_quotation_auto}
# 对外地址(用于 OAuth 资源元数据),按实际部署机 IP 调整 # 对外地址(用于 OAuth 资源元数据),按实际部署机 IP 调整
- MCP_PUBLIC_URL=${MCP_PUBLIC_URL_ERP:-http://192.168.1.119:8001} - MCP_PUBLIC_URL=${MCP_PUBLIC_URL_ERP:-http://192.168.1.119:8001}
# Bearer Token 表:token:client_id 逗号分隔,每个客户端一个独立 token # 鉴权库(mcp_auth),DbTokenVerifier 查 mcp_token 表校验
- MCP_AUTH_TOKENS=${MCP_AUTH_TOKENS_ERP:-erp-token-trae-2026:trae,erp-token-partner-a:partner-a} - 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
restart: unless-stopped restart: unless-stopped
mcp-for-crm: mcp-for-crm:
@@ -48,6 +54,28 @@ services:
- CRM_DB_NAME=${CRM_DB_NAME:-smart_quotation_auto} - CRM_DB_NAME=${CRM_DB_NAME:-smart_quotation_auto}
# 对外地址(用于 OAuth 资源元数据),按实际部署机 IP 调整 # 对外地址(用于 OAuth 资源元数据),按实际部署机 IP 调整
- MCP_PUBLIC_URL=${MCP_PUBLIC_URL_CRM:-http://192.168.1.119:8002} - MCP_PUBLIC_URL=${MCP_PUBLIC_URL_CRM:-http://192.168.1.119:8002}
# Bearer Token 表:token:client_id 逗号分隔,每个客户端一个独立 token # 鉴权库(mcp_auth),DbTokenVerifier 查 mcp_token 表校验
- MCP_AUTH_TOKENS=${MCP_AUTH_TOKENS_CRM:-crm-token-trae-2026:trae,crm-token-partner-a:partner-a} - 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
restart: unless-stopped
# MCP Auth Admin — Bearer Token 动态鉴权管理后台(FastAPI + React)
mcp-auth-admin:
build:
context: ./mcp-auth
dockerfile: Dockerfile
image: mcp-auth-admin:latest
container_name: mcp-auth-admin
network_mode: host
environment:
- AUTH_DB_HOST=${AUTH_DB_HOST:-192.168.1.206}
- AUTH_DB_PORT=${AUTH_DB_PORT:-5432}
- AUTH_DB_USER=${AUTH_DB_USER:-postgres}
- AUTH_DB_PASSWORD=${AUTH_DB_PASSWORD:-digiwin}
- AUTH_DB_NAME=${AUTH_DB_NAME:-mcp_auth}
- JWT_SECRET=${JWT_SECRET:-change-me-in-prod}
restart: unless-stopped restart: unless-stopped
+31
View File
@@ -0,0 +1,31 @@
# syntax=docker/dockerfile:1
# MCP Auth Admin — 多阶段构建
# 阶段1:Node 构建 React 前端 → 阶段2:Python 运行 FastAPI + 托管静态文件
# ---- 阶段1:构建前端 ----
FROM node:20-alpine AS frontend-build
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# ---- 阶段2:Python 运行时 ----
FROM python:3.12-slim
WORKDIR /app
# 依赖单独成层,利用缓存
COPY backend/requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
# 后端源码
COPY backend/app ./app
# 前端构建产物
COPY --from=frontend-build /app/frontend/dist ./app/static
WORKDIR /app
EXPOSE 8000
# STATIC_DIR 指向后端 app/static(相对 backend 启动目录)
ENV STATIC_DIR=/app/app/static
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
View File
+21
View File
@@ -0,0 +1,21 @@
"""配置:环境变量读取"""
import os
class Settings:
AUTH_DB_HOST: str = os.getenv("AUTH_DB_HOST", "127.0.0.1")
AUTH_DB_PORT: int = int(os.getenv("AUTH_DB_PORT", "5432"))
AUTH_DB_USER: str = os.getenv("AUTH_DB_USER", "postgres")
AUTH_DB_PASSWORD: str = os.getenv("AUTH_DB_PASSWORD", "digiwin")
AUTH_DB_NAME: str = os.getenv("AUTH_DB_NAME", "mcp_auth")
JWT_SECRET: str = os.getenv("JWT_SECRET", "change-me-in-prod")
JWT_ALG: str = "HS256"
JWT_EXP_HOURS: int = int(os.getenv("JWT_EXP_HOURS", "12"))
# 前端静态文件目录(Docker 构建后注入)
STATIC_DIR: str = os.getenv("STATIC_DIR", "../frontend/dist")
settings = Settings()
+28
View File
@@ -0,0 +1,28 @@
"""鉴权库连接池"""
import asyncpg
from .config import settings
_pool: asyncpg.Pool | None = None
async def get_pool() -> asyncpg.Pool:
global _pool
if _pool is None:
_pool = await asyncpg.create_pool(
host=settings.AUTH_DB_HOST,
port=settings.AUTH_DB_PORT,
user=settings.AUTH_DB_USER,
password=settings.AUTH_DB_PASSWORD,
database=settings.AUTH_DB_NAME,
min_size=2,
max_size=10,
)
return _pool
async def close_pool() -> None:
global _pool
if _pool is not None:
await _pool.close()
_pool = None
+18
View File
@@ -0,0 +1,18 @@
"""FastAPI 依赖:JWT 校验,提取当前管理员"""
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from . import security
bearer_scheme = HTTPBearer(auto_error=False)
async def current_admin(
creds: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
) -> dict:
if creds is None or creds.scheme.lower() != "bearer":
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "未提供 JWT")
payload = security.decode_jwt(creds.credentials)
if payload is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "JWT 无效或已过期")
return payload
+31
View File
@@ -0,0 +1,31 @@
"""安全工具:bcrypt 密码校验 + JWT 签发/校验"""
from datetime import datetime, timedelta, timezone
import bcrypt
import jwt
from .config import settings
def hash_password(plain: str) -> str:
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt(12)).decode()
def verify_password(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode(), hashed.encode())
def create_jwt(sub: str, username: str) -> str:
payload = {
"sub": str(sub),
"username": username,
"exp": datetime.now(timezone.utc) + timedelta(hours=settings.JWT_EXP_HOURS),
}
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALG)
def decode_jwt(token: str) -> dict | None:
try:
return jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALG])
except jwt.PyJWTError:
return None
+71
View File
@@ -0,0 +1,71 @@
"""MCP Auth Admin — FastAPI 主入口
启动时自动建默认管理员(admin/admin123,仅当 admin_user 表为空时)。
静态文件由前端构建产物提供(STATIC_DIR 指向)。
"""
import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
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
async def _ensure_default_admin() -> None:
"""首次启动时建默认管理员 admin/admin123(仅当表为空)。"""
pool = await get_pool()
count = await pool.fetchval("SELECT COUNT(*) FROM admin_user")
if count == 0:
hashed = security.hash_password("admin123")
await pool.execute(
"INSERT INTO admin_user (username, password_hash, display_name) VALUES ($1, $2, $3)",
"admin", hashed, "默认管理员",
)
print("[初始化] 已创建默认管理员 admin/admin123,请尽快修改密码")
@asynccontextmanager
async def lifespan(app: FastAPI):
await _ensure_default_admin()
yield
await close_pool()
app = FastAPI(
title="MCP Auth Admin",
description="MCP 服务 Bearer Token 动态鉴权管理后台",
version="1.0.0",
lifespan=lifespan,
)
# CORS(开发期前端 dev server 跨域;生产同源可不配)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# API 路由
app.include_router(auth.router)
app.include_router(tokens.router)
app.include_router(stats.router)
@app.get("/api/health")
async def health():
return {"status": "ok"}
# 静态文件(前端构建产物),必须在所有 API 路由之后挂载
static_dir = Path(settings.STATIC_DIR)
if static_dir.exists():
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
+50
View File
@@ -0,0 +1,50 @@
"""auth 路由:管理员登录"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from ..core import security
from ..core.db import get_pool
from ..core.deps import current_admin
router = APIRouter(prefix="/api/admin", tags=["admin"])
class LoginReq(BaseModel):
username: str
password: str
class LoginResp(BaseModel):
token: str
username: str
display_name: str | None = None
@router.post("/login", response_model=LoginResp)
async def login(req: LoginReq):
pool = await get_pool()
row = await pool.fetchrow(
"SELECT user_id, username, password_hash, display_name FROM admin_user WHERE username = $1",
req.username,
)
if row is None or not security.verify_password(req.password, row["password_hash"]):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "用户名或密码错误")
await pool.execute(
"UPDATE admin_user SET last_login_at = now() WHERE user_id = $1", row["user_id"]
)
token = security.create_jwt(sub=row["user_id"], username=row["username"])
return LoginResp(
token=token,
username=row["username"],
display_name=row["display_name"],
)
@router.get("/me")
async def me(admin: dict = Depends(current_admin)):
return {"username": admin.get("username"), "sub": admin.get("sub")}
+59
View File
@@ -0,0 +1,59 @@
"""stats 路由:token 统计概览"""
from fastapi import APIRouter, Depends
from ..core.db import get_pool
from ..core.deps import current_admin
router = APIRouter(prefix="/api/stats", tags=["stats"])
@router.get("")
async def stats(admin: dict = Depends(current_admin)):
pool = await get_pool()
total = await pool.fetchval("SELECT COUNT(*) FROM mcp_token")
active = await pool.fetchval("SELECT COUNT(*) FROM mcp_token WHERE status = 'active'")
revoked = await pool.fetchval("SELECT COUNT(*) FROM mcp_token WHERE status = 'revoked'")
# 各 service_scope 活跃分布
scope_rows = await pool.fetch(
"SELECT service_scope, COUNT(*) AS cnt FROM mcp_token WHERE status = 'active' GROUP BY service_scope"
)
by_scope = {r["service_scope"]: r["cnt"] for r in scope_rows}
# 近 24h 调用次数(基于 last_used_at)
calls_24h = await pool.fetchval(
"SELECT COUNT(*) FROM mcp_token WHERE last_used_at > now() - interval '24 hours'"
)
# 近 24h 活跃客户端
clients_24h = await pool.fetchval(
"SELECT COUNT(DISTINCT client_id) FROM mcp_token WHERE last_used_at > now() - interval '24 hours'"
)
# 最近 10 条调用记录
recent_rows = await pool.fetch(
"""SELECT token_prefix, client_id, last_used_svc, last_used_at
FROM mcp_token WHERE last_used_at IS NOT NULL
ORDER BY last_used_at DESC LIMIT 10"""
)
recent = [
{
"token_prefix": r["token_prefix"],
"client_id": r["client_id"],
"service": r["last_used_svc"],
"last_used_at": r["last_used_at"].isoformat() if r["last_used_at"] else None,
}
for r in recent_rows
]
return {
"total": total,
"active": active,
"revoked": revoked,
"by_scope": by_scope,
"calls_24h": calls_24h,
"clients_24h": clients_24h,
"recent": recent,
}
+192
View File
@@ -0,0 +1,192 @@
"""tokens 路由:token CRUD + 签发 + 吊销 + 日志"""
import hashlib
import json
import secrets
from datetime import datetime, timezone
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/tokens", tags=["tokens"])
VALID_SCOPES = ("erp", "crm", "both")
class TokenCreate(BaseModel):
client_id: str
service_scope: str = "both"
description: str | None = None
expires_at: datetime | None = None # null = 永不过期
class TokenUpdate(BaseModel):
description: str | None = None
expires_at: datetime | None = None
class RevokeReq(BaseModel):
reason: str | None = None
def _row_to_dict(row) -> dict:
return {
"token_id": row["token_id"],
"token_prefix": row["token_prefix"],
"client_id": row["client_id"],
"service_scope": row["service_scope"],
"status": row["status"],
"expires_at": row["expires_at"].isoformat() if row["expires_at"] else None,
"description": row["description"],
"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,
"revoke_reason": row["revoke_reason"],
"last_used_at": row["last_used_at"].isoformat() if row["last_used_at"] else None,
"last_used_svc": row["last_used_svc"],
}
@router.get("")
async def list_tokens(
client_id: str | None = Query(None),
status: str | None = Query(None),
service_scope: str | None = Query(None),
admin: dict = Depends(current_admin),
):
pool = await get_pool()
query = "SELECT * FROM mcp_token WHERE 1=1"
params: list = []
if client_id:
query += f" AND client_id = ${len(params)+1}"
params.append(client_id)
if status:
query += f" AND status = ${len(params)+1}"
params.append(status)
if service_scope:
query += f" AND service_scope = ${len(params)+1}"
params.append(service_scope)
query += " ORDER BY token_id DESC"
rows = await pool.fetch(query, *params)
return {"total": len(rows), "tokens": [_row_to_dict(r) for r in rows]}
@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} 之一")
# 生成明文 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)
VALUES ($1, $2, $3, $4, 'active', $5, $6, $7)
RETURNING token_id, token_prefix, client_id, service_scope, status,
expires_at, description, created_at, created_by""",
token_hash, token_prefix, req.client_id, req.service_scope,
req.expires_at, req.description, admin.get("username", "admin"),
)
# 审计日志
await pool.execute(
"INSERT INTO mcp_token_log (token_id, event, detail) VALUES ($1, 'issued', $2)",
row["token_id"],
json.dumps({"client_id": req.client_id, "service_scope": req.service_scope}),
)
return {
"token": plain, # 明文仅此一次
"token_id": row["token_id"],
"token_prefix": row["token_prefix"],
"client_id": row["client_id"],
"service_scope": row["service_scope"],
"message": "请立即保存此 token,之后无法再次查看",
}
@router.patch("/{token_id}")
async def update_token(
token_id: int,
req: TokenUpdate,
admin: dict = Depends(current_admin),
):
pool = await get_pool()
existing = await pool.fetchrow("SELECT token_id FROM mcp_token WHERE token_id = $1", token_id)
if existing is None:
raise HTTPException(404, "token 不存在")
updates, params = [], []
if req.description is not None:
updates.append(f"description = ${len(params)+1}")
params.append(req.description)
if req.expires_at is not None:
updates.append(f"expires_at = ${len(params)+1}")
params.append(req.expires_at)
if not updates:
raise HTTPException(400, "无更新字段")
params.append(token_id)
await pool.execute(
f"UPDATE mcp_token SET {', '.join(updates)} WHERE token_id = ${len(params)}",
*params,
)
return {"success": True, "token_id": token_id}
@router.delete("/{token_id}")
async def revoke_token(
token_id: int,
req: RevokeReq = RevokeReq(),
admin: dict = Depends(current_admin),
):
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(
"UPDATE mcp_token SET status = 'revoked', revoked_at = now(), revoke_reason = $2 WHERE token_id = $1",
token_id, req.reason,
)
await pool.execute(
"INSERT INTO mcp_token_log (token_id, event, detail) VALUES ($1, 'revoked', $2)",
token_id, json.dumps({"reason": req.reason, "by": admin.get("username")}),
)
return {"success": True, "token_id": token_id, "status": "revoked"}
@router.get("/{token_id}/logs")
async def token_logs(token_id: int, limit: int = 50, admin: dict = Depends(current_admin)):
pool = await get_pool()
rows = await pool.fetch(
"""SELECT log_id, event, service, client_ip, occurred_at, detail
FROM mcp_token_log WHERE token_id = $1
ORDER BY occurred_at DESC LIMIT $2""",
token_id, limit,
)
return {
"total": len(rows),
"logs": [
{
"log_id": r["log_id"],
"event": r["event"],
"service": r["service"],
"client_ip": r["client_ip"],
"occurred_at": r["occurred_at"].isoformat() if r["occurred_at"] else None,
"detail": r["detail"],
}
for r in rows
],
}
+6
View File
@@ -0,0 +1,6 @@
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
asyncpg>=0.30.0
bcrypt>=4.2.0
pyjwt>=2.9.0
pydantic>=2.9.0
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MCP Token 管理后台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3121
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "mcp-auth-admin-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^5.5.0",
"antd": "^5.21.0",
"axios": "^1.7.0",
"dayjs": "^1.11.13",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.5.0",
"vite": "^5.4.0"
}
}
+101
View File
@@ -0,0 +1,101 @@
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 Stats from './pages/Stats';
import Tokens from './pages/Tokens';
const { Header, Content, Sider } = Layout;
function AppLayout() {
const nav = useNavigate();
const loc = useLocation();
const [collapsed, setCollapsed] = useState(false);
const {
token: { colorBgContainer },
} = theme.useToken();
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider collapsible collapsed={collapsed} onCollapse={setCollapsed}>
<div
style={{
height: 48,
margin: 12,
color: '#fff',
textAlign: 'center',
lineHeight: '48px',
fontWeight: 600,
}}
>
MCP Auth
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[loc.pathname]}
items={[
{ key: '/tokens', label: 'Token 管理' },
{ key: '/stats', label: '统计概览' },
]}
onClick={({ key }) => nav(key)}
/>
</Sider>
<Layout>
<Header
style={{
padding: '0 24px',
background: colorBgContainer,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span>MCP Token 鉴权管理后台</span>
<span style={{ fontSize: 13, color: '#999' }}>
{localStorage.getItem('username') || ''} ·{' '}
<a
onClick={() => {
localStorage.clear();
window.location.href = '/login';
}}
>
退出
</a>
</span>
</Header>
<Content style={{ margin: 24 }}>
<div
style={{
padding: 24,
minHeight: 480,
background: colorBgContainer,
borderRadius: 8,
}}
>
<Routes>
<Route path="/tokens" element={<Tokens />} />
<Route path="/stats" element={<Stats />} />
<Route path="*" element={<Navigate to="/tokens" replace />} />
</Routes>
</div>
</Content>
</Layout>
</Layout>
);
}
export default function App() {
// 随路由变化重新读取 jwt,登录写入 localStorage 后跳转能即时生效
useLocation();
const jwt = localStorage.getItem('jwt');
return (
<Routes>
<Route path="/login" element={<Login />} />
{jwt ? (
<Route path="/*" element={<AppLayout />} />
) : (
<Route path="/*" element={<Navigate to="/login" replace />} />
)}
</Routes>
);
}
+32
View File
@@ -0,0 +1,32 @@
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
timeout: 15000,
});
// 请求拦截:自动带 JWT
api.interceptors.request.use((config) => {
const token = localStorage.getItem('jwt');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 响应拦截:401 跳登录
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('jwt');
localStorage.removeItem('username');
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}
return Promise.reject(err);
},
);
export default api;
+98
View File
@@ -0,0 +1,98 @@
import api from './client';
export interface TokenRow {
token_id: number;
token_prefix: string;
client_id: string;
service_scope: string;
status: string;
expires_at: string | null;
description: string | null;
created_at: string | null;
created_by: string;
revoked_at: string | null;
revoke_reason: string | null;
last_used_at: string | null;
last_used_svc: string | null;
}
export interface TokenCreate {
client_id: string;
service_scope: string;
description?: string;
expires_at?: string | null;
}
export interface CreateResult {
token: string;
token_id: number;
token_prefix: string;
client_id: string;
service_scope: string;
message: string;
}
export async function login(username: string, password: string) {
const { data } = await api.post('/admin/login', { username, password });
return data as { token: string; username: string; display_name: string | null };
}
export async function listTokens(params?: {
client_id?: string;
status?: string;
service_scope?: string;
}) {
const { data } = await api.get('/tokens', { params });
return data as { total: number; tokens: TokenRow[] };
}
export async function createToken(payload: TokenCreate) {
const { data } = await api.post('/tokens', payload);
return data as CreateResult;
}
export async function revokeToken(token_id: number, reason?: string) {
const { data } = await api.delete(`/tokens/${token_id}`, { data: { reason } });
return data;
}
export async function updateToken(
token_id: number,
payload: { description?: string; expires_at?: string | null },
) {
const { data } = await api.patch(`/tokens/${token_id}`, payload);
return data;
}
export async function tokenLogs(token_id: number) {
const { data } = await api.get(`/tokens/${token_id}/logs`);
return data as {
total: number;
logs: Array<{
log_id: number;
event: string;
service: string | null;
client_ip: string | null;
occurred_at: string | null;
detail: unknown;
}>;
};
}
export async function getStats() {
const { data } = await api.get('/stats');
return data as {
total: number;
active: number;
revoked: number;
by_scope: Record<string, number>;
calls_24h: number;
clients_24h: number;
recent: Array<{
token_prefix: string;
client_id: string;
service: string | null;
last_used_at: string | null;
}>;
};
}
+17
View File
@@ -0,0 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import App from './App';
import 'antd/dist/reset.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ConfigProvider locale={zhCN}>
<BrowserRouter>
<App />
</BrowserRouter>
</ConfigProvider>
</React.StrictMode>,
);
@@ -0,0 +1,51 @@
import { useState } from 'react';
import { Card, Form, Input, Button, message, Typography } from 'antd';
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { login } from '../../api';
export default function Login() {
const [loading, setLoading] = useState(false);
const nav = useNavigate();
const onFinish = async (values: { username: string; password: string }) => {
setLoading(true);
try {
const res = await login(values.username, values.password);
localStorage.setItem('jwt', res.token);
localStorage.setItem('username', res.username);
message.success('登录成功');
nav('/tokens');
} catch {
message.error('用户名或密码错误');
} finally {
setLoading(false);
}
};
return (
<div style={{ display: 'flex', justifyContent: 'center', paddingTop: '10vh' }}>
<Card style={{ width: 380 }}>
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 24 }}>
MCP Token 管理后台
</Typography.Title>
<Form onFinish={onFinish} size="large">
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input prefix={<UserOutlined />} placeholder="用户名" />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
登录
</Button>
</Form.Item>
</Form>
<Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center' }}>
默认账号 admin / admin123
</Typography.Text>
</Card>
</div>
);
}
@@ -0,0 +1,77 @@
import { useEffect, useState } from 'react';
import { Card, Col, Row, Statistic, Table, Tag, Typography } from 'antd';
import { getStats } from '../../api';
export default function Stats() {
const [data, setData] = useState<Awaited<ReturnType<typeof getStats>> | null>(null);
useEffect(() => {
getStats().then(setData).catch(() => {});
}, []);
if (!data) return <Typography>加载中…</Typography>;
return (
<div>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card>
<Statistic title="Token 总数" value={data.total} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="活跃" value={data.active} valueStyle={{ color: '#3f8600' }} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="已吊销" value={data.revoked} valueStyle={{ color: '#cf1322' }} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="近 24h 调用" value={data.calls_24h} />
</Card>
</Col>
</Row>
<Card title="按服务范围分布" style={{ marginBottom: 24 }}>
<Row gutter={16}>
{Object.entries(data.by_scope).map(([scope, cnt]) => (
<Col key={scope} span={8}>
<Statistic title={scope} value={cnt} />
</Col>
))}
{Object.keys(data.by_scope).length === 0 && (
<Typography.Text type="secondary">暂无数据</Typography.Text>
)}
</Row>
</Card>
<Card title="最近调用">
<Table
dataSource={data.recent}
rowKey={(_, i) => String(i)}
pagination={{ pageSize: 10 }}
columns={[
{ title: 'Token', dataIndex: 'token_prefix', key: 'token_prefix' },
{ title: '客户端', dataIndex: 'client_id', key: 'client_id' },
{
title: '服务',
dataIndex: 'service',
key: 'service',
render: (s: string) => (s ? <Tag color="blue">{s}</Tag> : '-'),
},
{
title: '调用时间',
dataIndex: 'last_used_at',
key: 'last_used_at',
render: (t: string) => (t ? new Date(t).toLocaleString('zh-CN') : '-'),
},
]}
/>
</Card>
</div>
);
}
@@ -0,0 +1,205 @@
import { useEffect, useState } from 'react';
import {
Button,
Form,
Input,
Modal,
Select,
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 {
createToken,
listTokens,
revokeToken,
type CreateResult,
type TokenRow,
} from '../../api';
const STATUS_COLOR: Record<string, string> = {
active: 'green',
revoked: 'red',
expired: 'orange',
};
export default function Tokens() {
const [rows, setRows] = useState<TokenRow[]>([]);
const [loading, setLoading] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [created, setCreated] = useState<CreateResult | null>(null);
const [form] = Form.useForm();
const load = async () => {
setLoading(true);
try {
const res = await listTokens();
setRows(res.tokens);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const onRevoke = async (id: number) => {
Modal.confirm({
title: '吊销此 Token?',
content: '吊销后最长 30s 缓存过期生效,且无法恢复。',
okText: '吊销',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
await revokeToken(id, '管理员手动吊销');
message.success('已吊销');
load();
},
});
};
const onCreate = async () => {
const values = await form.validateFields();
const res = await createToken({
client_id: values.client_id,
service_scope: values.service_scope,
description: values.description,
expires_at: values.expires_at ? values.expires_at.toISOString() : null,
});
setCreated(res);
setCreateOpen(false);
form.resetFields();
load();
};
const columns: ColumnsType<TokenRow> = [
{ title: '前缀', dataIndex: 'token_prefix', key: 'token_prefix', width: 140 },
{ title: '客户端', dataIndex: 'client_id', key: 'client_id', width: 120 },
{
title: '范围',
dataIndex: 'service_scope',
key: 'service_scope',
width: 80,
render: (s: string) => <Tag color="blue">{s}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
render: (s: string) => <Tag color={STATUS_COLOR[s] || 'default'}>{s}</Tag>,
},
{
title: '过期时间',
dataIndex: 'expires_at',
key: 'expires_at',
width: 160,
render: (t: string) => (t ? dayjs(t).format('YYYY-MM-DD HH:mm') : '永不过期'),
},
{ title: '说明', dataIndex: 'description', key: 'description' },
{
title: '最近调用',
dataIndex: 'last_used_at',
key: 'last_used_at',
width: 160,
render: (t: string, row: TokenRow) =>
t ? `${dayjs(t).format('MM-DD HH:mm')} (${row.last_used_svc || '-'})` : '-',
},
{
title: '操作',
key: 'action',
width: 100,
render: (_, row: TokenRow) =>
row.status === 'active' ? (
<Button type="link" danger size="small" onClick={() => onRevoke(row.token_id)}>
吊销
</Button>
) : (
<Typography.Text type="secondary">-</Typography.Text>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
签发 Token
</Button>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>
刷新
</Button>
</Space>
<Table
columns={columns}
dataSource={rows}
rowKey="token_id"
loading={loading}
pagination={{ pageSize: 15 }}
/>
{/* 签发表单 */}
<Modal
title="签发新 Token"
open={createOpen}
onOk={onCreate}
onCancel={() => setCreateOpen(false)}
okText="签发"
cancelText="取消"
>
<Form form={form} layout="vertical" initialValues={{ service_scope: 'both' }}>
<Form.Item
name="client_id"
label="客户端标识"
rules={[{ required: true, message: '请输入 client_id' }]}
>
<Input placeholder="如 trae / partner-a" />
</Form.Item>
<Form.Item name="service_scope" label="服务范围">
<Select
options={[
{ value: 'both', label: 'ERP + CRM' },
{ value: 'erp', label: '仅 ERP' },
{ value: 'crm', label: '仅 CRM' },
]}
/>
</Form.Item>
<Form.Item name="expires_at" label="过期时间(留空=永不过期)">
<Input type="datetime-local" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="description" label="说明">
<Input.TextArea rows={2} placeholder="用途说明" />
</Form.Item>
</Form>
</Modal>
{/* 签发结果:明文 token 仅此一次 */}
<Modal
title="Token 已签发"
open={created !== null}
onCancel={() => setCreated(null)}
footer={<Button type="primary" onClick={() => setCreated(null)}>我已保存</Button>}
>
<Typography.Paragraph type="warning">
请立即复制并保存,此 Token 仅显示一次,之后无法再次查看。
</Typography.Paragraph>
<Input.TextArea
rows={2}
value={created?.token || ''}
readOnly
style={{ fontFamily: 'monospace' }}
/>
<Typography.Paragraph type="secondary" style={{ marginTop: 12 }}>
{created?.message}
</Typography.Paragraph>
</Modal>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/index.ts","./src/pages/login/index.tsx","./src/pages/stats/index.tsx","./src/pages/tokens/index.tsx"],"version":"5.9.3"}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:8000',
},
},
build: {
outDir: 'dist',
},
})
+59
View File
@@ -0,0 +1,59 @@
-- ============================================================
-- MCP Auth — Bearer Token 动态鉴权管理库
-- 数据库:mcp_auth(独立于业务库 smart_quotation_auto)
-- 幂等可重跑:CREATE DATABASE 需手动执行(连接超库权限),下方表结构均 IF NOT EXISTS
-- ============================================================
-- 0. 库(手动执行,或由 DBA 预建)
-- CREATE DATABASE mcp_auth;
-- 1. mcp_token:token 主表
CREATE TABLE IF NOT EXISTS mcp_token (
token_id BIGSERIAL PRIMARY KEY,
token_hash VARCHAR(64) UNIQUE NOT NULL, -- sha256(明文),不存明文
token_prefix VARCHAR(16) NOT NULL, -- 明文前 12 字符 + '…',前端识别用
client_id VARCHAR(64) NOT NULL, -- 调用方标识(如 trae / partner-a)
service_scope VARCHAR(32) NOT NULL, -- 'erp' | 'crm' | 'both'
status VARCHAR(16) NOT NULL DEFAULT 'active', -- active/revoked/expired
expires_at TIMESTAMPTZ, -- null = 永不过期
description VARCHAR(200), -- 用途说明
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by VARCHAR(64) NOT NULL,
revoked_at TIMESTAMPTZ,
revoke_reason VARCHAR(200),
last_used_at TIMESTAMPTZ,
last_used_ip VARCHAR(64),
last_used_svc VARCHAR(32) -- 最近被哪个 MCP 服务命中(erp/crm)
);
CREATE INDEX IF NOT EXISTS idx_mcp_token_status ON mcp_token(status) WHERE status = 'active';
CREATE INDEX IF NOT EXISTS idx_mcp_token_client ON mcp_token(client_id);
-- 2. mcp_token_log:审计日志(可选,MCP 服务 verify_token 命中后异步写入)
CREATE TABLE IF NOT EXISTS mcp_token_log (
log_id BIGSERIAL PRIMARY KEY,
token_id BIGINT NOT NULL REFERENCES mcp_token(token_id) ON DELETE CASCADE,
event VARCHAR(32) NOT NULL, -- issued/verified/revoked/rejected/expired
service VARCHAR(32), -- erp/crm
client_ip VARCHAR(64),
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
detail JSONB
);
CREATE INDEX IF NOT EXISTS idx_mcp_token_log_token ON mcp_token_log(token_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_mcp_token_log_event ON mcp_token_log(event, occurred_at DESC);
-- 3. admin_user:管理员账号(bcrypt 密码哈希)
CREATE TABLE IF NOT EXISTS admin_user (
user_id BIGSERIAL PRIMARY KEY,
username VARCHAR(64) UNIQUE NOT NULL,
password_hash VARCHAR(128) NOT NULL, -- bcrypt
display_name VARCHAR(64),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_login_at TIMESTAMPTZ
);
-- 4. 默认管理员(密码: admin123,bcrypt $2b$12$... 由后端首次启动时注入,此处仅占位)
-- 实际部署:python -m backend.scripts.seed_admin 或由后端首次启动自动建
-- 这里给出手工生成 bcrypt 的 SQL 模板(替换 $BCRYPT_HASH 为实际值):
-- INSERT INTO admin_user (username, password_hash, display_name)
-- VALUES ('admin', '$BCRYPT_HASH', '默认管理员')
-- ON CONFLICT (username) DO NOTHING;
+94
View File
@@ -0,0 +1,94 @@
"""初始化 mcp_auth 库 + 灌入现有静态 token(一次性迁移脚本)。
用法:
# 开发库(47.101.220.40)
python seed.py --host 47.101.220.40 --user postgres --password digiwin
# 生产库(192.168.1.206)
python seed.py --host 192.168.1.206 --user postgres --password digiwin
幂等:可重复执行,已存在的 token 跳过。
"""
import argparse
import asyncio
import hashlib
import os
from pathlib import Path
import asyncpg
# 现有静态 token → (client_id, service_scope, description)
# 与 docker-compose.yml 历史配置一致,迁移后可从 compose 删除
STATIC_TOKENS = [
("erp-token-trae-2026", "trae", "both", "Trae IDE 客户端(跨 ERP+CRM)"),
("erp-token-partner-a", "partner-a", "erp", "合作伙伴 A(仅 ERP)"),
("crm-token-trae-2026", "trae", "both", "Trae IDE 客户端(跨 ERP+CRM)"),
("crm-token-partner-a", "partner-a", "crm", "合作伙伴 A(仅 CRM)"),
("dev-token-trae", "trae", "both", "开发环境共用 token"),
]
INIT_SQL_PATH = Path(__file__).resolve().parent.parent / "sql" / "init.sql"
async def main() -> None:
parser = argparse.ArgumentParser(description="初始化 mcp_auth 库并迁移静态 token")
parser.add_argument("--host", default=os.getenv("MCP_AUTH_DB_HOST", "127.0.0.1"))
parser.add_argument("--port", type=int, default=int(os.getenv("MCP_AUTH_DB_PORT", "5432")))
parser.add_argument("--user", default=os.getenv("MCP_AUTH_DB_USER", "postgres"))
parser.add_argument("--password", default=os.getenv("MCP_AUTH_DB_PASSWORD", "digiwin"))
parser.add_argument("--db", default="mcp_auth")
args = parser.parse_args()
# 1. 连超库建 mcp_auth(IF NOT EXISTS)
sys_conn = await asyncpg.connect(
host=args.host, port=args.port, user=args.user, password=args.password, database="postgres"
)
exists = await sys_conn.fetchval("SELECT 1 FROM pg_database WHERE datname = $1", args.db)
if not exists:
await sys_conn.execute(f'CREATE DATABASE "{args.db}"')
print(f"[建库] {args.db} 已创建")
else:
print(f"[建库] {args.db} 已存在,跳过")
await sys_conn.close()
# 2. 连 mcp_auth 建表
conn = await asyncpg.connect(
host=args.host, port=args.port, user=args.user, password=args.password, database=args.db
)
init_sql = INIT_SQL_PATH.read_text(encoding="utf-8")
await conn.execute(init_sql)
print(f"[建表] mcp_token / mcp_token_log / admin_user 已就绪")
# 3. 灌入静态 token(幂等:token_hash 已存在则跳过)
inserted = 0
skipped = 0
for token, client_id, scope, desc in STATIC_TOKENS:
token_hash = hashlib.sha256(token.encode()).hexdigest()
token_prefix = token[:12] + "…"
result = await conn.execute(
"""INSERT INTO mcp_token (token_hash, token_prefix, client_id, service_scope, status, description, created_by)
VALUES ($1, $2, $3, $4, 'active', $5, 'seed.py')
ON CONFLICT (token_hash) DO NOTHING""",
token_hash, token_prefix, client_id, scope, desc,
)
if result.endswith("1"):
inserted += 1
else:
skipped += 1
print(f"[迁移] 静态 token:新增 {inserted} 条,跳过 {skipped} 条(已存在)")
# 4. 列出当前 token 概览
rows = await conn.fetch(
"SELECT token_prefix, client_id, service_scope, status FROM mcp_token ORDER BY token_id"
)
print(f"[概览] mcp_token 表共 {len(rows)} 条:")
for r in rows:
print(f" {r['token_prefix']:<16} client={r['client_id']:<12} scope={r['service_scope']:<6} status={r['status']}")
await conn.close()
print("[完成] 下一步:删除 docker-compose.yml 里的 MCP_AUTH_TOKENS(已改用 DB)")
if __name__ == "__main__":
asyncio.run(main())
+116 -33
View File
@@ -1,59 +1,142 @@
"""静态 Bearer Token 鉴权(Authorization)。 """动态 Bearer Token 鉴权(Authorization)。
token → 客户端 映射来自环境变量 MCP_AUTH_TOKENS,格式(逗号分隔,每对 token:client_id): token 校验查 mcp_auth.mcp_token 表(sha256 hash 比对),进程内 LRU 缓存 30s。
MCP_AUTH_TOKENS=token-trae:trae,token-partner:partner-a token 表由独立管理后台(mcp-auth-admin)维护,MCP 服务只读。
每个客户端配置自己的 token 调用,服务端校验失败返回 401; 环境变量:
工具内可通过 get_caller() 获取当前调用方标识。 MCP_PUBLIC_URL 服务对外地址(OAuth 资源元数据),默认 http://localhost:{port}
MCP_AUTH_DB_HOST 鉴权库主机
MCP_AUTH_DB_PORT 鉴权库端口,默认 5432
MCP_AUTH_DB_USER 鉴权库用户
MCP_AUTH_DB_PASSWORD 鉴权库密码
MCP_AUTH_DB_NAME 鉴权库名,默认 mcp_auth
MCP_AUTH_CACHE_TTL 缓存秒数,默认 30
MCP_AUTH_SERVICE 当前服务标识(erp/crm),用于 service_scope 校验和 last_used_svc
""" """
import asyncio
import hashlib
import os import os
import time
import asyncpg
from mcp.server.auth.middleware.auth_context import get_access_token from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken from mcp.server.auth.provider import AccessToken
from mcp.server.auth.settings import AuthSettings from mcp.server.auth.settings import AuthSettings
class StaticTokenVerifier: # 鉴权库连接池(独立于业务库,进程级单例)
"""静态 token 查表校验:命中返回 AccessToken(client_id 即客户端标识),未命中返回 None(401)。""" _auth_pool: asyncpg.Pool | None = None
def __init__(self, tokens: dict[str, str]): _CACHE_TTL = int(os.getenv("MCP_AUTH_CACHE_TTL", "30"))
self._tokens = tokens
async def get_auth_pool() -> asyncpg.Pool:
"""获取鉴权库连接池(首次调用时惰性创建)。"""
global _auth_pool
if _auth_pool is None:
_auth_pool = await asyncpg.create_pool(
host=os.getenv("MCP_AUTH_DB_HOST", "127.0.0.1"),
port=int(os.getenv("MCP_AUTH_DB_PORT", "5432")),
user=os.getenv("MCP_AUTH_DB_USER", "postgres"),
password=os.getenv("MCP_AUTH_DB_PASSWORD", "digiwin"),
database=os.getenv("MCP_AUTH_DB_NAME", "mcp_auth"),
min_size=1,
max_size=5,
)
return _auth_pool
async def close_auth_pool() -> None:
"""关闭鉴权库连接池(进程退出时调用)。"""
global _auth_pool
if _auth_pool is not None:
await _auth_pool.close()
_auth_pool = None
class DbTokenVerifier:
"""查库校验 Bearer Token,命中返回 AccessToken,未命中/已吊销/已过期/服务范围不匹配返回 None(401)。
进程内 LRU 缓存(token_hash -> (row|None, fetched_at)),TTL 由 MCP_AUTH_CACHE_TTL 控制。
吊销后最长 TTL 秒内仍可能命中旧状态;需要即时生效可调用 invalidate(token)。
"""
def __init__(self, service: str):
self._service = service # 'erp' / 'crm'
self._cache: dict[str, tuple[asyncpg.Record | None, float]] = {}
def invalidate(self, token: str | None = None) -> None:
"""清缓存:token=None 清全部,否则清单个。供管理后台通知后调用(可选)。"""
if token is None:
self._cache.clear()
else:
self._cache.pop(hashlib.sha256(token.encode()).hexdigest(), None)
async def verify_token(self, token: str) -> AccessToken | None: async def verify_token(self, token: str) -> AccessToken | None:
client_id = self._tokens.get(token) token_hash = hashlib.sha256(token.encode()).hexdigest()
if client_id is None:
# 1. 查缓存
cached = self._cache.get(token_hash)
now = time.time()
if cached is not None and (now - cached[1]) < _CACHE_TTL:
row = cached[0]
else:
# 2. 查库
pool = await get_auth_pool()
row = await pool.fetchrow(
"""SELECT client_id, status, expires_at, service_scope
FROM mcp_token WHERE token_hash = $1""",
token_hash,
)
self._cache[token_hash] = (row, now)
# 3. 校验
if row is None:
return None return None
return AccessToken(token=token, client_id=client_id, scopes=[], expires_at=None) if row["status"] != "active":
return None
if row["expires_at"] is not None and row["expires_at"].timestamp() < now:
return None
# service_scope='both' 对所有服务放行;否则要求精确匹配当前服务
if row["service_scope"] != "both" and row["service_scope"] != self._service:
return None
# 4. 异步更新 last_used_*,不阻塞响应;失败忽略
asyncio.create_task(self._touch(token_hash))
return AccessToken(
token=token,
client_id=row["client_id"],
scopes=[],
expires_at=None,
)
async def _touch(self, token_hash: str) -> None:
"""异步更新 last_used_at/last_used_svc。"""
try:
pool = await get_auth_pool()
await pool.execute(
"""UPDATE mcp_token
SET last_used_at = now(), last_used_svc = $2
WHERE token_hash = $1""",
token_hash, self._service,
)
except Exception:
pass # 审计字段更新失败不影响鉴权
def _parse_tokens(raw: str) -> dict[str, str]: def get_auth(port: int) -> tuple[AuthSettings, DbTokenVerifier]:
"""解析 'token1:client1,token2:client2' → {token: client_id}"""
tokens: dict[str, str] = {}
for item in raw.split(","):
item = item.strip()
if not item:
continue
token, _, client_id = item.partition(":")
token, client_id = token.strip(), client_id.strip()
if token and client_id:
tokens[token] = client_id
return tokens
def get_auth(port: int) -> tuple[AuthSettings, StaticTokenVerifier]:
"""构建 MCPServer 的 (auth, token_verifier) 参数。 """构建 MCPServer 的 (auth, token_verifier) 参数。
服务对外地址默认 http://localhost:{port},部署时用 MCP_PUBLIC_URL 覆盖 连接池在首次 verify_token 时惰性创建,此处不连库。
(如 http://192.168.1.119:8002),用于 OAuth 资源元数据发现。 service 标识取自 MCP_AUTH_SERVICE 环境变量,默认按 port 推断(8001→erp / 8002→crm)。
""" """
url = os.getenv("MCP_PUBLIC_URL") or f"http://localhost:{port}" url = os.getenv("MCP_PUBLIC_URL") or f"http://localhost:{port}"
tokens = _parse_tokens(os.getenv("MCP_AUTH_TOKENS", "")) service = os.getenv("MCP_AUTH_SERVICE") or ("erp" if port == 8001 else "crm" if port == 8002 else "unknown")
if not tokens:
raise RuntimeError("环境变量 MCP_AUTH_TOKENS 未配置,格式:token1:client1,token2:client2")
return ( return (
AuthSettings(issuer_url=url, resource_server_url=url), AuthSettings(issuer_url=url, resource_server_url=url),
StaticTokenVerifier(tokens), DbTokenVerifier(service=service),
) )
+116 -33
View File
@@ -1,59 +1,142 @@
"""静态 Bearer Token 鉴权(Authorization)。 """动态 Bearer Token 鉴权(Authorization)。
token → 客户端 映射来自环境变量 MCP_AUTH_TOKENS,格式(逗号分隔,每对 token:client_id): token 校验查 mcp_auth.mcp_token 表(sha256 hash 比对),进程内 LRU 缓存 30s。
MCP_AUTH_TOKENS=token-trae:trae,token-partner:partner-a token 表由独立管理后台(mcp-auth-admin)维护,MCP 服务只读。
每个客户端配置自己的 token 调用,服务端校验失败返回 401; 环境变量:
工具内可通过 get_caller() 获取当前调用方标识。 MCP_PUBLIC_URL 服务对外地址(OAuth 资源元数据),默认 http://localhost:{port}
MCP_AUTH_DB_HOST 鉴权库主机
MCP_AUTH_DB_PORT 鉴权库端口,默认 5432
MCP_AUTH_DB_USER 鉴权库用户
MCP_AUTH_DB_PASSWORD 鉴权库密码
MCP_AUTH_DB_NAME 鉴权库名,默认 mcp_auth
MCP_AUTH_CACHE_TTL 缓存秒数,默认 30
MCP_AUTH_SERVICE 当前服务标识(erp/crm),用于 service_scope 校验和 last_used_svc
""" """
import asyncio
import hashlib
import os import os
import time
import asyncpg
from mcp.server.auth.middleware.auth_context import get_access_token from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken from mcp.server.auth.provider import AccessToken
from mcp.server.auth.settings import AuthSettings from mcp.server.auth.settings import AuthSettings
class StaticTokenVerifier: # 鉴权库连接池(独立于业务库,进程级单例)
"""静态 token 查表校验:命中返回 AccessToken(client_id 即客户端标识),未命中返回 None(401)。""" _auth_pool: asyncpg.Pool | None = None
def __init__(self, tokens: dict[str, str]): _CACHE_TTL = int(os.getenv("MCP_AUTH_CACHE_TTL", "30"))
self._tokens = tokens
async def get_auth_pool() -> asyncpg.Pool:
"""获取鉴权库连接池(首次调用时惰性创建)。"""
global _auth_pool
if _auth_pool is None:
_auth_pool = await asyncpg.create_pool(
host=os.getenv("MCP_AUTH_DB_HOST", "127.0.0.1"),
port=int(os.getenv("MCP_AUTH_DB_PORT", "5432")),
user=os.getenv("MCP_AUTH_DB_USER", "postgres"),
password=os.getenv("MCP_AUTH_DB_PASSWORD", "digiwin"),
database=os.getenv("MCP_AUTH_DB_NAME", "mcp_auth"),
min_size=1,
max_size=5,
)
return _auth_pool
async def close_auth_pool() -> None:
"""关闭鉴权库连接池(进程退出时调用)。"""
global _auth_pool
if _auth_pool is not None:
await _auth_pool.close()
_auth_pool = None
class DbTokenVerifier:
"""查库校验 Bearer Token,命中返回 AccessToken,未命中/已吊销/已过期/服务范围不匹配返回 None(401)。
进程内 LRU 缓存(token_hash -> (row|None, fetched_at)),TTL 由 MCP_AUTH_CACHE_TTL 控制。
吊销后最长 TTL 秒内仍可能命中旧状态;需要即时生效可调用 invalidate(token)。
"""
def __init__(self, service: str):
self._service = service # 'erp' / 'crm'
self._cache: dict[str, tuple[asyncpg.Record | None, float]] = {}
def invalidate(self, token: str | None = None) -> None:
"""清缓存:token=None 清全部,否则清单个。供管理后台通知后调用(可选)。"""
if token is None:
self._cache.clear()
else:
self._cache.pop(hashlib.sha256(token.encode()).hexdigest(), None)
async def verify_token(self, token: str) -> AccessToken | None: async def verify_token(self, token: str) -> AccessToken | None:
client_id = self._tokens.get(token) token_hash = hashlib.sha256(token.encode()).hexdigest()
if client_id is None:
# 1. 查缓存
cached = self._cache.get(token_hash)
now = time.time()
if cached is not None and (now - cached[1]) < _CACHE_TTL:
row = cached[0]
else:
# 2. 查库
pool = await get_auth_pool()
row = await pool.fetchrow(
"""SELECT client_id, status, expires_at, service_scope
FROM mcp_token WHERE token_hash = $1""",
token_hash,
)
self._cache[token_hash] = (row, now)
# 3. 校验
if row is None:
return None return None
return AccessToken(token=token, client_id=client_id, scopes=[], expires_at=None) if row["status"] != "active":
return None
if row["expires_at"] is not None and row["expires_at"].timestamp() < now:
return None
# service_scope='both' 对所有服务放行;否则要求精确匹配当前服务
if row["service_scope"] != "both" and row["service_scope"] != self._service:
return None
# 4. 异步更新 last_used_*,不阻塞响应;失败忽略
asyncio.create_task(self._touch(token_hash))
return AccessToken(
token=token,
client_id=row["client_id"],
scopes=[],
expires_at=None,
)
async def _touch(self, token_hash: str) -> None:
"""异步更新 last_used_at/last_used_svc。"""
try:
pool = await get_auth_pool()
await pool.execute(
"""UPDATE mcp_token
SET last_used_at = now(), last_used_svc = $2
WHERE token_hash = $1""",
token_hash, self._service,
)
except Exception:
pass # 审计字段更新失败不影响鉴权
def _parse_tokens(raw: str) -> dict[str, str]: def get_auth(port: int) -> tuple[AuthSettings, DbTokenVerifier]:
"""解析 'token1:client1,token2:client2' → {token: client_id}"""
tokens: dict[str, str] = {}
for item in raw.split(","):
item = item.strip()
if not item:
continue
token, _, client_id = item.partition(":")
token, client_id = token.strip(), client_id.strip()
if token and client_id:
tokens[token] = client_id
return tokens
def get_auth(port: int) -> tuple[AuthSettings, StaticTokenVerifier]:
"""构建 MCPServer 的 (auth, token_verifier) 参数。 """构建 MCPServer 的 (auth, token_verifier) 参数。
服务对外地址默认 http://localhost:{port},部署时用 MCP_PUBLIC_URL 覆盖 连接池在首次 verify_token 时惰性创建,此处不连库。
(如 http://192.168.1.119:8002),用于 OAuth 资源元数据发现。 service 标识取自 MCP_AUTH_SERVICE 环境变量,默认按 port 推断(8001→erp / 8002→crm)。
""" """
url = os.getenv("MCP_PUBLIC_URL") or f"http://localhost:{port}" url = os.getenv("MCP_PUBLIC_URL") or f"http://localhost:{port}"
tokens = _parse_tokens(os.getenv("MCP_AUTH_TOKENS", "")) service = os.getenv("MCP_AUTH_SERVICE") or ("erp" if port == 8001 else "crm" if port == 8002 else "unknown")
if not tokens:
raise RuntimeError("环境变量 MCP_AUTH_TOKENS 未配置,格式:token1:client1,token2:client2")
return ( return (
AuthSettings(issuer_url=url, resource_server_url=url), AuthSettings(issuer_url=url, resource_server_url=url),
StaticTokenVerifier(tokens), DbTokenVerifier(service=service),
) )