From 2d19c736a6ec55eeb7f37dd6c9cbb1c133d4a784 Mon Sep 17 00:00:00 2001 From: dongsk Date: Thu, 27 Aug 2026 16:06:23 +0800 Subject: [PATCH] =?UTF-8?q?=E7=99=BB=E5=BD=951?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 7 + package.json | 1 + src/App.tsx | 2 + src/index.css | 5 + src/pages/HomePage.tsx | 67 +++++++++- src/pages/LoginPage.tsx | 231 +++++++++++++++++++++++++++++++++ src/shared/services/iamAuth.ts | 186 ++++++++++++++++++++++++++ vite.config.ts | 6 + 8 files changed, 501 insertions(+), 4 deletions(-) create mode 100644 src/pages/LoginPage.tsx create mode 100644 src/shared/services/iamAuth.ts diff --git a/package-lock.json b/package-lock.json index 09136b2..799be3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.13", + "jsencrypt": "^3.5.4", "lucide-react": "^0.468.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -1633,6 +1634,12 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsencrypt": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/jsencrypt/-/jsencrypt-3.5.4.tgz", + "integrity": "sha512-kNjfYEMNASxrDGsmcSQh/rUTmcoRfSUkxnAz+MMywM8jtGu+fFEZ3nJjHM58zscVnwR0fYmG9sGkTDjqUdpiwA==", + "license": "MIT" + }, "node_modules/json2mq": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", diff --git a/package.json b/package.json index b3f49a3..4b971db 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.13", + "jsencrypt": "^3.5.4", "lucide-react": "^0.468.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/src/App.tsx b/src/App.tsx index c174930..0e33858 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import { Routes, Route } from 'react-router-dom'; import HomePage from '@/pages/HomePage'; import SSOLogin from '@/pages/SSOLogin'; +import LoginPage from '@/pages/LoginPage'; import AIMCockpit from '@/modules/ai-matching/pages/AIMCockpit'; import CostCockpit from '@/modules/cost-analysis/pages/CostCockpit'; import KnowledgeGraphHome from '@/modules/knowledge-graph/pages/KnowledgeGraphHome'; @@ -15,6 +16,7 @@ export default function App() { return ( } /> + } /> } /> } /> } /> diff --git a/src/index.css b/src/index.css index f333ae9..09c5ef5 100644 --- a/src/index.css +++ b/src/index.css @@ -152,6 +152,11 @@ to { opacity: 1; transform: translateY(0); } } +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + @keyframes glow-pulse { 0%, 100% { box-shadow: 0 0 10px rgba(0,212,255,0.2); } 50% { box-shadow: 0 0 30px rgba(0,212,255,0.5); } diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 04669d5..7fe1171 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,10 +1,11 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { BrainCircuit, Calculator, Network, Boxes, ReceiptText, CalendarClock, Sparkles } from 'lucide-react'; +import { BrainCircuit, Calculator, Network, Boxes, ReceiptText, CalendarClock, Sparkles, LogOut } from 'lucide-react'; import { useTheme } from '@/shared/context/ThemeContext'; import StarBackground from '@/shared/components/StarBackground'; import ThemeToggle from '@/shared/components/ThemeToggle'; import { modules } from '@/modules/registry'; +import { getUserInfo, clearSession, type SessionUserInfo } from '@/shared/services/iamAuth'; const ICON_MAP: Record> = { BrainCircuit, @@ -21,6 +22,15 @@ export default function HomePage() { const { theme } = useTheme(); const isDark = theme === 'dark'; const [searchParams] = useSearchParams(); + const [userInfo, setUserInfo] = useState(() => getUserInfo()); + + // 登录守卫:未登录跳转 /login + useEffect(() => { + const token = sessionStorage.getItem('userToken'); + if (!token) { + navigate('/login', { replace: true }); + } + }, [navigate]); // 首页模块多时需纵向滚动;离开后恢复全局 overflow:hidden(其他模块自管布局) useEffect(() => { @@ -36,6 +46,12 @@ export default function HomePage() { } }, [searchParams]); + const handleLogout = () => { + clearSession(); + setUserInfo(null); + navigate('/login', { replace: true }); + }; + return (
- {/* 右上角主题切换 */} -
+ {/* 右上角:用户信息 + 主题切换 */} +
+ {userInfo && ( +
+
+ {userInfo.userId.charAt(0).toUpperCase()} +
+ + {userInfo.userId} + + +
+ )}
diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx new file mode 100644 index 0000000..83a84ec --- /dev/null +++ b/src/pages/LoginPage.tsx @@ -0,0 +1,231 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { User, Lock, Building2, LogIn, AlertCircle, Loader2 } from 'lucide-react'; +import StarBackground from '@/shared/components/StarBackground'; +import { iamLogin, saveSession } from '@/shared/services/iamAuth'; +import { useTheme } from '@/shared/context/ThemeContext'; + +const inputStyle = (isDark: boolean): React.CSSProperties => ({ + width: '100%', + boxSizing: 'border-box', + padding: '12px 14px 12px 42px', + borderRadius: 10, + border: `1px solid ${isDark ? 'rgba(0,212,255,0.25)' : 'rgba(0,0,0,0.12)'}`, + background: isDark ? 'rgba(0,212,255,0.06)' : '#fff', + color: isDark ? '#e8f4ff' : '#1e293b', + fontSize: 14, + outline: 'none', + transition: 'border-color 0.2s, box-shadow 0.2s', +}); + +const iconStyle = (isDark: boolean): React.CSSProperties => ({ + position: 'absolute', + left: 14, + top: '50%', + transform: 'translateY(-50%)', + color: isDark ? 'rgba(0,212,255,0.6)' : '#94a3b8', +}); + +export default function LoginPage() { + const navigate = useNavigate(); + const { theme } = useTheme(); + const isDark = theme === 'dark'; + + const [userId, setUserId] = useState(''); + const [password, setPassword] = useState(''); + const [tenantId, setTenantId] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + document.body.style.overflow = 'auto'; + return () => { document.body.style.overflow = ''; }; + }, []); + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + if (!userId.trim() || !password) { + setError('请输入账号和密码'); + return; + } + setLoading(true); + setError(''); + try { + const result = await iamLogin({ userId: userId.trim(), password, tenantId: tenantId.trim() || undefined }); + saveSession(result.token, { + userId: result.userId, + tenantId: result.tenantId, + loginTime: new Date().toLocaleString('zh-CN'), + }); + navigate('/', { replace: true }); + } catch (ex) { + setError(ex instanceof Error ? ex.message : '登录失败,请稍后重试'); + } finally { + setLoading(false); + } + }; + + return ( +
+ {isDark ? : ( +
+ )} + +
+ {/* 品牌区 */} +
+ Digiwin +
+

+ EIOSpace +

+
+

+ 企业智能 · 数据驱动 · 产业赋能 +

+
+ + {/* 登录表单 */} +
+
+ +
+ + setUserId(e.target.value)} + placeholder="请输入账号" + autoComplete="username" + style={inputStyle(isDark)} + onFocus={(e) => { e.currentTarget.style.borderColor = '#00d4ff'; }} + onBlur={(e) => { e.currentTarget.style.borderColor = isDark ? 'rgba(0,212,255,0.25)' : 'rgba(0,0,0,0.12)'; }} + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + placeholder="请输入密码" + autoComplete="current-password" + style={inputStyle(isDark)} + onFocus={(e) => { e.currentTarget.style.borderColor = '#00d4ff'; }} + onBlur={(e) => { e.currentTarget.style.borderColor = isDark ? 'rgba(0,212,255,0.25)' : 'rgba(0,0,0,0.12)'; }} + /> +
+
+ +
+ +
+ + setTenantId(e.target.value)} + placeholder="不填则自动匹配已授权租户" + style={inputStyle(isDark)} + onFocus={(e) => { e.currentTarget.style.borderColor = '#00d4ff'; }} + onBlur={(e) => { e.currentTarget.style.borderColor = isDark ? 'rgba(0,212,255,0.25)' : 'rgba(0,0,0,0.12)'; }} + /> +
+
+ + {error && ( +
+ + {error} +
+ )} + + +
+ +

