sso-login调整

This commit is contained in:
2026-08-27 16:37:33 +08:00
parent 2d19c736a6
commit 989120cab8
4 changed files with 291 additions and 67 deletions
+2 -2
View File
@@ -139,13 +139,13 @@ export default function HomePage() {
color: '#04121f', color: '#04121f',
background: 'linear-gradient(135deg, #00d4ff, #4f9eff)', background: 'linear-gradient(135deg, #00d4ff, #4f9eff)',
}}> }}>
{userInfo.userId.charAt(0).toUpperCase()} {(userInfo.userName ?? userInfo.userId).charAt(0).toUpperCase()}
</div> </div>
<span style={{ <span style={{
fontSize: 13, fontSize: 13,
...(isDark ? { color: 'rgba(168,216,255,0.85)' } : { color: '#334155' }), ...(isDark ? { color: 'rgba(168,216,255,0.85)' } : { color: '#334155' }),
}}> }}>
{userInfo.userId} {userInfo.userName ?? userInfo.userId}
</span> </span>
<button <button
onClick={handleLogout} onClick={handleLogout}
+4 -27
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { User, Lock, Building2, LogIn, AlertCircle, Loader2 } from 'lucide-react'; import { User, Lock, LogIn, AlertCircle, Loader2 } from 'lucide-react';
import StarBackground from '@/shared/components/StarBackground'; import StarBackground from '@/shared/components/StarBackground';
import { iamLogin, saveSession } from '@/shared/services/iamAuth'; import { iamLogin, saveSession } from '@/shared/services/iamAuth';
import { useTheme } from '@/shared/context/ThemeContext'; import { useTheme } from '@/shared/context/ThemeContext';
@@ -33,7 +33,6 @@ export default function LoginPage() {
const [userId, setUserId] = useState(''); const [userId, setUserId] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [tenantId, setTenantId] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -51,12 +50,8 @@ export default function LoginPage() {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const result = await iamLogin({ userId: userId.trim(), password, tenantId: tenantId.trim() || undefined }); const result = await iamLogin({ userId: userId.trim(), password });
saveSession(result.token, { saveSession(result.token, result.userInfo);
userId: result.userId,
tenantId: result.tenantId,
loginTime: new Date().toLocaleString('zh-CN'),
});
navigate('/', { replace: true }); navigate('/', { replace: true });
} catch (ex) { } catch (ex) {
setError(ex instanceof Error ? ex.message : '登录失败,请稍后重试'); setError(ex instanceof Error ? ex.message : '登录失败,请稍后重试');
@@ -138,7 +133,7 @@ export default function LoginPage() {
</div> </div>
</div> </div>
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 20 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 6, color: isDark ? 'rgba(168,216,255,0.8)' : '#475569' }}> <label style={{ display: 'block', fontSize: 13, marginBottom: 6, color: isDark ? 'rgba(168,216,255,0.8)' : '#475569' }}>
密码 密码
</label> </label>
@@ -157,24 +152,6 @@ export default function LoginPage() {
</div> </div>
</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 && ( {error && (
<div style={{ <div style={{
display: 'flex', alignItems: 'center', gap: 8, display: 'flex', alignItems: 'center', gap: 8,
+63 -5
View File
@@ -1,21 +1,47 @@
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { Spin } from 'antd'; import { Spin } from 'antd';
import { AlertCircle } from 'lucide-react';
import { iamSsoLogin, saveSession } from '@/shared/services/iamAuth';
/** /**
* SSO 登录回调入口 * SSO 登录回调入口
* 接收 URL 参数 userToken,写入 sessionStorage 后跳转首页 *
* 流程:
* 1. 从 URL 参数获取 userToken
* 2. 调用 iamSsoLogin 完成:
* - POST /identity/token/refresh/app 刷新应用 token + 用户信息
* - POST /identity/login/info 获取登录详情
* - POST /tenant?appId=APPID 拉取租户列表,选默认租户
* - POST /identity/token/refresh/tenant 切换租户刷新 token
* 3. userInfo 写入 sessionStorage,跳转首页
*/ */
const SSOLogin: React.FC = () => { const SSOLogin: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [error, setError] = useState<string>('');
useEffect(() => { useEffect(() => {
const userToken = searchParams.get('userToken'); const userToken = searchParams.get('userToken');
if (userToken) { if (!userToken) {
sessionStorage.setItem('userToken', 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 }); navigate('/', { replace: true });
} catch (ex) {
if (cancelled) return;
setError(ex instanceof Error ? ex.message : 'SSO 登录失败');
}
})();
return () => { cancelled = true; };
}, [searchParams, navigate]); }, [searchParams, navigate]);
return ( return (
@@ -26,8 +52,40 @@ const SSOLogin: React.FC = () => {
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
background: '#0a1628', background: '#0a1628',
color: '#a8d8ff',
fontFamily: "-apple-system, 'SF Pro Display', 'PingFang SC', 'Helvetica Neue', Arial, sans-serif",
}}> }}>
<Spin size="large" tip="正在登录..." /> {error ? (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16,
padding: '32px 40px',
borderRadius: 16,
background: 'rgba(239,68,68,0.08)',
border: '1px solid rgba(239,68,68,0.4)',
maxWidth: 460,
}}>
<AlertCircle size={36} color="#fca5a5" />
<div style={{ fontSize: 16, fontWeight: 600, color: '#fca5a5' }}>SSO 登录失败</div>
<div style={{ fontSize: 13, color: 'rgba(168,216,255,0.7)', textAlign: 'center', wordBreak: 'break-all' }}>
{error}
</div>
<button
onClick={() => navigate('/login', { replace: true })}
style={{
marginTop: 8, padding: '8px 20px', borderRadius: 8,
border: '1px solid rgba(0,212,255,0.4)', background: 'rgba(0,212,255,0.1)',
color: '#00d4ff', fontSize: 13, cursor: 'pointer',
}}
>
前往登录页
</button>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}>
<Spin size="large" />
<div style={{ fontSize: 14, color: 'rgba(168,216,255,0.7)' }}>正在登录...</div>
</div>
)}
</div> </div>
); );
}; };
+221 -32
View File
@@ -3,23 +3,24 @@ import { JSEncrypt } from 'jsencrypt';
/** /**
* 鼎捷云 IAM 登录服务(纯前端实现) * 鼎捷云 IAM 登录服务(纯前端实现)
* *
* 加密流程(RSA + AES): * 完整登录流程:
* 1. 客户端生成 RSA 密钥对 * 1. RSA+AES 加密链路获取 userToken(/api/iam/v2/identity/login)
* 2. GET /api/iam/v2/identity/publickey 获取服务端公钥 * 2. 拉取用户授权租户列表(POST /api/iam/v2/tenant?appId=APPID),默认选第一个
* 3. 用服务端公钥加密客户端公钥 → clientEncryptPublicKey * 3. 切换租户刷新 token(POST /api/iam/v2/identity/token/refresh/tenant)
* 4. POST /api/iam/v2/identity/aeskey 获取被加密的 AES 密钥 * 4. 将完整用户信息(含 authoredUser)写入 sessionStorage
* 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) // 代理路径(vite.config.ts 中 /iam-api → https://iam.digiwincloud.com.cn)
const IAM_BASE = '/iam-api/api/iam/v2/identity'; const IAM_API_BASE = '/iam-api/api/iam/v2';
const IAM_IDENTITY_BASE = `${IAM_API_BASE}/identity`;
// 应用 apptoken(digi-middleware-auth-app) // 应用 apptoken(digi-middleware-auth-app)
export const APP_TOKEN = export const APP_TOKEN =
'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImRhdGEtYnVzaW5lc3MtZGVtbyIsInNpZCI6MH0.Spo64LstbWxjYNefVFAbEbgfjzZoQGNcqKSGuYUOCRk'; 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImRhdGEtYnVzaW5lc3MtZGVtbyIsInNpZCI6MH0.Spo64LstbWxjYNefVFAbEbgfjzZoQGNcqKSGuYUOCRk';
// 应用 ID(用于租户列表查询)
export const APPID = 'data-business-demo';
// IAM AES 加密固定 IV(16 字节) // IAM AES 加密固定 IV(16 字节)
const AES_IV = 'ghUb#er57HBh(u%g'; const AES_IV = 'ghUb#er57HBh(u%g';
@@ -48,8 +49,8 @@ async function aesEncryptToBase64(plainText: string, aesKey: string): Promise<st
return btoa(bin); return btoa(bin);
} }
/** 通用请求头 */ /** 应用层请求头(含 apptoken) */
function iamHeaders(extra?: Record<string, string>): Record<string, string> { function appHeaders(extra?: Record<string, string>): Record<string, string> {
return { return {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'digi-middleware-auth-app': APP_TOKEN, 'digi-middleware-auth-app': APP_TOKEN,
@@ -57,6 +58,25 @@ function iamHeaders(extra?: Record<string, string>): Record<string, string> {
}; };
} }
/** 用户鉴权请求头(含 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 { export interface IamLoginParams {
userId: string; userId: string;
password: string; password: string;
@@ -64,15 +84,17 @@ export interface IamLoginParams {
} }
export interface IamLoginResult { export interface IamLoginResult {
/** 最终 userToken(经 refresh/tenant 刷新后) */
token: string; token: string;
/** userId */
userId: string; userId: string;
tenantId: string; /** 完整用户信息(含 login 原始返回 + authoredUser + 默认租户) */
raw: Record<string, unknown>; userInfo: Record<string, unknown>;
} }
/** /**
* IAM 普通登录(identityType: query) * IAM 普通登录(identityType: query)
* 支持用户 id / 手机号 / 邮箱 * 完整流程:加密登录 → 拉取租户列表 → 切换默认租户刷新 token
*/ */
export async function iamLogin({ userId, password, tenantId }: IamLoginParams): Promise<IamLoginResult> { export async function iamLogin({ userId, password, tenantId }: IamLoginParams): Promise<IamLoginResult> {
// 1. 客户端生成 RSA 密钥对(1024) // 1. 客户端生成 RSA 密钥对(1024)
@@ -82,7 +104,7 @@ export async function iamLogin({ userId, password, tenantId }: IamLoginParams):
const clientPublicKeyB64 = stripPem(client.getPublicKey()); const clientPublicKeyB64 = stripPem(client.getPublicKey());
// 2. 获取服务端公钥 // 2. 获取服务端公钥
const pkRes = await fetch(`${IAM_BASE}/publickey`, { headers: iamHeaders() }); const pkRes = await fetch(`${IAM_IDENTITY_BASE}/publickey`, { headers: appHeaders() });
if (!pkRes.ok) throw new Error(`获取服务端公钥失败 (HTTP ${pkRes.status})`); if (!pkRes.ok) throw new Error(`获取服务端公钥失败 (HTTP ${pkRes.status})`);
const pkJson = await pkRes.json(); const pkJson = await pkRes.json();
const serverPublicKey: string = pkJson.publicKey; const serverPublicKey: string = pkJson.publicKey;
@@ -95,9 +117,9 @@ export async function iamLogin({ userId, password, tenantId }: IamLoginParams):
if (!clientEncryptPublicKey) throw new Error('加密客户端公钥失败'); if (!clientEncryptPublicKey) throw new Error('加密客户端公钥失败');
// 4. 获取加密的 AES 密钥 // 4. 获取加密的 AES 密钥
const aesRes = await fetch(`${IAM_BASE}/aeskey`, { const aesRes = await fetch(`${IAM_IDENTITY_BASE}/aeskey`, {
method: 'POST', method: 'POST',
headers: iamHeaders(), headers: appHeaders(),
body: JSON.stringify({ clientEncryptPublicKey }), body: JSON.stringify({ clientEncryptPublicKey }),
}); });
if (!aesRes.ok) throw new Error(`获取 AES 密钥失败 (HTTP ${aesRes.status})`); if (!aesRes.ok) throw new Error(`获取 AES 密钥失败 (HTTP ${aesRes.status})`);
@@ -114,32 +136,190 @@ export async function iamLogin({ userId, password, tenantId }: IamLoginParams):
const passwordHash = await aesEncryptToBase64(password, aesKey); const passwordHash = await aesEncryptToBase64(password, aesKey);
// 7. 登录 // 7. 登录
const body: Record<string, string> = { const loginBody: Record<string, string> = {
userId, userId,
passwordHash, passwordHash,
clientEncryptPublicKey, clientEncryptPublicKey,
identityType: 'query', identityType: 'query',
}; };
if (tenantId) body.tenantId = tenantId; if (tenantId) loginBody.tenantId = tenantId;
const loginRes = await fetch(`${IAM_BASE}/login`, { const loginRes = await fetch(`${IAM_IDENTITY_BASE}/login`, {
method: 'POST', method: 'POST',
headers: iamHeaders(), headers: appHeaders(),
body: JSON.stringify(body), body: JSON.stringify(loginBody),
}); });
const loginJson = await loginRes.json().catch(() => ({})); const loginJson = (await loginRes.json().catch(() => ({}))) as Record<string, unknown>;
const token: string | undefined = loginJson.token ?? loginJson.data?.token ?? loginJson.userToken; const initialToken = pickToken(loginJson);
if (!loginRes.ok || !token) { if (!loginRes.ok || !initialToken) {
const msg = loginJson.message || loginJson.msg || loginJson.error || `HTTP ${loginRes.status}`; const msg = loginJson.message || loginJson.msg || loginJson.error || `HTTP ${loginRes.status}`;
throw new Error(`登录失败: ${msg}`); throw new Error(`登录失败: ${msg}`);
} }
return { // 8. 拉取用户授权租户列表 + 切换默认租户
token, const tenantCtx = await switchDefaultTenant(initialToken);
// 9. 组装完整 userInfo:
// - authoredUser 内容平铺到顶层(sid/userName/email/currTenantList 等)
// - token 使用最终刷新后的 token
// - 保留 isLoggedin 标识
const userInfo: Record<string, unknown> = {
...(tenantCtx.authoredUser ?? {}),
...(loginJson ?? {}),
userId, userId,
tenantId: loginJson.tenantId ?? tenantId ?? '', token: tenantCtx.token,
raw: loginJson, isLoggedin: true,
...(tenantCtx.currTenantList ? { currTenantList: tenantCtx.currTenantList } : {}),
};
return {
token: tenantCtx.token,
userId,
userInfo,
};
}
/**
* 切换默认租户(公用流程)
*
* 1. POST /api/iam/v2/tenant?appId=APPID 拉取租户列表
* 2. 优先选 isDefault=true 的租户,否则取第一个
* 3. POST /api/iam/v2/identity/token/refresh/tenant body={tenantSid}
* 4. 返回刷新后的 token + authoredUser + currTenantList
*
* 异常不抛出,回退到传入的 userToken
*/
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>;
// 兼容数组 / {data:[]} / {list:[]} / {result:[]}
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;
// 优先选 isDefault=true 的租户,否则取第一个
const defaultTenant = (tenants.find((t) => (t as Record<string, unknown>)?.isDefault === true)
?? tenants[0]) as Record<string, unknown>;
const tenantSid = (defaultTenant.sid as number | string | undefined)
?? (defaultTenant.tenantSid as number | string | undefined)
?? (defaultTenant.id as number | string | undefined);
if (tenantSid !== undefined && tenantSid !== null) {
// 切换默认租户,刷新 token
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;
// authoredUser 平铺到 userInfo 顶层
if (refreshJson.authoredUser && typeof refreshJson.authoredUser === 'object') {
authoredUser = refreshJson.authoredUser as Record<string, unknown>;
} else if (refreshJson.data && typeof refreshJson.data === 'object'
&& (refreshJson.data as Record<string, unknown>).authoredUser) {
authoredUser = (refreshJson.data as Record<string, unknown>).authoredUser as Record<string, unknown>;
} else {
// 整个 refresh 返回作为 authoredUser(兼容字段直接在顶层)
authoredUser = refreshJson;
}
}
}
}
} catch (ex) {
// 租户切换失败不阻断登录,仍使用原 token
console.warn('[IAM] 租户切换流程异常,将使用原 token', ex);
}
return { token: finalToken, authoredUser, currTenantList };
}
/**
* SSO 登录(基于外部传入的 userToken)
*
* 流程:
* 1. POST /api/iam/v2/identity/token/refresh/app 刷新应用 token + 用户信息
* 2. POST /api/iam/v2/identity/login/info 获取登录详情
* 3. 调用 switchDefaultTenant 拉取租户列表 + 切换默认租户
* 4. 组装完整 userInfo(authoredUser 平铺到顶层)
*/
export async function iamSsoLogin(initialUserToken: string): Promise<IamLoginResult> {
// 1. token/refresh/app:刷新应用 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. login/info:获取登录详情
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
// 优先级:tenantCtx.authoredUser > refreshAppJson > loginInfoJson
// userId 从 loginInfo/refreshApp 中提取
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,
}; };
} }
@@ -151,12 +331,21 @@ const KEY_APP_TOKEN = 'digi-middleware-auth-app';
export interface SessionUserInfo { export interface SessionUserInfo {
userId: string; userId: string;
tenantId: string; userName?: string;
loginTime: string; token: string;
tenantId?: string;
tenantName?: string;
tenantSid?: number;
sid?: number;
email?: string;
telephone?: string;
isLoggedin?: boolean;
currTenantList?: unknown[];
[key: string]: unknown;
} }
/** 保存登录会话 */ /** 保存登录会话 */
export function saveSession(token: string, info: SessionUserInfo): void { export function saveSession(token: string, info: Record<string, unknown>): void {
sessionStorage.setItem(KEY_USER_TOKEN, token); sessionStorage.setItem(KEY_USER_TOKEN, token);
sessionStorage.setItem(KEY_USER_INFO, JSON.stringify(info)); sessionStorage.setItem(KEY_USER_INFO, JSON.stringify(info));
sessionStorage.setItem(KEY_APP_TOKEN, APP_TOKEN); sessionStorage.setItem(KEY_APP_TOKEN, APP_TOKEN);