添加iam鉴权

This commit is contained in:
2026-08-31 14:28:17 +08:00
parent eb91c6fe84
commit 3639004764
21 changed files with 810 additions and 191 deletions
+4 -1
View File
@@ -71,5 +71,8 @@ services:
- AUTH_DB_USER=${AUTH_DB_USER:-postgres} - AUTH_DB_USER=${AUTH_DB_USER:-postgres}
- AUTH_DB_PASSWORD=${AUTH_DB_PASSWORD:-digiwin} - AUTH_DB_PASSWORD=${AUTH_DB_PASSWORD:-digiwin}
- AUTH_DB_NAME=${AUTH_DB_NAME:-mcp_auth} - 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 restart: unless-stopped
+9 -3
View File
@@ -10,9 +10,15 @@ class Settings:
AUTH_DB_PASSWORD: str = os.getenv("AUTH_DB_PASSWORD", "digiwin") AUTH_DB_PASSWORD: str = os.getenv("AUTH_DB_PASSWORD", "digiwin")
AUTH_DB_NAME: str = os.getenv("AUTH_DB_NAME", "mcp_auth") AUTH_DB_NAME: str = os.getenv("AUTH_DB_NAME", "mcp_auth")
JWT_SECRET: str = os.getenv("JWT_SECRET", "change-me-in-prod") # 鼎捷云 IAM 配置
JWT_ALG: str = "HS256" IAM_BASE_URL: str = os.getenv("IAM_BASE_URL", "https://iam.digiwincloud.com.cn")
JWT_EXP_HOURS: int = int(os.getenv("JWT_EXP_HOURS", "12")) # 应用 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 构建后注入) # 前端静态文件目录(Docker 构建后注入)
STATIC_DIR: str = os.getenv("STATIC_DIR", "../frontend/dist") STATIC_DIR: str = os.getenv("STATIC_DIR", "../frontend/dist")
+27 -12
View File
@@ -1,18 +1,33 @@
"""FastAPI 依赖:JWT 校验,提取当前管理员""" """FastAPI 依赖:IAM token 校验,提取当前管理员
from fastapi import Depends, HTTPException, status 从请求头 digi-middleware-auth-user / digi-middleware-auth-app 读取凭证,
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer 调用 IAM /api/iam/v2/identity/token/analyze 校验并解析用户信息。
from . import security """
bearer_scheme = HTTPBearer(auto_error=False) from fastapi import Header, HTTPException, status
from . import iam
async def current_admin( 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: ) -> dict:
if creds is None or creds.scheme.lower() != "bearer": if not user_token:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "未提供 JWT") raise HTTPException(status.HTTP_401_UNAUTHORIZED, "未提供 IAM userToken")
payload = security.decode_jwt(creds.credentials)
if payload is None: info = await iam.analyze_token(user_token, app_token)
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "JWT 无效或已过期") if info is None:
return payload 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,
}
+80
View File
@@ -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
-31
View File
@@ -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
+5 -17
View File
@@ -1,10 +1,10 @@
"""MCP Auth Admin — FastAPI 主入口 """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 指向)。 静态文件由前端构建产物提供(STATIC_DIR 指向)。
""" """
import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
@@ -12,30 +12,18 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from app.core import security
from app.core.config import settings from app.core.config import settings
from app.core.db import close_pool, get_pool 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 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
await _ensure_default_admin() await get_pool()
yield yield
await close_pool() await close_pool()
await close_iam_client()
app = FastAPI( app = FastAPI(
+11 -41
View File
@@ -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 from ..core.deps import current_admin
router = APIRouter(prefix="/api/admin", tags=["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") @router.get("/me")
async def me(admin: dict = Depends(current_admin)): 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"),
}
+1
View File
@@ -4,3 +4,4 @@ asyncpg>=0.30.0
bcrypt>=4.2.0 bcrypt>=4.2.0
pyjwt>=2.9.0 pyjwt>=2.9.0
pydantic>=2.9.0 pydantic>=2.9.0
httpx>=0.27.0
+3
View File
@@ -4,6 +4,9 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MCP Token 管理后台</title> <title>MCP Token 管理后台</title>
<style>
.iam-login-input::placeholder { color: rgba(168,216,255,0.6) !important; }
</style>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+7
View File
@@ -12,6 +12,7 @@
"antd": "^5.21.0", "antd": "^5.21.0",
"axios": "^1.7.0", "axios": "^1.7.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"jsencrypt": "^3.3.2",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.26.0" "react-router-dom": "^6.26.0"
@@ -2042,6 +2043,12 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT" "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": { "node_modules/jsesc": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+1
View File
@@ -13,6 +13,7 @@
"antd": "^5.21.0", "antd": "^5.21.0",
"axios": "^1.7.0", "axios": "^1.7.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"jsencrypt": "^3.3.2",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.26.0" "react-router-dom": "^6.26.0"
+21 -10
View File
@@ -2,11 +2,23 @@ import { Layout, Menu, theme } from 'antd';
import { useState } from 'react'; import { useState } from 'react';
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import Login from './pages/Login'; import Login from './pages/Login';
import SSOLogin from './pages/SSOLogin';
import Services from './pages/Services'; import Services from './pages/Services';
import Stats from './pages/Stats'; import Stats from './pages/Stats';
import Tokens from './pages/Tokens'; import Tokens from './pages/Tokens';
import { clearSession, getUserInfo } from './services/iamAuth';
const { Header, Content, Sider } = Layout; const { Header, Content, Sider } = Layout;
/** 登录守卫:sessionStorage 无 userToken 则跳登录页(携带来源路径,登录后回跳) */
function RequireAuth({ children }: { children: React.ReactNode }) {
const location = useLocation();
const token = sessionStorage.getItem('userToken');
if (!token) {
return <Navigate to="/login" replace state={{ from: location.pathname + location.search }} />;
}
return <>{children}</>;
}
function AppLayout() { function AppLayout() {
const nav = useNavigate(); const nav = useNavigate();
const loc = useLocation(); const loc = useLocation();
@@ -15,6 +27,9 @@ function AppLayout() {
token: { colorBgContainer }, token: { colorBgContainer },
} = theme.useToken(); } = theme.useToken();
const userInfo = getUserInfo();
const displayName = userInfo?.userName || userInfo?.userId || '';
return ( return (
<Layout style={{ minHeight: '100vh' }}> <Layout style={{ minHeight: '100vh' }}>
<Sider collapsible collapsed={collapsed} onCollapse={setCollapsed}> <Sider collapsible collapsed={collapsed} onCollapse={setCollapsed}>
@@ -54,11 +69,11 @@ function AppLayout() {
> >
<span>MCP Token 鉴权管理后台</span> <span>MCP Token 鉴权管理后台</span>
<span style={{ fontSize: 13, color: '#999' }}> <span style={{ fontSize: 13, color: '#999' }}>
{localStorage.getItem('username') || ''} ·{' '} {displayName} ·{' '}
<a <a
onClick={() => { onClick={() => {
localStorage.clear(); clearSession();
window.location.href = '/login'; nav('/login', { replace: true });
}} }}
> >
退出 退出
@@ -88,17 +103,13 @@ function AppLayout() {
} }
export default function App() { export default function App() {
// 随路由变化重新读取 jwt,登录写入 localStorage 后跳转能即时生效 // 路由变化时重渲染,重读 sessionStorage 登录态
useLocation(); useLocation();
const jwt = localStorage.getItem('jwt');
return ( return (
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
{jwt ? ( <Route path="/sso-login" element={<SSOLogin />} />
<Route path="/*" element={<AppLayout />} /> <Route path="/*" element={<RequireAuth><AppLayout /></RequireAuth>} />
) : (
<Route path="/*" element={<Navigate to="/login" replace />} />
)}
</Routes> </Routes>
); );
} }
+10 -7
View File
@@ -1,27 +1,30 @@
import axios from 'axios'; import axios from 'axios';
import { APP_TOKEN } from '../services/iamAuth';
const api = axios.create({ const api = axios.create({
baseURL: '/api', baseURL: '/api',
timeout: 15000, timeout: 15000,
}); });
// 请求拦截:自动带 JWT // 请求拦截:自动带 IAM 鉴权头(digi-middleware-auth-user / -app)
api.interceptors.request.use((config) => { api.interceptors.request.use((config) => {
const token = localStorage.getItem('jwt'); const token = sessionStorage.getItem('userToken');
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}`; config.headers['digi-middleware-auth-user'] = token;
config.headers['digi-middleware-auth-app'] = APP_TOKEN;
} }
return config; return config;
}); });
// 响应拦截:401 跳登录 // 响应拦截:401 清理会话并跳登录页
api.interceptors.response.use( api.interceptors.response.use(
(res) => res, (res) => res,
(err) => { (err) => {
if (err.response?.status === 401) { if (err.response?.status === 401) {
localStorage.removeItem('jwt'); const path = window.location.pathname;
localStorage.removeItem('username'); if (path !== '/login' && !path.startsWith('/sso-login')) {
if (window.location.pathname !== '/login') { sessionStorage.removeItem('userToken');
sessionStorage.removeItem('userInfo');
window.location.href = '/login'; window.location.href = '/login';
} }
} }
-5
View File
@@ -32,11 +32,6 @@ export interface CreateResult {
message: 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?: { export async function listTokens(params?: {
client_id?: string; client_id?: string;
status?: string; status?: string;
+138 -28
View File
@@ -1,51 +1,161 @@
import { useState } from 'react'; import { useState } from 'react';
import { Card, Form, Input, Button, message, Typography } from 'antd'; import { Button, Form, Input } from 'antd';
import { LockOutlined, UserOutlined } from '@ant-design/icons'; import { LockOutlined, UserOutlined, CloseCircleOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { login } from '../../api'; import { iamLogin, saveSession } from '../../services/iamAuth';
/**
* IAM 登录页(鼎捷云统一身份认证)
*
* 参考 ai-platform 实现:账号密码经 RSA+AES 加密链路上送 IAM,
* 登录成功后 userToken + userInfo 写入 sessionStorage,回跳原页面。
*/
export default function Login() { export default function Login() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const nav = useNavigate(); 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); setLoading(true);
setError('');
try { try {
const res = await login(values.username, values.password); const result = await iamLogin({ userId: values.userId.trim(), password: values.password });
localStorage.setItem('jwt', res.token); saveSession(result.token, result.userInfo);
localStorage.setItem('username', res.username); // 回跳原页面(守卫记录的 from),否则去首页
message.success('登录成功'); const from = (loc.state as { from?: string })?.from ?? '/';
nav('/tokens'); nav(from, { replace: true });
} catch { } catch (ex) {
message.error('用户名或密码错误'); setError(ex instanceof Error ? ex.message : '登录失败,请稍后重试');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
return ( return (
<div style={{ display: 'flex', justifyContent: 'center', paddingTop: '10vh' }}> <div style={{
<Card style={{ width: 380 }}> width: '100vw',
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 24 }}> minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
boxSizing: 'border-box',
background: 'linear-gradient(160deg, #0a1628 0%, #0d1b30 50%, #0a1628 100%)',
fontFamily: "-apple-system, 'SF Pro Display', 'SF Pro Text', 'PingFang SC', 'Helvetica Neue', Arial, sans-serif",
color: '#a8d8ff',
}}>
{/* 装饰光晕 */}
<div style={{
position: 'fixed', top: '20%', left: '50%', transform: 'translateX(-50%)',
width: 600, height: 600, borderRadius: '50%',
background: 'radial-gradient(circle, rgba(0,212,255,0.12) 0%, transparent 70%)',
filter: 'blur(40px)', pointerEvents: 'none', zIndex: 0,
}} />
<div style={{
position: 'relative', zIndex: 1,
width: 400, maxWidth: '92vw',
padding: '40px 36px 32px',
borderRadius: 20,
background: 'rgba(10,22,40,0.75)',
backdropFilter: 'blur(16px)',
border: '1px solid rgba(0,212,255,0.2)',
boxShadow: '0 24px 64px rgba(0,0,0,0.5), 0 0 48px rgba(0,212,255,0.08)',
}}>
{/* 品牌区 */}
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<h1 style={{
fontSize: 26, fontWeight: 800, letterSpacing: '0.04em', margin: 0,
color: '#e8f4ff', textShadow: '0 0 16px rgba(0,212,255,0.5)',
}}>
MCP Token 管理后台 MCP Token 管理后台
</Typography.Title> </h1>
<Form onFinish={onFinish} size="large"> </div>
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input prefix={<UserOutlined />} placeholder="用户名" /> {/* 登录表单 */}
<Form onFinish={onFinish} size="large" autoComplete="on">
<Form.Item
name="userId"
rules={[{ required: true, message: '请输入账号' }]}
>
<Input
prefix={<UserOutlined style={{ color: 'rgba(0,212,255,0.6)' }} />}
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',
}}
/>
</Form.Item> </Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password prefix={<LockOutlined />} placeholder="密码" /> <Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined style={{ color: 'rgba(0,212,255,0.6)' }} />}
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',
}}
/>
</Form.Item> </Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}> {error && (
登录 <div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 12px', marginBottom: 16,
borderRadius: 8, fontSize: 13,
background: 'rgba(239,68,68,0.12)',
border: '1px solid rgba(239,68,68,0.4)',
color: '#fca5a5',
}}>
<CloseCircleOutlined style={{ flexShrink: 0 }} />
{error}
</div>
)}
<Form.Item style={{ marginBottom: 0 }}>
<Button
type="primary"
htmlType="submit"
block
loading={loading}
disabled={loading}
style={{
height: 44,
borderRadius: 10,
fontSize: 15,
fontWeight: 600,
letterSpacing: '0.08em',
border: 'none',
background: 'linear-gradient(90deg, #00d4ff, #4f9eff)',
boxShadow: '0 8px 24px rgba(0,212,255,0.35)',
}}
>
{loading ? '登录中...' : '登 录'}
</Button> </Button>
</Form.Item> </Form.Item>
</Form> </Form>
<Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center' }}>
默认账号 admin / admin123 <p style={{ fontSize: 13, marginTop: 24, textAlign: 'center', color: 'rgba(168,216,255,0.6)' }}>
</Typography.Text> 鼎捷云统一身份认证 · IAM
</Card> </p>
</div>
</div> </div>
); );
} }
@@ -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<string>('');
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 (
<div style={{
width: '100vw',
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#0a1628',
color: '#a8d8ff',
fontFamily: "-apple-system, 'SF Pro Display', 'PingFang SC', 'Helvetica Neue', Arial, sans-serif",
}}>
{error ? (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16,
padding: '32px 40px',
borderRadius: 16,
background: 'rgba(239,68,68,0.08)',
border: '1px solid rgba(239,68,68,0.4)',
maxWidth: 460,
}}>
<CloseCircleOutlined style={{ fontSize: 36, color: '#fca5a5' }} />
<div style={{ fontSize: 16, fontWeight: 600, color: '#fca5a5' }}>SSO 登录失败</div>
<div style={{ fontSize: 13, color: 'rgba(168,216,255,0.7)', textAlign: 'center', wordBreak: 'break-all' }}>
{error}
</div>
<button
onClick={() => 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',
}}
>
前往登录页
</button>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}>
<Spin size="large" />
<div style={{ fontSize: 14, color: 'rgba(168,216,255,0.7)' }}>正在登录...</div>
</div>
)}
</div>
);
};
export default SSOLogin;
@@ -86,7 +86,6 @@ export default function Services() {
}; };
const onCreate = async () => { const onCreate = async () => {
try {
const values = await form.validateFields(); const values = await form.validateFields();
const res = await registerService({ const res = await registerService({
service_name: values.service_name, service_name: values.service_name,
@@ -96,12 +95,6 @@ export default function Services() {
setCreateOpen(false); setCreateOpen(false);
form.resetFields(); form.resetFields();
load(); load();
} catch (err: any) {
const detail = err?.response?.data?.detail;
if (detail) {
message.error(detail);
}
}
}; };
const onCopy = (text: string) => { const onCopy = (text: string) => {
@@ -101,7 +101,6 @@ export default function Tokens() {
}; };
const onCreate = async () => { const onCreate = async () => {
try {
const values = await form.validateFields(); const values = await form.validateFields();
const res = await createToken({ const res = await createToken({
client_id: values.client_id, client_id: values.client_id,
@@ -113,12 +112,6 @@ export default function Tokens() {
setCreateOpen(false); setCreateOpen(false);
form.resetFields(); form.resetFields();
load(); load();
} catch (err: any) {
const detail = err?.response?.data?.detail;
if (detail) {
message.error(detail);
}
}
}; };
const columns: ColumnsType<TokenRow> = [ const columns: ColumnsType<TokenRow> = [
+372
View File
@@ -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<string> {
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<string, string>): Record<string, string> {
return {
'Content-Type': 'application/json',
'digi-middleware-auth-app': APP_TOKEN,
...extra,
};
}
/** 用户鉴权请求头(含 apptoken + usertoken) */
function userHeaders(userToken: string, extra?: Record<string, string>): Record<string, string> {
return appHeaders({
'digi-middleware-auth-user': userToken,
...extra,
});
}
/** 从任意对象中尝试提取 token 字符串 */
function pickToken(obj: Record<string, unknown>): string | undefined {
if (typeof obj.token === 'string') return obj.token;
if (typeof obj.userToken === 'string') return obj.userToken;
const data = obj.data as Record<string, unknown> | undefined;
if (data && typeof data.token === 'string') return data.token;
const result = obj.result as Record<string, unknown> | 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<string, unknown>;
}
/**
* IAM 普通登录(identityType: query)
* 完整流程:加密登录 → 拉取租户列表 → 切换默认租户刷新 token
*/
export async function iamLogin({ userId, password, tenantId }: IamLoginParams): Promise<IamLoginResult> {
// 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<string, string> = {
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<string, unknown>;
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<string, unknown> = {
...(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<string, unknown>;
currTenantList?: unknown[];
}> {
let finalToken = userToken;
let authoredUser: Record<string, unknown> | 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<string, unknown>;
// 兼容数组 / {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<string, unknown>)?.isDefault === true)
?? tenants[0]) as Record<string, unknown>;
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<string, unknown>;
const refreshedToken = pickToken(refreshJson);
if (refreshedToken) finalToken = refreshedToken;
// authoredUser 平铺到 userInfo 顶层
if (refreshJson.authoredUser && typeof refreshJson.authoredUser === 'object') {
authoredUser = refreshJson.authoredUser as Record<string, unknown>;
} else if (refreshJson.data && typeof refreshJson.data === 'object'
&& (refreshJson.data as Record<string, unknown>).authoredUser) {
authoredUser = (refreshJson.data as Record<string, unknown>).authoredUser as Record<string, unknown>;
} 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<IamLoginResult> {
// 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<string, unknown>;
const appRefreshedToken = pickToken(refreshAppJson) ?? initialUserToken;
// 2. login/info:获取登录详情
let loginInfoJson: Record<string, unknown> = {};
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<string, unknown>;
}
} 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<string, unknown> = {
...(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<string, unknown>): 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);
}
+1 -1
View File
@@ -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"} {"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"}
+6
View File
@@ -7,6 +7,12 @@ export default defineConfig({
port: 5173, port: 5173,
proxy: { proxy: {
'/api': 'http://localhost:8000', '/api': 'http://localhost:8000',
// 鼎捷云 IAM 登录服务代理(避免 CORS / 网络问题)
'/iam-api': {
target: 'https://iam.digiwincloud.com.cn',
changeOrigin: true,
rewrite: (p) => p.replace(/^\/iam-api/, ''),
},
}, },
}, },
build: { build: {