diff --git a/mcp-auth/IAM接入文档.md b/mcp-auth/IAM接入文档.md new file mode 100644 index 0000000..ebcac5c --- /dev/null +++ b/mcp-auth/IAM接入文档.md @@ -0,0 +1,962 @@ +# 鼎捷云 IAM 登录接入指南 + +> 面向新项目从零接入鼎捷云统一身份认证(IAM),涵盖前端登录、SSO 回调、后端 Token 鉴权全链路。 + +--- + +## 一、整体架构 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 浏览器 │ +│ │ +│ ┌───────────┐ ┌─────────────┐ ┌───────────────────────┐ │ +│ │ 登录页 │────→│ iamAuth.ts │────→│ sessionStorage │ │ +│ │ /login │ │ RSA+AES │ │ userToken / userInfo │ │ +│ └───────────┘ └──────┬──────┘ └───────────┬───────────┘ │ +│ │ │ │ +│ ┌───────────┐ │ ┌────────────▼──────────┐ │ +│ │ SSO 回调 │─────────────┘ │ axios 拦截器 │ │ +│ │ /sso-login│ │ 自动带 IAM 鉴权请求头 │ │ +│ └───────────┘ └────────────┬──────────┘ │ +└──────────────────────────────────────────────────────┼────────────┘ + │ + ┌─────────────────────────────────────┼──────────────────┐ + │ │ │ + ┌──────▼─────┐ ┌──────▼──────┐ ┌──────▼──────┐ + │ Vite 代理 │ │ 后端 API │ │ 鼎捷云 IAM │ + │ /iam-api → │ │ FastAPI │ │ │ + │ /api → 8000 │ │ current_admin│───────→│ token/analyze│ + └────────────┘ └─────────────┘ └─────────────┘ +``` + +### 两种登录方式 + +**方式一:账号密码登录(普通登录)** + +``` +用户输入账号密码 + → 前端 RSA+AES 加密链路调 IAM /identity/login 拿 userToken + → 拉取租户列表 → 切换默认租户刷新 token + → userToken + userInfo 存 sessionStorage → 跳首页 +``` + +**方式二:SSO 单点登录** + +``` +外部系统跳转 /sso-login?userToken=xxx + → 前端调 iamSsoLogin(userToken) + → POST /identity/token/refresh/app 刷新应用 token + → POST /identity/login/info 获取登录详情 + → POST /tenant?appId=APPID 拉取租户列表,选默认租户 + → POST /identity/token/refresh/tenant 切换租户刷新 token + → userInfo 写入 sessionStorage → 跳首页 +``` + +### 后端鉴权流程 + +``` +前端请求(带 digi-middleware-auth-user / digi-middleware-auth-app 头) + → 后端 current_admin 依赖读取请求头 + → 调 IAM POST /api/iam/v2/identity/token/analyze 校验 + → 200 + 含 id/name → 返回管理员信息(缓存 30s) + → 非 200 / 无用户标识 → 401 +``` + +--- + +## 二、前置准备 + +### 2.1 在鼎捷云 IAM 注册应用 + +接入前需在鼎捷云 IAM 平台完成应用注册,获取以下凭证: + +| 凭证 | 说明 | 用途 | +|---|---|---| +| `apptoken` | 应用 token(digi-middleware-auth-app) | 所有 IAM API 请求头必带 | +| `appId` | 应用 ID | 租户列表查询参数 | + +### 2.2 配置 SSO 回调地址 + +在 IAM 应用配置中,将 SSO 回调地址设为: + +``` +https://你的域名/sso-login +``` + +IAM 认证成功后会以 `?userToken=xxx` 形式回跳到该地址。 + +### 2.3 IAM 关键接口清单 + +| 接口 | 方法 | 用途 | +|---|---|---| +| `/api/iam/v2/identity/publickey` | GET | 获取服务端 RSA 公钥 | +| `/api/iam/v2/identity/aeskey` | POST | 用客户端公钥换取加密的 AES 密钥 | +| `/api/iam/v2/identity/login` | POST | 账号密码登录 | +| `/api/iam/v2/identity/token/refresh/app` | POST | SSO 刷新应用 token | +| `/api/iam/v2/identity/login/info` | POST | 获取登录详情 | +| `/api/iam/v2/identity/token/refresh/tenant` | POST | 切换租户刷新 token | +| `/api/iam/v2/tenant?appId=APPID` | POST | 拉取用户授权租户列表 | +| `/api/iam/v2/identity/token/analyze` | POST | **后端校验 token(核心)** | + +IAM 服务地址:`https://iam.digiwincloud.com.cn` + +--- + +## 三、前端接入 + +### 3.1 创建前端项目 + +```bash +npm create vite@latest frontend -- --template react-ts +cd frontend +npm install +npm install react-router-dom axios antd @ant-design/icons jsencrypt dayjs +``` + +依赖说明: + +| 依赖 | 用途 | +|---|---| +| `react-router-dom` | 路由(登录页、SSO 回调、路由守卫) | +| `axios` | HTTP 请求 + 拦截器带 IAM 头 | +| `antd` | UI 组件库 | +| `jsencrypt` | IAM 登录 RSA 密钥对生成与加密 | +| `dayjs` | 日期格式化(可选) | + +### 3.2 Vite 代理配置 + +`vite.config.ts`: + +```ts +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + // 后端 API 代理 + '/api': 'http://localhost:8000', + // 鼎捷云 IAM 代理(避免浏览器跨域) + '/iam-api': { + target: 'https://iam.digiwincloud.com.cn', + changeOrigin: true, + rewrite: (p) => p.replace(/^\/iam-api/, ''), + }, + }, + }, +}) +``` + +### 3.3 创建 IAM 登录服务 + +`src/services/iamAuth.ts` — 核心模块,封装 IAM 登录、SSO、会话管理: + +```ts +import { JSEncrypt } from 'jsencrypt'; + +/** + * 鼎捷云 IAM 登录服务 + * + * 完整登录流程: + * 1. RSA+AES 加密链路获取 userToken(/api/iam/v2/identity/login) + * 2. 拉取用户授权租户列表,默认选第一个 + * 3. 切换租户刷新 token + * 4. 将完整用户信息写入 sessionStorage + */ + +// 代理路径(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`; + +// ★ 替换为你的 IAM 应用凭证 +export const APP_TOKEN = '你的应用 apptoken'; +export const APPID = '你的应用ID'; + +// 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 */ +async function aesEncryptToBase64(plainText: string, aesKey: string): Promise { + const enc = new TextEncoder(); + const keyData = enc.encode(aesKey); + const ivData = enc.encode(AES_IV); + const cryptoKey = await crypto.subtle.importKey('raw', keyData, { name: 'AES-CBC' }, false, ['encrypt']); + const cipherBuf = await crypto.subtle.encrypt({ name: 'AES-CBC', iv: ivData }, cryptoKey, enc.encode(plainText)); + const bytes = new Uint8Array(cipherBuf); + let bin = ''; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin); +} + +/** 应用层请求头(含 apptoken) */ +function appHeaders(extra?: Record): Record { + return { 'Content-Type': 'application/json', 'digi-middleware-auth-app': APP_TOKEN, ...extra }; +} + +/** 用户鉴权请求头(含 apptoken + usertoken) */ +function userHeaders(userToken: string, extra?: Record): Record { + return appHeaders({ 'digi-middleware-auth-user': userToken, ...extra }); +} + +/** 从响应中提取 token 字符串 */ +function pickToken(obj: Record): string | undefined { + if (typeof obj.token === 'string') return obj.token; + if (typeof obj.userToken === 'string') return obj.userToken; + const data = obj.data as Record | undefined; + if (data && typeof data.token === 'string') return data.token; + const result = obj.result as Record | undefined; + if (result && typeof result.token === 'string') return result.token; + return undefined; +} + +export interface IamLoginParams { userId: string; password: string; tenantId?: string } +export interface IamLoginResult { token: string; userId: string; userInfo: Record } + +/** + * IAM 普通登录(identityType: query) + * 加密链路:生成 RSA 密钥对 → 获取服务端公钥 → 加密客户端公钥 → 换 AES 密钥 → 加密密码 → 登录 + */ +export async function iamLogin({ userId, password, tenantId }: IamLoginParams): Promise { + // 1. 客户端生成 RSA 密钥对(1024) + const client = new JSEncrypt({ default_key_size: '1024' }); + client.getKey(); + const clientPrivateKeyPem = client.getPrivateKey(); + const clientPublicKeyB64 = stripPem(client.getPublicKey()); + + // 2. 获取服务端公钥 + const pkRes = await fetch(`${IAM_IDENTITY_BASE}/publickey`, { headers: appHeaders() }); + if (!pkRes.ok) throw new Error(`获取服务端公钥失败 (HTTP ${pkRes.status})`); + const pkJson = await pkRes.json(); + const serverPublicKey: string = pkJson.publicKey; + if (!serverPublicKey) throw new Error('服务端公钥为空'); + + // 3. 服务端公钥加密客户端公钥 + const server = new JSEncrypt(); + server.setPublicKey(wrapPem(serverPublicKey)); + const clientEncryptPublicKey = server.encrypt(clientPublicKeyB64); + if (!clientEncryptPublicKey) throw new Error('加密客户端公钥失败'); + + // 4. 获取加密的 AES 密钥 + const aesRes = await fetch(`${IAM_IDENTITY_BASE}/aeskey`, { + method: 'POST', headers: appHeaders(), + body: JSON.stringify({ clientEncryptPublicKey }), + }); + if (!aesRes.ok) throw new Error(`获取 AES 密钥失败 (HTTP ${aesRes.status})`); + const aesJson = await aesRes.json(); + const encryptAesKey: string = aesJson.encryptAesKey; + if (!encryptAesKey) throw new Error(`获取 AES 密钥失败: ${JSON.stringify(aesJson)}`); + + // 5. 客户端私钥解密 AES 密钥 + client.setPrivateKey(clientPrivateKeyPem); + const aesKey = client.decrypt(encryptAesKey); + if (!aesKey) throw new Error('解密 AES 密钥失败'); + + // 6. AES 加密密码 + const passwordHash = await aesEncryptToBase64(password, aesKey); + + // 7. 登录 + const loginBody: Record = { userId, passwordHash, clientEncryptPublicKey, identityType: 'query' }; + if (tenantId) loginBody.tenantId = tenantId; + const loginRes = await fetch(`${IAM_IDENTITY_BASE}/login`, { method: 'POST', headers: appHeaders(), body: JSON.stringify(loginBody) }); + const loginJson = (await loginRes.json().catch(() => ({}))) as Record; + const initialToken = pickToken(loginJson); + if (!loginRes.ok || !initialToken) { + const msg = loginJson.message || loginJson.msg || loginJson.error || `HTTP ${loginRes.status}`; + throw new Error(`登录失败: ${msg}`); + } + + // 8. 拉取租户列表 + 切换默认租户 + const tenantCtx = await switchDefaultTenant(initialToken); + + // 9. 组装完整 userInfo + const userInfo: Record = { + ...(tenantCtx.authoredUser ?? {}), ...(loginJson ?? {}), userId, + token: tenantCtx.token, isLoggedin: true, + ...(tenantCtx.currTenantList ? { currTenantList: tenantCtx.currTenantList } : {}), + }; + return { token: tenantCtx.token, userId, userInfo }; +} + +/** + * 切换默认租户 + * 1. POST /tenant?appId=APPID 拉取租户列表 + * 2. 优先选 isDefault=true 的租户,否则取第一个 + * 3. POST /identity/token/refresh/tenant 刷新 token + */ +export async function switchDefaultTenant(userToken: string): Promise<{ + token: string; authoredUser?: Record; currTenantList?: unknown[] +}> { + let finalToken = userToken; + let authoredUser: Record | undefined; + let currTenantList: unknown[] | undefined; + try { + const tenantRes = await fetch(`${IAM_API_BASE}/tenant?appId=${encodeURIComponent(APPID)}`, { method: 'POST', headers: userHeaders(userToken) }); + if (!tenantRes.ok) throw new Error(`获取租户列表失败 (HTTP ${tenantRes.status})`); + const tenantJson = (await tenantRes.json().catch(() => ({}))) as Record; + 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; + const defaultTenant = (tenants.find((t) => (t as Record)?.isDefault === true) ?? tenants[0]) as Record; + const tenantSid = defaultTenant.sid ?? defaultTenant.tenantSid ?? defaultTenant.id; + if (tenantSid !== undefined && tenantSid !== null) { + const refreshRes = await fetch(`${IAM_IDENTITY_BASE}/token/refresh/tenant`, { + method: 'POST', headers: userHeaders(userToken), body: JSON.stringify({ tenantSid }), + }); + if (refreshRes.ok) { + const refreshJson = (await refreshRes.json().catch(() => ({}))) as Record; + const refreshedToken = pickToken(refreshJson); + if (refreshedToken) finalToken = refreshedToken; + if (refreshJson.authoredUser && typeof refreshJson.authoredUser === 'object') { + authoredUser = refreshJson.authoredUser as Record; + } else { + authoredUser = refreshJson; + } + } + } + } + } catch (ex) { + console.warn('[IAM] 租户切换流程异常,将使用原 token', ex); + } + return { token: finalToken, authoredUser, currTenantList }; +} + +/** + * SSO 登录(基于外部传入的 userToken) + */ +export async function iamSsoLogin(initialUserToken: string): Promise { + // 1. 刷新应用 token + const refreshAppRes = await fetch(`${IAM_IDENTITY_BASE}/token/refresh/app`, { method: 'POST', headers: userHeaders(initialUserToken) }); + if (!refreshAppRes.ok) throw new Error(`SSO token 刷新失败 (HTTP ${refreshAppRes.status})`); + const refreshAppJson = (await refreshAppRes.json().catch(() => ({}))) as Record; + const appRefreshedToken = pickToken(refreshAppJson) ?? initialUserToken; + + // 2. 获取登录详情 + let loginInfoJson: Record = {}; + try { + const infoRes = await fetch(`${IAM_IDENTITY_BASE}/login/info`, { method: 'POST', headers: userHeaders(appRefreshedToken) }); + if (infoRes.ok) loginInfoJson = (await infoRes.json().catch(() => ({}))) as Record; + } catch (ex) { console.warn('[IAM] login/info 调用异常', ex); } + + // 3. 切换默认租户 + const tenantCtx = await switchDefaultTenant(appRefreshedToken); + + // 4. 组装 userInfo + const userId = (loginInfoJson.userId as string) ?? (refreshAppJson.userId as string) ?? (tenantCtx.authoredUser?.userId as string) ?? ''; + const userInfo: Record = { + ...(tenantCtx.authoredUser ?? {}), ...(refreshAppJson ?? {}), ...(loginInfoJson ?? {}), + userId, token: tenantCtx.token, isLoggedin: true, + ...(tenantCtx.currTenantList ? { currTenantList: tenantCtx.currTenantList } : {}), + }; + return { token: tenantCtx.token, userId, userInfo }; +} + +/* ---------------- sessionStorage 会话管理 ---------------- */ + +const KEY_USER_TOKEN = 'userToken'; +const KEY_USER_INFO = 'userInfo'; +const KEY_APP_TOKEN = 'digi-middleware-auth-app'; + +export function saveSession(token: string, info: Record): void { + sessionStorage.setItem(KEY_USER_TOKEN, token); + sessionStorage.setItem(KEY_USER_INFO, JSON.stringify(info)); + sessionStorage.setItem(KEY_APP_TOKEN, APP_TOKEN); +} + +export function getUserToken(): string | null { return sessionStorage.getItem(KEY_USER_TOKEN); } + +export function getUserInfo(): Record | null { + const raw = sessionStorage.getItem(KEY_USER_INFO); + if (!raw) return null; + try { return JSON.parse(raw); } catch { return null; } +} + +export function clearSession(): void { + sessionStorage.removeItem(KEY_USER_TOKEN); + sessionStorage.removeItem(KEY_USER_INFO); + sessionStorage.removeItem(KEY_APP_TOKEN); +} +``` + +### 3.4 创建登录页 + +`src/pages/Login/index.tsx`: + +```tsx +import { useState } from 'react'; +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'; + +export default function Login() { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const nav = useNavigate(); + const loc = useLocation(); + + const onFinish = async (values: { userId: string; password: string }) => { + setLoading(true); + setError(''); + try { + const result = await iamLogin({ userId: values.userId.trim(), password: values.password }); + saveSession(result.token, result.userInfo); + const from = (loc.state as { from?: string })?.from ?? '/'; + nav(from, { replace: true }); + } catch (ex) { + setError(ex instanceof Error ? ex.message : '登录失败,请稍后重试'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ + } placeholder="账号(用户ID / 手机号 / 邮箱)" className="iam-login-input" /> + + + } placeholder="密码" className="iam-login-input" /> + + {error &&
{error}
} + + + +
+

