feat: integrate Jinxunda web with portal-bound SSO

This commit is contained in:
leefer
2026-09-22 17:43:23 +08:00
parent 2a24d1b570
commit ad67c31a81
15 changed files with 315 additions and 9 deletions
+1
View File
@@ -5,6 +5,7 @@
# 不匹配 pc-client/src/components 等源码子目录,保证其可入库
/components/
.selftest/
pc-client/dist-portal/
data/
**/agconnect-services.json
+1 -1
View File
@@ -1,6 +1,6 @@
FROM node:20-alpine
WORKDIR /app
COPY server.js admin.html ./
COPY server.js portal-sso.js admin.html ./
ENV PORT=10010 DATA_FILE=/app/data/employees.json
EXPOSE 10010
CMD ["node", "server.js"]
+142
View File
@@ -0,0 +1,142 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const digest = s => crypto.createHash('sha256').update(s).digest('hex');
const hex = s => typeof s === 'string' && /^[a-f0-9]{64}$/.test(s);
// Browser-bound portal tickets. OpenIM user IDs are permanent empids, not mutable staff numbers.
module.exports = function createPortalBridge(deps, env = process.env) {
const enabled = env.PORTAL_SSO_ENABLED === '1';
if (!enabled) return { handle: async () => false };
const portal = new URL(env.PORTAL_PUBLIC_URL);
const internal = new URL(env.PORTAL_INTERNAL_URL);
const secret = env.PORTAL_SSO_SECRET || '';
if (!['http:', 'https:'].includes(internal.protocol) || portal.protocol !== 'https:' || secret.length < 32)
throw new Error('Invalid portal configuration');
const file = path.join(path.dirname(env.DATA_FILE), 'portal-sessions.json');
let sessions = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {};
const cookieFlags = '; HttpOnly; Secure; SameSite=Lax; Path=/';
const cookieName = '__Host-jxd_portal_sid';
const save = () => {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file + '.tmp', JSON.stringify(sessions), { mode: 0o600 });
fs.renameSync(file + '.tmp', file);
};
const cookies = req => Object.fromEntries(String(req.headers.cookie || '').split(';').map(p => {
const n = p.indexOf('='); return [p.slice(0, n).trim(), p.slice(n + 1)];
}));
async function call(operation, payload) {
const response = await fetch(new URL('/api/sso/apps/jinxunda/' + operation, internal), {
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(5000),
headers: { Authorization: 'Bearer ' + secret, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error('Portal unavailable');
return response.json();
}
function reply(res, status, value) {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' });
res.end(JSON.stringify(value));
}
function redirect(res, location, cookie) {
res.writeHead(303, { Location: location, 'Set-Cookie': cookie,
'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' }); res.end();
}
async function valid(row) {
const r = await call('introspect', { session_handle: row.handle });
return r.active && r.identity?.empid === row.empid && r.identity?.local_uid === row.localUid;
}
async function revoke(key) {
const row = sessions[key];
if (!row) return;
row.revoked = true;
save();
// Persist revocation before kicking; failed kicks are retried by the sweep.
const token = await deps.getAdminToken();
const result = await deps.imApi('/auth/force_logout', { platformID: 5, userID: row.imUid }, token);
if (result.errCode !== 0) throw new Error('IM revocation pending');
delete sessions[key]; save();
}
let sweeping = false;
const timer = setInterval(async () => {
if (sweeping) return;
sweeping = true;
try {
for (const [key, row] of Object.entries(sessions)) {
let active = false;
try { active = !row.revoked && await valid(row); } catch { /* fail closed */ }
if (!active) { try { await revoke(key); } catch { /* retry next sweep */ } }
}
} finally { sweeping = false; }
}, 10000);
timer.unref();
return {
close: () => clearInterval(timer),
async handle(req, res) {
const url = new URL(req.url, 'http://localhost');
const route = url.pathname;
if (route === '/portal-sso') {
if (req.method !== 'GET') { reply(res, 405, { message: 'Method not allowed' }); return true; }
try {
const ticket = url.searchParams.get('ticket');
if (!ticket) {
const verifier = crypto.randomBytes(32).toString('hex');
const theme = url.searchParams.get('theme') === 'night' ? 'night' : 'day';
const value = [verifier, Date.now(), theme].join('.');
redirect(res, new URL('/api/sso/apps/jinxunda/authorize?challenge=' + digest(verifier), portal).href,
'__Host-jxd_proof=' + value + cookieFlags + '; Max-Age=120');
return true;
}
const [verifier, timestamp, theme] = (cookies(req)['__Host-jxd_proof'] || '').split('.');
if (!hex(ticket) || !hex(verifier) || !Number.isFinite(Number(timestamp))
|| Date.now() - Number(timestamp) > 120000 || Number(timestamp) > Date.now())
throw new Error('Invalid browser proof');
const result = await call('exchange', { ticket, verifier });
const who = result.identity;
if (!hex(result.session_handle) || !/^[a-f0-9-]{36}$/.test(who?.empid || '')
|| !Number.isSafeInteger(who?.local_uid) || who.local_uid < 1)
throw new Error('Invalid identity');
// OpenIM rejects UUID hyphens. Preserve empid and derive its lossless 32-hex representation.
const imUid = who.empid.replace(/-/g, '');
if (!/^[a-f0-9]{32}$/.test(imUid)) throw new Error('Invalid permanent identity');
if (!await deps.ensureImUser(imUid, who.display_name)) throw new Error('IM unavailable');
const token = await deps.getImUserToken(imUid, 5);
const sid = crypto.randomBytes(32).toString('hex');
sessions[digest(sid)] = { handle: result.session_handle, empid: who.empid, imUid,
localUid: who.local_uid, staffNo: who.username, name: who.display_name,
token: token.token, theme: theme === 'night' ? 'night' : 'day' };
save();
redirect(res, '/#/chat', [cookieName + '=' + sid + cookieFlags + '; Max-Age=28800',
'__Host-jxd_proof=' + cookieFlags + '; Max-Age=0']);
} catch (error) {
console.error('Portal SSO failed:', String(error.message).replace(/[a-f0-9]{32,}/gi, '[redacted]').slice(0, 180));
reply(res, 401, { message: '统一登录未完成,请返回门户重新进入。', portal: portal.origin });
}
return true;
}
if (route === '/api/portal/session') {
if (req.method !== 'GET') { reply(res, 405, {}); return true; }
const sid = cookies(req)[cookieName];
const key = hex(sid) ? digest(sid) : '';
const row = sessions[key];
try {
if (!row || row.revoked || !await valid(row)) throw new Error('Invalid session');
reply(res, 200, { userID: row.imUid, imToken: row.token, chatToken: row.token,
displayName: row.name, staffNo: row.staffNo, theme: row.theme, portal: portal.origin });
} catch {
if (row) { try { await revoke(key); } catch { /* retry */ }
}
reply(res, 401, { message: '请从统一门户进入金讯达', portal: portal.origin });
}
return true;
}
// The existing app must not retain a parallel password/admin entry.
if (route === '/api/login' || route === '/' || route === '/admin' || route.startsWith('/api/admin/')) {
reply(res, 403, { message: '账号及登录统一由门户管理', portal: portal.origin }); return true;
}
return false;
},
};
};
+2
View File
@@ -357,8 +357,10 @@ const routes = {
},
};
const portalBridge = require('./portal-sso')({ getAdminToken, imApi, ensureImUser, getImUserToken });
const server = http.createServer(async (req, res) => {
try {
if (await portalBridge.handle(req, res)) return;
const url = new URL(req.url, 'http://localhost');
req.urlObj = url;
if (req.method === 'OPTIONS') return send(res, 204, {});
+65
View File
@@ -0,0 +1,65 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const http = require('http');
const fs = require('fs');
const os = require('os');
const path = require('path');
const createBridge = require('./portal-sso');
test('portal proof, exchange, active session, revocation, and legacy login closure', async () => {
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'jinniu-sso-test-'));
let active = true, exchanges = 0, kicks = 0;
const identity = { empid: 'fefafefa-1234-1234-1234-123456789abc', local_uid: 1, username: '019999', display_name: '管理员' };
const portal = http.createServer(async (req, res) => {
assert.equal(req.headers.authorization, 'Bearer ' + 's'.repeat(32));
res.setHeader('Content-Type', 'application/json');
if (req.url.endsWith('/exchange')) {
exchanges++;
res.end(JSON.stringify({ identity, session_handle: 'c'.repeat(64) }));
} else res.end(JSON.stringify({active, identity}));
});
await new Promise(r => portal.listen(0,'127.0.0.1',r));
const bridge = createBridge({
ensureImUser: async (uid) => { assert.equal(uid, identity.empid.replace(/-/g,'')); return true; },
getImUserToken: async (uid, pid) => { assert.equal(pid, 5); return {token:'private-im-token'}; },
getAdminToken: async () => 'private-admin-token',
imApi: async (route, body) => { assert.equal(body.userID, identity.empid.replace(/-/g,'')); kicks++; return {errCode:0}; },
}, {PORTAL_SSO_ENABLED:'1', PORTAL_SSO_SECRET:'s'.repeat(32), PORTAL_PUBLIC_URL:'https://bg.example.test',
PORTAL_INTERNAL_URL:'http://127.0.0.1:'+portal.address().port, DATA_FILE:path.join(temp,'employees.json')});
const server = http.createServer(async (req,res) => { if (!await bridge.handle(req,res)) { res.writeHead(404);res.end(); } });
await new Promise(r => server.listen(0,'127.0.0.1',r));
const base = 'http://127.0.0.1:'+server.address().port;
try {
assert.equal((await fetch(base+'/api/login',{method:'POST'})).status,403);
assert.equal((await fetch(base+'/api/admin/employees')).status,403);
assert.equal((await fetch(base+'/api/portal/session')).status,401);
assert.equal((await fetch(base+'/portal-sso?ticket='+'a'.repeat(64))).status,401);
assert.equal(exchanges,0);
const start = await fetch(base+'/portal-sso?theme=night',{redirect:'manual'});
assert.equal(start.status,303);
assert.match(start.headers.get('location'),/^https:\/\/bg\.example\.test\/api\/sso\/apps\/jinxunda\/authorize\?challenge=[a-f0-9]{64}$/);
const proof = start.headers.get('set-cookie').split(';')[0];
const finish = await fetch(base+'/portal-sso?ticket='+'a'.repeat(64),{redirect:'manual',headers:{cookie:proof}});
assert.equal(finish.status,303);
const cookie = finish.headers.getSetCookie()[0].split(';')[0];
assert.match(finish.headers.getSetCookie()[0],/HttpOnly; Secure; SameSite=Lax/);
let r = await fetch(base+'/api/portal/session',{headers:{cookie}});
assert.equal(r.headers.get('cache-control'),'no-store');
const profile=await r.json();
assert.equal(profile.userID,identity.empid.replace(/-/g,''));
assert.equal(profile.staffNo,'019999');
assert.equal(profile.theme,'night');
active=false;
r = await fetch(base+'/api/portal/session',{headers:{cookie}});
assert.equal(r.status,401);
assert.equal(kicks,1);
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(temp,'portal-sessions.json'))),{});
} finally {
bridge.close();
await new Promise(r=>server.close(r));
await new Promise(r=>portal.close(r));
assert.ok(path.resolve(temp).startsWith(path.resolve(os.tmpdir())+path.sep));
fs.rmSync(temp,{recursive:true});
}
});
+24
View File
@@ -0,0 +1,24 @@
# 金讯达统一门户网页试运行
网页地址 https://jxd.jinniu.ink,唯一身份入口 https://bg.jinniu.ink。
account-service 设置 PORTAL_SSO_ENABLED=1、PORTAL_PUBLIC_URL、PORTAL_INTERNAL_URL 和独立不少于32字符的 PORTAL_SSO_SECRET。该密钥必须与门户 JINXUNDA_SSO_SECRET 一致。现有 OpenIM 管理服务密钥仍通过私有环境文件提供。
`/portal-sso` 使用浏览器 verifier 绑定的短期一次性票据。身份使用永久 empid,OpenIM UID 为去掉 UUID 连字符的32位十六进制表示。不能用工号改名重新创建聊天身份。
`/api/portal/session` 同源且禁止缓存;每次验证门户会话。后台每10秒撤销失效用户平台5会话,失败会重试。会话文件仅服务端可读,原口令登录和管理API在门户模式禁用。
构建 pc-client 网页版本时配置:
```
VITE_PORTAL_MODE=1
VITE_PORTAL_URL=https://bg.jinniu.ink
VITE_API_URL=https://jxd.jinniu.ink
VITE_WS_URL=wss://jxd.jinniu.ink/msg_gateway
VITE_ACCOUNT_URL=https://jxd.jinniu.ink/account
VITE_LIVEKIT_URL=wss://jxd.jinniu.ink/livekit/
```
执行 `npx vite build --config vite.portal.config.ts`,输出 dist-portal。Nginx 将根路径/资源指向该目录,SSO及会话接口代理账号服务;原OpenIM、对象存储、LiveKit路由保留。
验证:`node --test test-portal-sso.js test-admin-page.js`(在account-service目录);真实HTTPS兑换OpenIM凭证、浏览器进入聊天、返回门户、退出撤销已验收。
仅保留一个正式管理员,因此双人消息、音视频、附件尚未完成真实员工验收。原生Windows/Android客户端仍为历史登录机制,需要另行适配门户登录;不能视为本次网页交付已覆盖。
+3 -2
View File
@@ -25,6 +25,7 @@ import { OverlayVisibleHandle } from "../../hooks/useOverlayVisible";
import About from "./About";
import styles from "./left-nav-bar.module.scss";
import PersonalSettings from "./PersonalSettings";
import { portalMode } from '@/portal';
const AppIcon = () => (
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-[var(--primary)]">
@@ -281,7 +282,7 @@ const LeftNavBar = memo(() => {
))}
</div>
<Popover
{!portalMode && <Popover
content={ProfileContent}
trigger="click"
placement="rightBottom"
@@ -297,7 +298,7 @@ const LeftNavBar = memo(() => {
src={selfInfo.faceURL}
text={selfInfo.nickname}
/>
</Popover>
</Popover>}
<PersonalSettings ref={personalSettingsRef} />
<About ref={aboutRef} />
</div>
+3 -1
View File
@@ -9,6 +9,7 @@ import { useUserStore } from "@/store";
import GlobalModals from "./GlobalModals";
import LeftNavBar from "./LeftNavBar";
import { useGlobalEvent } from "./useGlobalEvents";
import { portalMode, portalHome } from '@/portal';
export const MainContentLayout = () => {
useGlobalEvent();
@@ -35,7 +36,8 @@ export const MainContentLayout = () => {
return (
<Spin className="!max-h-none" spinning={showLockLoading} tip={loadingTip}>
<Layout className="relative h-full flex-row">
{portalMode && <header className="portal-header"><strong>金讯达</strong><span>金牛集团 · 内部通讯</span><a href={portalHome + '?theme=' + document.documentElement.dataset.theme}>返回统一门户</a></header>}
<Layout className="relative h-full flex-row" style={portalMode ? {height: 'calc(100% - 56px)'} : undefined}>
<LeftNavBar />
<Outlet />
{/* 悬浮窗口控制条:无边框窗口的拖动区 + 最小化/关闭 */}
+19 -1
View File
@@ -7,6 +7,8 @@ import ReactDOM from "react-dom/client";
import App from "./App";
import { isSaveLog } from "./config";
import { initializePortal, portalHome, portalMode } from './portal';
import './portal.scss';
if (window.electronAPI && isSaveLog) {
const sdkLogger = log.scope("openim-sdk-core");
@@ -16,6 +18,22 @@ if (window.electronAPI && isSaveLog) {
console.error = rendererLogger.error.bind(rendererLogger);
}
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(<App />);
initializePortal().then(ready => {
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(ready ? <App /> : <main style={{padding: 48}}><h1>金讯达</h1>
<p>请从统一门户登录并进入金讯达。</p><a href={portalHome}>返回统一门户</a></main>);
if (ready && portalMode) {
let checking = false;
setInterval(async () => {
if (checking) return;
checking = true;
try {
const response = await fetch('/api/portal/session', { cache: 'no-store' });
if (!response.ok) window.location.replace(portalHome);
} catch { window.location.replace(portalHome); }
finally { checking = false; }
}, 10000);
}
});
postMessage({ payload: "removeLoading" }, "*");
+2 -1
View File
@@ -1,5 +1,6 @@
import { Layout } from "antd";
import { useTranslation } from "react-i18next";
import { portalMode } from "@/portal";
export const EmptyChat = () => {
const { t } = useTranslation();
@@ -15,7 +16,7 @@ export const EmptyChat = () => {
/>
</svg>
</div>
<div className="mt-4 text-base font-medium">{t("placeholder.title")}</div>
<div className="mt-4 text-base font-medium">{portalMode ? '金讯达' : t("placeholder.title")}</div>
<div className="mt-1 text-xs text-[var(--sub-text)]">
{t("placeholder.subTitle")}
</div>
+3
View File
@@ -8,10 +8,13 @@ import { feedbackToast } from "@/utils/common";
import styles from "./index.module.scss";
import LoginForm from "./LoginForm";
import { portalMode, portalHome } from '@/portal';
export const Login = () => {
const [_, copyToClipboard] = useCopyToClipboard();
if (portalMode) return <main style={{padding: 48}}><p>登录已失效,请返回门户重新进入。</p><a href={portalHome}>返回统一门户</a></main>;
const handleCopy = () => {
copyToClipboard(`${`${APP_NAME} ${APP_VERSION}`}/${SDK_VERSION}`);
feedbackToast({ msg: t("toast.copySuccess") });
+20
View File
@@ -0,0 +1,20 @@
html[data-portal='true'] {
--primary: #b32635;
--primary-active: #f6e8e9;
--top-search-bar: #b32635;
--chat-bubble-sender: #f4dfe1;
.portal-header { height: 56px; display: flex; align-items: center; gap: 20px; padding: 0 24px; border-bottom: 1px solid var(--gap-text); background: #fff; }
.portal-header strong { color: var(--primary); font-size: 20px; }
.portal-header span { color: var(--sub-text); }
.portal-header a { margin-left: auto; color: var(--primary); }
.ant-spin-nested-loading, .ant-spin-container { height: 100%; }
&[data-theme='night'] {
color-scheme: dark;
--primary: #e2737d; --primary-active: #35272a; --base-black: #e9e5df;
--sub-text: #a8a29a; --gap-text: #3b3634; --search-bg: #292626;
--chat-bg: #201e1d; --chat-bubble: #312d2b; --chat-bubble-sender: #4b3034;
background: #201e1d;
.portal-header, .bg-white, .ant-layout { background: #252221 !important; }
input, textarea, .ant-input, .ant-modal-content, .ant-popover-inner { background: #292626; color: #e9e5df; }
}
}
+20
View File
@@ -0,0 +1,20 @@
import { setIMProfile, clearIMProfile } from './utils/storage';
export const portalMode = import.meta.env.VITE_PORTAL_MODE === '1';
export const portalHome = import.meta.env.VITE_PORTAL_URL || '';
export async function initializePortal(): Promise<boolean> {
if (!portalMode) return true;
try {
const response = await fetch('/api/portal/session', { cache: 'no-store', credentials: 'same-origin' });
if (!response.ok) throw new Error('Session expired');
const profile = await response.json();
await setIMProfile(profile);
document.documentElement.dataset.theme = profile.theme === 'night' ? 'night' : 'day';
document.documentElement.dataset.portal = 'true';
document.title = '金讯达 · 金牛集团';
return true;
} catch {
clearIMProfile();
return false;
}
}
+1 -3
View File
@@ -25,9 +25,7 @@ export const setIMProfile = ({
imToken: string;
userID: string;
}) => {
setTMToken(imToken);
setChatToken(chatToken);
setTMUserID(userID);
return Promise.all([setTMToken(imToken), setChatToken(chatToken), setTMUserID(userID)]);
};
export const setLocale = (locale: string) => localStorage.setItem("IM_LOCALE", locale);
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';
export default defineConfig({
plugins: [react()],
resolve: { alias: { '@': path.resolve(__dirname, 'src') } },
build: { outDir: 'dist-portal', sourcemap: false },
});