From 3639004764442db8e34b6ca6e834eea0ce850d90 Mon Sep 17 00:00:00 2001 From: dongsk Date: Mon, 31 Aug 2026 14:28:17 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0iam=E9=89=B4=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 5 +- mcp-auth/backend/app/core/config.py | 12 +- mcp-auth/backend/app/core/deps.py | 39 +- mcp-auth/backend/app/core/iam.py | 80 ++++ mcp-auth/backend/app/core/security.py | 31 -- mcp-auth/backend/app/main.py | 22 +- mcp-auth/backend/app/routers/auth.py | 52 +-- mcp-auth/backend/requirements.txt | 1 + mcp-auth/frontend/index.html | 3 + mcp-auth/frontend/package-lock.json | 7 + mcp-auth/frontend/package.json | 1 + mcp-auth/frontend/src/App.tsx | 31 +- mcp-auth/frontend/src/api/client.ts | 17 +- mcp-auth/frontend/src/api/index.ts | 5 - mcp-auth/frontend/src/pages/Login/index.tsx | 168 ++++++-- .../frontend/src/pages/SSOLogin/index.tsx | 93 +++++ .../frontend/src/pages/Services/index.tsx | 25 +- mcp-auth/frontend/src/pages/Tokens/index.tsx | 29 +- mcp-auth/frontend/src/services/iamAuth.ts | 372 ++++++++++++++++++ mcp-auth/frontend/tsconfig.tsbuildinfo | 2 +- mcp-auth/frontend/vite.config.ts | 6 + 21 files changed, 810 insertions(+), 191 deletions(-) create mode 100644 mcp-auth/backend/app/core/iam.py delete mode 100644 mcp-auth/backend/app/core/security.py create mode 100644 mcp-auth/frontend/src/pages/SSOLogin/index.tsx create mode 100644 mcp-auth/frontend/src/services/iamAuth.ts 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', + }} + /> - - - - 默认账号 admin / admin123 - - + +

+ 鼎捷云统一身份认证 · IAM +

+
); } 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} +
+ +
+ ) : ( +
+ +
正在登录...
+
+ )} +
+ ); +}; + +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: {