新增调整
This commit is contained in:
@@ -0,0 +1,962 @@
|
||||
# 鼎捷云 IAM 登录接入指南
|
||||
|
||||
> 面向新项目从零接入鼎捷云统一身份认证(IAM),涵盖前端登录、SSO 回调、后端 Token 鉴权全链路。
|
||||
|
||||
---
|
||||
|
||||
## 一、整体架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 浏览器 │
|
||||
│ │
|
||||
│ ┌───────────┐ ┌─────────────┐ ┌───────────────────────┐ │
|
||||
│ │ 登录页 │────→│ iamAuth.ts │────→│ sessionStorage │ │
|
||||
│ │ /login │ │ RSA+AES │ │ userToken / userInfo │ │
|
||||
│ └───────────┘ └──────┬──────┘ └───────────┬───────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌───────────┐ │ ┌────────────▼──────────┐ │
|
||||
│ │ SSO 回调 │─────────────┘ │ axios 拦截器 │ │
|
||||
│ │ /sso-login│ │ 自动带 IAM 鉴权请求头 │ │
|
||||
│ └───────────┘ └────────────┬──────────┘ │
|
||||
└──────────────────────────────────────────────────────┼────────────┘
|
||||
│
|
||||
┌─────────────────────────────────────┼──────────────────┐
|
||||
│ │ │
|
||||
┌──────▼─────┐ ┌──────▼──────┐ ┌──────▼──────┐
|
||||
│ Vite 代理 │ │ 后端 API │ │ 鼎捷云 IAM │
|
||||
│ /iam-api → │ │ FastAPI │ │ │
|
||||
│ /api → 8000 │ │ current_admin│───────→│ token/analyze│
|
||||
└────────────┘ └─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
### 两种登录方式
|
||||
|
||||
**方式一:账号密码登录(普通登录)**
|
||||
|
||||
```
|
||||
用户输入账号密码
|
||||
→ 前端 RSA+AES 加密链路调 IAM /identity/login 拿 userToken
|
||||
→ 拉取租户列表 → 切换默认租户刷新 token
|
||||
→ userToken + userInfo 存 sessionStorage → 跳首页
|
||||
```
|
||||
|
||||
**方式二:SSO 单点登录**
|
||||
|
||||
```
|
||||
外部系统跳转 /sso-login?userToken=xxx
|
||||
→ 前端调 iamSsoLogin(userToken)
|
||||
→ POST /identity/token/refresh/app 刷新应用 token
|
||||
→ POST /identity/login/info 获取登录详情
|
||||
→ POST /tenant?appId=APPID 拉取租户列表,选默认租户
|
||||
→ POST /identity/token/refresh/tenant 切换租户刷新 token
|
||||
→ userInfo 写入 sessionStorage → 跳首页
|
||||
```
|
||||
|
||||
### 后端鉴权流程
|
||||
|
||||
```
|
||||
前端请求(带 digi-middleware-auth-user / digi-middleware-auth-app 头)
|
||||
→ 后端 current_admin 依赖读取请求头
|
||||
→ 调 IAM POST /api/iam/v2/identity/token/analyze 校验
|
||||
→ 200 + 含 id/name → 返回管理员信息(缓存 30s)
|
||||
→ 非 200 / 无用户标识 → 401
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、前置准备
|
||||
|
||||
### 2.1 在鼎捷云 IAM 注册应用
|
||||
|
||||
接入前需在鼎捷云 IAM 平台完成应用注册,获取以下凭证:
|
||||
|
||||
| 凭证 | 说明 | 用途 |
|
||||
|---|---|---|
|
||||
| `apptoken` | 应用 token(digi-middleware-auth-app) | 所有 IAM API 请求头必带 |
|
||||
| `appId` | 应用 ID | 租户列表查询参数 |
|
||||
|
||||
### 2.2 配置 SSO 回调地址
|
||||
|
||||
在 IAM 应用配置中,将 SSO 回调地址设为:
|
||||
|
||||
```
|
||||
https://你的域名/sso-login
|
||||
```
|
||||
|
||||
IAM 认证成功后会以 `?userToken=xxx` 形式回跳到该地址。
|
||||
|
||||
### 2.3 IAM 关键接口清单
|
||||
|
||||
| 接口 | 方法 | 用途 |
|
||||
|---|---|---|
|
||||
| `/api/iam/v2/identity/publickey` | GET | 获取服务端 RSA 公钥 |
|
||||
| `/api/iam/v2/identity/aeskey` | POST | 用客户端公钥换取加密的 AES 密钥 |
|
||||
| `/api/iam/v2/identity/login` | POST | 账号密码登录 |
|
||||
| `/api/iam/v2/identity/token/refresh/app` | POST | SSO 刷新应用 token |
|
||||
| `/api/iam/v2/identity/login/info` | POST | 获取登录详情 |
|
||||
| `/api/iam/v2/identity/token/refresh/tenant` | POST | 切换租户刷新 token |
|
||||
| `/api/iam/v2/tenant?appId=APPID` | POST | 拉取用户授权租户列表 |
|
||||
| `/api/iam/v2/identity/token/analyze` | POST | **后端校验 token(核心)** |
|
||||
|
||||
IAM 服务地址:`https://iam.digiwincloud.com.cn`
|
||||
|
||||
---
|
||||
|
||||
## 三、前端接入
|
||||
|
||||
### 3.1 创建前端项目
|
||||
|
||||
```bash
|
||||
npm create vite@latest frontend -- --template react-ts
|
||||
cd frontend
|
||||
npm install
|
||||
npm install react-router-dom axios antd @ant-design/icons jsencrypt dayjs
|
||||
```
|
||||
|
||||
依赖说明:
|
||||
|
||||
| 依赖 | 用途 |
|
||||
|---|---|
|
||||
| `react-router-dom` | 路由(登录页、SSO 回调、路由守卫) |
|
||||
| `axios` | HTTP 请求 + 拦截器带 IAM 头 |
|
||||
| `antd` | UI 组件库 |
|
||||
| `jsencrypt` | IAM 登录 RSA 密钥对生成与加密 |
|
||||
| `dayjs` | 日期格式化(可选) |
|
||||
|
||||
### 3.2 Vite 代理配置
|
||||
|
||||
`vite.config.ts`:
|
||||
|
||||
```ts
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// 后端 API 代理
|
||||
'/api': 'http://localhost:8000',
|
||||
// 鼎捷云 IAM 代理(避免浏览器跨域)
|
||||
'/iam-api': {
|
||||
target: 'https://iam.digiwincloud.com.cn',
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace(/^\/iam-api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### 3.3 创建 IAM 登录服务
|
||||
|
||||
`src/services/iamAuth.ts` — 核心模块,封装 IAM 登录、SSO、会话管理:
|
||||
|
||||
```ts
|
||||
import { JSEncrypt } from 'jsencrypt';
|
||||
|
||||
/**
|
||||
* 鼎捷云 IAM 登录服务
|
||||
*
|
||||
* 完整登录流程:
|
||||
* 1. RSA+AES 加密链路获取 userToken(/api/iam/v2/identity/login)
|
||||
* 2. 拉取用户授权租户列表,默认选第一个
|
||||
* 3. 切换租户刷新 token
|
||||
* 4. 将完整用户信息写入 sessionStorage
|
||||
*/
|
||||
|
||||
// 代理路径(vite.config.ts 中 /iam-api → https://iam.digiwincloud.com.cn)
|
||||
const IAM_API_BASE = '/iam-api/api/iam/v2';
|
||||
const IAM_IDENTITY_BASE = `${IAM_API_BASE}/identity`;
|
||||
|
||||
// ★ 替换为你的 IAM 应用凭证
|
||||
export const APP_TOKEN = '你的应用 apptoken';
|
||||
export const APPID = '你的应用ID';
|
||||
|
||||
// IAM AES 加密固定 IV(16 字节)
|
||||
const AES_IV = 'ghUb#er57HBh(u%g';
|
||||
|
||||
/** PEM 包装/剥离工具 */
|
||||
function wrapPem(base64Key: string): string {
|
||||
if (base64Key.includes('BEGIN')) return base64Key;
|
||||
const body = base64Key.replace(/-----(BEGIN|END)[^-]+-----/g, '').replace(/\s+/g, '');
|
||||
const lines = body.match(/.{1,64}/g) || [];
|
||||
return `-----BEGIN PUBLIC KEY-----\n${lines.join('\n')}\n-----END PUBLIC KEY-----`;
|
||||
}
|
||||
|
||||
function stripPem(pem: string): string {
|
||||
return pem.replace(/-----(BEGIN|END)[^-]+-----/g, '').replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
/** AES-CBC/PKCS7 加密,输出 base64 */
|
||||
async function aesEncryptToBase64(plainText: string, aesKey: string): Promise<string> {
|
||||
const enc = new TextEncoder();
|
||||
const keyData = enc.encode(aesKey);
|
||||
const ivData = enc.encode(AES_IV);
|
||||
const cryptoKey = await crypto.subtle.importKey('raw', keyData, { name: 'AES-CBC' }, false, ['encrypt']);
|
||||
const cipherBuf = await crypto.subtle.encrypt({ name: 'AES-CBC', iv: ivData }, cryptoKey, enc.encode(plainText));
|
||||
const bytes = new Uint8Array(cipherBuf);
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
/** 应用层请求头(含 apptoken) */
|
||||
function appHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||
return { 'Content-Type': 'application/json', 'digi-middleware-auth-app': APP_TOKEN, ...extra };
|
||||
}
|
||||
|
||||
/** 用户鉴权请求头(含 apptoken + usertoken) */
|
||||
function userHeaders(userToken: string, extra?: Record<string, string>): Record<string, string> {
|
||||
return appHeaders({ 'digi-middleware-auth-user': userToken, ...extra });
|
||||
}
|
||||
|
||||
/** 从响应中提取 token 字符串 */
|
||||
function pickToken(obj: Record<string, unknown>): string | undefined {
|
||||
if (typeof obj.token === 'string') return obj.token;
|
||||
if (typeof obj.userToken === 'string') return obj.userToken;
|
||||
const data = obj.data as Record<string, unknown> | undefined;
|
||||
if (data && typeof data.token === 'string') return data.token;
|
||||
const result = obj.result as Record<string, unknown> | undefined;
|
||||
if (result && typeof result.token === 'string') return result.token;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface IamLoginParams { userId: string; password: string; tenantId?: string }
|
||||
export interface IamLoginResult { token: string; userId: string; userInfo: Record<string, unknown> }
|
||||
|
||||
/**
|
||||
* IAM 普通登录(identityType: query)
|
||||
* 加密链路:生成 RSA 密钥对 → 获取服务端公钥 → 加密客户端公钥 → 换 AES 密钥 → 加密密码 → 登录
|
||||
*/
|
||||
export async function iamLogin({ userId, password, tenantId }: IamLoginParams): Promise<IamLoginResult> {
|
||||
// 1. 客户端生成 RSA 密钥对(1024)
|
||||
const client = new JSEncrypt({ default_key_size: '1024' });
|
||||
client.getKey();
|
||||
const clientPrivateKeyPem = client.getPrivateKey();
|
||||
const clientPublicKeyB64 = stripPem(client.getPublicKey());
|
||||
|
||||
// 2. 获取服务端公钥
|
||||
const pkRes = await fetch(`${IAM_IDENTITY_BASE}/publickey`, { headers: appHeaders() });
|
||||
if (!pkRes.ok) throw new Error(`获取服务端公钥失败 (HTTP ${pkRes.status})`);
|
||||
const pkJson = await pkRes.json();
|
||||
const serverPublicKey: string = pkJson.publicKey;
|
||||
if (!serverPublicKey) throw new Error('服务端公钥为空');
|
||||
|
||||
// 3. 服务端公钥加密客户端公钥
|
||||
const server = new JSEncrypt();
|
||||
server.setPublicKey(wrapPem(serverPublicKey));
|
||||
const clientEncryptPublicKey = server.encrypt(clientPublicKeyB64);
|
||||
if (!clientEncryptPublicKey) throw new Error('加密客户端公钥失败');
|
||||
|
||||
// 4. 获取加密的 AES 密钥
|
||||
const aesRes = await fetch(`${IAM_IDENTITY_BASE}/aeskey`, {
|
||||
method: 'POST', headers: appHeaders(),
|
||||
body: JSON.stringify({ clientEncryptPublicKey }),
|
||||
});
|
||||
if (!aesRes.ok) throw new Error(`获取 AES 密钥失败 (HTTP ${aesRes.status})`);
|
||||
const aesJson = await aesRes.json();
|
||||
const encryptAesKey: string = aesJson.encryptAesKey;
|
||||
if (!encryptAesKey) throw new Error(`获取 AES 密钥失败: ${JSON.stringify(aesJson)}`);
|
||||
|
||||
// 5. 客户端私钥解密 AES 密钥
|
||||
client.setPrivateKey(clientPrivateKeyPem);
|
||||
const aesKey = client.decrypt(encryptAesKey);
|
||||
if (!aesKey) throw new Error('解密 AES 密钥失败');
|
||||
|
||||
// 6. AES 加密密码
|
||||
const passwordHash = await aesEncryptToBase64(password, aesKey);
|
||||
|
||||
// 7. 登录
|
||||
const loginBody: Record<string, string> = { userId, passwordHash, clientEncryptPublicKey, identityType: 'query' };
|
||||
if (tenantId) loginBody.tenantId = tenantId;
|
||||
const loginRes = await fetch(`${IAM_IDENTITY_BASE}/login`, { method: 'POST', headers: appHeaders(), body: JSON.stringify(loginBody) });
|
||||
const loginJson = (await loginRes.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const initialToken = pickToken(loginJson);
|
||||
if (!loginRes.ok || !initialToken) {
|
||||
const msg = loginJson.message || loginJson.msg || loginJson.error || `HTTP ${loginRes.status}`;
|
||||
throw new Error(`登录失败: ${msg}`);
|
||||
}
|
||||
|
||||
// 8. 拉取租户列表 + 切换默认租户
|
||||
const tenantCtx = await switchDefaultTenant(initialToken);
|
||||
|
||||
// 9. 组装完整 userInfo
|
||||
const userInfo: Record<string, unknown> = {
|
||||
...(tenantCtx.authoredUser ?? {}), ...(loginJson ?? {}), userId,
|
||||
token: tenantCtx.token, isLoggedin: true,
|
||||
...(tenantCtx.currTenantList ? { currTenantList: tenantCtx.currTenantList } : {}),
|
||||
};
|
||||
return { token: tenantCtx.token, userId, userInfo };
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换默认租户
|
||||
* 1. POST /tenant?appId=APPID 拉取租户列表
|
||||
* 2. 优先选 isDefault=true 的租户,否则取第一个
|
||||
* 3. POST /identity/token/refresh/tenant 刷新 token
|
||||
*/
|
||||
export async function switchDefaultTenant(userToken: string): Promise<{
|
||||
token: string; authoredUser?: Record<string, unknown>; currTenantList?: unknown[]
|
||||
}> {
|
||||
let finalToken = userToken;
|
||||
let authoredUser: Record<string, unknown> | undefined;
|
||||
let currTenantList: unknown[] | undefined;
|
||||
try {
|
||||
const tenantRes = await fetch(`${IAM_API_BASE}/tenant?appId=${encodeURIComponent(APPID)}`, { method: 'POST', headers: userHeaders(userToken) });
|
||||
if (!tenantRes.ok) throw new Error(`获取租户列表失败 (HTTP ${tenantRes.status})`);
|
||||
const tenantJson = (await tenantRes.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
let tenants: unknown[] = [];
|
||||
if (Array.isArray(tenantJson)) tenants = tenantJson;
|
||||
else if (Array.isArray(tenantJson.data)) tenants = tenantJson.data as unknown[];
|
||||
else if (Array.isArray(tenantJson.list)) tenants = tenantJson.list as unknown[];
|
||||
else if (Array.isArray(tenantJson.result)) tenants = tenantJson.result as unknown[];
|
||||
|
||||
if (tenants.length > 0) {
|
||||
currTenantList = tenants;
|
||||
const defaultTenant = (tenants.find((t) => (t as Record<string, unknown>)?.isDefault === true) ?? tenants[0]) as Record<string, unknown>;
|
||||
const tenantSid = defaultTenant.sid ?? defaultTenant.tenantSid ?? defaultTenant.id;
|
||||
if (tenantSid !== undefined && tenantSid !== null) {
|
||||
const refreshRes = await fetch(`${IAM_IDENTITY_BASE}/token/refresh/tenant`, {
|
||||
method: 'POST', headers: userHeaders(userToken), body: JSON.stringify({ tenantSid }),
|
||||
});
|
||||
if (refreshRes.ok) {
|
||||
const refreshJson = (await refreshRes.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const refreshedToken = pickToken(refreshJson);
|
||||
if (refreshedToken) finalToken = refreshedToken;
|
||||
if (refreshJson.authoredUser && typeof refreshJson.authoredUser === 'object') {
|
||||
authoredUser = refreshJson.authoredUser as Record<string, unknown>;
|
||||
} else {
|
||||
authoredUser = refreshJson;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex) {
|
||||
console.warn('[IAM] 租户切换流程异常,将使用原 token', ex);
|
||||
}
|
||||
return { token: finalToken, authoredUser, currTenantList };
|
||||
}
|
||||
|
||||
/**
|
||||
* SSO 登录(基于外部传入的 userToken)
|
||||
*/
|
||||
export async function iamSsoLogin(initialUserToken: string): Promise<IamLoginResult> {
|
||||
// 1. 刷新应用 token
|
||||
const refreshAppRes = await fetch(`${IAM_IDENTITY_BASE}/token/refresh/app`, { method: 'POST', headers: userHeaders(initialUserToken) });
|
||||
if (!refreshAppRes.ok) throw new Error(`SSO token 刷新失败 (HTTP ${refreshAppRes.status})`);
|
||||
const refreshAppJson = (await refreshAppRes.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const appRefreshedToken = pickToken(refreshAppJson) ?? initialUserToken;
|
||||
|
||||
// 2. 获取登录详情
|
||||
let loginInfoJson: Record<string, unknown> = {};
|
||||
try {
|
||||
const infoRes = await fetch(`${IAM_IDENTITY_BASE}/login/info`, { method: 'POST', headers: userHeaders(appRefreshedToken) });
|
||||
if (infoRes.ok) loginInfoJson = (await infoRes.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
} catch (ex) { console.warn('[IAM] login/info 调用异常', ex); }
|
||||
|
||||
// 3. 切换默认租户
|
||||
const tenantCtx = await switchDefaultTenant(appRefreshedToken);
|
||||
|
||||
// 4. 组装 userInfo
|
||||
const userId = (loginInfoJson.userId as string) ?? (refreshAppJson.userId as string) ?? (tenantCtx.authoredUser?.userId as string) ?? '';
|
||||
const userInfo: Record<string, unknown> = {
|
||||
...(tenantCtx.authoredUser ?? {}), ...(refreshAppJson ?? {}), ...(loginInfoJson ?? {}),
|
||||
userId, token: tenantCtx.token, isLoggedin: true,
|
||||
...(tenantCtx.currTenantList ? { currTenantList: tenantCtx.currTenantList } : {}),
|
||||
};
|
||||
return { token: tenantCtx.token, userId, userInfo };
|
||||
}
|
||||
|
||||
/* ---------------- sessionStorage 会话管理 ---------------- */
|
||||
|
||||
const KEY_USER_TOKEN = 'userToken';
|
||||
const KEY_USER_INFO = 'userInfo';
|
||||
const KEY_APP_TOKEN = 'digi-middleware-auth-app';
|
||||
|
||||
export function saveSession(token: string, info: Record<string, unknown>): void {
|
||||
sessionStorage.setItem(KEY_USER_TOKEN, token);
|
||||
sessionStorage.setItem(KEY_USER_INFO, JSON.stringify(info));
|
||||
sessionStorage.setItem(KEY_APP_TOKEN, APP_TOKEN);
|
||||
}
|
||||
|
||||
export function getUserToken(): string | null { return sessionStorage.getItem(KEY_USER_TOKEN); }
|
||||
|
||||
export function getUserInfo(): Record<string, unknown> | null {
|
||||
const raw = sessionStorage.getItem(KEY_USER_INFO);
|
||||
if (!raw) return null;
|
||||
try { return JSON.parse(raw); } catch { return null; }
|
||||
}
|
||||
|
||||
export function clearSession(): void {
|
||||
sessionStorage.removeItem(KEY_USER_TOKEN);
|
||||
sessionStorage.removeItem(KEY_USER_INFO);
|
||||
sessionStorage.removeItem(KEY_APP_TOKEN);
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 创建登录页
|
||||
|
||||
`src/pages/Login/index.tsx`:
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react';
|
||||
import { Button, Form, Input } from 'antd';
|
||||
import { LockOutlined, UserOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { iamLogin, saveSession } from '../../services/iamAuth';
|
||||
|
||||
export default function Login() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const nav = useNavigate();
|
||||
const loc = useLocation();
|
||||
|
||||
const onFinish = async (values: { userId: string; password: string }) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await iamLogin({ userId: values.userId.trim(), password: values.password });
|
||||
saveSession(result.token, result.userInfo);
|
||||
const from = (loc.state as { from?: string })?.from ?? '/';
|
||||
nav(from, { replace: true });
|
||||
} catch (ex) {
|
||||
setError(ex instanceof Error ? ex.message : '登录失败,请稍后重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ /* 深色背景样式 */ }}>
|
||||
<Form onFinish={onFinish} size="large" autoComplete="on">
|
||||
<Form.Item name="userId" rules={[{ required: true, message: '请输入账号' }]}>
|
||||
<Input prefix={<UserOutlined />} placeholder="账号(用户ID / 手机号 / 邮箱)" className="iam-login-input" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="密码" className="iam-login-input" />
|
||||
</Form.Item>
|
||||
{error && <div><CloseCircleOutlined /> {error}</div>}
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||
{loading ? '登录中...' : '登 录'}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<p style={{ textAlign: 'center', color: 'rgba(168,216,255,0.6)' }}>鼎捷云统一身份认证 · IAM</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
> **placeholder 颜色**:antd Input 的 `className` 传给外层 wrapper,需在 `index.html` 用后代选择器命中实际 `input`:
|
||||
>
|
||||
> ```html
|
||||
> <style>
|
||||
> .iam-login-input input::placeholder { color: #858D9A !important; }
|
||||
> </style>
|
||||
> ```
|
||||
|
||||
### 3.5 创建 SSO 回调页
|
||||
|
||||
`src/pages/SSOLogin/index.tsx`:
|
||||
|
||||
```tsx
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Spin } from 'antd';
|
||||
import { CloseCircleOutlined } from '@ant-design/icons';
|
||||
import { iamSsoLogin, saveSession } from '../../services/iamAuth';
|
||||
|
||||
const SSOLogin: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const userToken = searchParams.get('userToken');
|
||||
if (!userToken) { setError('URL 缺少 userToken 参数'); return; }
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const result = await iamSsoLogin(userToken);
|
||||
if (cancelled) return;
|
||||
saveSession(result.token, result.userInfo);
|
||||
navigate('/', { replace: true });
|
||||
} catch (ex) {
|
||||
if (cancelled) return;
|
||||
setError(ex instanceof Error ? ex.message : 'SSO 登录失败');
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [searchParams, navigate]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<CloseCircleOutlined />
|
||||
<div>SSO 登录失败:{error}</div>
|
||||
<button onClick={() => navigate('/login', { replace: true })}>前往登录页</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div><Spin size="large" /><div>正在登录...</div></div>;
|
||||
};
|
||||
|
||||
export default SSOLogin;
|
||||
```
|
||||
|
||||
### 3.6 路由守卫
|
||||
|
||||
`src/App.tsx`:
|
||||
|
||||
```tsx
|
||||
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import Login from './pages/Login';
|
||||
import SSOLogin from './pages/SSOLogin';
|
||||
import { clearSession, getUserInfo } from './services/iamAuth';
|
||||
|
||||
/** 未登录跳登录页,携带来源路径用于回跳 */
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const location = useLocation();
|
||||
const token = sessionStorage.getItem('userToken');
|
||||
if (!token) {
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname + location.search }} />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
useLocation();
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/sso-login" element={<SSOLogin />} />
|
||||
<Route path="/*" element={<RequireAuth>{/* 你的业务页面 */}</RequireAuth>} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3.7 Axios 拦截器
|
||||
|
||||
`src/api/client.ts` — 请求自动带 IAM 鉴权头,401 清理会话跳登录:
|
||||
|
||||
```ts
|
||||
import axios from 'axios';
|
||||
import { APP_TOKEN } from '../services/iamAuth';
|
||||
|
||||
const api = axios.create({ baseURL: '/api', timeout: 15000 });
|
||||
|
||||
// 请求拦截:自动带 IAM 鉴权头
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = sessionStorage.getItem('userToken');
|
||||
if (token) {
|
||||
config.headers['digi-middleware-auth-user'] = token;
|
||||
config.headers['digi-middleware-auth-app'] = APP_TOKEN;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// 响应拦截:401 清理会话跳登录
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
const path = window.location.pathname;
|
||||
if (path !== '/login' && !path.startsWith('/sso-login')) {
|
||||
sessionStorage.removeItem('userToken');
|
||||
sessionStorage.removeItem('userInfo');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
return Promise.reject(err);
|
||||
},
|
||||
);
|
||||
|
||||
export default api;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、后端接入
|
||||
|
||||
### 4.1 创建后端项目
|
||||
|
||||
```bash
|
||||
mkdir backend && cd backend
|
||||
python3 -m venv venv && source venv/bin/activate
|
||||
pip install fastapi uvicorn[standard] asyncpg httpx pydantic
|
||||
```
|
||||
|
||||
`requirements.txt`:
|
||||
|
||||
```
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.30.0
|
||||
asyncpg>=0.30.0
|
||||
httpx>=0.27.0
|
||||
pydantic>=2.9.0
|
||||
```
|
||||
|
||||
### 4.2 配置项
|
||||
|
||||
`app/core/config.py`:
|
||||
|
||||
```python
|
||||
"""配置:环境变量读取"""
|
||||
|
||||
import os
|
||||
|
||||
class Settings:
|
||||
# IAM 配置
|
||||
IAM_BASE_URL: str = os.getenv("IAM_BASE_URL", "https://iam.digiwincloud.com.cn")
|
||||
IAM_APP_TOKEN: str = os.getenv("IAM_APP_TOKEN", "你的应用 apptoken")
|
||||
IAM_CACHE_TTL: int = int(os.getenv("IAM_CACHE_TTL", "30")) # token 校验缓存秒数
|
||||
|
||||
# 前端静态文件目录(生产环境)
|
||||
STATIC_DIR: str = os.getenv("STATIC_DIR", "../frontend/dist")
|
||||
|
||||
settings = Settings()
|
||||
```
|
||||
|
||||
环境变量:
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|---|---|---|
|
||||
| `IAM_BASE_URL` | IAM 服务地址 | `https://iam.digiwincloud.com.cn` |
|
||||
| `IAM_APP_TOKEN` | 应用 apptoken | 无,**必填** |
|
||||
| `IAM_CACHE_TTL` | token 校验缓存秒数 | `30` |
|
||||
|
||||
### 4.3 IAM Token 校验服务
|
||||
|
||||
`app/core/iam.py` — 调用 IAM `token/analyze` 校验用户 token,带进程内缓存:
|
||||
|
||||
```python
|
||||
"""鼎捷云 IAM token 鉴权服务
|
||||
|
||||
通过调用 IAM /api/iam/v2/identity/token/analyze 校验请求头中的
|
||||
digi-middleware-auth-user / digi-middleware-auth-app,解析出用户信息。
|
||||
进程内缓存(userToken -> userInfo),TTL 由 IAM_CACHE_TTL 控制。
|
||||
"""
|
||||
|
||||
import time
|
||||
import httpx
|
||||
from .config import settings
|
||||
|
||||
_http_client: httpx.AsyncClient | None = None
|
||||
_cache: dict[str, tuple[dict | None, float]] = {}
|
||||
|
||||
def _get_http_client() -> httpx.AsyncClient:
|
||||
global _http_client
|
||||
if _http_client is None:
|
||||
_http_client = httpx.AsyncClient(timeout=5.0)
|
||||
return _http_client
|
||||
|
||||
async def close_iam_client() -> None:
|
||||
"""关闭 httpx 客户端(进程退出时调用)。"""
|
||||
global _http_client
|
||||
if _http_client is not None:
|
||||
await _http_client.aclose()
|
||||
_http_client = None
|
||||
|
||||
async def analyze_token(user_token: str, app_token: str | None = None) -> dict | None:
|
||||
"""校验 IAM userToken,返回用户信息 dict 或 None。"""
|
||||
# 1. 查缓存
|
||||
now = time.time()
|
||||
cached = _cache.get(user_token)
|
||||
if cached is not None and (now - cached[1]) < settings.IAM_CACHE_TTL:
|
||||
return cached[0]
|
||||
|
||||
# 2. 调用 IAM analyze
|
||||
headers = {
|
||||
"digi-middleware-auth-user": user_token,
|
||||
"digi-middleware-auth-app": app_token or settings.IAM_APP_TOKEN,
|
||||
}
|
||||
try:
|
||||
client = _get_http_client()
|
||||
resp = await client.post(
|
||||
f"{settings.IAM_BASE_URL}/api/iam/v2/identity/token/analyze",
|
||||
headers=headers,
|
||||
)
|
||||
except Exception as ex:
|
||||
_cache[user_token] = (None, now)
|
||||
print(f"[IAM] analyze 请求异常: {ex}")
|
||||
return None
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
if data.get("id") or data.get("name"):
|
||||
_cache[user_token] = (data, now)
|
||||
return data
|
||||
_cache[user_token] = (None, now)
|
||||
return None
|
||||
|
||||
_cache[user_token] = (None, now)
|
||||
return None
|
||||
```
|
||||
|
||||
### 4.4 IAM analyze 返回格式
|
||||
|
||||
`POST /api/iam/v2/identity/token/analyze` 成功返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"tenantSid": 1029303229426688,
|
||||
"tenantId": "datasolution",
|
||||
"tenantName": "鼎捷雅典娜演示",
|
||||
"sid": 50891717448256,
|
||||
"id": "dongsk@digiwin.com",
|
||||
"name": "董书康",
|
||||
"telephone": "13851711172",
|
||||
"email": "dongsk@digiwin.com",
|
||||
"appId": "data-business-demo",
|
||||
"tokenType": "digiwin",
|
||||
"tokenExpiresIn": 3232527,
|
||||
"identityType": "service"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `id` | 用户标识(邮箱/手机号/用户ID) |
|
||||
| `name` | 用户姓名 |
|
||||
| `email` | 邮箱 |
|
||||
| `telephone` | 手机号 |
|
||||
| `tenantId` | 租户 ID |
|
||||
| `tenantName` | 租户名称 |
|
||||
|
||||
### 4.5 依赖注入
|
||||
|
||||
`app/core/deps.py` — FastAPI 依赖,从请求头读取 IAM 凭证并校验:
|
||||
|
||||
```python
|
||||
"""FastAPI 依赖:IAM token 校验,提取当前用户"""
|
||||
|
||||
from fastapi import Header, HTTPException, status
|
||||
from . import iam
|
||||
|
||||
async def current_admin(
|
||||
user_token: str | None = Header(None, alias="digi-middleware-auth-user"),
|
||||
app_token: str | None = Header(None, alias="digi-middleware-auth-app"),
|
||||
) -> dict:
|
||||
if not user_token:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "未提供 IAM userToken")
|
||||
|
||||
info = await iam.analyze_token(user_token, app_token)
|
||||
if info is None:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "IAM token 无效或已过期")
|
||||
|
||||
# 返回统一用户信息
|
||||
return {
|
||||
"username": info.get("name") or info.get("id") or "unknown",
|
||||
"userId": info.get("id"),
|
||||
"name": info.get("name"),
|
||||
"email": info.get("email"),
|
||||
"tenantId": info.get("tenantId"),
|
||||
"tenantName": info.get("tenantName"),
|
||||
}
|
||||
```
|
||||
|
||||
### 4.6 业务路由示例
|
||||
|
||||
`app/routers/auth.py` — 所有需要鉴权的路由依赖 `current_admin`:
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter, Depends
|
||||
from ..core.deps import current_admin
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
@router.get("/me")
|
||||
async def me(admin: dict = Depends(current_admin)):
|
||||
"""返回当前 IAM 登录用户信息"""
|
||||
return {
|
||||
"userId": admin.get("userId"),
|
||||
"username": admin.get("username"),
|
||||
"name": admin.get("name"),
|
||||
"email": admin.get("email"),
|
||||
"tenantId": admin.get("tenantId"),
|
||||
}
|
||||
```
|
||||
|
||||
### 4.7 应用入口
|
||||
|
||||
`app/main.py`:
|
||||
|
||||
```python
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.core.iam import close_iam_client
|
||||
from app.routers import auth # 及其他路由
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
yield
|
||||
await close_iam_client()
|
||||
|
||||
app = FastAPI(title="你的应用", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router)
|
||||
# app.include_router(其他路由)
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
```
|
||||
|
||||
### 4.8 启动后端
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
IAM_APP_TOKEN=你的应用apptoken \
|
||||
python3 -m uvicorn app.main:app --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、Docker 部署
|
||||
|
||||
### 5.1 Dockerfile(多阶段构建)
|
||||
|
||||
```dockerfile
|
||||
# 阶段1:构建前端
|
||||
FROM node:20-alpine AS frontend-build
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# 阶段2:Python 运行时
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY backend/requirements.txt ./requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY backend/app ./app
|
||||
COPY --from=frontend-build /app/frontend/dist ./app/static
|
||||
ENV STATIC_DIR=/app/app/static
|
||||
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
### 5.2 docker-compose.yml
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
network_mode: host
|
||||
environment:
|
||||
- IAM_BASE_URL=https://iam.digiwincloud.com.cn
|
||||
- IAM_APP_TOKEN=${IAM_APP_TOKEN}
|
||||
- IAM_CACHE_TTL=30
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、验证与联调
|
||||
|
||||
### 6.1 启动
|
||||
|
||||
```bash
|
||||
# 后端
|
||||
cd backend
|
||||
IAM_APP_TOKEN=你的应用apptoken python3 -m uvicorn app.main:app --port 8000
|
||||
|
||||
# 前端
|
||||
cd frontend
|
||||
npm install && npm run dev
|
||||
```
|
||||
|
||||
### 6.2 验证鉴权链路
|
||||
|
||||
```bash
|
||||
# 有效 token → 200 返回用户信息
|
||||
curl -s http://localhost:8000/api/admin/me \
|
||||
-H 'digi-middleware-auth-user: <userToken>' \
|
||||
-H 'digi-middleware-auth-app: <appToken>'
|
||||
|
||||
# 无 token → 401
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/api/admin/me
|
||||
|
||||
# 无效 token → 401
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/api/admin/me \
|
||||
-H 'digi-middleware-auth-user: invalid-xxx'
|
||||
```
|
||||
|
||||
### 6.3 通过前端代理验证
|
||||
|
||||
```bash
|
||||
# 前端代理 → 后端
|
||||
curl -s http://localhost:5173/api/admin/me \
|
||||
-H 'digi-middleware-auth-user: <userToken>' \
|
||||
-H 'digi-middleware-auth-app: <appToken>'
|
||||
|
||||
# 前端代理 → IAM
|
||||
curl -s -X POST http://localhost:5173/iam-api/api/iam/v2/identity/token/analyze \
|
||||
-H 'digi-middleware-auth-user: <userToken>' \
|
||||
-H 'digi-middleware-auth-app: <appToken>'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、接入清单
|
||||
|
||||
### 前端
|
||||
|
||||
- [ ] `npm install jsencrypt react-router-dom axios antd`
|
||||
- [ ] 创建 `src/services/iamAuth.ts`(替换 `APP_TOKEN` / `APPID`)
|
||||
- [ ] 创建 `src/pages/Login/index.tsx`(账号密码登录页)
|
||||
- [ ] 创建 `src/pages/SSOLogin/index.tsx`(SSO 回调页)
|
||||
- [ ] `App.tsx` 配置 `/login`、`/sso-login` 路由 + `RequireAuth` 守卫
|
||||
- [ ] `src/api/client.ts` 请求拦截器带 IAM 头,401 清理跳登录
|
||||
- [ ] `vite.config.ts` 添加 `/iam-api` 代理
|
||||
- [ ] `index.html` 添加 placeholder 颜色样式(可选)
|
||||
|
||||
### 后端
|
||||
|
||||
- [ ] `requirements.txt` 添加 `httpx>=0.27.0`
|
||||
- [ ] `config.py` 添加 `IAM_BASE_URL` / `IAM_APP_TOKEN` / `IAM_CACHE_TTL`
|
||||
- [ ] 创建 `core/iam.py`(analyze_token + 缓存)
|
||||
- [ ] 创建 `core/deps.py`(current_admin 校验 IAM token)
|
||||
- [ ] `main.py` lifespan 增加 `close_iam_client()`
|
||||
- [ ] 业务路由 `Depends(current_admin)` 鉴权
|
||||
|
||||
### IAM 平台
|
||||
|
||||
- [ ] 注册应用,获取 `apptoken` 和 `appId`
|
||||
- [ ] 配置 SSO 回调地址为 `https://你的域名/sso-login`
|
||||
|
||||
---
|
||||
|
||||
## 八、注意事项
|
||||
|
||||
1. **IAM 应用凭证**:`APP_TOKEN` / `APPID` 必须替换为你在鼎捷云 IAM 注册的应用凭证,不可复用其他应用的。
|
||||
|
||||
2. **SSO 回调地址**:IAM 认证成功后以 `?userToken=xxx` 回跳 `/sso-login`,需在 IAM 平台提前配置。
|
||||
|
||||
3. **缓存策略**:后端 `analyze_token` 默认缓存 30s(`IAM_CACHE_TTL`),减少对 IAM 的重复调用。token 失效后最长 30s 生效,如需即时生效可调小或关闭缓存。
|
||||
|
||||
4. **placeholder 颜色**:antd Input 的 `className` 传给外层 wrapper,需用 `.className input::placeholder` 后代选择器才能命中实际 `input` 元素。
|
||||
|
||||
5. **会话存储**:本方案使用 `sessionStorage`,关闭浏览器标签后登录态丢失。如需持久化可改为 `localStorage`(注意 XSS 风险)。
|
||||
|
||||
6. **生产环境代理**:生产部署时若前后端同源(Dockerfile 多阶段构建),前端 `/iam-api` 代理不再生效,需在 Nginx 或后端配置 IAM 反向代理:
|
||||
```nginx
|
||||
location /iam-api/ {
|
||||
proxy_pass https://iam.digiwincloud.com.cn/;
|
||||
}
|
||||
```
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MCP Token 管理后台</title>
|
||||
<style>
|
||||
.iam-login-input::placeholder { color: rgba(168,216,255,0.6) !important; }
|
||||
.iam-login-input input::placeholder { color: #858D9A !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user