+ 鼎捷云统一身份认证 · IAM +

+
+
+ ); +} diff --git a/src/shared/services/iamAuth.ts b/src/shared/services/iamAuth.ts new file mode 100644 index 0000000..bca6eb5 --- /dev/null +++ b/src/shared/services/iamAuth.ts @@ -0,0 +1,186 @@ +import { JSEncrypt } from 'jsencrypt'; + +/** + * 鼎捷云 IAM 登录服务(纯前端实现) + * + * 加密流程(RSA + AES): + * 1. 客户端生成 RSA 密钥对 + * 2. GET /api/iam/v2/identity/publickey 获取服务端公钥 + * 3. 用服务端公钥加密客户端公钥 → clientEncryptPublicKey + * 4. POST /api/iam/v2/identity/aeskey 获取被加密的 AES 密钥 + * 5. 用客户端私钥解密得到 aesKey(16 位) + * 6. AES-CBC 加密密码 → passwordHash + * 7. POST /api/iam/v2/identity/login 获取 userToken + */ + +// 代理路径(vite.config.ts 中 /iam-api → https://iam.digiwincloud.com.cn) +const IAM_BASE = '/iam-api/api/iam/v2/identity'; + +// 应用 apptoken(digi-middleware-auth-app) +export const APP_TOKEN = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImRhdGEtYnVzaW5lc3MtZGVtbyIsInNpZCI6MH0.Spo64LstbWxjYNefVFAbEbgfjzZoQGNcqKSGuYUOCRk'; + +// 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(与 Java AES/CBC/PKCS5Padding 等价) */ +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); +} + +/** 通用请求头 */ +function iamHeaders(extra?: Record): Record { + return { + 'Content-Type': 'application/json', + 'digi-middleware-auth-app': APP_TOKEN, + ...extra, + }; +} + +export interface IamLoginParams { + userId: string; + password: string; + tenantId?: string; +} + +export interface IamLoginResult { + token: string; + userId: string; + tenantId: string; + raw: Record; +} + +/** + * IAM 普通登录(identityType: query) + * 支持用户 id / 手机号 / 邮箱 + */ +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_BASE}/publickey`, { headers: iamHeaders() }); + 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_BASE}/aeskey`, { + method: 'POST', + headers: iamHeaders(), + 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 body: Record = { + userId, + passwordHash, + clientEncryptPublicKey, + identityType: 'query', + }; + if (tenantId) body.tenantId = tenantId; + + const loginRes = await fetch(`${IAM_BASE}/login`, { + method: 'POST', + headers: iamHeaders(), + body: JSON.stringify(body), + }); + const loginJson = await loginRes.json().catch(() => ({})); + + const token: string | undefined = loginJson.token ?? loginJson.data?.token ?? loginJson.userToken; + if (!loginRes.ok || !token) { + const msg = loginJson.message || loginJson.msg || loginJson.error || `HTTP ${loginRes.status}`; + throw new Error(`登录失败: ${msg}`); + } + + return { + token, + userId, + tenantId: loginJson.tenantId ?? tenantId ?? '', + raw: loginJson, + }; +} + +/* ---------------- sessionStorage 会话管理 ---------------- */ + +const KEY_USER_TOKEN = 'userToken'; +const KEY_USER_INFO = 'userInfo'; +const KEY_APP_TOKEN = 'digi-middleware-auth-app'; + +export interface SessionUserInfo { + userId: string; + tenantId: string; + loginTime: string; +} + +/** 保存登录会话 */ +export function saveSession(token: string, info: SessionUserInfo): void { + sessionStorage.setItem(KEY_USER_TOKEN, token); + sessionStorage.setItem(KEY_USER_INFO, JSON.stringify(info)); + sessionStorage.setItem(KEY_APP_TOKEN, APP_TOKEN); +} + +/** 获取当前 userToken */ +export function getUserToken(): string | null { + return sessionStorage.getItem(KEY_USER_TOKEN); +} + +/** 获取当前用户信息 */ +export function getUserInfo(): SessionUserInfo | null { + const raw = sessionStorage.getItem(KEY_USER_INFO); + if (!raw) return null; + try { + return JSON.parse(raw) as SessionUserInfo; + } catch { + return null; + } +} + +/** 退出登录,清空会话 */ +export function clearSession(): void { + sessionStorage.removeItem(KEY_USER_TOKEN); + sessionStorage.removeItem(KEY_USER_INFO); + sessionStorage.removeItem(KEY_APP_TOKEN); +} diff --git a/vite.config.ts b/vite.config.ts index 73689ec..da97692 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -25,6 +25,12 @@ export default defineConfig({ changeOrigin: true, rewrite: (p) => p.replace(/^\/bexapi/, '/api'), }, + // 鼎捷云 IAM 登录服务代理(避免 CORS / 网络问题) + '/iam-api': { + target: 'https://iam.digiwincloud.com.cn', + changeOrigin: true, + rewrite: (p) => p.replace(/^\/iam-api/, ''), + }, }, }, })