新增mcp_auth项目
This commit is contained in:
+17
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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")}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
Generated
+3121
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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"}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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;
|
||||||
@@ -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
@@ -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
@@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user