新增调整

This commit is contained in:
2026-08-31 19:45:46 +08:00
parent 3639004764
commit 58ec92e241
2 changed files with 963 additions and 1 deletions
+962
View File
@@ -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<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 { token: string; userId: string; userInfo: Record<string, unknown> }
/**
* IAM 普通登录(identityType: query)
* 加密链路:生成 RSA 密钥对 → 获取服务端公钥 → 加密客户端公钥 → 换 AES 密钥 → 加密密码 → 登录
*/
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 /tenant?appId=APPID 拉取租户列表
* 2. 优先选 isDefault=true 的租户,否则取第一个
* 3. POST /identity/token/refresh/tenant 刷新 token
*/
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>;
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<string, unknown>)?.isDefault === true) ?? tenants[0]) as Record<string, unknown>;
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<string, unknown>;
const refreshedToken = pickToken(refreshJson);
if (refreshedToken) finalToken = refreshedToken;
if (refreshJson.authoredUser && typeof refreshJson.authoredUser === 'object') {
authoredUser = refreshJson.authoredUser as Record<string, unknown>;
} else {
authoredUser = refreshJson;
}
}
}
}
} catch (ex) {
console.warn('[IAM] 租户切换流程异常,将使用原 token', ex);
}
return { token: finalToken, authoredUser, currTenantList };
}
/**
* SSO 登录(基于外部传入的 userToken)
*/
export async function iamSsoLogin(initialUserToken: string): Promise<IamLoginResult> {
// 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<string, unknown>;
const appRefreshedToken = pickToken(refreshAppJson) ?? initialUserToken;
// 2. 获取登录详情
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 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);
}
export function getUserToken(): string | null { return sessionStorage.getItem(KEY_USER_TOKEN); }
export function getUserInfo(): Record<string, unknown> | 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 (
<div style={{ /* 深色背景样式 */ }}>
<Form onFinish={onFinish} size="large" autoComplete="on">
<Form.Item name="userId" rules={[{ required: true, message: '请输入账号' }]}>
<Input prefix={<UserOutlined />} placeholder="账号(用户ID / 手机号 / 邮箱)" className="iam-login-input" />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password prefix={<LockOutlined />} placeholder="密码" className="iam-login-input" />
</Form.Item>
{error && <div><CloseCircleOutlined /> {error}</div>}
<Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
{loading ? '登录中...' : '登 录'}
</Button>
</Form.Item>
</Form>
<p style={{ textAlign: 'center', color: 'rgba(168,216,255,0.6)' }}>鼎捷云统一身份认证 · IAM</p>
</div>
);
}
```
> **placeholder 颜色**:antd Input 的 `className` 传给外层 wrapper,需在 `index.html` 用后代选择器命中实际 `input`:
>
> ```html
> <style>
> .iam-login-input input::placeholder { color: #858D9A !important; }
> </style>
> ```
### 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 (
<div>
<CloseCircleOutlined />
<div>SSO 登录失败:{error}</div>
<button onClick={() => navigate('/login', { replace: true })}>前往登录页</button>
</div>
);
}
return <div><Spin size="large" /><div>正在登录...</div></div>;
};
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 <Navigate to="/login" replace state={{ from: location.pathname + location.search }} />;
}
return <>{children}</>;
}
export default function App() {
useLocation();
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/sso-login" element={<SSOLogin />} />
<Route path="/*" element={<RequireAuth>{/* 你的业务页面 */}</RequireAuth>} />
</Routes>
);
}
```
### 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: <userToken>' \
-H 'digi-middleware-auth-app: <appToken>'
# 无 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: <userToken>' \
-H 'digi-middleware-auth-app: <appToken>'
# 前端代理 → IAM
curl -s -X POST http://localhost:5173/iam-api/api/iam/v2/identity/token/analyze \
-H 'digi-middleware-auth-user: <userToken>' \
-H 'digi-middleware-auth-app: <appToken>'
```
---
## 七、接入清单
### 前端
- [ ] `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/;
}
```
+1 -1
View File
@@ -5,7 +5,7 @@
<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> <style>
.iam-login-input::placeholder { color: rgba(168,216,255,0.6) !important; } .iam-login-input input::placeholder { color: #858D9A !important; }
</style> </style>
</head> </head>
<body> <body>