登录1
This commit is contained in:
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/sso-login" element={<SSOLogin />} />
|
||||
<Route path="/ai-matching" element={<AIMCockpit />} />
|
||||
<Route path="/cost-analysis" element={<CostCockpit />} />
|
||||
|
||||
@@ -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); }
|
||||
|
||||
+63
-4
@@ -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<string, React.ComponentType<{ size?: number; color?: string }>> = {
|
||||
BrainCircuit,
|
||||
@@ -21,6 +22,15 @@ export default function HomePage() {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const [searchParams] = useSearchParams();
|
||||
const [userInfo, setUserInfo] = useState<SessionUserInfo | null>(() => 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 (
|
||||
<div style={{
|
||||
width: '100vw', minHeight: '100vh',
|
||||
@@ -112,8 +128,51 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右上角主题切换 */}
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center' }}>
|
||||
{/* 右上角:用户信息 + 主题切换 */}
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
{userInfo && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: '50%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 13, fontWeight: 700,
|
||||
color: '#04121f',
|
||||
background: 'linear-gradient(135deg, #00d4ff, #4f9eff)',
|
||||
}}>
|
||||
{userInfo.userId.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: 13,
|
||||
...(isDark ? { color: 'rgba(168,216,255,0.85)' } : { color: '#334155' }),
|
||||
}}>
|
||||
{userInfo.userId}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
title="退出登录"
|
||||
style={{
|
||||
marginLeft: 4,
|
||||
display: 'flex', alignItems: 'center', gap: 4,
|
||||
padding: '5px 12px',
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${isDark ? 'rgba(0,212,255,0.3)' : 'rgba(0,0,0,0.12)'}`,
|
||||
background: isDark ? 'rgba(0,212,255,0.06)' : '#fff',
|
||||
color: isDark ? 'rgba(168,216,255,0.8)' : '#475569',
|
||||
fontSize: 12,
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 0.2s, color 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.color = '#ef4444'; }}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = isDark ? 'rgba(0,212,255,0.3)' : 'rgba(0,0,0,0.12)';
|
||||
e.currentTarget.style.color = isDark ? 'rgba(168,216,255,0.8)' : '#475569';
|
||||
}}
|
||||
>
|
||||
<LogOut size={13} />
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div style={{
|
||||
width: '100vw',
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
boxSizing: 'border-box',
|
||||
fontFamily: "-apple-system, 'SF Pro Display', 'SF Pro Text', 'PingFang SC', 'Helvetica Neue', Arial, sans-serif",
|
||||
color: isDark ? '#a8d8ff' : '#1e3a6e',
|
||||
}}>
|
||||
{isDark ? <StarBackground theme="dark" /> : (
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, width: '100%', height: '100%',
|
||||
background: 'linear-gradient(160deg, #f8f8fb 0%, #f2f3f7 50%, #eef1f7 100%)',
|
||||
zIndex: 0,
|
||||
}} />
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
position: 'relative', zIndex: 1,
|
||||
width: 400, maxWidth: '92vw',
|
||||
padding: '40px 36px 32px',
|
||||
borderRadius: 20,
|
||||
background: isDark ? 'rgba(10,22,40,0.75)' : 'rgba(255,255,255,0.9)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
border: `1px solid ${isDark ? 'rgba(0,212,255,0.2)' : 'rgba(0,0,0,0.06)'}`,
|
||||
boxShadow: isDark ? '0 24px 64px rgba(0,0,0,0.5), 0 0 48px rgba(0,212,255,0.08)' : '0 24px 64px rgba(30,58,110,0.12)',
|
||||
}}>
|
||||
{/* 品牌区 */}
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<img
|
||||
src="/digiwin.svg"
|
||||
alt="Digiwin"
|
||||
style={{ height: 36, objectFit: 'contain', filter: isDark ? 'drop-shadow(0 0 8px rgba(0,212,255,0.4))' : 'none' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10, marginTop: 14 }}>
|
||||
<h1 style={{
|
||||
fontSize: 26, fontWeight: 800, letterSpacing: '0.04em', margin: 0,
|
||||
...(isDark
|
||||
? { color: '#e8f4ff', textShadow: '0 0 16px rgba(0,212,255,0.5)' }
|
||||
: { color: '#111827' }),
|
||||
}}>
|
||||
EIOSpace
|
||||
</h1>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, marginTop: 6, ...(isDark ? { color: 'rgba(168,216,255,0.6)' } : { color: '#6b7280' }) }}>
|
||||
企业智能 · 数据驱动 · 产业赋能
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 登录表单 */}
|
||||
<form onSubmit={handleLogin}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, marginBottom: 6, color: isDark ? 'rgba(168,216,255,0.8)' : '#475569' }}>
|
||||
账号(用户ID / 手机号 / 邮箱)
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<User size={16} style={iconStyle(isDark)} />
|
||||
<input
|
||||
type="text"
|
||||
value={userId}
|
||||
onChange={(e) => 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)'; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, marginBottom: 6, color: isDark ? 'rgba(168,216,255,0.8)' : '#475569' }}>
|
||||
密码
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Lock size={16} style={iconStyle(isDark)} />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => 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)'; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, marginBottom: 6, color: isDark ? 'rgba(168,216,255,0.8)' : '#475569' }}>
|
||||
租户 ID(可选)
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Building2 size={16} style={iconStyle(isDark)} />
|
||||
<input
|
||||
type="text"
|
||||
value={tenantId}
|
||||
onChange={(e) => 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)'; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 12px', marginBottom: 16,
|
||||
borderRadius: 8, fontSize: 13,
|
||||
background: 'rgba(239,68,68,0.12)',
|
||||
border: '1px solid rgba(239,68,68,0.4)',
|
||||
color: '#fca5a5',
|
||||
}}>
|
||||
<AlertCircle size={15} style={{ flexShrink: 0 }} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '13px 0',
|
||||
borderRadius: 10,
|
||||
border: 'none',
|
||||
cursor: loading ? 'wait' : 'pointer',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.08em',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
color: '#04121f',
|
||||
background: 'linear-gradient(90deg, #00d4ff, #4f9eff)',
|
||||
boxShadow: '0 8px 24px rgba(0,212,255,0.35)',
|
||||
transition: 'transform 0.15s, box-shadow 0.15s, opacity 0.15s',
|
||||
opacity: loading ? 0.7 : 1,
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!loading) e.currentTarget.style.transform = 'translateY(-1px)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.transform = 'none'; }}
|
||||
>
|
||||
{loading ? <Loader2 size={17} style={{ animation: 'spin 1s linear infinite' }} /> : <LogIn size={17} />}
|
||||
{loading ? '登录中...' : '登 录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p style={{
|
||||
textAlign: 'center', fontSize: 12, marginTop: 24,
|
||||
...(isDark ? { color: 'rgba(168,216,255,0.45)' } : { color: '#94a3b8' }),
|
||||
}}>
|
||||
鼎捷云统一身份认证 · IAM
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<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);
|
||||
}
|
||||
|
||||
/** 通用请求头 */
|
||||
function iamHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* IAM 普通登录(identityType: query)
|
||||
* 支持用户 id / 手机号 / 邮箱
|
||||
*/
|
||||
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_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<string, string> = {
|
||||
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);
|
||||
}
|
||||
@@ -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/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user