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

33 KiB
Raw Permalink Blame History

鼎捷云 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.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 反向代理:

    location /iam-api/ {
        proxy_pass https://iam.digiwincloud.com.cn/;
    }