mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-14 06:43:16 +08:00
Bind UDS identity on WebSocket via Host ws module; hide workspaces when anonymous.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
382264f9d6
commit
7b893d9b3b
2 changed files with 144 additions and 65 deletions
|
|
@ -26,26 +26,49 @@ function parseCookie(header, name) {
|
||||||
* @param {import('node:http').IncomingMessage} req
|
* @param {import('node:http').IncomingMessage} req
|
||||||
* @param {{ sessionStore: any, rolesStore: any }} deps
|
* @param {{ sessionStore: any, rolesStore: any }} deps
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @type {WeakMap<object, object|null|undefined>} */
|
/** @type {WeakMap<object, object|null|undefined>} */
|
||||||
const upgradeSocketIdentity = new WeakMap()
|
const upgradeSocketIdentity = new WeakMap()
|
||||||
|
|
||||||
function bindWebSocketListenersToIdentity(ws, getIdentity) {
|
function loadWsModule(requireFn) {
|
||||||
|
const attempts = []
|
||||||
|
if (typeof requireFn === 'function') attempts.push(() => requireFn('ws'))
|
||||||
|
attempts.push(() => {
|
||||||
|
const { createRequire } = require('node:module')
|
||||||
|
const { join } = require('node:path')
|
||||||
|
return createRequire(join(process.cwd(), 'package.json'))('ws')
|
||||||
|
})
|
||||||
|
attempts.push(() => {
|
||||||
|
const cache = requireFn?.cache || require.cache || {}
|
||||||
|
for (const id of Object.keys(cache)) {
|
||||||
|
const norm = id.replace(/\\/g, '/')
|
||||||
|
if (norm.endsWith('/node_modules/ws/index.js') || norm.endsWith('/node_modules/ws/wrapper.mjs')) {
|
||||||
|
return cache[id].exports
|
||||||
|
}
|
||||||
|
if (norm.includes('/node_modules/ws/lib/websocket-server')) {
|
||||||
|
const { createRequire } = require('node:module')
|
||||||
|
const pkg = id.replace(/lib[\\/]websocket-server\.js$/i, 'package.json')
|
||||||
|
try { return createRequire(pkg)('.') } catch { /* continue */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
for (const tryLoad of attempts) {
|
||||||
|
try {
|
||||||
|
const mod = tryLoad()
|
||||||
|
if (mod && (mod.WebSocketServer || mod.Server)) return mod
|
||||||
|
} catch { /* next */ }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindWebSocketListenersToIdentity(ws, identity) {
|
||||||
if (!ws || ws.__udsAuthBound) return
|
if (!ws || ws.__udsAuthBound) return
|
||||||
ws.__udsAuthBound = true
|
ws.__udsAuthBound = true
|
||||||
const wrap = (listener) => {
|
const wrap = (listener) => {
|
||||||
if (typeof listener !== 'function') return listener
|
if (typeof listener !== 'function') return listener
|
||||||
return function udsAuthBoundListener(...args) {
|
return function udsAuthBoundListener(...args) {
|
||||||
const run = (identity) => runWithUserContext(identity, () => listener.apply(this, args))
|
return runWithUserContext(identity, () => listener.apply(this, args))
|
||||||
try {
|
|
||||||
const idOrPromise = typeof getIdentity === 'function' ? getIdentity() : getIdentity
|
|
||||||
if (idOrPromise && typeof idOrPromise.then === 'function') {
|
|
||||||
return idOrPromise.then(run)
|
|
||||||
}
|
|
||||||
return run(idOrPromise)
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[uds-auth] ws identity bind failed:', err?.message || err)
|
|
||||||
return run(null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const method of ['on', 'once', 'addListener']) {
|
for (const method of ['on', 'once', 'addListener']) {
|
||||||
|
|
@ -55,42 +78,69 @@ function bindWebSocketListenersToIdentity(ws, getIdentity) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keep UDS identity on remote.mux WebSocket messages (ALS dies after upgrade). */
|
/**
|
||||||
function patchWebSocketServerForUdsIdentity(resolveIdentity) {
|
* remote.mux streams lose HTTP ALS after upgrade. Bind identity onto ws listeners.
|
||||||
if (patchWebSocketServerForUdsIdentity.done) return
|
* Lazy-load `ws` from Host module cache / cwd — plugin folder cannot require it directly.
|
||||||
patchWebSocketServerForUdsIdentity.done = true
|
*/
|
||||||
let WebSocketServer
|
function patchWebSocketServerForUdsIdentity(resolveIdentitySync) {
|
||||||
try {
|
const wsMod = loadWsModule(typeof require === 'function' ? require : null)
|
||||||
const ws = require('ws')
|
if (!wsMod) return false
|
||||||
WebSocketServer = ws.WebSocketServer || ws.Server
|
const WebSocketServer = wsMod.WebSocketServer || wsMod.Server
|
||||||
} catch {
|
if (!WebSocketServer?.prototype?.handleUpgrade) return false
|
||||||
return
|
if (WebSocketServer.prototype.handleUpgrade.__udsAuthPatched) return true
|
||||||
}
|
|
||||||
if (!WebSocketServer?.prototype?.handleUpgrade) return
|
|
||||||
const orig = WebSocketServer.prototype.handleUpgrade
|
const orig = WebSocketServer.prototype.handleUpgrade
|
||||||
WebSocketServer.prototype.handleUpgrade = function udsAuthHandleUpgrade(req, socket, head, cb) {
|
function udsAuthHandleUpgrade(req, socket, head, cb) {
|
||||||
const getIdentity = () => {
|
let identity
|
||||||
if (req && Object.prototype.hasOwnProperty.call(req, '__udsAuthIdentity')) {
|
try {
|
||||||
return req.__udsAuthIdentity
|
identity = (req && Object.prototype.hasOwnProperty.call(req, '__udsAuthIdentity'))
|
||||||
}
|
? req.__udsAuthIdentity
|
||||||
if (socket && upgradeSocketIdentity.has(socket)) {
|
: (socket ? upgradeSocketIdentity.get(socket) : undefined)
|
||||||
return upgradeSocketIdentity.get(socket)
|
if (identity === undefined && typeof resolveIdentitySync === 'function') {
|
||||||
}
|
identity = resolveIdentitySync(req)
|
||||||
if (typeof resolveIdentity !== 'function') return null
|
|
||||||
return Promise.resolve(resolveIdentity(req)).then((identity) => {
|
|
||||||
try { if (req) req.__udsAuthIdentity = identity } catch { /* ignore */ }
|
try { if (req) req.__udsAuthIdentity = identity } catch { /* ignore */ }
|
||||||
if (socket) upgradeSocketIdentity.set(socket, identity)
|
if (socket) upgradeSocketIdentity.set(socket, identity)
|
||||||
return identity
|
}
|
||||||
})
|
} catch {
|
||||||
|
identity = null
|
||||||
}
|
}
|
||||||
const wrappedCb = typeof cb === 'function'
|
const wrappedCb = typeof cb === 'function'
|
||||||
? (wsSocket) => {
|
? (wsSocket) => {
|
||||||
bindWebSocketListenersToIdentity(wsSocket, getIdentity)
|
bindWebSocketListenersToIdentity(wsSocket, identity || null)
|
||||||
return cb(wsSocket)
|
return cb(wsSocket)
|
||||||
}
|
}
|
||||||
: cb
|
: cb
|
||||||
return orig.call(this, req, socket, head, wrappedCb)
|
return orig.call(this, req, socket, head, wrappedCb)
|
||||||
}
|
}
|
||||||
|
udsAuthHandleUpgrade.__udsAuthPatched = true
|
||||||
|
WebSocketServer.prototype.handleUpgrade = udsAuthHandleUpgrade
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync ACL identity from cookies + roles (no sessionStore). Used on WS upgrade/messages.
|
||||||
|
*/
|
||||||
|
export function resolveIdentityFromRequestSync(req, deps) {
|
||||||
|
const cookie = req?.headers?.cookie || ''
|
||||||
|
const empNo = parseCookie(cookie, 'PORTALSSOUser')
|
||||||
|
|| parseCookie(cookie, 'ZTEDPGSSOUser')
|
||||||
|
|| parseCookie(cookie, 'UDS_FALLBACK_USER')
|
||||||
|
if (!empNo) return null
|
||||||
|
|
||||||
|
const { rolesStore } = deps
|
||||||
|
let role = rolesStore.getRole(empNo)
|
||||||
|
const isFallback = empNo === 'administrator' || !!parseCookie(cookie, 'UDS_FALLBACK_USER')
|
||||||
|
return {
|
||||||
|
empNo: String(empNo),
|
||||||
|
role,
|
||||||
|
permissions: computePermissions(role),
|
||||||
|
userContext: {
|
||||||
|
empNo: String(empNo),
|
||||||
|
userId: String(empNo),
|
||||||
|
isAuthenticated: true,
|
||||||
|
authMode: 'cookie-sync',
|
||||||
|
},
|
||||||
|
kind: isFallback ? 'fallback' : 'uds',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveIdentityFromRequest(req, deps) {
|
export async function resolveIdentityFromRequest(req, deps) {
|
||||||
|
|
@ -113,22 +163,18 @@ export async function resolveIdentityFromRequest(req, deps) {
|
||||||
const isFallback = empNo === 'administrator'
|
const isFallback = empNo === 'administrator'
|
||||||
|| !!parseCookie(cookie, 'UDS_FALLBACK_USER')
|
|| !!parseCookie(cookie, 'UDS_FALLBACK_USER')
|
||||||
|
|
||||||
// Host restart / session TTL can drop sessionStore while browser cookies remain.
|
// Cookie empNo is enough for Host ACL. sessionStore may be empty after restart.
|
||||||
// Still authorize ACL from cookies + roles — otherwise sidebar fail-open shows
|
|
||||||
// sessions but page/follow throw "登录后才能访问会话".
|
|
||||||
if (!userContext) {
|
if (!userContext) {
|
||||||
if (!token && !isFallback) return null
|
|
||||||
userContext = {
|
userContext = {
|
||||||
empNo: String(empNo),
|
empNo: String(empNo),
|
||||||
userId: String(empNo),
|
userId: String(empNo),
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
authMode: isFallback ? 'fallback-cookie' : 'cookie-acl',
|
authMode: token ? 'cookie-acl' : (isFallback ? 'fallback-cookie' : 'cookie-empno'),
|
||||||
token: token || undefined,
|
token: token || undefined,
|
||||||
lastActiveAt: new Date().toISOString(),
|
lastActiveAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// bootstrap / resolve role
|
|
||||||
let role = rolesStore.getRole(empNo)
|
let role = rolesStore.getRole(empNo)
|
||||||
if (empNo !== 'administrator' && typeof rolesStore.bootstrapFirstUser === 'function') {
|
if (empNo !== 'administrator' && typeof rolesStore.bootstrapFirstUser === 'function') {
|
||||||
try {
|
try {
|
||||||
|
|
@ -151,15 +197,24 @@ export async function resolveIdentityFromRequest(req, deps) {
|
||||||
* Patch webServer so every route handler runs inside UDS ALS.
|
* Patch webServer so every route handler runs inside UDS ALS.
|
||||||
* @param {any} server
|
* @param {any} server
|
||||||
* @param {(req: any) => Promise<object|null>} resolveIdentity
|
* @param {(req: any) => Promise<object|null>} resolveIdentity
|
||||||
|
* @param {(req: any) => object|null} resolveIdentitySync
|
||||||
*/
|
*/
|
||||||
export function patchWebServerWithIdentity(server, resolveIdentity) {
|
export function patchWebServerWithIdentity(server, resolveIdentity, resolveIdentitySync) {
|
||||||
if (!server || server.__udsAuthPatched) return () => {}
|
if (!server || server.__udsAuthPatched) return () => {}
|
||||||
server.__udsAuthPatched = true
|
server.__udsAuthPatched = true
|
||||||
|
|
||||||
const wrap = (handler) => {
|
const wrap = (handler) => {
|
||||||
if (typeof handler !== 'function' || handler.__udsWrapped) return handler
|
if (typeof handler !== 'function' || handler.__udsWrapped) return handler
|
||||||
const wrapped = async (req, res, ...rest) => {
|
const wrapped = async (req, res, ...rest) => {
|
||||||
const identity = await resolveIdentity(req)
|
const syncIdentity = typeof resolveIdentitySync === 'function'
|
||||||
|
? resolveIdentitySync(req)
|
||||||
|
: null
|
||||||
|
let identity = syncIdentity
|
||||||
|
try {
|
||||||
|
identity = (await resolveIdentity(req)) || syncIdentity
|
||||||
|
} catch {
|
||||||
|
identity = syncIdentity
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const pathname = new URL(req.url || '/', 'http://x').pathname
|
const pathname = new URL(req.url || '/', 'http://x').pathname
|
||||||
if (
|
if (
|
||||||
|
|
@ -207,21 +262,32 @@ export function patchWebServerWithIdentity(server, resolveIdentity) {
|
||||||
server.registerFallback = (handler) => origFallback(wrap(handler))
|
server.registerFallback = (handler) => origFallback(wrap(handler))
|
||||||
}
|
}
|
||||||
|
|
||||||
patchWebSocketServerForUdsIdentity(resolveIdentity)
|
const bindUpgradeIdentity = async (req, socket, head, prev) => {
|
||||||
|
// Retry ws patch until Host has loaded the module.
|
||||||
|
patchWebSocketServerForUdsIdentity(resolveIdentitySync)
|
||||||
|
const syncIdentity = typeof resolveIdentitySync === 'function'
|
||||||
|
? resolveIdentitySync(req)
|
||||||
|
: null
|
||||||
|
let identity = syncIdentity
|
||||||
|
try {
|
||||||
|
identity = (await resolveIdentity(req)) || syncIdentity
|
||||||
|
} catch {
|
||||||
|
identity = syncIdentity
|
||||||
|
}
|
||||||
|
try { req.__udsAuthIdentity = identity } catch { /* ignore */ }
|
||||||
|
if (socket) upgradeSocketIdentity.set(socket, identity)
|
||||||
|
return withUserContext(identity, () => prev(req, socket, head))
|
||||||
|
}
|
||||||
|
|
||||||
|
patchWebSocketServerForUdsIdentity(resolveIdentitySync)
|
||||||
|
|
||||||
// Wrap upgrades already registered (gateway may load before us).
|
|
||||||
try {
|
try {
|
||||||
const table = server.upgrades
|
const table = server.upgrades
|
||||||
if (table && typeof table.entries === 'function') {
|
if (table && typeof table.entries === 'function') {
|
||||||
for (const [path, route] of table.entries()) {
|
for (const [path, route] of table.entries()) {
|
||||||
if (!route?.handler || route.handler.__udsWrapped) continue
|
if (!route?.handler || route.handler.__udsWrapped) continue
|
||||||
const prev = route.handler
|
const prev = route.handler
|
||||||
const wrapped = async (req, socket, head) => {
|
const wrapped = async (req, socket, head) => bindUpgradeIdentity(req, socket, head, prev)
|
||||||
const identity = await resolveIdentity(req)
|
|
||||||
try { req.__udsAuthIdentity = identity } catch { /* ignore */ }
|
|
||||||
if (socket) upgradeSocketIdentity.set(socket, identity)
|
|
||||||
return withUserContext(identity, () => prev(req, socket, head))
|
|
||||||
}
|
|
||||||
wrapped.__udsWrapped = true
|
wrapped.__udsWrapped = true
|
||||||
table.set(path, { ...route, handler: wrapped })
|
table.set(path, { ...route, handler: wrapped })
|
||||||
}
|
}
|
||||||
|
|
@ -232,12 +298,7 @@ export function patchWebServerWithIdentity(server, resolveIdentity) {
|
||||||
if (origRegisterUpgrade) {
|
if (origRegisterUpgrade) {
|
||||||
server.registerUpgrade = (route) => origRegisterUpgrade({
|
server.registerUpgrade = (route) => origRegisterUpgrade({
|
||||||
...route,
|
...route,
|
||||||
handler: async (req, socket, head) => {
|
handler: async (req, socket, head) => bindUpgradeIdentity(req, socket, head, route.handler),
|
||||||
const identity = await resolveIdentity(req)
|
|
||||||
try { req.__udsAuthIdentity = identity } catch { /* ignore */ }
|
|
||||||
if (socket) upgradeSocketIdentity.set(socket, identity)
|
|
||||||
return withUserContext(identity, () => route.handler(req, socket, head))
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -246,6 +307,8 @@ export function patchWebServerWithIdentity(server, resolveIdentity) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function throwForbidden(message) {
|
function throwForbidden(message) {
|
||||||
try {
|
try {
|
||||||
const { RemoteError } = require('@deepseek-ai/dsh-typert-protocol')
|
const { RemoteError } = require('@deepseek-ai/dsh-typert-protocol')
|
||||||
|
|
@ -460,11 +523,25 @@ export function installDshAcl(ctx, {
|
||||||
wc.follow = async function* (signal) {
|
wc.follow = async function* (signal) {
|
||||||
const identity = getUserContext()
|
const identity = getUserContext()
|
||||||
// Super / fallback: passthrough — never drop historical workspaces.
|
// Super / fallback: passthrough — never drop historical workspaces.
|
||||||
if (!identity?.empNo || identity.permissions?.canViewAllSessions) {
|
if (identity?.permissions?.canViewAllSessions) {
|
||||||
// Missing ALS: fail-open (avoid empty sidebar). Super: always all workspaces.
|
|
||||||
yield* origFollow(signal)
|
yield* origFollow(signal)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!identity?.empNo) {
|
||||||
|
// Anonymous: hide all workspaces (do not leak names).
|
||||||
|
for await (const frame of origFollow(signal)) {
|
||||||
|
if (frame?.type === 'baseline' && frame.value) {
|
||||||
|
yield { ...frame, value: { ...frame.value, items: [] } }
|
||||||
|
} else if (frame?.type === 'order') {
|
||||||
|
yield { ...frame, workspaceIds: [] }
|
||||||
|
} else if (frame?.type === 'upsert') {
|
||||||
|
/* drop */
|
||||||
|
} else {
|
||||||
|
yield frame
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
for await (const frame of origFollow(signal)) {
|
for await (const frame of origFollow(signal)) {
|
||||||
const next = filterFrame(identity, frame)
|
const next = filterFrame(identity, frame)
|
||||||
if (next) yield next
|
if (next) yield next
|
||||||
|
|
|
||||||
|
|
@ -727,7 +727,7 @@ async function initServices(ctx, config) {
|
||||||
const { RolesStore } = await import('./roles.js')
|
const { RolesStore } = await import('./roles.js')
|
||||||
const { SessionAclStore } = await import('./session-acl.js')
|
const { SessionAclStore } = await import('./session-acl.js')
|
||||||
const { UserWorkspaceStore } = await import('./workspace-provision.js')
|
const { UserWorkspaceStore } = await import('./workspace-provision.js')
|
||||||
const { patchWebServerWithIdentity, resolveIdentityFromRequest, installDshAcl } = await import('./dsh-acl.js')
|
const { patchWebServerWithIdentity, resolveIdentityFromRequest, resolveIdentityFromRequestSync, installDshAcl } = await import('./dsh-acl.js')
|
||||||
|
|
||||||
_pluginCtx = ctx
|
_pluginCtx = ctx
|
||||||
_currentConfig = config
|
_currentConfig = config
|
||||||
|
|
@ -767,13 +767,15 @@ async function initServices(ctx, config) {
|
||||||
}, _sessionStore, _rolesStore)
|
}, _sessionStore, _rolesStore)
|
||||||
_apiHandlers = createApiHandlers({ session: INTERNAL.session }, _sessionStore, _rolesStore)
|
_apiHandlers = createApiHandlers({ session: INTERNAL.session }, _sessionStore, _rolesStore)
|
||||||
|
|
||||||
const resolveIdentity = (req) => resolveIdentityFromRequest(req, {
|
const identityDeps = {
|
||||||
sessionStore: _sessionStore,
|
sessionStore: _sessionStore,
|
||||||
rolesStore: _rolesStore,
|
rolesStore: _rolesStore,
|
||||||
})
|
}
|
||||||
|
const resolveIdentity = (req) => resolveIdentityFromRequest(req, identityDeps)
|
||||||
|
const resolveIdentitySync = (req) => resolveIdentityFromRequestSync(req, identityDeps)
|
||||||
|
|
||||||
const patchServer = (server) => {
|
const patchServer = (server) => {
|
||||||
patchWebServerWithIdentity(server, resolveIdentity)
|
patchWebServerWithIdentity(server, resolveIdentity, resolveIdentitySync)
|
||||||
}
|
}
|
||||||
const present = tryGet(ctx, 'webServer')
|
const present = tryGet(ctx, 'webServer')
|
||||||
if (present) patchServer(present)
|
if (present) patchServer(present)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue