添加iam鉴权
This commit is contained in:
+4
-1
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 主入口
|
"""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(
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Generated
+7
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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> = [
|
||||||
|
|||||||
@@ -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,
|
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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user