diff --git a/docker-compose.yml b/docker-compose.yml
index ee22c1b..b08a995 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -71,5 +71,8 @@ services:
- 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}
+ # 鼎捷云 IAM 鉴权配置
+ - IAM_BASE_URL=${IAM_BASE_URL:-https://iam.digiwincloud.com.cn}
+ - IAM_APP_TOKEN=${IAM_APP_TOKEN:-eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImRhdGEtYnVzaW5lc3MtZGVtbyIsInNpZCI6MH0.Spo64LstbWxjYNefVFAbEbgfjzZoQGNcqKSGuYUOCRk}
+ - IAM_CACHE_TTL=${IAM_CACHE_TTL:-30}
restart: unless-stopped
diff --git a/mcp-auth/backend/app/core/config.py b/mcp-auth/backend/app/core/config.py
index e0dbcee..aa0867f 100644
--- a/mcp-auth/backend/app/core/config.py
+++ b/mcp-auth/backend/app/core/config.py
@@ -10,9 +10,15 @@ class Settings:
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"))
+ # 鼎捷云 IAM 配置
+ IAM_BASE_URL: str = os.getenv("IAM_BASE_URL", "https://iam.digiwincloud.com.cn")
+ # 应用 apptoken(digi-middleware-auth-app)
+ IAM_APP_TOKEN: str = os.getenv(
+ "IAM_APP_TOKEN",
+ "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImRhdGEtYnVzaW5lc3MtZGVtbyIsInNpZCI6MH0.Spo64LstbWxjYNefVFAbEbgfjzZoQGNcqKSGuYUOCRk",
+ )
+ # IAM token 校验缓存秒数
+ IAM_CACHE_TTL: int = int(os.getenv("IAM_CACHE_TTL", "30"))
# 前端静态文件目录(Docker 构建后注入)
STATIC_DIR: str = os.getenv("STATIC_DIR", "../frontend/dist")
diff --git a/mcp-auth/backend/app/core/deps.py b/mcp-auth/backend/app/core/deps.py
index 0e2a9f9..c3e9d91 100644
--- a/mcp-auth/backend/app/core/deps.py
+++ b/mcp-auth/backend/app/core/deps.py
@@ -1,18 +1,33 @@
-"""FastAPI 依赖:JWT 校验,提取当前管理员"""
+"""FastAPI 依赖:IAM token 校验,提取当前管理员
-from fastapi import Depends, HTTPException, status
-from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
-from . import security
+从请求头 digi-middleware-auth-user / digi-middleware-auth-app 读取凭证,
+调用 IAM /api/iam/v2/identity/token/analyze 校验并解析用户信息。
+"""
-bearer_scheme = HTTPBearer(auto_error=False)
+from fastapi import Header, HTTPException, status
+
+from . import iam
async def current_admin(
- creds: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
+ user_token: str | None = Header(None, alias="digi-middleware-auth-user"),
+ app_token: str | None = Header(None, alias="digi-middleware-auth-app"),
) -> 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
+ if not user_token:
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "未提供 IAM userToken")
+
+ info = await iam.analyze_token(user_token, app_token)
+ if info is None:
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "IAM token 无效或已过期")
+
+ # 返回统一的管理员信息(兼容原有 admin.get("username") 调用)
+ return {
+ "username": info.get("name") or info.get("id") or "unknown",
+ "userId": info.get("id"),
+ "name": info.get("name"),
+ "email": info.get("email"),
+ "telephone": info.get("telephone"),
+ "tenantId": info.get("tenantId"),
+ "tenantName": info.get("tenantName"),
+ "raw": info,
+ }
diff --git a/mcp-auth/backend/app/core/iam.py b/mcp-auth/backend/app/core/iam.py
new file mode 100644
index 0000000..07f6a8b
--- /dev/null
+++ b/mcp-auth/backend/app/core/iam.py
@@ -0,0 +1,80 @@
+"""鼎捷云 IAM token 鉴权服务
+
+通过调用 IAM `/api/iam/v2/identity/token/analyze` 校验请求头中的
+digi-middleware-auth-user / digi-middleware-auth-app,解析出用户信息。
+
+进程内 LRU 缓存(userToken -> userInfo),TTL 由 IAM_CACHE_TTL 控制,
+减少对 IAM 的重复调用。
+"""
+
+import time
+
+import httpx
+
+from .config import settings
+
+# httpx 异步客户端(进程级单例,复用连接池)
+_http_client: httpx.AsyncClient | None = None
+
+# 缓存:userToken -> (user_info, fetched_at)
+_cache: dict[str, tuple[dict | None, float]] = {}
+
+
+def _get_http_client() -> httpx.AsyncClient:
+ global _http_client
+ if _http_client is None:
+ _http_client = httpx.AsyncClient(timeout=5.0)
+ return _http_client
+
+
+async def close_iam_client() -> None:
+ """关闭 httpx 客户端(进程退出时调用)。"""
+ global _http_client
+ if _http_client is not None:
+ await _http_client.aclose()
+ _http_client = None
+
+
+async def analyze_token(user_token: str, app_token: str | None = None) -> dict | None:
+ """校验 IAM userToken,返回用户信息 dict 或 None。
+
+ 调用 POST {IAM_BASE_URL}/api/iam/v2/identity/token/analyze,
+ 请求头携带 digi-middleware-auth-app 与 digi-middleware-auth-user。
+ 成功时返回包含 id/name/tenantId/tenantName 等字段的 dict。
+ """
+ # 1. 查缓存
+ now = time.time()
+ cached = _cache.get(user_token)
+ if cached is not None and (now - cached[1]) < settings.IAM_CACHE_TTL:
+ return cached[0]
+
+ # 2. 调用 IAM analyze
+ headers = {
+ "digi-middleware-auth-user": user_token,
+ "digi-middleware-auth-app": app_token or settings.IAM_APP_TOKEN,
+ }
+ try:
+ client = _get_http_client()
+ resp = await client.post(
+ f"{settings.IAM_BASE_URL}/api/iam/v2/identity/token/analyze",
+ headers=headers,
+ )
+ except Exception as ex:
+ # IAM 不可达,缓存短时间避免雪崩
+ _cache[user_token] = (None, now)
+ print(f"[IAM] analyze 请求异常: {ex}")
+ return None
+
+ if resp.status_code == 200:
+ data = resp.json()
+ # 兼容字段:id(用户标识)/ name(姓名)
+ if data.get("id") or data.get("name"):
+ _cache[user_token] = (data, now)
+ return data
+ # 返回体无用户标识,视为无效
+ _cache[user_token] = (None, now)
+ return None
+
+ # 非 200(token 无效/过期),缓存避免雪崩
+ _cache[user_token] = (None, now)
+ return None
diff --git a/mcp-auth/backend/app/core/security.py b/mcp-auth/backend/app/core/security.py
deleted file mode 100644
index 2b92ac9..0000000
--- a/mcp-auth/backend/app/core/security.py
+++ /dev/null
@@ -1,31 +0,0 @@
-"""安全工具: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
diff --git a/mcp-auth/backend/app/main.py b/mcp-auth/backend/app/main.py
index 175f07a..df25b48 100644
--- a/mcp-auth/backend/app/main.py
+++ b/mcp-auth/backend/app/main.py
@@ -1,10 +1,10 @@
"""MCP Auth Admin — FastAPI 主入口
-启动时自动建默认管理员(admin/admin123,仅当 admin_user 表为空时)。
+鉴权统一走鼎捷云 IAM:请求头 digi-middleware-auth-user / digi-middleware-auth-app
+经 IAM /api/iam/v2/identity/token/analyze 校验。
静态文件由前端构建产物提供(STATIC_DIR 指向)。
"""
-import os
from contextlib import asynccontextmanager
from pathlib import Path
@@ -12,30 +12,18 @@ 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.core.iam import close_iam_client
from app.routers import auth, services, stats, tokens, verify
-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()
+ await get_pool()
yield
await close_pool()
+ await close_iam_client()
app = FastAPI(
diff --git a/mcp-auth/backend/app/routers/auth.py b/mcp-auth/backend/app/routers/auth.py
index 2e16038..e8b355e 100644
--- a/mcp-auth/backend/app/routers/auth.py
+++ b/mcp-auth/backend/app/routers/auth.py
@@ -1,50 +1,20 @@
-"""auth 路由:管理员登录"""
+"""auth 路由:当前登录用户信息(IAM 鉴权)"""
-from datetime import datetime, timezone
+from fastapi import APIRouter, Depends
-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")}
+ """返回当前 IAM 登录用户信息(由 deps.current_admin 从 IAM analyze 解析)。"""
+ return {
+ "userId": admin.get("userId"),
+ "username": admin.get("username"),
+ "name": admin.get("name"),
+ "email": admin.get("email"),
+ "tenantId": admin.get("tenantId"),
+ "tenantName": admin.get("tenantName"),
+ }
diff --git a/mcp-auth/backend/requirements.txt b/mcp-auth/backend/requirements.txt
index ff4ab88..2c62db1 100644
--- a/mcp-auth/backend/requirements.txt
+++ b/mcp-auth/backend/requirements.txt
@@ -4,3 +4,4 @@ asyncpg>=0.30.0
bcrypt>=4.2.0
pyjwt>=2.9.0
pydantic>=2.9.0
+httpx>=0.27.0
diff --git a/mcp-auth/frontend/index.html b/mcp-auth/frontend/index.html
index 28d9c9d..f04a0d2 100644
--- a/mcp-auth/frontend/index.html
+++ b/mcp-auth/frontend/index.html
@@ -4,6 +4,9 @@
MCP Token 管理后台
+
diff --git a/mcp-auth/frontend/package-lock.json b/mcp-auth/frontend/package-lock.json
index 5ffd2d0..73afb76 100644
--- a/mcp-auth/frontend/package-lock.json
+++ b/mcp-auth/frontend/package-lock.json
@@ -12,6 +12,7 @@
"antd": "^5.21.0",
"axios": "^1.7.0",
"dayjs": "^1.11.13",
+ "jsencrypt": "^3.3.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"
@@ -2042,6 +2043,12 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
+ "node_modules/jsencrypt": {
+ "version": "3.5.4",
+ "resolved": "https://registry.npmjs.org/jsencrypt/-/jsencrypt-3.5.4.tgz",
+ "integrity": "sha512-kNjfYEMNASxrDGsmcSQh/rUTmcoRfSUkxnAz+MMywM8jtGu+fFEZ3nJjHM58zscVnwR0fYmG9sGkTDjqUdpiwA==",
+ "license": "MIT"
+ },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
diff --git a/mcp-auth/frontend/package.json b/mcp-auth/frontend/package.json
index 7a5ffa1..069d844 100644
--- a/mcp-auth/frontend/package.json
+++ b/mcp-auth/frontend/package.json
@@ -13,6 +13,7 @@
"antd": "^5.21.0",
"axios": "^1.7.0",
"dayjs": "^1.11.13",
+ "jsencrypt": "^3.3.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"
diff --git a/mcp-auth/frontend/src/App.tsx b/mcp-auth/frontend/src/App.tsx
index 7acbaa6..5565ecf 100644
--- a/mcp-auth/frontend/src/App.tsx
+++ b/mcp-auth/frontend/src/App.tsx
@@ -2,11 +2,23 @@ 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 SSOLogin from './pages/SSOLogin';
import Services from './pages/Services';
import Stats from './pages/Stats';
import Tokens from './pages/Tokens';
+import { clearSession, getUserInfo } from './services/iamAuth';
const { Header, Content, Sider } = Layout;
+/** 登录守卫:sessionStorage 无 userToken 则跳登录页(携带来源路径,登录后回跳) */
+function RequireAuth({ children }: { children: React.ReactNode }) {
+ const location = useLocation();
+ const token = sessionStorage.getItem('userToken');
+ if (!token) {
+ return ;
+ }
+ return <>{children}>;
+}
+
function AppLayout() {
const nav = useNavigate();
const loc = useLocation();
@@ -15,6 +27,9 @@ function AppLayout() {
token: { colorBgContainer },
} = theme.useToken();
+ const userInfo = getUserInfo();
+ const displayName = userInfo?.userName || userInfo?.userId || '';
+
return (
@@ -54,11 +69,11 @@ function AppLayout() {
>
MCP Token 鉴权管理后台
- {localStorage.getItem('username') || ''} ·{' '}
+ {displayName} ·{' '}
{
- localStorage.clear();
- window.location.href = '/login';
+ clearSession();
+ nav('/login', { replace: true });
}}
>
退出
@@ -88,17 +103,13 @@ function AppLayout() {
}
export default function App() {
- // 随路由变化重新读取 jwt,登录写入 localStorage 后跳转能即时生效
+ // 路由变化时重渲染,重读 sessionStorage 登录态
useLocation();
- const jwt = localStorage.getItem('jwt');
return (
} />
- {jwt ? (
- } />
- ) : (
- } />
- )}
+ } />
+ } />
);
}
diff --git a/mcp-auth/frontend/src/api/client.ts b/mcp-auth/frontend/src/api/client.ts
index 96516da..4743f41 100644
--- a/mcp-auth/frontend/src/api/client.ts
+++ b/mcp-auth/frontend/src/api/client.ts
@@ -1,27 +1,30 @@
import axios from 'axios';
+import { APP_TOKEN } from '../services/iamAuth';
const api = axios.create({
baseURL: '/api',
timeout: 15000,
});
-// 请求拦截:自动带 JWT
+// 请求拦截:自动带 IAM 鉴权头(digi-middleware-auth-user / -app)
api.interceptors.request.use((config) => {
- const token = localStorage.getItem('jwt');
+ const token = sessionStorage.getItem('userToken');
if (token) {
- config.headers.Authorization = `Bearer ${token}`;
+ config.headers['digi-middleware-auth-user'] = token;
+ config.headers['digi-middleware-auth-app'] = APP_TOKEN;
}
return config;
});
-// 响应拦截:401 跳登录
+// 响应拦截:401 清理会话并跳登录页
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
- localStorage.removeItem('jwt');
- localStorage.removeItem('username');
- if (window.location.pathname !== '/login') {
+ const path = window.location.pathname;
+ if (path !== '/login' && !path.startsWith('/sso-login')) {
+ sessionStorage.removeItem('userToken');
+ sessionStorage.removeItem('userInfo');
window.location.href = '/login';
}
}
diff --git a/mcp-auth/frontend/src/api/index.ts b/mcp-auth/frontend/src/api/index.ts
index 5b38b9d..d0802f4 100644
--- a/mcp-auth/frontend/src/api/index.ts
+++ b/mcp-auth/frontend/src/api/index.ts
@@ -32,11 +32,6 @@ export interface CreateResult {
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;
diff --git a/mcp-auth/frontend/src/pages/Login/index.tsx b/mcp-auth/frontend/src/pages/Login/index.tsx
index 8a36652..b4daf49 100644
--- a/mcp-auth/frontend/src/pages/Login/index.tsx
+++ b/mcp-auth/frontend/src/pages/Login/index.tsx
@@ -1,51 +1,161 @@
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';
+import { Button, Form, Input } from 'antd';
+import { LockOutlined, UserOutlined, CloseCircleOutlined } from '@ant-design/icons';
+import { useLocation, useNavigate } from 'react-router-dom';
+import { iamLogin, saveSession } from '../../services/iamAuth';
+/**
+ * IAM 登录页(鼎捷云统一身份认证)
+ *
+ * 参考 ai-platform 实现:账号密码经 RSA+AES 加密链路上送 IAM,
+ * 登录成功后 userToken + userInfo 写入 sessionStorage,回跳原页面。
+ */
export default function Login() {
const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
const nav = useNavigate();
+ const loc = useLocation();
- const onFinish = async (values: { username: string; password: string }) => {
+ const onFinish = async (values: { userId: string; password: string }) => {
+ if (!values.userId.trim() || !values.password) {
+ setError('请输入账号和密码');
+ return;
+ }
setLoading(true);
+ setError('');
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('用户名或密码错误');
+ const result = await iamLogin({ userId: values.userId.trim(), password: values.password });
+ saveSession(result.token, result.userInfo);
+ // 回跳原页面(守卫记录的 from),否则去首页
+ const from = (loc.state as { from?: string })?.from ?? '/';
+ nav(from, { replace: true });
+ } catch (ex) {
+ setError(ex instanceof Error ? ex.message : '登录失败,请稍后重试');
} finally {
setLoading(false);
}
};
return (
-
-
-
- MCP Token 管理后台
-
-
- } placeholder="用户名" />
+
+ {/* 装饰光晕 */}
+
+
+
+ {/* 品牌区 */}
+
+
+ MCP Token 管理后台
+
+
+
+ {/* 登录表单 */}
+
+ }
+ placeholder="账号(用户ID / 手机号 / 邮箱)"
+ autoComplete="username"
+ className="iam-login-input"
+ style={{
+ borderRadius: 10,
+ background: 'rgba(0,212,255,0.06)',
+ borderColor: 'rgba(0,212,255,0.25)',
+ color: '#e8f4ff',
+ }}
+ />
-
- } placeholder="密码" />
+
+
+ }
+ placeholder="密码"
+ autoComplete="current-password"
+ className="iam-login-input"
+ style={{
+ borderRadius: 10,
+ background: 'rgba(0,212,255,0.06)',
+ borderColor: 'rgba(0,212,255,0.25)',
+ color: '#e8f4ff',
+ }}
+ />
-
-
);
}
diff --git a/mcp-auth/frontend/src/pages/SSOLogin/index.tsx b/mcp-auth/frontend/src/pages/SSOLogin/index.tsx
new file mode 100644
index 0000000..993b57f
--- /dev/null
+++ b/mcp-auth/frontend/src/pages/SSOLogin/index.tsx
@@ -0,0 +1,93 @@
+import { useEffect, useState } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import { Spin } from 'antd';
+import { CloseCircleOutlined } from '@ant-design/icons';
+import { iamSsoLogin, saveSession } from '../../services/iamAuth';
+
+/**
+ * SSO 登录回调入口
+ *
+ * 流程:
+ * 1. 从 URL 参数获取 userToken
+ * 2. 调用 iamSsoLogin 完成:
+ * - POST /identity/token/refresh/app 刷新应用 token + 用户信息
+ * - POST /identity/login/info 获取登录详情
+ * - POST /tenant?appId=APPID 拉取租户列表,选默认租户
+ * - POST /identity/token/refresh/tenant 切换租户刷新 token
+ * 3. userInfo 写入 sessionStorage,跳转首页
+ */
+const SSOLogin: React.FC = () => {
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ const userToken = searchParams.get('userToken');
+ if (!userToken) {
+ setError('URL 缺少 userToken 参数');
+ return;
+ }
+
+ let cancelled = false;
+ (async () => {
+ try {
+ const result = await iamSsoLogin(userToken);
+ if (cancelled) return;
+ saveSession(result.token, result.userInfo);
+ navigate('/', { replace: true });
+ } catch (ex) {
+ if (cancelled) return;
+ setError(ex instanceof Error ? ex.message : 'SSO 登录失败');
+ }
+ })();
+
+ return () => { cancelled = true; };
+ }, [searchParams, navigate]);
+
+ return (
+
+ {error ? (
+
+
+
SSO 登录失败
+
+ {error}
+
+
navigate('/login', { replace: true })}
+ style={{
+ marginTop: 8, padding: '8px 20px', borderRadius: 8,
+ border: '1px solid rgba(0,212,255,0.4)', background: 'rgba(0,212,255,0.1)',
+ color: '#00d4ff', fontSize: 13, cursor: 'pointer',
+ }}
+ >
+ 前往登录页
+
+
+ ) : (
+
+ )}
+
+ );
+};
+
+export default SSOLogin;
diff --git a/mcp-auth/frontend/src/pages/Services/index.tsx b/mcp-auth/frontend/src/pages/Services/index.tsx
index e05f314..e1705fb 100644
--- a/mcp-auth/frontend/src/pages/Services/index.tsx
+++ b/mcp-auth/frontend/src/pages/Services/index.tsx
@@ -86,22 +86,15 @@ export default function Services() {
};
const onCreate = async () => {
- try {
- const values = await form.validateFields();
- const res = await registerService({
- service_name: values.service_name,
- description: values.description,
- });
- setCreated(res);
- setCreateOpen(false);
- form.resetFields();
- load();
- } catch (err: any) {
- const detail = err?.response?.data?.detail;
- if (detail) {
- message.error(detail);
- }
- }
+ const values = await form.validateFields();
+ const res = await registerService({
+ service_name: values.service_name,
+ description: values.description,
+ });
+ setCreated(res);
+ setCreateOpen(false);
+ form.resetFields();
+ load();
};
const onCopy = (text: string) => {
diff --git a/mcp-auth/frontend/src/pages/Tokens/index.tsx b/mcp-auth/frontend/src/pages/Tokens/index.tsx
index 9c924fc..820cf7b 100644
--- a/mcp-auth/frontend/src/pages/Tokens/index.tsx
+++ b/mcp-auth/frontend/src/pages/Tokens/index.tsx
@@ -101,24 +101,17 @@ export default function Tokens() {
};
const onCreate = async () => {
- try {
- 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();
- } catch (err: any) {
- const detail = err?.response?.data?.detail;
- if (detail) {
- message.error(detail);
- }
- }
+ 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 = [
diff --git a/mcp-auth/frontend/src/services/iamAuth.ts b/mcp-auth/frontend/src/services/iamAuth.ts
new file mode 100644
index 0000000..0294df0
--- /dev/null
+++ b/mcp-auth/frontend/src/services/iamAuth.ts
@@ -0,0 +1,372 @@
+import { JSEncrypt } from 'jsencrypt';
+
+/**
+ * 鼎捷云 IAM 登录服务(纯前端实现,参考 ai-platform)
+ *
+ * 完整登录流程:
+ * 1. RSA+AES 加密链路获取 userToken(/api/iam/v2/identity/login)
+ * 2. 拉取用户授权租户列表(POST /api/iam/v2/tenant?appId=APPID),默认选第一个
+ * 3. 切换租户刷新 token(POST /api/iam/v2/identity/token/refresh/tenant)
+ * 4. 将完整用户信息(含 authoredUser)写入 sessionStorage
+ *
+ * 注意:APP_TOKEN / APPID 为 IAM 应用凭证,需替换为本应用在鼎捷云 IAM 注册的配置。
+ */
+
+// 代理路径(vite.config.ts 中 /iam-api → https://iam.digiwincloud.com.cn)
+const IAM_API_BASE = '/iam-api/api/iam/v2';
+const IAM_IDENTITY_BASE = `${IAM_API_BASE}/identity`;
+
+// 应用 apptoken(digi-middleware-auth-app)
+export const APP_TOKEN =
+ 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImRhdGEtYnVzaW5lc3MtZGVtbyIsInNpZCI6MH0.Spo64LstbWxjYNefVFAbEbgfjzZoQGNcqKSGuYUOCRk';
+
+// 应用 ID(用于租户列表查询)
+export const APPID = 'data-business-demo';
+
+// IAM AES 加密固定 IV(16 字节)
+const AES_IV = 'ghUb#er57HBh(u%g';
+
+/** PEM 包装/剥离工具 */
+function wrapPem(base64Key: string): string {
+ if (base64Key.includes('BEGIN')) return base64Key;
+ const body = base64Key.replace(/-----(BEGIN|END)[^-]+-----/g, '').replace(/\s+/g, '');
+ const lines = body.match(/.{1,64}/g) || [];
+ return `-----BEGIN PUBLIC KEY-----\n${lines.join('\n')}\n-----END PUBLIC KEY-----`;
+}
+
+function stripPem(pem: string): string {
+ return pem.replace(/-----(BEGIN|END)[^-]+-----/g, '').replace(/\s+/g, '');
+}
+
+/** AES-CBC/PKCS7 加密,输出 base64(与 Java AES/CBC/PKCS5Padding 等价) */
+async function aesEncryptToBase64(plainText: string, aesKey: string): Promise {
+ const enc = new TextEncoder();
+ const keyData = enc.encode(aesKey);
+ const ivData = enc.encode(AES_IV);
+ const cryptoKey = await crypto.subtle.importKey('raw', keyData, { name: 'AES-CBC' }, false, ['encrypt']);
+ const cipherBuf = await crypto.subtle.encrypt({ name: 'AES-CBC', iv: ivData }, cryptoKey, enc.encode(plainText));
+ const bytes = new Uint8Array(cipherBuf);
+ let bin = '';
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
+ return btoa(bin);
+}
+
+/** 应用层请求头(含 apptoken) */
+function appHeaders(extra?: Record): Record {
+ return {
+ 'Content-Type': 'application/json',
+ 'digi-middleware-auth-app': APP_TOKEN,
+ ...extra,
+ };
+}
+
+/** 用户鉴权请求头(含 apptoken + usertoken) */
+function userHeaders(userToken: string, extra?: Record): Record {
+ return appHeaders({
+ 'digi-middleware-auth-user': userToken,
+ ...extra,
+ });
+}
+
+/** 从任意对象中尝试提取 token 字符串 */
+function pickToken(obj: Record): string | undefined {
+ if (typeof obj.token === 'string') return obj.token;
+ if (typeof obj.userToken === 'string') return obj.userToken;
+ const data = obj.data as Record | undefined;
+ if (data && typeof data.token === 'string') return data.token;
+ const result = obj.result as Record | undefined;
+ if (result && typeof result.token === 'string') return result.token;
+ return undefined;
+}
+
+export interface IamLoginParams {
+ userId: string;
+ password: string;
+ tenantId?: string;
+}
+
+export interface IamLoginResult {
+ /** 最终 userToken(经 refresh/tenant 刷新后) */
+ token: string;
+ /** userId */
+ userId: string;
+ /** 完整用户信息(含 login 原始返回 + authoredUser + 默认租户) */
+ userInfo: Record;
+}
+
+/**
+ * IAM 普通登录(identityType: query)
+ * 完整流程:加密登录 → 拉取租户列表 → 切换默认租户刷新 token
+ */
+export async function iamLogin({ userId, password, tenantId }: IamLoginParams): Promise {
+ // 1. 客户端生成 RSA 密钥对(1024)
+ const client = new JSEncrypt({ default_key_size: '1024' });
+ client.getKey();
+ const clientPrivateKeyPem = client.getPrivateKey();
+ const clientPublicKeyB64 = stripPem(client.getPublicKey());
+
+ // 2. 获取服务端公钥
+ const pkRes = await fetch(`${IAM_IDENTITY_BASE}/publickey`, { headers: appHeaders() });
+ if (!pkRes.ok) throw new Error(`获取服务端公钥失败 (HTTP ${pkRes.status})`);
+ const pkJson = await pkRes.json();
+ const serverPublicKey: string = pkJson.publicKey;
+ if (!serverPublicKey) throw new Error('服务端公钥为空');
+
+ // 3. 服务端公钥加密客户端公钥
+ const server = new JSEncrypt();
+ server.setPublicKey(wrapPem(serverPublicKey));
+ const clientEncryptPublicKey = server.encrypt(clientPublicKeyB64);
+ if (!clientEncryptPublicKey) throw new Error('加密客户端公钥失败');
+
+ // 4. 获取加密的 AES 密钥
+ const aesRes = await fetch(`${IAM_IDENTITY_BASE}/aeskey`, {
+ method: 'POST',
+ headers: appHeaders(),
+ body: JSON.stringify({ clientEncryptPublicKey }),
+ });
+ if (!aesRes.ok) throw new Error(`获取 AES 密钥失败 (HTTP ${aesRes.status})`);
+ const aesJson = await aesRes.json();
+ const encryptAesKey: string = aesJson.encryptAesKey;
+ if (!encryptAesKey) throw new Error(`获取 AES 密钥失败: ${JSON.stringify(aesJson)}`);
+
+ // 5. 客户端私钥解密 AES 密钥
+ client.setPrivateKey(clientPrivateKeyPem);
+ const aesKey = client.decrypt(encryptAesKey);
+ if (!aesKey) throw new Error('解密 AES 密钥失败');
+
+ // 6. AES 加密密码
+ const passwordHash = await aesEncryptToBase64(password, aesKey);
+
+ // 7. 登录
+ const loginBody: Record = {
+ userId,
+ passwordHash,
+ clientEncryptPublicKey,
+ identityType: 'query',
+ };
+ if (tenantId) loginBody.tenantId = tenantId;
+
+ const loginRes = await fetch(`${IAM_IDENTITY_BASE}/login`, {
+ method: 'POST',
+ headers: appHeaders(),
+ body: JSON.stringify(loginBody),
+ });
+ const loginJson = (await loginRes.json().catch(() => ({}))) as Record;
+
+ const initialToken = pickToken(loginJson);
+ if (!loginRes.ok || !initialToken) {
+ const msg = loginJson.message || loginJson.msg || loginJson.error || `HTTP ${loginRes.status}`;
+ throw new Error(`登录失败: ${msg}`);
+ }
+
+ // 8. 拉取用户授权租户列表 + 切换默认租户
+ const tenantCtx = await switchDefaultTenant(initialToken);
+
+ // 9. 组装完整 userInfo
+ const userInfo: Record = {
+ ...(tenantCtx.authoredUser ?? {}),
+ ...(loginJson ?? {}),
+ userId,
+ token: tenantCtx.token,
+ isLoggedin: true,
+ ...(tenantCtx.currTenantList ? { currTenantList: tenantCtx.currTenantList } : {}),
+ };
+
+ return {
+ token: tenantCtx.token,
+ userId,
+ userInfo,
+ };
+}
+
+/**
+ * 切换默认租户(公用流程)
+ *
+ * 1. POST /api/iam/v2/tenant?appId=APPID 拉取租户列表
+ * 2. 优先选 isDefault=true 的租户,否则取第一个
+ * 3. POST /api/iam/v2/identity/token/refresh/tenant body={tenantSid}
+ * 4. 返回刷新后的 token + authoredUser + currTenantList
+ *
+ * 异常不抛出,回退到传入的 userToken
+ */
+export async function switchDefaultTenant(
+ userToken: string,
+): Promise<{
+ token: string;
+ authoredUser?: Record;
+ currTenantList?: unknown[];
+}> {
+ let finalToken = userToken;
+ let authoredUser: Record | undefined;
+ let currTenantList: unknown[] | undefined;
+
+ try {
+ const tenantRes = await fetch(`${IAM_API_BASE}/tenant?appId=${encodeURIComponent(APPID)}`, {
+ method: 'POST',
+ headers: userHeaders(userToken),
+ });
+ if (!tenantRes.ok) throw new Error(`获取租户列表失败 (HTTP ${tenantRes.status})`);
+ const tenantJson = (await tenantRes.json().catch(() => ({}))) as Record;
+
+ // 兼容数组 / {data:[]} / {list:[]} / {result:[]}
+ let tenants: unknown[] = [];
+ if (Array.isArray(tenantJson)) {
+ tenants = tenantJson;
+ } else if (Array.isArray(tenantJson.data)) {
+ tenants = tenantJson.data as unknown[];
+ } else if (Array.isArray(tenantJson.list)) {
+ tenants = tenantJson.list as unknown[];
+ } else if (Array.isArray(tenantJson.result)) {
+ tenants = tenantJson.result as unknown[];
+ }
+
+ if (tenants.length > 0) {
+ currTenantList = tenants;
+ // 优先选 isDefault=true 的租户,否则取第一个
+ const defaultTenant = (tenants.find((t) => (t as Record)?.isDefault === true)
+ ?? tenants[0]) as Record;
+ const tenantSid = (defaultTenant.sid as number | string | undefined)
+ ?? (defaultTenant.tenantSid as number | string | undefined)
+ ?? (defaultTenant.id as number | string | undefined);
+
+ if (tenantSid !== undefined && tenantSid !== null) {
+ // 切换默认租户,刷新 token
+ const refreshRes = await fetch(`${IAM_IDENTITY_BASE}/token/refresh/tenant`, {
+ method: 'POST',
+ headers: userHeaders(userToken),
+ body: JSON.stringify({ tenantSid }),
+ });
+ if (refreshRes.ok) {
+ const refreshJson = (await refreshRes.json().catch(() => ({}))) as Record;
+ const refreshedToken = pickToken(refreshJson);
+ if (refreshedToken) finalToken = refreshedToken;
+ // authoredUser 平铺到 userInfo 顶层
+ if (refreshJson.authoredUser && typeof refreshJson.authoredUser === 'object') {
+ authoredUser = refreshJson.authoredUser as Record;
+ } else if (refreshJson.data && typeof refreshJson.data === 'object'
+ && (refreshJson.data as Record).authoredUser) {
+ authoredUser = (refreshJson.data as Record).authoredUser as Record;
+ } else {
+ // 整个 refresh 返回作为 authoredUser(兼容字段直接在顶层)
+ authoredUser = refreshJson;
+ }
+ }
+ }
+ }
+ } catch (ex) {
+ // 租户切换失败不阻断登录,仍使用原 token
+ console.warn('[IAM] 租户切换流程异常,将使用原 token', ex);
+ }
+
+ return { token: finalToken, authoredUser, currTenantList };
+}
+
+/**
+ * SSO 登录(基于外部传入的 userToken)
+ *
+ * 流程:
+ * 1. POST /api/iam/v2/identity/token/refresh/app 刷新应用 token + 用户信息
+ * 2. POST /api/iam/v2/identity/login/info 获取登录详情
+ * 3. 调用 switchDefaultTenant 拉取租户列表 + 切换默认租户
+ * 4. 组装完整 userInfo(authoredUser 平铺到顶层)
+ */
+export async function iamSsoLogin(initialUserToken: string): Promise {
+ // 1. token/refresh/app:刷新应用 token
+ const refreshAppRes = await fetch(`${IAM_IDENTITY_BASE}/token/refresh/app`, {
+ method: 'POST',
+ headers: userHeaders(initialUserToken),
+ });
+ if (!refreshAppRes.ok) throw new Error(`SSO token 刷新失败 (HTTP ${refreshAppRes.status})`);
+ const refreshAppJson = (await refreshAppRes.json().catch(() => ({}))) as Record;
+ const appRefreshedToken = pickToken(refreshAppJson) ?? initialUserToken;
+
+ // 2. login/info:获取登录详情
+ let loginInfoJson: Record = {};
+ try {
+ const infoRes = await fetch(`${IAM_IDENTITY_BASE}/login/info`, {
+ method: 'POST',
+ headers: userHeaders(appRefreshedToken),
+ });
+ if (infoRes.ok) {
+ loginInfoJson = (await infoRes.json().catch(() => ({}))) as Record;
+ }
+ } catch (ex) {
+ console.warn('[IAM] login/info 调用异常', ex);
+ }
+
+ // 3. 切换默认租户
+ const tenantCtx = await switchDefaultTenant(appRefreshedToken);
+
+ // 4. 组装完整 userInfo
+ const userId = (loginInfoJson.userId as string)
+ ?? (refreshAppJson.userId as string)
+ ?? (tenantCtx.authoredUser?.userId as string)
+ ?? '';
+
+ const userInfo: Record = {
+ ...(tenantCtx.authoredUser ?? {}),
+ ...(refreshAppJson ?? {}),
+ ...(loginInfoJson ?? {}),
+ userId,
+ token: tenantCtx.token,
+ isLoggedin: true,
+ ...(tenantCtx.currTenantList ? { currTenantList: tenantCtx.currTenantList } : {}),
+ };
+
+ return {
+ token: tenantCtx.token,
+ userId,
+ userInfo,
+ };
+}
+
+/* ---------------- sessionStorage 会话管理 ---------------- */
+
+const KEY_USER_TOKEN = 'userToken';
+const KEY_USER_INFO = 'userInfo';
+const KEY_APP_TOKEN = 'digi-middleware-auth-app';
+
+export interface SessionUserInfo {
+ userId: string;
+ userName?: string;
+ token: string;
+ tenantId?: string;
+ tenantName?: string;
+ tenantSid?: number;
+ sid?: number;
+ email?: string;
+ telephone?: string;
+ isLoggedin?: boolean;
+ currTenantList?: unknown[];
+ [key: string]: unknown;
+}
+
+/** 保存登录会话 */
+export function saveSession(token: string, info: Record): void {
+ sessionStorage.setItem(KEY_USER_TOKEN, token);
+ sessionStorage.setItem(KEY_USER_INFO, JSON.stringify(info));
+ sessionStorage.setItem(KEY_APP_TOKEN, APP_TOKEN);
+}
+
+/** 获取当前 userToken */
+export function getUserToken(): string | null {
+ return sessionStorage.getItem(KEY_USER_TOKEN);
+}
+
+/** 获取当前用户信息 */
+export function getUserInfo(): SessionUserInfo | null {
+ const raw = sessionStorage.getItem(KEY_USER_INFO);
+ if (!raw) return null;
+ try {
+ return JSON.parse(raw) as SessionUserInfo;
+ } catch {
+ return null;
+ }
+}
+
+/** 退出登录,清空会话 */
+export function clearSession(): void {
+ sessionStorage.removeItem(KEY_USER_TOKEN);
+ sessionStorage.removeItem(KEY_USER_INFO);
+ sessionStorage.removeItem(KEY_APP_TOKEN);
+}
diff --git a/mcp-auth/frontend/tsconfig.tsbuildinfo b/mcp-auth/frontend/tsconfig.tsbuildinfo
index 5f8a5ee..4a2841b 100644
--- a/mcp-auth/frontend/tsconfig.tsbuildinfo
+++ b/mcp-auth/frontend/tsconfig.tsbuildinfo
@@ -1 +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"}
\ No newline at end of file
+{"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/ssologin/index.tsx","./src/pages/services/index.tsx","./src/pages/stats/index.tsx","./src/pages/tokens/index.tsx","./src/services/iamauth.ts"],"version":"5.9.3"}
\ No newline at end of file
diff --git a/mcp-auth/frontend/vite.config.ts b/mcp-auth/frontend/vite.config.ts
index 003aa76..782fded 100644
--- a/mcp-auth/frontend/vite.config.ts
+++ b/mcp-auth/frontend/vite.config.ts
@@ -7,6 +7,12 @@ export default defineConfig({
port: 5173,
proxy: {
'/api': 'http://localhost:8000',
+ // 鼎捷云 IAM 登录服务代理(避免 CORS / 网络问题)
+ '/iam-api': {
+ target: 'https://iam.digiwincloud.com.cn',
+ changeOrigin: true,
+ rewrite: (p) => p.replace(/^\/iam-api/, ''),
+ },
},
},
build: {