mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-17 04:23:16 +08:00
fix(uds-auth): emergency login workspaces, Desktop key prompts, persistent sessions
Emergency (fallback / sealed-box) admins lost the workspace sidebar and saw "sign-in failed / add API key" on DSH Desktop because the remote.mux opens anonymously at boot and every early call was rejected terminally. - gateway: anonymous workspace/follow reaches dsh-acl's empty baseline; anonymous streams are parked until the carrier aborts instead of failing; anonymous workspace/initializeDefault answers "nothing created" - request-auth: a dead UDS_SESSION cookie no longer shadows a valid WS ticket or bridge (sync + async variants used by all callers) - dsh-acl: /api/* waits (bounded 15s) for the gateway ACL during Host startup instead of 403, so the Desktop welcome read no longer fails - desktop-bootstrap: key/account projection is best-effort per reference - client: emit connection/reset after login so boot-time caches (settings describe mirror -> Settings > Models) re-read under the real principal - sessions: absolute lifetime fixed at 7 days (was min(ttl, 7d) = 30 min); bridge expiry slides with its session; MemoryStore persisted to sessions.json (bearer hash only, userData AES-256-GCM sealed) - README: session lifetime/persistence and DSH compatibility notes Also includes previously uncommitted uds-auth 0.3.x work in this tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fa4d5d6d97
commit
df97c5e042
72 changed files with 8436 additions and 1192 deletions
|
|
@ -2,13 +2,17 @@
|
|||
* Outbound HTTP(S) that must NOT use HTTP_PROXY / HTTPS_PROXY.
|
||||
* ZTE icenter / UAC user APIs are intranet-only; corporate forward proxies
|
||||
* typically return 401/403 and never reach the real service.
|
||||
*
|
||||
* SEC-16: subject must strictly equal requested empNo — no substring / digit-contains match.
|
||||
*/
|
||||
import http from 'node:http'
|
||||
import https from 'node:https'
|
||||
|
||||
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* @param {string|URL} url
|
||||
* @param {{ method?: string, headers?: Record<string,string|number>, body?: string|Buffer, timeoutMs?: number }} opts
|
||||
* @param {{ method?: string, headers?: Record<string,string|number>, body?: string|Buffer, timeoutMs?: number, maxBytes?: number }} opts
|
||||
* @returns {Promise<{ statusCode: number, headers: object, body: string, json: any }>}
|
||||
*/
|
||||
export function directRequest(url, opts = {}) {
|
||||
|
|
@ -21,7 +25,8 @@ export function directRequest(url, opts = {}) {
|
|||
if (body && headers['Content-Length'] == null && headers['content-length'] == null) {
|
||||
headers['Content-Length'] = body.length
|
||||
}
|
||||
const timeoutMs = opts.timeoutMs ?? 3000
|
||||
const timeoutMs = Math.min(Math.max(Number(opts.timeoutMs) || 3000, 500), 60000)
|
||||
const maxBytes = opts.maxBytes ?? MAX_RESPONSE_BYTES
|
||||
|
||||
// Fresh Agent — never inherit globalAgent (often patched by proxy bootstraps).
|
||||
const agent = new lib.Agent({ keepAlive: false })
|
||||
|
|
@ -38,7 +43,16 @@ export function directRequest(url, opts = {}) {
|
|||
timeout: timeoutMs,
|
||||
}, (res) => {
|
||||
const chunks = []
|
||||
res.on('data', (c) => chunks.push(c))
|
||||
let total = 0
|
||||
res.on('data', (c) => {
|
||||
total += c.length
|
||||
if (total > maxBytes) {
|
||||
req.destroy()
|
||||
reject(new Error(`directRequest response too large (>${maxBytes})`))
|
||||
return
|
||||
}
|
||||
chunks.push(c)
|
||||
})
|
||||
res.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf8')
|
||||
let json = null
|
||||
|
|
@ -61,8 +75,20 @@ export function directRequest(url, opts = {}) {
|
|||
})
|
||||
}
|
||||
|
||||
function extractSubject(row) {
|
||||
if (!row || typeof row !== 'object') return ''
|
||||
return String(
|
||||
row.employeeShortId || row.employeeNO || row.empUIID || row.empNo || '',
|
||||
).trim()
|
||||
}
|
||||
|
||||
function subjectsEqual(a, b) {
|
||||
return String(a || '').trim() === String(b || '').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* POST userSearchUrl with X-Emp-No + X-Auth-Value (intranet, no proxy).
|
||||
* Success requires: HTTP 2xx, business success code, exactly one subject-matching row.
|
||||
*/
|
||||
export async function searchUserByEmpNoToken({
|
||||
userSearchUrl,
|
||||
|
|
@ -76,14 +102,15 @@ export async function searchUserByEmpNoToken({
|
|||
if (!userSearchUrl || !empNo || !token) {
|
||||
return { ok: false, reason: 'missing_args' }
|
||||
}
|
||||
const want = String(empNo).trim()
|
||||
const body = JSON.stringify({
|
||||
employeeShortId: empNo,
|
||||
employeeShortId: want,
|
||||
enableLabel: true,
|
||||
keyword: empNo,
|
||||
keyword: want,
|
||||
})
|
||||
const headers = {
|
||||
'Content-Type': 'application/json;charset=UTF-8',
|
||||
[empNoHeader]: empNo,
|
||||
[empNoHeader]: want,
|
||||
[authValueHeader]: token,
|
||||
}
|
||||
if (origin) {
|
||||
|
|
@ -98,18 +125,21 @@ export async function searchUserByEmpNoToken({
|
|||
return { ok: false, reason: 'network', error: err.message }
|
||||
}
|
||||
|
||||
if (res.statusCode === 401 || res.statusCode === 403) {
|
||||
// Only accept 2xx — do not treat HTTP 500 + biz success as ok (SEC-16).
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'http_auth',
|
||||
reason: res.statusCode === 401 || res.statusCode === 403 ? 'http_auth' : 'http_status',
|
||||
statusCode: res.statusCode,
|
||||
msg: res.json?.code?.msg || res.json?.msg || res.body.slice(0, 200),
|
||||
hint: 'userSearchUrl must be reachable on intranet WITHOUT HTTP(S)_PROXY',
|
||||
hint: res.statusCode === 401 || res.statusCode === 403
|
||||
? 'userSearchUrl must be reachable on intranet WITHOUT HTTP(S)_PROXY'
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const result = res.json
|
||||
if (!result) {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return { ok: false, reason: 'bad_json', statusCode: res.statusCode, raw: res.body.slice(0, 300) }
|
||||
}
|
||||
|
||||
|
|
@ -129,35 +159,42 @@ export async function searchUserByEmpNoToken({
|
|||
: Array.isArray(result?.bo?.list) ? result.bo.list
|
||||
: []
|
||||
if (!list.length) {
|
||||
return { ok: false, reason: 'empty', statusCode: res.statusCode, result }
|
||||
return { ok: false, reason: 'empty', statusCode: res.statusCode }
|
||||
}
|
||||
|
||||
const emp = list[0]
|
||||
const resolvedEmpNo = String(
|
||||
emp.employeeShortId || emp.employeeNO || emp.empUIID || emp.empNo || empNo,
|
||||
).trim()
|
||||
if (resolvedEmpNo && resolvedEmpNo !== String(empNo).trim()
|
||||
&& !String(empNo).includes(resolvedEmpNo)
|
||||
&& !resolvedEmpNo.includes(String(empNo).trim())) {
|
||||
const a = String(empNo).replace(/\D/g, '')
|
||||
const b = resolvedEmpNo.replace(/\D/g, '')
|
||||
if (!a || !b || !(a.includes(b) || b.includes(a))) {
|
||||
return { ok: false, reason: 'emp_mismatch', resolvedEmpNo }
|
||||
// Strict subject equality — no substring / digit-contains (SEC-16).
|
||||
const matches = list.filter((row) => subjectsEqual(extractSubject(row), want))
|
||||
if (matches.length !== 1) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: matches.length === 0 ? 'emp_mismatch' : 'ambiguous',
|
||||
statusCode: res.statusCode,
|
||||
matchCount: matches.length,
|
||||
}
|
||||
}
|
||||
|
||||
const emp = matches[0]
|
||||
const subject = extractSubject(emp)
|
||||
if (!subject || !subjectsEqual(subject, want)) {
|
||||
return { ok: false, reason: 'emp_mismatch', resolvedEmpNo: subject }
|
||||
}
|
||||
// Reserved / emergency account names never come from UAC search.
|
||||
if (subject === 'administrator' || subject.startsWith('__')) {
|
||||
return { ok: false, reason: 'reserved_subject' }
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
profile: {
|
||||
empNo: String(empNo).trim(),
|
||||
username: emp.name || emp.empName || emp.userName || empNo,
|
||||
empNo: subject,
|
||||
username: emp.name || emp.empName || emp.userName || subject,
|
||||
department: emp.deptFullName || emp.deptName || emp.deptShortName || emp.orgNamePath || emp.orgName || emp.department || '',
|
||||
organization: emp.orgNamePath || emp.orgName || '',
|
||||
email: emp.email || emp.mail || '',
|
||||
phone: emp.mobile || emp.phone || '',
|
||||
raw: emp,
|
||||
token,
|
||||
// Omit raw upstream blob from default profile to limit accidental logging.
|
||||
token: undefined,
|
||||
},
|
||||
result,
|
||||
result: { code: result.code },
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue