# 鼎捷云 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/; } ```