添加iam鉴权
This commit is contained in:
+4
-1
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -4,3 +4,4 @@ asyncpg>=0.30.0
|
||||
bcrypt>=4.2.0
|
||||
pyjwt>=2.9.0
|
||||
pydantic>=2.9.0
|
||||
httpx>=0.27.0
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MCP Token 管理后台</title>
|
||||
<style>
|
||||
.iam-login-input::placeholder { color: rgba(168,216,255,0.6) !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 <Navigate to="/login" replace state={{ from: location.pathname + location.search }} />;
|
||||
}
|
||||
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 (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider collapsible collapsed={collapsed} onCollapse={setCollapsed}>
|
||||
@@ -54,11 +69,11 @@ function AppLayout() {
|
||||
>
|
||||
<span>MCP Token 鉴权管理后台</span>
|
||||
<span style={{ fontSize: 13, color: '#999' }}>
|
||||
{localStorage.getItem('username') || ''} ·{' '}
|
||||
{displayName} ·{' '}
|
||||
<a
|
||||
onClick={() => {
|
||||
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 (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
{jwt ? (
|
||||
<Route path="/*" element={<AppLayout />} />
|
||||
) : (
|
||||
<Route path="/*" element={<Navigate to="/login" replace />} />
|
||||
)}
|
||||
<Route path="/sso-login" element={<SSOLogin />} />
|
||||
<Route path="/*" element={<RequireAuth><AppLayout /></RequireAuth>} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', paddingTop: '10vh' }}>
|
||||
<Card style={{ width: 380 }}>
|
||||
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||
<div style={{
|
||||
width: '100vw',
|
||||
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 管理后台
|
||||
</Typography.Title>
|
||||
<Form onFinish={onFinish} size="large">
|
||||
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input prefix={<UserOutlined />} placeholder="用户名" />
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* 登录表单 */}
|
||||
<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 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>
|
||||
<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>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center' }}>
|
||||
默认账号 admin / admin123
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
|
||||
<p style={{ fontSize: 13, marginTop: 24, textAlign: 'center', color: 'rgba(168,216,255,0.6)' }}>
|
||||
鼎捷云统一身份认证 · IAM
|
||||
</p>
|
||||
</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 () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const res = await registerService({
|
||||
service_name: values.service_name,
|
||||
@@ -96,12 +95,6 @@ export default function Services() {
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch (err: any) {
|
||||
const detail = err?.response?.data?.detail;
|
||||
if (detail) {
|
||||
message.error(detail);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onCopy = (text: string) => {
|
||||
|
||||
@@ -101,7 +101,6 @@ export default function Tokens() {
|
||||
};
|
||||
|
||||
const onCreate = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const res = await createToken({
|
||||
client_id: values.client_id,
|
||||
@@ -113,12 +112,6 @@ export default function Tokens() {
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch (err: any) {
|
||||
const detail = err?.response?.data?.detail;
|
||||
if (detail) {
|
||||
message.error(detail);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<TokenRow> = [
|
||||
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user