Files
mcp-auth/IAM接入文档.md
T
2026-09-02 17:31:52 +08:00

963 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 鼎捷云 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/;
}
```