33 KiB
鼎捷云 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 创建前端项目
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:
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、会话管理:
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:
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:<style> .iam-login-input input::placeholder { color: #858D9A !important; } </style>
3.5 创建 SSO 回调页
src/pages/SSOLogin/index.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:
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 清理会话跳登录:
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 创建后端项目
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:
"""配置:环境变量读取"""
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,带进程内缓存:
"""鼎捷云 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 成功返回:
{
"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 凭证并校验:
"""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:
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:
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 启动后端
cd backend
IAM_APP_TOKEN=你的应用apptoken \
python3 -m uvicorn app.main:app --host 127.0.0.1 --port 8000
五、Docker 部署
5.1 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
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 启动
# 后端
cd backend
IAM_APP_TOKEN=你的应用apptoken python3 -m uvicorn app.main:app --port 8000
# 前端
cd frontend
npm install && npm run dev
6.2 验证鉴权链路
# 有效 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 通过前端代理验证
# 前端代理 → 后端
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.0config.py添加IAM_BASE_URL/IAM_APP_TOKEN/IAM_CACHE_TTL- 创建
core/iam.py(analyze_token + 缓存) - 创建
core/deps.py(current_admin 校验 IAM token) main.pylifespan 增加close_iam_client()- 业务路由
Depends(current_admin)鉴权
IAM 平台
- 注册应用,获取
apptoken和appId - 配置 SSO 回调地址为
https://你的域名/sso-login
八、注意事项
-
IAM 应用凭证:
APP_TOKEN/APPID必须替换为你在鼎捷云 IAM 注册的应用凭证,不可复用其他应用的。 -
SSO 回调地址:IAM 认证成功后以
?userToken=xxx回跳/sso-login,需在 IAM 平台提前配置。 -
缓存策略:后端
analyze_token默认缓存 30s(IAM_CACHE_TTL),减少对 IAM 的重复调用。token 失效后最长 30s 生效,如需即时生效可调小或关闭缓存。 -
placeholder 颜色:antd Input 的
className传给外层 wrapper,需用.className input::placeholder后代选择器才能命中实际input元素。 -
会话存储:本方案使用
sessionStorage,关闭浏览器标签后登录态丢失。如需持久化可改为localStorage(注意 XSS 风险)。 -
生产环境代理:生产部署时若前后端同源(Dockerfile 多阶段构建),前端
/iam-api代理不再生效,需在 Nginx 或后端配置 IAM 反向代理:location /iam-api/ { proxy_pass https://iam.digiwincloud.com.cn/; }