diff --git a/.gitignore b/.gitignore index 3f3e4c0..56df3f6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ # 不匹配 pc-client/src/components 等源码子目录,保证其可入库 /components/ .selftest/ +pc-client/dist-portal/ data/ **/agconnect-services.json diff --git a/account-service/Dockerfile b/account-service/Dockerfile index acbf676..746a6ee 100644 --- a/account-service/Dockerfile +++ b/account-service/Dockerfile @@ -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"] diff --git a/account-service/portal-sso.js b/account-service/portal-sso.js new file mode 100644 index 0000000..d52e996 --- /dev/null +++ b/account-service/portal-sso.js @@ -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; + }, + }; +}; diff --git a/account-service/server.js b/account-service/server.js index 002d8ef..000cfd6 100644 --- a/account-service/server.js +++ b/account-service/server.js @@ -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, {}); diff --git a/account-service/test-portal-sso.js b/account-service/test-portal-sso.js new file mode 100644 index 0000000..bea3b52 --- /dev/null +++ b/account-service/test-portal-sso.js @@ -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}); + } +}); diff --git a/docs/portal-web-trial.md b/docs/portal-web-trial.md new file mode 100644 index 0000000..77d5b91 --- /dev/null +++ b/docs/portal-web-trial.md @@ -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客户端仍为历史登录机制,需要另行适配门户登录;不能视为本次网页交付已覆盖。 diff --git a/pc-client/src/layout/LeftNavBar/index.tsx b/pc-client/src/layout/LeftNavBar/index.tsx index 93d9488..a941c5c 100644 --- a/pc-client/src/layout/LeftNavBar/index.tsx +++ b/pc-client/src/layout/LeftNavBar/index.tsx @@ -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 = () => (
@@ -281,7 +282,7 @@ const LeftNavBar = memo(() => { ))}
- { src={selfInfo.faceURL} text={selfInfo.nickname} /> - + } diff --git a/pc-client/src/layout/MainContentLayout.tsx b/pc-client/src/layout/MainContentLayout.tsx index 28ec79d..d1e26a6 100644 --- a/pc-client/src/layout/MainContentLayout.tsx +++ b/pc-client/src/layout/MainContentLayout.tsx @@ -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 ( - + {portalMode &&
金讯达金牛集团 · 内部通讯 {/* 悬浮窗口控制条:无边框窗口的拖动区 + 最小化/关闭 */} diff --git a/pc-client/src/main.tsx b/pc-client/src/main.tsx index 32d5d46..8e9df9b 100644 --- a/pc-client/src/main.tsx +++ b/pc-client/src/main.tsx @@ -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(); +initializePortal().then(ready => { + const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); + root.render(ready ? :

金讯达

+

请从统一门户登录并进入金讯达。

返回统一门户
); + 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" }, "*"); diff --git a/pc-client/src/pages/chat/EmptyChat.tsx b/pc-client/src/pages/chat/EmptyChat.tsx index 50c265d..8970153 100644 --- a/pc-client/src/pages/chat/EmptyChat.tsx +++ b/pc-client/src/pages/chat/EmptyChat.tsx @@ -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 = () => { /> -
{t("placeholder.title")}
+
{portalMode ? '金讯达' : t("placeholder.title")}
{t("placeholder.subTitle")}
diff --git a/pc-client/src/pages/login/index.tsx b/pc-client/src/pages/login/index.tsx index 2c5b4c6..c8894df 100644 --- a/pc-client/src/pages/login/index.tsx +++ b/pc-client/src/pages/login/index.tsx @@ -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

登录已失效,请返回门户重新进入。

返回统一门户
; + const handleCopy = () => { copyToClipboard(`${`${APP_NAME} ${APP_VERSION}`}/${SDK_VERSION}`); feedbackToast({ msg: t("toast.copySuccess") }); diff --git a/pc-client/src/portal.scss b/pc-client/src/portal.scss new file mode 100644 index 0000000..89405e7 --- /dev/null +++ b/pc-client/src/portal.scss @@ -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; } + } +} diff --git a/pc-client/src/portal.ts b/pc-client/src/portal.ts new file mode 100644 index 0000000..d9393b5 --- /dev/null +++ b/pc-client/src/portal.ts @@ -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 { + 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; + } +} diff --git a/pc-client/src/utils/storage.ts b/pc-client/src/utils/storage.ts index e362145..bb17a06 100644 --- a/pc-client/src/utils/storage.ts +++ b/pc-client/src/utils/storage.ts @@ -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); diff --git a/pc-client/vite.portal.config.ts b/pc-client/vite.portal.config.ts new file mode 100644 index 0000000..152de6d --- /dev/null +++ b/pc-client/vite.portal.config.ts @@ -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 }, +});