鼎捷云统一身份认证 · IAM

+
+ ); +} +``` + +> **placeholder 颜色**:antd Input 的 `className` 传给外层 wrapper,需在 `index.html` 用后代选择器命中实际 `input`: +> +> ```html +> +> ``` + +### 3.5 创建 SSO 回调页 + +`src/pages/SSOLogin/index.tsx`: + +```tsx +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'; + +const SSOLogin: React.FC = () => { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const [error, setError] = useState(''); + + useEffect(() => { + const userToken = searchParams.get('userToken'); + if (!userToken) { setError('URL 缺少 userToken 参数'); return; } + + let cancelled = false; + (async () => { + try { + const result = await iamSsoLogin(userToken); + if (cancelled) return; + saveSession(result.token, result.userInfo); + navigate('/', { replace: true }); + } catch (ex) { + if (cancelled) return; + setError(ex instanceof Error ? ex.message : 'SSO 登录失败'); + } + })(); + return () => { cancelled = true; }; + }, [searchParams, navigate]); + + if (error) { + return ( +
+ +
SSO 登录失败:{error}
+ +
+ ); + } + return
正在登录...
; +}; + +export default SSOLogin; +``` + +### 3.6 路由守卫 + +`src/App.tsx`: + +```tsx +import { Navigate, Route, Routes, useLocation } from 'react-router-dom'; +import Login from './pages/Login'; +import SSOLogin from './pages/SSOLogin'; +import { clearSession, getUserInfo } from './services/iamAuth'; + +/** 未登录跳登录页,携带来源路径用于回跳 */ +function RequireAuth({ children }: { children: React.ReactNode }) { + const location = useLocation(); + const token = sessionStorage.getItem('userToken'); + if (!token) { + return ; + } + return <>{children}; +} + +export default function App() { + useLocation(); + return ( + + } /> + } /> + {/* 你的业务页面 */}} /> + + ); +} +``` + +### 3.7 Axios 拦截器 + +`src/api/client.ts` — 请求自动带 IAM 鉴权头,401 清理会话跳登录: + +```ts +import axios from 'axios'; +import { APP_TOKEN } from '../services/iamAuth'; + +const api = axios.create({ baseURL: '/api', timeout: 15000 }); + +// 请求拦截:自动带 IAM 鉴权头 +api.interceptors.request.use((config) => { + const token = sessionStorage.getItem('userToken'); + if (token) { + config.headers['digi-middleware-auth-user'] = token; + config.headers['digi-middleware-auth-app'] = APP_TOKEN; + } + return config; +}); + +// 响应拦截:401 清理会话跳登录 +api.interceptors.response.use( + (res) => res, + (err) => { + if (err.response?.status === 401) { + const path = window.location.pathname; + if (path !== '/login' && !path.startsWith('/sso-login')) { + sessionStorage.removeItem('userToken'); + sessionStorage.removeItem('userInfo'); + window.location.href = '/login'; + } + } + return Promise.reject(err); + }, +); + +export default api; +``` + +--- + +## 四、后端接入 + +### 4.1 创建后端项目 + +```bash +mkdir backend && cd backend +python3 -m venv venv && source venv/bin/activate +pip install fastapi uvicorn[standard] asyncpg httpx pydantic +``` + +`requirements.txt`: + +``` +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 +asyncpg>=0.30.0 +httpx>=0.27.0 +pydantic>=2.9.0 +``` + +### 4.2 配置项 + +`app/core/config.py`: + +```python +"""配置:环境变量读取""" + +import os + +class Settings: + # IAM 配置 + IAM_BASE_URL: str = os.getenv("IAM_BASE_URL", "https://iam.digiwincloud.com.cn") + IAM_APP_TOKEN: str = os.getenv("IAM_APP_TOKEN", "你的应用 apptoken") + IAM_CACHE_TTL: int = int(os.getenv("IAM_CACHE_TTL", "30")) # token 校验缓存秒数 + + # 前端静态文件目录(生产环境) + STATIC_DIR: str = os.getenv("STATIC_DIR", "../frontend/dist") + +settings = Settings() +``` + +环境变量: + +| 变量 | 说明 | 默认值 | +|---|---|---| +| `IAM_BASE_URL` | IAM 服务地址 | `https://iam.digiwincloud.com.cn` | +| `IAM_APP_TOKEN` | 应用 apptoken | 无,**必填** | +| `IAM_CACHE_TTL` | token 校验缓存秒数 | `30` | + +### 4.3 IAM Token 校验服务 + +`app/core/iam.py` — 调用 IAM `token/analyze` 校验用户 token,带进程内缓存: + +```python +"""鼎捷云 IAM token 鉴权服务 + +通过调用 IAM /api/iam/v2/identity/token/analyze 校验请求头中的 +digi-middleware-auth-user / digi-middleware-auth-app,解析出用户信息。 +进程内缓存(userToken -> userInfo),TTL 由 IAM_CACHE_TTL 控制。 +""" + +import time +import httpx +from .config import settings + +_http_client: httpx.AsyncClient | None = None +_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。""" + # 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: + _cache[user_token] = (None, now) + print(f"[IAM] analyze 请求异常: {ex}") + return None + + if resp.status_code == 200: + data = resp.json() + if data.get("id") or data.get("name"): + _cache[user_token] = (data, now) + return data + _cache[user_token] = (None, now) + return None + + _cache[user_token] = (None, now) + return None +``` + +### 4.4 IAM analyze 返回格式 + +`POST /api/iam/v2/identity/token/analyze` 成功返回: + +```json +{ + "tenantSid": 1029303229426688, + "tenantId": "datasolution", + "tenantName": "鼎捷雅典娜演示", + "sid": 50891717448256, + "id": "dongsk@digiwin.com", + "name": "董书康", + "telephone": "13851711172", + "email": "dongsk@digiwin.com", + "appId": "data-business-demo", + "tokenType": "digiwin", + "tokenExpiresIn": 3232527, + "identityType": "service" +} +``` + +| 字段 | 说明 | +|---|---| +| `id` | 用户标识(邮箱/手机号/用户ID) | +| `name` | 用户姓名 | +| `email` | 邮箱 | +| `telephone` | 手机号 | +| `tenantId` | 租户 ID | +| `tenantName` | 租户名称 | + +### 4.5 依赖注入 + +`app/core/deps.py` — FastAPI 依赖,从请求头读取 IAM 凭证并校验: + +```python +"""FastAPI 依赖:IAM token 校验,提取当前用户""" + +from fastapi import Header, HTTPException, status +from . import iam + +async def current_admin( + user_token: str | None = Header(None, alias="digi-middleware-auth-user"), + app_token: str | None = Header(None, alias="digi-middleware-auth-app"), +) -> dict: + 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 无效或已过期") + + # 返回统一用户信息 + return { + "username": info.get("name") or info.get("id") or "unknown", + "userId": info.get("id"), + "name": info.get("name"), + "email": info.get("email"), + "tenantId": info.get("tenantId"), + "tenantName": info.get("tenantName"), + } +``` + +### 4.6 业务路由示例 + +`app/routers/auth.py` — 所有需要鉴权的路由依赖 `current_admin`: + +```python +from fastapi import APIRouter, Depends +from ..core.deps import current_admin + +router = APIRouter(prefix="/api/admin", tags=["admin"]) + +@router.get("/me") +async def me(admin: dict = Depends(current_admin)): + """返回当前 IAM 登录用户信息""" + return { + "userId": admin.get("userId"), + "username": admin.get("username"), + "name": admin.get("name"), + "email": admin.get("email"), + "tenantId": admin.get("tenantId"), + } +``` + +### 4.7 应用入口 + +`app/main.py`: + +```python +from contextlib import asynccontextmanager +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.core.iam import close_iam_client +from app.routers import auth # 及其他路由 + +@asynccontextmanager +async def lifespan(app: FastAPI): + yield + await close_iam_client() + +app = FastAPI(title="你的应用", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth.router) +# app.include_router(其他路由) + +@app.get("/api/health") +async def health(): + return {"status": "ok"} +``` + +### 4.8 启动后端 + +```bash +cd backend +IAM_APP_TOKEN=你的应用apptoken \ + python3 -m uvicorn app.main:app --host 127.0.0.1 --port 8000 +``` + +--- + +## 五、Docker 部署 + +### 5.1 Dockerfile(多阶段构建) + +```dockerfile +# 阶段1:构建前端 +FROM node:20-alpine AS frontend-build +WORKDIR /app/frontend +COPY frontend/package*.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + +# 阶段2:Python 运行时 +FROM python:3.12-slim +WORKDIR /app +COPY backend/requirements.txt ./requirements.txt +RUN pip install --no-cache-dir -r requirements.txt +COPY backend/app ./app +COPY --from=frontend-build /app/frontend/dist ./app/static +ENV STATIC_DIR=/app/app/static +CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +### 5.2 docker-compose.yml + +```yaml +services: + app: + build: . + network_mode: host + environment: + - IAM_BASE_URL=https://iam.digiwincloud.com.cn + - IAM_APP_TOKEN=${IAM_APP_TOKEN} + - IAM_CACHE_TTL=30 + restart: unless-stopped +``` + +--- + +## 六、验证与联调 + +### 6.1 启动 + +```bash +# 后端 +cd backend +IAM_APP_TOKEN=你的应用apptoken python3 -m uvicorn app.main:app --port 8000 + +# 前端 +cd frontend +npm install && npm run dev +``` + +### 6.2 验证鉴权链路 + +```bash +# 有效 token → 200 返回用户信息 +curl -s http://localhost:8000/api/admin/me \ + -H 'digi-middleware-auth-user: ' \ + -H 'digi-middleware-auth-app: ' + +# 无 token → 401 +curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/api/admin/me + +# 无效 token → 401 +curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/api/admin/me \ + -H 'digi-middleware-auth-user: invalid-xxx' +``` + +### 6.3 通过前端代理验证 + +```bash +# 前端代理 → 后端 +curl -s http://localhost:5173/api/admin/me \ + -H 'digi-middleware-auth-user: ' \ + -H 'digi-middleware-auth-app: ' + +# 前端代理 → IAM +curl -s -X POST http://localhost:5173/iam-api/api/iam/v2/identity/token/analyze \ + -H 'digi-middleware-auth-user: ' \ + -H 'digi-middleware-auth-app: ' +``` + +--- + +## 七、接入清单 + +### 前端 + +- [ ] `npm install jsencrypt react-router-dom axios antd` +- [ ] 创建 `src/services/iamAuth.ts`(替换 `APP_TOKEN` / `APPID`) +- [ ] 创建 `src/pages/Login/index.tsx`(账号密码登录页) +- [ ] 创建 `src/pages/SSOLogin/index.tsx`(SSO 回调页) +- [ ] `App.tsx` 配置 `/login`、`/sso-login` 路由 + `RequireAuth` 守卫 +- [ ] `src/api/client.ts` 请求拦截器带 IAM 头,401 清理跳登录 +- [ ] `vite.config.ts` 添加 `/iam-api` 代理 +- [ ] `index.html` 添加 placeholder 颜色样式(可选) + +### 后端 + +- [ ] `requirements.txt` 添加 `httpx>=0.27.0` +- [ ] `config.py` 添加 `IAM_BASE_URL` / `IAM_APP_TOKEN` / `IAM_CACHE_TTL` +- [ ] 创建 `core/iam.py`(analyze_token + 缓存) +- [ ] 创建 `core/deps.py`(current_admin 校验 IAM token) +- [ ] `main.py` lifespan 增加 `close_iam_client()` +- [ ] 业务路由 `Depends(current_admin)` 鉴权 + +### IAM 平台 + +- [ ] 注册应用,获取 `apptoken` 和 `appId` +- [ ] 配置 SSO 回调地址为 `https://你的域名/sso-login` + +--- + +## 八、注意事项 + +1. **IAM 应用凭证**:`APP_TOKEN` / `APPID` 必须替换为你在鼎捷云 IAM 注册的应用凭证,不可复用其他应用的。 + +2. **SSO 回调地址**:IAM 认证成功后以 `?userToken=xxx` 回跳 `/sso-login`,需在 IAM 平台提前配置。 + +3. **缓存策略**:后端 `analyze_token` 默认缓存 30s(`IAM_CACHE_TTL`),减少对 IAM 的重复调用。token 失效后最长 30s 生效,如需即时生效可调小或关闭缓存。 + +4. **placeholder 颜色**:antd Input 的 `className` 传给外层 wrapper,需用 `.className input::placeholder` 后代选择器才能命中实际 `input` 元素。 + +5. **会话存储**:本方案使用 `sessionStorage`,关闭浏览器标签后登录态丢失。如需持久化可改为 `localStorage`(注意 XSS 风险)。 + +6. **生产环境代理**:生产部署时若前后端同源(Dockerfile 多阶段构建),前端 `/iam-api` 代理不再生效,需在 Nginx 或后端配置 IAM 反向代理: + ```nginx + location /iam-api/ { + proxy_pass https://iam.digiwincloud.com.cn/; + } + ``` diff --git a/mcp-auth/frontend/index.html b/mcp-auth/frontend/index.html index f04a0d2..241c3fe 100644 --- a/mcp-auth/frontend/index.html +++ b/mcp-auth/frontend/index.html @@ -5,7 +5,7 @@ MCP Token 管理后台