fix(uds-auth): emergency login workspaces, Desktop key prompts, persistent sessions
Some checks are pending
ci / test (push) Waiting to run
ci / test-postgresql (push) Waiting to run

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:
oliver 2026-10-12 02:11:09 +08:00
parent fa4d5d6d97
commit df97c5e042
72 changed files with 8436 additions and 1192 deletions

View file

@ -1,9 +1,15 @@
/**
* Loopback agent APIs: credentials + outbound proxy.
* SEC-05: no XFF trust; no arbitrary empNo credential fetch.
* Caller must prove a local session bearer whose empNo owns the DSH sessionId
* (or matches an explicit capability in a later phase).
*/
import { directRequest } from './uds/user-search.js'
import { requestIsLoopback } from './skill-credentials.js'
import { apiError, resolveLocale } from './i18n.js'
import { extractSessionBearer } from './session/request-auth.js'
import { readJsonBodyLimited, DEFAULT_MAX_BODY } from './utils/read-body.js'
import { audit } from './utils/audit-log.js'
const DEFAULT_OUTBOUND_HOSTS = [
'icenterapi.zte.com.cn',
@ -33,68 +39,171 @@ function readSessionId(req, body) {
}
async function readJsonBody(req) {
let raw = ''
for await (const chunk of req) raw += chunk
if (!raw) return {}
try { return JSON.parse(raw) } catch { return {} }
try {
return await readJsonBodyLimited(req, { maxBytes: DEFAULT_MAX_BODY })
} catch (err) {
if (err?.code === 'payload_too_large' || err?.code === 'invalid_json') throw err
return {}
}
}
/**
* @param {{
* resolveCredentialsForSession: (sessionId: string) => Promise<{empNo:string,token:string}|null>,
* resolveCredentialsForEmpNo: (empNo: string) => Promise<{empNo:string,token:string}|null>,
* resolveCallerSession: (req: any) => Promise<{empNo:string,sessionId?:string}|null>,
* getSessionOwner: (sessionId: string) => string|null,
* empNoHeader?: string,
* authValueHeader?: string,
* outboundHosts?: string[],
* sessionBridge?: any,
* allowRawTokenResponse?: boolean|(() => boolean),
* taskCapabilities?: import('./task-capability.js').TaskCapabilityStore,
* }} deps
*/
export function createAgentAuthHandlers(deps) {
const empNoHeader = deps.empNoHeader || 'X-Emp-No'
const authValueHeader = deps.authValueHeader || 'X-Auth-Value'
/** R05: live flag — closing allowRawAgentToken must take effect without restart. */
function allowRawToken() {
if (typeof deps.allowRawTokenResponse === 'function') {
return deps.allowRawTokenResponse() === true
}
return deps.allowRawTokenResponse === true
}
function allowedHosts() {
const list = typeof deps.outboundHosts === 'function'
? deps.outboundHosts()
: deps.outboundHosts
if (Array.isArray(list) && list.length) return list.map(String)
return DEFAULT_OUTBOUND_HOSTS.slice()
// Empty whitelist = deny all (SEC-18); do not fall back to defaults when explicitly [].
if (Array.isArray(list)) return list.map(String).map((s) => s.trim().toLowerCase()).filter(Boolean)
// Explicit empty string also denies all; only undefined/null uses packaged defaults.
if (list === '') return []
if (list == null) return DEFAULT_OUTBOUND_HOSTS.map((s) => s.toLowerCase())
return String(list).split(/[,;\s]+/).map((s) => s.trim().toLowerCase()).filter(Boolean)
}
/** Exact hostname match only — no automatic subdomain wildcard (SEC-18). */
function hostAllowed(hostname) {
const host = String(hostname || '').toLowerCase()
return allowedHosts().some((h) => host === h.toLowerCase() || host.endsWith('.' + h.toLowerCase()))
if (!host) return false
const list = allowedHosts()
if (!list.length) return false
return list.includes(host)
}
async function resolveFromRequest(req, body = {}) {
async function resolveCaller(req, body = {}) {
// R06: task capability (skill/cron) — preferred over requiring a browser session.
const caps = deps.taskCapabilities
if (caps) {
const raw = caps.extractFromRequest?.(req)
|| body?.taskCapability
|| body?.capability
|| null
if (raw) {
const sessionId = readSessionId(req, body)
const verified = caps.verify(raw, {
audience: 'uds-auth-agent',
dshSessionId: sessionId || undefined,
})
if (verified?.empNo) {
return {
empNo: verified.empNo,
sessionId: verified.dshSessionId || sessionId || undefined,
via: 'task_capability',
capabilityId: verified.id,
scopes: verified.scopes,
}
}
return null
}
}
if (typeof deps.resolveCallerSession === 'function') {
return deps.resolveCallerSession(req)
}
return null
}
/**
* Authorize: loopback + (local session bearer OR task capability)
* + sessionId owned by caller. body.empNo is ignored as an auth input.
* @param {string} [requiredScope] A06: exact scope for the endpoint ('credentials'|'outbound')
*/
async function resolveAuthorizedCreds(req, body = {}, requiredScope) {
const caller = await resolveCaller(req, body)
if (!caller?.empNo) return { error: 'caller_unauthenticated', creds: null, sessionId: null }
const sessionId = readSessionId(req, body)
if (sessionId) {
const creds = await deps.resolveCredentialsForSession(String(sessionId))
if (creds) return { creds, sessionId: String(sessionId) }
if (!sessionId) {
return { error: 'session_id_required', creds: null, sessionId: null }
}
const empNo = body.empNo || req.headers['x-uds-emp-no']
if (empNo) {
const creds = await deps.resolveCredentialsForEmpNo(String(empNo))
if (creds) return { creds, sessionId: sessionId ? String(sessionId) : null }
const owner = deps.getSessionOwner?.(String(sessionId)) || null
if (!owner || String(owner) !== String(caller.empNo)) {
return { error: 'session_owner_mismatch', creds: null, sessionId: String(sessionId) }
}
return { creds: null, sessionId: sessionId ? String(sessionId) : null }
// A06: credentials vs outbound require their own scope — not either-or.
if (caller.via === 'task_capability' && requiredScope) {
if (!Array.isArray(caller.scopes) || !caller.scopes.includes(requiredScope)) {
return { error: 'capability_scope_denied', creds: null, sessionId: String(sessionId) }
}
}
// Live account check when roles store is wired.
if (typeof deps.isAccountActive === 'function' && !deps.isAccountActive(caller.empNo)) {
return { error: 'account_disabled', creds: null, sessionId: String(sessionId) }
}
const creds = await deps.resolveCredentialsForSession(String(sessionId))
if (!creds || String(creds.empNo) !== String(caller.empNo)) {
return { error: 'no_skill_credentials', creds: null, sessionId: String(sessionId) }
}
return { error: null, creds, sessionId: String(sessionId), caller }
}
async function handleAgentCredentials(req, res) {
if (!requestIsLoopback(req)) {
audit({ action: 'agent_credentials', decision: 'deny', reasonCode: 'loopback_only' })
return sendErr(req, res, 403, 'loopback_only_credentials')
}
if (!allowRawToken()) {
audit({ action: 'agent_credentials', decision: 'deny', reasonCode: 'raw_token_disabled' })
return sendJSON(res, 403, {
...apiError('raw_token_disabled', resolveLocale(req)),
hint: 'use /uds-auth/api/agent-outbound with a verified local session',
})
}
const method = (req.method || 'GET').toUpperCase()
if (method !== 'GET' && method !== 'POST') {
return sendErr(req, res, 405, 'method_not_allowed')
}
const body = method === 'POST' ? await readJsonBody(req) : {}
const { creds, sessionId } = await resolveFromRequest(req, body)
if (!creds) {
let body = {}
try {
body = method === 'POST' ? await readJsonBody(req) : {}
} catch (err) {
return sendErr(req, res, err.statusCode || 400, err.code || 'invalid_json')
}
const { error, creds, sessionId } = await resolveAuthorizedCreds(req, body, 'credentials')
if (error || !creds) {
audit({
action: 'agent_credentials',
decision: 'deny',
reasonCode: error || 'no_skill_credentials',
meta: { sessionId },
})
return sendJSON(res, 401, {
...apiError('no_skill_credentials', resolveLocale(req)),
...apiError(error || 'no_skill_credentials', resolveLocale(req)),
sessionId: sessionId || null,
})
}
audit({
action: 'agent_credentials',
decision: 'allow',
actor: creds.empNo,
resource: sessionId,
})
return sendJSON(res, 200, {
empNo: creds.empNo,
token: creds.token,
@ -111,9 +220,9 @@ export function createAgentAuthHandlers(deps) {
return sendErr(req, res, 405, 'method_not_allowed')
}
const body = await readJsonBody(req)
const { creds } = await resolveFromRequest(req, body)
if (!creds) {
return sendErr(req, res, 401, 'no_skill_credentials')
const { error, creds } = await resolveAuthorizedCreds(req, body, 'outbound')
if (error || !creds) {
return sendErr(req, res, 401, error || 'no_skill_credentials')
}
const targetUrl = body.url
@ -126,19 +235,31 @@ export function createAgentAuthHandlers(deps) {
} catch {
return sendErr(req, res, 400, 'invalid_url')
}
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
return sendErr(req, res, 400, 'unsupported_protocol')
// Credentials must never travel over cleartext HTTP (SEC-18).
if (parsed.protocol !== 'https:') {
return sendErr(req, res, 400, 'https_required')
}
if (parsed.username || parsed.password) {
return sendErr(req, res, 400, 'invalid_url')
}
if (!hostAllowed(parsed.hostname)) {
return sendJSON(res, 403, apiError('host_not_allowed', resolveLocale(req), { host: parsed.hostname }))
}
const upstreamMethod = String(body.method || 'POST').toUpperCase()
const headers = { ...(body.headers && typeof body.headers === 'object' ? body.headers : {}) }
for (const k of Object.keys(headers)) {
const lower = k.toLowerCase()
if (lower === 'x-auth-value' || lower === 'x-emp-no' || lower === 'host' || lower === 'content-length') {
delete headers[k]
if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'].includes(upstreamMethod)) {
return sendErr(req, res, 400, 'method_not_allowed')
}
// Rebuild headers from whitelist only — never forward caller Authorization/Cookie.
const allowedHeaderNames = new Set([
'content-type', 'accept', 'accept-language', 'x-lang-id', 'x-requested-with',
])
const headers = {}
if (body.headers && typeof body.headers === 'object') {
for (const [k, v] of Object.entries(body.headers)) {
if (allowedHeaderNames.has(String(k).toLowerCase()) && v != null) {
headers[k] = String(v).slice(0, 1024)
}
}
}
headers[empNoHeader] = creds.empNo
@ -157,13 +278,12 @@ export function createAgentAuthHandlers(deps) {
method: upstreamMethod,
headers,
body: upstreamBody,
timeoutMs: Number(body.timeoutMs) || 30000,
timeoutMs: Math.min(Number(body.timeoutMs) || 30000, 60000),
})
res.statusCode = out.statusCode || 502
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.setHeader('Cache-Control', 'no-store')
res.setHeader('X-Uds-Outbound-Status', String(out.statusCode || 0))
// Wrap so skill gets status + body without leaking injected auth headers
res.end(JSON.stringify({
statusCode: out.statusCode,
headers: sanitizeUpstreamHeaders(out.headers),
@ -177,7 +297,7 @@ export function createAgentAuthHandlers(deps) {
}
}
return { handleAgentCredentials, handleOutbound, DEFAULT_OUTBOUND_HOSTS }
return { handleAgentCredentials, handleOutbound, DEFAULT_OUTBOUND_HOSTS, extractSessionBearer }
}
function sanitizeUpstreamHeaders(headers) {

View file

@ -2,6 +2,11 @@ import { ROLES, ROLE_LABELS } from './roles.js'
import { requirePermission } from './middleware/auth-middleware.js'
import { apiError, apiOk, resolveLocale, roleLabel } from './i18n.js'
import { clearBrowserIdentity } from './identity-cache.js'
import { SESSION_COOKIE, clearSessionCookie } from './session/request-auth.js'
import { extractSessionBearerAsync } from './session/request-auth.js'
import { readBodyLimited, DEFAULT_MAX_BODY } from './utils/read-body.js'
import { audit } from './utils/audit-log.js'
import { identityFromSessionRecord } from './dsh-acl.js'
/**
* API handlers — permission guards + localized messages via stable error codes.
@ -13,6 +18,17 @@ import { clearBrowserIdentity } from './identity-cache.js'
* @param {{ skillCredentials?: { delete: (empNo: string) => void }, retainSkillCredentialsOnLogout?: () => boolean }} [extra]
*/
export function createApiHandlers(config, sessionStore, rolesStore, extra = {}) {
// Read again inside the RolesStore mutation queue, after a slow body or a
// concurrent demotion/logout; the role captured at request admission is stale.
const liveActorRole = ctx => () => {
if (rolesStore.isDisabled?.(ctx.empNo)) return ROLES.USER
const sid = ctx.sessionId || ctx.userContext?._sessionId
if (sid && typeof sessionStore.getBySessionIdSync === 'function') {
const row = sessionStore.getBySessionIdSync(sid)
return identityFromSessionRecord(row, rolesStore)?.role || ROLES.USER
}
return rolesStore.getRole?.(ctx.empNo) || ctx.role
}
async function sendRes(res, code, data) {
res.statusCode = code >= 200 && code < 300 ? 200 : code
res.setHeader('Content-Type', 'application/json; charset=utf-8')
@ -44,8 +60,27 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
async function logout(ctx) {
const empNo = ctx.empNo
if (empNo) await sessionStore.delete(empNo)
try { extra.sessionBridge?.revokeEmpNo?.(empNo) } catch { /* ignore */ }
const sessionId = ctx.sessionId || ctx.userContext?._sessionId
// Prefer revoke current device; fall back to all devices for legacy callers.
if (sessionId && typeof sessionStore.revokeSession === 'function') {
await sessionStore.revokeSession(sessionId)
} else if (empNo) {
await sessionStore.delete(empNo)
}
try {
const extracted = await extractSessionBearerAsync(ctx.req, {
sessionBridge: extra.sessionBridge,
isLiveBearer: async (bearer) => !!(await sessionStore.getByBearer?.(bearer)),
})
if (extracted?.bearer) {
extra.sessionBridge?.revokeToken?.(extracted.bearer)
if (typeof sessionStore.revokeBearer === 'function') {
await sessionStore.revokeBearer(extracted.bearer)
}
} else if (empNo) {
extra.sessionBridge?.revokeEmpNo?.(empNo)
}
} catch { /* ignore */ }
try { clearBrowserIdentity() } catch { /* ignore */ }
const retain = extra.retainSkillCredentialsOnLogout
? extra.retainSkillCredentialsOnLogout() !== false
@ -53,7 +88,15 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
if (empNo && !retain) {
try { extra.skillCredentials?.delete(empNo) } catch { /* ignore */ }
}
const clear = []
// Task capabilities outlive UI logout only if Host minted them for cron;
// revoke current-user caps when logout is full-account style (no retain path).
if (empNo && typeof extra.revokeTaskCapabilitiesForEmpNo === 'function' && !retain) {
try { extra.revokeTaskCapabilitiesForEmpNo(empNo) } catch { /* ignore */ }
}
const clear = [
clearSessionCookie({ secure: false }),
clearSessionCookie({ secure: true }),
]
for (const name of [
'UDS_FALLBACK_USER',
'UDS_FALLBACK_UI',
@ -63,7 +106,7 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
'ZTEDPGSSOUser',
'ZTEDPGSSOCookie',
]) {
const httpOnly = name === 'UDS_FALLBACK_USER' || name === 'UDS_LOCAL_ADMIN'
const httpOnly = name === 'UDS_FALLBACK_USER' || name === 'UDS_LOCAL_ADMIN' || name === SESSION_COOKIE
const base = httpOnly
? (name + '=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax')
: (name + '=; Max-Age=0; Path=/; SameSite=Lax')
@ -71,21 +114,41 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
clear.push(base + '; Secure')
}
ctx.res.setHeader('Set-Cookie', clear)
ctx.res.setHeader('Cache-Control', 'no-store')
audit({
action: 'logout',
decision: 'allow',
actor: empNo || null,
resource: sessionId || null,
})
await ok(ctx, 'logged_out')
}
async function getCurrentUser(ctx) {
const ws = ctx.provisionedWorkspace || null
ctx.res.setHeader('Cache-Control', 'no-store')
// SEC-07: field whitelist — never return upstream token / authValue
const uc = ctx.userContext
const safe = uc ? {
empNo: ctx.empNo,
userId: uc.userId || ctx.empNo,
username: uc.username || null,
displayName: uc.displayName || uc.username || null,
department: uc.department || null,
organization: uc.organization || null,
lang: uc.lang || null,
isAuthenticated: true,
authMode: uc.authMode || null,
role: ctx.role,
permissions: ctx.permissions,
workspaceId: ws?.workspaceId || null,
workspacePath: ws?.path || null,
authenticatedAt: uc.authenticatedAt || null,
lastActiveAt: uc.lastActiveAt || null,
} : null
await sendRes(ctx.res, 200, {
data: ctx.userContext ? {
...ctx.userContext,
empNo: ctx.empNo,
role: ctx.role,
permissions: ctx.permissions,
workspaceId: ws?.workspaceId || null,
workspacePath: ws?.path || null,
} : null,
authenticated: !!ctx.userContext,
data: safe,
authenticated: !!safe,
})
}
@ -118,7 +181,14 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
return fail(ctx, 'invalid_role_params', 400)
}
try {
await rolesStore.setRole(empNo, role, ctx.role)
await rolesStore.setRole(empNo, role, liveActorRole(ctx))
audit({
action: 'set_role',
decision: 'allow',
actor: ctx.empNo,
target: empNo,
meta: { role },
})
await ok(ctx, 'role_updated', { empNo, role: roleLabel(role, locale(ctx)) })
} catch (err) {
await mapThrown(ctx, err)
@ -136,8 +206,8 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
}
const targetRole = role && Object.values(ROLES).includes(role) ? role : ROLES.USER
try {
await rolesStore.ensureUser(empNo, ctx.role)
if (role) await rolesStore.setRole(empNo, role, ctx.role)
await rolesStore.ensureUser(empNo, liveActorRole(ctx))
if (role) await rolesStore.setRole(empNo, role, liveActorRole(ctx))
await ok(ctx, 'user_added', { empNo, role: roleLabel(targetRole, locale(ctx)) })
} catch (err) {
await mapThrown(ctx, err)
@ -154,15 +224,42 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
return fail(ctx, 'emp_no_required', 400)
}
try {
await rolesStore.removeUser(empNo, ctx.role)
await rolesStore.removeUser(empNo, liveActorRole(ctx))
await sessionStore.delete(empNo)
try { extra.skillCredentials?.delete(empNo) } catch { /* ignore */ }
// A06: revoke task capabilities when account is disabled/removed.
try { extra.revokeTaskCapabilitiesForEmpNo?.(empNo) } catch { /* ignore */ }
await ok(ctx, 'user_removed', { empNo })
} catch (err) {
await mapThrown(ctx, err)
}
}
function isPasswordModeSession(row) {
const mode = String(row?.userData?.authMode || row?.authMode || row?.kind || '')
if (mode.includes('local-admin') || mode === 'sealed_box' || mode === 'local_admin') {
return false
}
return mode.includes('fallback') || mode === 'fallback' || row?.kind === 'fallback'
}
async function revokePasswordFallbackSessions() {
// D04/F02: revoke password-mode only — keep independent sealed-box sessions/bridges.
const empNo = 'administrator'
try {
if (typeof sessionStore.revokeEmpNoMatching === 'function') {
await sessionStore.revokeEmpNoMatching(empNo, isPasswordModeSession)
} else if (typeof sessionStore.revokeEmpNo === 'function') {
// Stores without matching API: do not wipe sealed-box with a blanket delete.
}
} catch { /* ignore */ }
try {
extra.sessionBridge?.revokeEmpNoKinds?.(empNo, [
'fallback', 'fallback_password', 'fallback-password', 'fallback-login',
])
} catch { /* ignore */ }
}
async function setFallbackPassword(ctx) {
if (!requirePermission(ctx, 'super_admin')) {
return fail(ctx, 'forbidden_set_fallback', 403)
@ -170,7 +267,8 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
const body = await readBody(ctx.req)
const { password } = body || {}
try {
await rolesStore.setFallbackPassword(password, ctx.role)
await rolesStore.setFallbackPassword(password, liveActorRole(ctx))
await revokePasswordFallbackSessions()
await ok(ctx, 'fallback_password_set')
} catch (err) {
await mapThrown(ctx, err)
@ -181,8 +279,13 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
if (!requirePermission(ctx, 'super_admin')) {
return fail(ctx, 'forbidden_clear_fallback', 403)
}
await rolesStore.clearFallbackPassword(ctx.role)
await ok(ctx, 'fallback_password_cleared')
try {
await rolesStore.clearFallbackPassword(liveActorRole(ctx))
await revokePasswordFallbackSessions()
await ok(ctx, 'fallback_password_cleared')
} catch (err) {
await mapThrown(ctx, err)
}
}
async function fallbackStatus(ctx) {
@ -198,10 +301,7 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
const body = await readBody(ctx.req)
const enabled = !!(body && body.enabled)
try {
rolesStore.setViewAllSessions(ctx.empNo, enabled)
if (typeof rolesStore.flush === 'function') {
await rolesStore.flush()
}
await rolesStore.setViewAllSessionsDurable(ctx.empNo, enabled)
ctx.permissions = rolesStore.resolvePermissions(ctx.empNo, ctx.role)
await ok(ctx, enabled ? 'view_all_sessions_on' : 'view_all_sessions_off', null, {
permissions: ctx.permissions,
@ -227,7 +327,11 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
}
async function readBody(req) {
let body = ''
for await (const chunk of req) body += chunk
try { return JSON.parse(body) } catch { return null }
try {
const raw = await readBodyLimited(req, { maxBytes: DEFAULT_MAX_BODY })
if (!raw) return null
return JSON.parse(raw)
} catch {
return null
}
}

View file

@ -33,7 +33,7 @@ window.__ModuleLoader__.load({
"ui.settingsTitle": "UAC 认证",
"ui.settingsIntro": "工号+token 双校验;UAC 挂死时用应急账号 administrator 密码登录。",
"ui.loginRequiredPage": "请先登录后查看此页",
"ui.roleHint": "当前角色:{role}。超管只是身份标签,与管理员权限相同;首位扫码登录且 roles.json 为空时自动获得超管身份,也可在「用户管理」设为管理员。应急账号 administrator 需先设密码,再在登录面板用账密登录。",
"ui.roleHint": "当前角色:{role}。超管只是身份标签,与管理员权限相同;显式配置的初始管理员在首次登录时获得超管身份,也可在「用户管理」设为管理员。应急账号 administrator 需先设密码,再在登录面板用账密登录。",
"ui.deployConfig": "部署配置",
"ui.userSearchUrl": "用户搜索 URL(token 校验)",
"ui.workspaceRoot": "工作区根目录(空=$DSH_HOME/user-workspaces)",
@ -45,7 +45,7 @@ window.__ModuleLoader__.load({
"ui.fallbackStatus": "状态:{status}。可在此改密或关闭。仅在扫码不可用时从登录面板切换。",
"ui.enabled": "已启用",
"ui.disabled": "未启用",
"ui.fallbackPassword": "应急密码(至少 6 位)",
"ui.fallbackPassword": "应急密码(至少 10 位)",
"ui.saveFallbackPassword": "保存应急密码",
"ui.fallbackPasswordSet": "应急密码已设置",
"ui.confirmClearFallback": "确认清除应急密码?",
@ -75,6 +75,9 @@ window.__ModuleLoader__.load({
"ui.pleaseScan": "请使用 iCenter 扫码登录",
"ui.refreshQr": "刷新二维码",
"ui.fallbackLink": "UAC 不可用?应急账号登录",
"ui.fallbackDisabledLink": "应急登录(未启用)",
"ui.fallbackDisabledHint": "应急密码未配置或已停用。请用已登录的管理员在「账户 / 权限」中设置新的独立强密码后重试。旧共享默认密码不可用。",
"ui.fallbackDisabledDetail": "当前未启用应急密码。用已授权管理员登录后,在设置中配置新密码即可恢复此入口。",
"ui.fallbackLogin": "应急登录",
"ui.fallbackDetail": "UAC / 扫码不可用时使用",
"ui.localKeyLink": "本机密钥解锁",
@ -118,7 +121,9 @@ window.__ModuleLoader__.load({
"err.invalid_credentials": "用户名或密码错误",
"err.last_super_admin_demote": "系统至少需要 1 个超级管理员,不能降级最后一个",
"err.last_super_admin_delete": "系统至少需要 1 个超级管理员,不能删除最后一个",
"err.password_too_short": "密码至少 6 位",
"err.password_too_short": "密码至少 10 位",
"err.password_too_common": "不能使用已知默认口令",
"err.account_disabled": "账号已停用",
"err.local_admin_not_configured": "未配置本机管理员密封盒",
"err.key_required": "请输入解密密钥",
"err.decrypt_failed": "密钥无法解密,登录失败",
@ -173,7 +178,7 @@ window.__ModuleLoader__.load({
"ui.settingsTitle": "UAC Auth",
"ui.settingsIntro": "EmpNo + token verification; when UAC is down, sign in with emergency account administrator.",
"ui.loginRequiredPage": "Sign in to view this page",
"ui.roleHint": "Current role: {role}. Super admin is only an identity label with the same permissions as admin; the first QR login with an empty roles.json gets that identity, or grant admin in User management. Set the emergency password before using administrator on the login panel.",
"ui.roleHint": "Current role: {role}. Super admin is only an identity label with the same permissions as admin; the explicitly configured initial administrator receives that identity on first login, or grant admin in User management. Set the emergency password before using administrator on the login panel.",
"ui.deployConfig": "Deploy config",
"ui.userSearchUrl": "User search URL (token verify)",
"ui.workspaceRoot": "Workspace root (empty=$DSH_HOME/user-workspaces)",
@ -185,7 +190,7 @@ window.__ModuleLoader__.load({
"ui.fallbackStatus": "Status: {status}. Change or disable here. Switch from the login panel only when QR is unavailable.",
"ui.enabled": "Enabled",
"ui.disabled": "Disabled",
"ui.fallbackPassword": "Emergency password (min 6 chars)",
"ui.fallbackPassword": "Emergency password (min 10 chars)",
"ui.saveFallbackPassword": "Save emergency password",
"ui.fallbackPasswordSet": "Emergency password set",
"ui.confirmClearFallback": "Clear emergency password?",
@ -215,6 +220,9 @@ window.__ModuleLoader__.load({
"ui.pleaseScan": "Scan with iCenter to sign in",
"ui.refreshQr": "Refresh QR",
"ui.fallbackLink": "UAC down? Emergency account",
"ui.fallbackDisabledLink": "Emergency login (disabled)",
"ui.fallbackDisabledHint": "Emergency password is not configured or was disabled. Ask a signed-in admin to set a new strong password under Account / Permissions. The old shared default password cannot be used.",
"ui.fallbackDisabledDetail": "Emergency password is off. After an authorized admin sets a new password in settings, this entry works again.",
"ui.fallbackLogin": "Emergency login",
"ui.fallbackDetail": "Use when UAC / QR is unavailable",
"ui.localKeyLink": "Unlock with local key",
@ -258,7 +266,9 @@ window.__ModuleLoader__.load({
"err.invalid_credentials": "Invalid username or password",
"err.last_super_admin_demote": "At least one super admin is required; cannot demote the last one",
"err.last_super_admin_delete": "At least one super admin is required; cannot delete the last one",
"err.password_too_short": "Password must be at least 6 characters",
"err.password_too_short": "Password must be at least 10 characters",
"err.password_too_common": "Cannot use the known default password",
"err.account_disabled": "Account is disabled",
"err.local_admin_not_configured": "Local admin sealed box is not configured",
"err.key_required": "Decryption key required",
"err.decrypt_failed": "Key could not decrypt — sign-in failed",
@ -421,8 +431,10 @@ window.__ModuleLoader__.load({
'html[data-uds-can-settings="0"] [data-uds-foot-slot="settings"],html[data-uds-logged-in="0"] [data-uds-foot-slot="settings"]{display:none!important}',
'html[data-uds-can-settings="0"] button[aria-label="设置"],html[data-uds-can-settings="0"] button[aria-label="Settings"],html[data-uds-logged-in="0"] button[aria-label="设置"],html[data-uds-logged-in="0"] button[aria-label="Settings"]{display:none!important}',
'html[data-uds-can-create-ws="0"] button[aria-label="添加工作区"],html[data-uds-can-create-ws="0"] button[aria-label="Add workspace"]{display:none!important}html[data-uds-logged-in="0"] [role="tree"][aria-label="会话"],html[data-uds-logged-in="0"] [role="tree"][aria-label="Sessions"],html[data-uds-logged-in="0"] [class*="WorkspaceBrowser"],html[data-uds-logged-in="0"] [class*="workspaceBrowser"],html[data-uds-logged-in="0"] .dsh-ct-entry,html[data-uds-logged-in="0"] .dsh-ct-region,html[data-uds-logged-in="0"] .dsh-ct-main,html[data-uds-logged-in="0"] [data-dsh-ct-mode="on"] .dsh-ct-region{display:none!important}html[data-uds-can-create-ws="0"] button[aria-label="选择工作区"],html[data-uds-can-create-ws="0"] button[aria-label="Choose workspace"],html[data-uds-can-create-ws="0"] [aria-label="选择工作区"],html[data-uds-can-create-ws="0"] [aria-label="Choose workspace"]{display:none!important}html[data-uds-logged-in="0"] [class*="cardWorkspaceTrigger"],html[data-uds-logged-in="0"] [data-composer-card][class*="cardWorkspaceTrigger"]{pointer-events:none!important;opacity:.45!important;cursor:not-allowed!important}/* uds-anon-hide-workspaces *//* uds-anon-hide-conversation:removed */html[data-uds-logged-in="0"] [class*="WorkspaceBrowser"],html[data-uds-logged-in="0"] [class*="workspaceBrowser"],html[data-uds-logged-in="0"] [class*="workspaceRow"],html[data-uds-logged-in="0"] [class*="WorkspaceRow"]{display:none!important}',
/* uds-session-only-sidebar */
'html[data-uds-can-create-ws="0"][data-uds-logged-in="1"] [class*="projectRow"]:not([class*="dsh-ct-project"]),html[data-uds-can-create-ws="0"][data-uds-logged-in="1"] [class*="ProjectRow"]:not([class*="dsh-ct-project"]){display:none!important}',
/* Session-only sidebar: hide workspace project rows only after we force flat
(data-uds-session-only). Do not key on can-create-ws alone — a race to 0
would hide named workspaces and leave only「未分组」visible. */
'html[data-uds-session-only="1"][data-uds-logged-in="1"] [class*="projectRow"]:not([class*="dsh-ct-project"]),html[data-uds-session-only="1"][data-uds-logged-in="1"] [class*="ProjectRow"]:not([class*="dsh-ct-project"]){display:none!important}',
'.uds-auth-badge{display:inline-flex;align-items:center;justify-content:flex-start;gap:0;max-width:min(160px,42vw);min-width:0;height:32px;padding:0 8px;box-sizing:border-box;border:none;border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:13px;font-weight:400;line-height:20px;cursor:pointer;overflow:hidden}',
'.uds-auth-badge:hover{background:var(--dsw-alias-interactive-bg-hover)}',
'.uds-auth-host.is-rail .uds-auth-badge{width:auto;max-width:100%;height:32px;padding:0 6px;border-radius:8px}',
@ -558,14 +570,12 @@ window.__ModuleLoader__.load({
if (emp && kind === 'fallback') {
try { setCookie('UDS_FALLBACK_UI', emp, 7) } catch { /* Desktop may ignore */ }
}
// Ensure mux reconnects with bridge query (soft path before full reload).
// Prefetch WS tickets then soft-reconnect — sync XHR mint is unreliable on Desktop.
try {
window.__udsAuthBridgeMuxPrimed = false
if (typeof window.__udsAuthReconnect === 'function') {
setTimeout(() => {
try { window.__udsAuthReconnect() } catch { /* ignore */ }
}, 50)
}
setTimeout(() => {
try { void reconnectWithWsTicket() } catch { /* ignore */ }
}, 50)
} catch { /* ignore */ }
}
@ -579,16 +589,302 @@ window.__ModuleLoader__.load({
}
}
/** Append bridge to WS URL — browsers cannot set custom WS headers; Desktop has no cookies. */
/** Resolve fetch/WS input to absolute URL (R01). */
function resolveTransportUrl(input) {
try {
if (typeof Request !== 'undefined' && input instanceof Request) {
return new URL(input.url)
}
const base = typeof location !== 'undefined' ? location.href : 'http://localhost/'
return new URL(String(input), base)
} catch {
return null
}
}
function defaultPort(protocol) {
if (protocol === 'https:' || protocol === 'wss:') return '443'
if (protocol === 'http:' || protocol === 'ws:') return '80'
return ''
}
function hostnameIsLoopback(hostname) {
const h = String(hostname || '').toLowerCase()
const bare = h.startsWith('[') && h.endsWith(']') ? h.slice(1, -1) : h
return bare === 'localhost' || bare === '127.0.0.1' || bare === '::1'
}
/** Desktop boot exposes the real Host HTTP origin for mux/WS. */
function getDesktopTransportBase() {
try {
const base = globalThis.__DSH_TRANSPORT__?.streamBaseUrl
if (!base) return null
return new URL(base)
} catch {
return null
}
}
function sameHostAuthority(a, b) {
if (!a || !b) return false
const aSecure = a.protocol === 'https:' || a.protocol === 'wss:'
const bSecure = b.protocol === 'https:' || b.protocol === 'wss:'
if (aSecure !== bSecure) return false
return a.hostname === b.hostname
&& String(a.port || defaultPort(a.protocol)) === String(b.port || defaultPort(b.protocol))
}
/**
* A05/D01/D02: attach bridge only to the current Host authority.
* Desktop dsh-app:// maps relative Host routes + streamBaseUrl / loopback Gateway.
* HTTP↔WS and HTTPS↔WSS map as the same Host; never HTTPS→WS.
*/
function isTrustedHostUrl(input) {
if (typeof location === 'undefined') return false
const parsed = resolveTransportUrl(input)
if (!parsed) return false
const urlProto = parsed.protocol
// Desktop renderer: document is dsh-app://app — Host is a separate HTTP origin.
// 0.3.9 behavior (regression in 0.3.10): when streamBaseUrl is not ready yet,
// still trust loopback Gateway so WS tickets attach; otherwise workspace follow
// stays anonymous and every session lands under「未分组」.
if (location.protocol === 'dsh-app:') {
if (location.hostname !== 'app') return false
if (urlProto === 'dsh-app:') return parsed.hostname === 'app' && !parsed.port
if (!['http:', 'https:', 'ws:', 'wss:'].includes(urlProto)) return false
const transport = getDesktopTransportBase()
if (transport) {
return sameHostAuthority(parsed, {
protocol: transport.protocol,
hostname: transport.hostname,
port: transport.port,
})
}
return hostnameIsLoopback(parsed.hostname)
}
const locProto = location.protocol
const locSecure = locProto === 'https:' || locProto === 'wss:'
const urlSecure = urlProto === 'https:' || urlProto === 'wss:'
if (locSecure !== urlSecure) return false
if (!['http:', 'https:', 'ws:', 'wss:'].includes(urlProto)) return false
return parsed.hostname === location.hostname
&& String(parsed.port || defaultPort(urlProto))
=== String(location.port || defaultPort(locProto))
}
/**
* T01: one-time WS tickets. WebSocket ctor is sync, so we keep a small
* prefetched cache filled by async fetch (same path as login /api/me).
*
* Desktop regression: sync XHR to absolute streamBaseUrl trips CORS
* (custom bridge headers + OPTIONS→401), and relative sync XHR often
* returns an empty body on dsh-app://. Mux then upgrades anonymously;
* workspace follow cannot “wait” its way into an identity →「未分组」.
*/
const _wsTicketCache = []
let _wsTicketPrefetch = null
function wsTicketRequestUrls() {
const urls = ['/uds-auth/api/ws-ticket']
try {
if (typeof location !== 'undefined' && location.protocol === 'dsh-app:') {
const transport = getDesktopTransportBase()
// Absolute only as async-fetch fallback (never sync XHR — CORS).
if (transport) urls.push(new URL('/uds-auth/api/ws-ticket', transport).toString())
}
} catch { /* ignore */ }
return urls
}
async function prefetchWsTickets(count = 2) {
const b = readBridge()
if (!b?.empNo || !b?.token) return 0
const need = Math.max(0, Number(count) || 0)
if (need <= 0) return 0
if (_wsTicketPrefetch) {
try { await _wsTicketPrefetch } catch { /* ignore */ }
}
let minted = 0
const run = (async () => {
for (let i = 0; i < need; i++) {
let got = null
for (const ticketUrl of wsTicketRequestUrls()) {
try {
const res = await fetch(ticketUrl, {
method: 'POST',
credentials: 'include',
redirect: 'error',
headers: {
'Content-Type': 'application/json',
'X-UDS-Bridge-EmpNo': b.empNo,
'X-UDS-Bridge-Token': b.token,
...(b.kind ? { 'X-UDS-Bridge-Kind': b.kind } : {}),
},
body: '{}',
})
if (!res.ok) continue
const data = await res.json().catch(() => ({}))
if (data?.ticket) {
got = String(data.ticket)
break
}
} catch {
/* try next URL */
}
}
if (!got) break
_wsTicketCache.push(got)
minted++
}
})()
_wsTicketPrefetch = run.finally(() => {
if (_wsTicketPrefetch === run) _wsTicketPrefetch = null
})
try { await _wsTicketPrefetch } catch { /* ignore */ }
return minted
}
function mintWsTicketSync() {
if (_wsTicketCache.length) {
const ticket = _wsTicketCache.shift()
if (_wsTicketCache.length < 1) {
try { void prefetchWsTickets(2) } catch { /* ignore */ }
}
return ticket
}
const b = readBridge()
if (!b?.empNo || !b?.token) return null
// Last resort: same-origin sync XHR only (unit harness / http Host UI).
// Skip absolute Host URLs here — custom headers require CORS preflight.
try {
const xhr = new XMLHttpRequest()
xhr.open('POST', '/uds-auth/api/ws-ticket', false)
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.setRequestHeader('X-UDS-Bridge-EmpNo', b.empNo)
xhr.setRequestHeader('X-UDS-Bridge-Token', b.token)
if (b.kind) xhr.setRequestHeader('X-UDS-Bridge-Kind', b.kind || 'fallback')
xhr.send('{}')
if (xhr.status >= 200 && xhr.status < 300) {
const data = JSON.parse(xhr.responseText || '{}')
if (data.ticket) return String(data.ticket)
}
} catch { /* ignore */ }
try {
console.warn('[uds-auth] WS ticket mint failed — workspace sidebar may stay empty until reconnect')
} catch { /* ignore */ }
return null
}
let _wsAuthRetryTimer = null
let _wsAuthRetryCount = 0
let _wsAuthReconnectShared = null
let _softReconnectTimer = null
let _softReconnectQuietUntil = 0
/**
* Soft mux reconnect with debounce + quiet period.
*
* Desktop boot used to fire two ticketed reconnects (~200ms AuthBadge +
* ~250ms connection-inject). WorkspaceStateStream accepts the anonymous
* empty baseline, then dies terminally when a second CarrierError lands
* before the authenticated baseline is accepted (attempt>1 while
* connection.generation is live). Sidebar stays「未分组」forever even
* though a fresh follow with a ticket returns every workspace.
*/
/**
* Ask every client cache to re-read after the principal changes (login,
* identity attach after reconnect, logout). Debounced: one reset per burst.
*/
let _authResetTimer = null
function scheduleAuthReset(delay = 1500) {
try {
if (_authResetTimer) clearTimeout(_authResetTimer)
_authResetTimer = setTimeout(() => {
_authResetTimer = null
try { window.__udsAuthEmitReset?.() } catch { /* ignore */ }
}, delay)
} catch { /* ignore */ }
}
function softReconnectAuth(opts = {}) {
const force = !!(opts && opts.force)
const now = Date.now()
if (!force && now < _softReconnectQuietUntil) return
if (_softReconnectTimer) {
clearTimeout(_softReconnectTimer)
_softReconnectTimer = null
}
const delay = force ? 0 : 450
_softReconnectTimer = setTimeout(() => {
_softReconnectTimer = null
if (!force && Date.now() < _softReconnectQuietUntil) return
try {
if (typeof window.__udsAuthReconnect === 'function') {
window.__udsAuthReconnect()
} else {
window.location.reload()
return
}
// Keep a quiet window so the authenticated workspace baseline can land.
_softReconnectQuietUntil = Date.now() + 2800
scheduleAuthReset(1500)
} catch {
try { window.location.reload() } catch { /* ignore */ }
}
}, delay)
}
async function reconnectWithWsTicket() {
if (_wsAuthReconnectShared) return _wsAuthReconnectShared
_wsAuthReconnectShared = (async () => {
// One ticket is enough for the coalesced reconnect; spare is filled after quiet.
let minted = 0
try { minted = await prefetchWsTickets(1) } catch { minted = 0 }
// Even with 0 tickets, reconnect — withBridgeQuery falls back to loopback
// bridge query params so workspace follow still gets an identity.
softReconnectAuth()
setTimeout(() => {
try { void prefetchWsTickets(1) } catch { /* ignore */ }
}, 3000)
if (minted <= 0 && readBridge() && _wsAuthRetryCount < 5) {
_wsAuthRetryCount += 1
if (_wsAuthRetryTimer) clearTimeout(_wsAuthRetryTimer)
_wsAuthRetryTimer = setTimeout(() => {
try { void reconnectWithWsTicket() } catch { /* ignore */ }
}, 800 * _wsAuthRetryCount)
} else if (minted > 0) {
_wsAuthRetryCount = 0
}
})().finally(() => {
_wsAuthReconnectShared = null
})
return _wsAuthReconnectShared
}
/** Append one-time WS ticket (preferred) or loopback bridge query (Desktop fallback). */
function withBridgeQuery(url) {
const b = readBridge()
if (!b) return url
if (!isTrustedHostUrl(url)) return url
try {
const parsed = new URL(String(url), typeof location !== 'undefined' ? location.href : 'http://localhost/')
if (parsed.searchParams.has('udsBridgeToken')) return parsed.toString()
parsed.searchParams.set('udsBridgeEmpNo', b.empNo)
parsed.searchParams.set('udsBridgeToken', b.token)
parsed.searchParams.set('udsBridgeKind', b.kind || 'fallback')
const parsed = resolveTransportUrl(url)
if (!parsed) return url
if (!['ws:', 'wss:'].includes(parsed.protocol) || parsed.pathname !== '/api/remote.mux') return url
if (parsed.searchParams.has('udsWsTicket') || parsed.searchParams.has('udsBridgeToken')) {
return parsed.toString()
}
const ticket = mintWsTicketSync()
if (ticket) {
parsed.searchParams.set('udsWsTicket', ticket)
return parsed.toString()
}
// 0.3.9 Desktop path: ticket mint often fails on dsh-app:// (empty XHR /
// CORS). Loopback Host accepts udsBridge* on upgrade only.
parsed.searchParams.set('udsBridgeEmpNo', String(b.empNo))
parsed.searchParams.set('udsBridgeToken', String(b.token))
if (b.kind) parsed.searchParams.set('udsBridgeKind', String(b.kind))
return parsed.toString()
} catch {
return url
@ -615,11 +911,11 @@ window.__ModuleLoader__.load({
const origFetch = window.fetch.bind(window)
window.fetch = async function udsAuthBridgeFetch(input, init) {
const bHeaders = bridgeAuthHeaders()
if (!bHeaders['X-UDS-Bridge-Token']) {
if (!bHeaders['X-UDS-Bridge-Token'] || !isTrustedHostUrl(input)) {
return origFetch(input, init)
}
const opts = init ? { ...init } : {}
const prev = opts.headers
const prev = opts.headers || (typeof input === 'object' ? input.headers : null)
if (prev && typeof prev.forEach === 'function') {
const h = { ...bHeaders }
prev.forEach((v, k) => { h[k] = v })
@ -628,6 +924,9 @@ window.__ModuleLoader__.load({
opts.headers = { ...bHeaders, ...(prev || {}) }
}
if (opts.credentials == null) opts.credentials = 'include'
// Custom bridge headers are not stripped like Authorization on redirects.
// Refuse redirects while attaching a credential to the owned Host.
opts.redirect = 'error'
return origFetch(input, opts)
}
} catch { /* ignore */ }
@ -706,6 +1005,7 @@ window.__ModuleLoader__.load({
if (root.getAttribute('data-uds-logged-in') !== '1') return false
// Must be explicit 0 — missing/default must not force flat for supers.
if (root.getAttribute('data-uds-can-create-ws') !== '0') return false
try { root.setAttribute('data-uds-session-only', '1') } catch { /* ignore */ }
const state = readViewState()
if (state.groupBy === 'flat') {
try { window.localStorage.setItem(FORCED_FLAT_KEY, '1') } catch { /* ignore */ }
@ -727,6 +1027,7 @@ window.__ModuleLoader__.load({
if (root.getAttribute('data-uds-auth-ready') !== '1') return false
if (root.getAttribute('data-uds-logged-in') !== '1') return false
if (root.getAttribute('data-uds-can-create-ws') !== '1') return false
try { root.setAttribute('data-uds-session-only', '0') } catch { /* ignore */ }
const state = readViewState()
if (state.groupBy === 'workspace') {
try { window.localStorage.removeItem(FORCED_FLAT_KEY) } catch { /* ignore */ }
@ -753,19 +1054,16 @@ window.__ModuleLoader__.load({
}
function reloadAfterLogin() {
// Login Set-Cookie must land before WS upgrade — hard reload is required.
try { ensureFlatSessionSidebar() } catch { /* ignore */ }
try { window.location.reload() } catch { /* ignore */ }
}
/** Soft WS reconnect (logout / auth flip). Prefer this over full reload. */
function softReconnectAuth() {
// Login Set-Cookie / bridge must land before WS upgrade — hard reload is required.
// Admin-class (incl. sealed_box) must restore workspace grouping; only
// non-creators are forced into the flat session list.
try {
if (typeof window.__udsAuthReconnect === 'function') {
window.__udsAuthReconnect()
return
if (document.documentElement.getAttribute('data-uds-can-create-ws') === '1') {
ensureWorkspaceGroupedSidebar()
} else {
ensureFlatSessionSidebar()
}
} catch { /* fall through */ }
} catch { /* ignore */ }
try { window.location.reload() } catch { /* ignore */ }
}
@ -1064,7 +1362,7 @@ function reloadAfterLogin() {
},
} : prev)
setViewAllMsg(res.message || (enabled ? t('ui.viewAllSessionsOn') : t('ui.viewAllSessionsOff')))
try { softReconnectAuth() } catch { /* ignore */ }
try { softReconnectAuth({ force: true }) } catch { /* ignore */ }
} catch (err) {
setViewAllMsg(apiMessage(err) || t('ui.saveFailed'))
} finally {
@ -1245,10 +1543,18 @@ function reloadAfterLogin() {
document.documentElement.setAttribute('data-uds-logged-in', user ? '1' : '0')
document.documentElement.setAttribute('data-uds-can-settings', canSettings ? '1' : '0')
document.documentElement.setAttribute('data-uds-can-create-ws', canCreateWs ? '1' : '0')
// Admin-class must not keep session-only CSS (would hide named workspaces).
if (canCreateWs) {
document.documentElement.setAttribute('data-uds-session-only', '0')
} else if (user) {
document.documentElement.setAttribute('data-uds-session-only', '1')
} else {
document.documentElement.setAttribute('data-uds-session-only', '0')
}
// Drop stale remote.mux identity after logout so workspace names disappear.
if (prev === '1' && !user) {
clearSessionIfAnonymous(window.__udsAuthSessions)
try { softReconnectAuth() } catch { /* ignore */ }
try { softReconnectAuth({ force: true }) } catch { /* ignore */ }
}
// Do not reset auth attrs in the effect cleanup: React remount/strict-mode
// would briefly look logged-out and trigger flat↔workspace reload loops.
@ -1259,6 +1565,7 @@ function reloadAfterLogin() {
if (qrRef.current.timeout) { clearTimeout(qrRef.current.timeout); qrRef.current.timeout = null }
qrRef.current.key = null
qrRef.current.value = null
qrRef.current.binding = null
qrRef.current.deadline = 0
}, [])
@ -1281,6 +1588,8 @@ function reloadAfterLogin() {
try {
const me = await fetchJson('/uds-auth/api/me')
if (!me?.authenticated || !me?.data) {
try { window.__udsAuthWsAuthedOnce = false } catch { /* ignore */ }
try { window.__udsAuthResetEmp = null } catch { /* ignore */ }
setUser(null)
window.dispatchEvent(new Event('uds-auth-changed'))
return false
@ -1300,6 +1609,24 @@ function reloadAfterLogin() {
permissions,
})
window.dispatchEvent(new Event('uds-auth-changed'))
try {
const resetEmp = String(d.empNo || d.userId || '')
if (resetEmp && window.__udsAuthResetEmp !== resetEmp) {
window.__udsAuthResetEmp = resetEmp
scheduleAuthReset(1500)
}
} catch { /* ignore */ }
// Desktop: first mux open often races ahead of bridge/ticket. Prefetch
// then one coalesced soft-reconnect — never on every /api/me refresh.
try {
if (readBridge() && !window.__udsAuthWsAuthedOnce) {
_wsAuthRetryCount = 0
window.__udsAuthWsAuthedOnce = true
setTimeout(() => {
try { void reconnectWithWsTicket() } catch { /* ignore */ }
}, 200)
}
} catch { /* ignore */ }
return true
} catch {
setUser(null)
@ -1334,9 +1661,10 @@ function reloadAfterLogin() {
setQrImg('')
return
}
const { qrCodeStr, qrCodeKey, qrCodeValue, loginSystemCode, originSystemCode } = started
const { qrCodeStr, qrCodeKey, qrCodeValue, browserBinding, loginSystemCode, originSystemCode } = started
qrRef.current.key = qrCodeKey
qrRef.current.value = qrCodeValue
qrRef.current.binding = browserBinding
qrRef.current.deadline = Date.now() + QR_TIMEOUT_MS
setQrImg('/uds-auth/qr?data=' + encodeURIComponent(qrCodeStr))
setQrStatus(t('ui.qrScanPrompt'))
@ -1345,81 +1673,39 @@ function reloadAfterLogin() {
markQrExpired()
}, QR_TIMEOUT_MS)
qrRef.current.timer = setInterval(async () => {
if (!qrRef.current.key) return
if (!qrRef.current.key || qrRef.current.polling) return
if (qrRef.current.deadline && Date.now() >= qrRef.current.deadline) {
markQrExpired()
return
}
try {
const verify = await fetchJson(
'/uds-auth/verify-code?qrCodeKey=' + encodeURIComponent(qrRef.current.key)
+ '&qrCodeValue=' + encodeURIComponent(qrRef.current.value)
+ '&loginClientIp=' + encodeURIComponent('127.0.0.1')
+ '&loginSystemCode=' + encodeURIComponent(loginSystemCode || config.loginSystemCode)
+ '&originSystemCode=' + encodeURIComponent(originSystemCode || config.originSystemCode || ''),
)
qrRef.current.polling = true
const result = await fetchJson('/uds-auth/qr-proxy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
qrCodeKey: qrRef.current.key,
qrCodeValue: qrRef.current.value,
loginClientIp: '127.0.0.1',
originSystemCode: originSystemCode || config.originSystemCode || '',
loginSystemCode: loginSystemCode || config.loginSystemCode,
verifyCode: verify.verifyCode,
}),
headers: { 'Content-Type': 'application/json', 'X-UDS-QR-Binding': browserBinding },
body: JSON.stringify({ qrCodeKey, qrCodeValue }),
})
if (qrRef.current.key !== qrCodeKey) return
const codeCode = result.code?.code || ''
const boCode = result.bo?.code || ''
if (codeCode === '0000' && boCode === '0000') {
stopQr()
setQrExpired(false)
setQrFailed(false)
const other = result.other || {}
const empNo = other.account || other.empNo || ''
const token = other.token || other.authValue || ''
if (empNo && token) {
// Cookie path (http/web). Desktop dsh-app:// ignores these — bridge/bind below.
setCookie('PORTALSSOUser', empNo, 7)
setCookie('PORTALSSOCookie', token, 7)
try {
const info = await fetchJson(
'/uds-auth/user-info?empNo=' + encodeURIComponent(empNo)
+ '&token=' + encodeURIComponent(token),
)
const ic = info?.code?.code ?? info?.code
const list = Array.isArray(info?.bo) ? info.bo
: Array.isArray(info?.bo?.rows) ? info.bo.rows
: Array.isArray(info?.bo?.list) ? info.bo.list
: []
if ((ic !== '0000' && ic !== 0 && ic !== '0') || !list.length) {
setQrStatus(t('ui.userInfoFailed'))
console.warn('[uds-auth] user-info after QR failed', info)
return
}
} catch (err) {
setQrStatus(t('ui.userInfoFailed') + ': ' + (err.message || err))
return
}
try {
const bound = await fetchJson('/uds-auth/api/bridge/bind', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ empNo, token }),
})
adoptAuthSession(bound)
} catch (err) {
console.warn('[uds-auth] bridge/bind after QR failed', err)
// http hosts may still have cookies; Desktop will stay logged out without bridge.
}
setQrStatus(t('ui.loginSuccess'))
await refreshUser()
setOpen(false)
reconnectAfterLogin()
} else {
setQrStatus(t('ui.missingToken'))
const auth = result.auth
if (!auth?.success || !auth?.bridge?.token) {
setQrStatus(t('ui.loginFailed'))
return
}
adoptAuthSession(auth)
setQrStatus(t('ui.loginSuccess'))
await refreshUser()
if (document.documentElement.getAttribute('data-uds-logged-in') !== '1') {
setQrStatus(t('ui.loginFailed'))
return
}
setOpen(false)
reconnectAfterLogin()
return
}
if (codeCode === '0000' && boCode === '4002') {
@ -1435,7 +1721,9 @@ function reloadAfterLogin() {
setQrFailed(false)
setQrStatus(result.bo?.msg || boCode || t('ui.loginFailed'))
} catch {
setQrStatus(t('ui.networkError'))
if (qrRef.current.key === qrCodeKey) setQrStatus(t('ui.networkError'))
} finally {
qrRef.current.polling = false
}
}, 2000)
} catch (err) {
@ -1508,6 +1796,9 @@ function reloadAfterLogin() {
fetchJson('/uds-auth/api/fallback/status')
.then((st) => { if (!cancelled) setFallbackEnabled(!!st.enabled) })
.catch(() => {})
fetchJson('/uds-auth/api/local-admin/status')
.then((st) => { if (!cancelled) setLocalKeyEnabled(!!st.enabled) })
.catch(() => { if (!cancelled) setLocalKeyEnabled(false) })
return () => { cancelled = true }
}, [open, user])
@ -1613,12 +1904,17 @@ function reloadAfterLogin() {
),
!qrOverlay && h('div', { className: 'uds-auth-qr-status' }, qrStatus || t('ui.loading')),
),
// Always offer emergency login when QR failed/expired; otherwise only if enabled.
(fallbackEnabled || qrFailed || qrExpired) && h('button', {
// U01: emergency password always discoverable — disabled state is shown, not hidden.
// Local sealed-box unlock stays hidden unless Host actually has a box configured.
h('button', {
type: 'button',
className: 'uds-auth-btn-link',
onClick: () => { stopQr(); setLoginMode('fallback'); setFbErr('') },
}, t('ui.fallbackLink')),
onClick: () => {
stopQr()
setLoginMode('fallback')
setFbErr(fallbackEnabled ? '' : t('ui.fallbackDisabledHint'))
},
}, fallbackEnabled ? t('ui.fallbackLink') : t('ui.fallbackDisabledLink')),
localKeyEnabled && h('button', {
type: 'button',
className: 'uds-auth-btn-link',
@ -1659,7 +1955,8 @@ function reloadAfterLogin() {
: h(React.Fragment, null,
h('div', { className: 'uds-auth-info' },
h('div', { className: 'uds-auth-info-name' }, t('ui.fallbackLogin')),
h('div', { className: 'uds-auth-info-detail' }, t('ui.fallbackDetail')),
h('div', { className: 'uds-auth-info-detail' },
fallbackEnabled ? t('ui.fallbackDetail') : t('ui.fallbackDisabledDetail')),
),
h('div', { className: 'uds-auth-fallback' },
h('label', { htmlFor: 'uds-fb-user' }, t('ui.username')),
@ -1668,6 +1965,7 @@ function reloadAfterLogin() {
value: fbUser,
onChange: (e) => setFbUser(e.target.value),
autoComplete: 'username',
disabled: !fallbackEnabled,
}),
h('label', { htmlFor: 'uds-fb-pass' }, t('ui.password')),
h('input', {
@ -1676,14 +1974,15 @@ function reloadAfterLogin() {
value: fbPass,
onChange: (e) => setFbPass(e.target.value),
autoComplete: 'current-password',
onKeyDown: (e) => { if (e.key === 'Enter') submitFallback() },
disabled: !fallbackEnabled,
onKeyDown: (e) => { if (e.key === 'Enter' && fallbackEnabled) submitFallback() },
}),
fbErr && h('div', { className: 'uds-auth-settings-msg err' }, fbErr),
h('button', {
type: 'button',
className: 'uds-auth-btn uds-auth-btn-primary',
style: { width: '100%', margin: '12px 0 0' },
disabled: fbBusy,
disabled: fbBusy || !fallbackEnabled,
onClick: submitFallback,
}, fbBusy ? t('ui.loggingIn') : t('ui.login')),
),
@ -1706,6 +2005,8 @@ function reloadAfterLogin() {
try { await fetchJson('/uds-auth/api/logout', { method: 'POST' }) } catch { /* ignore */ }
clearAuthCookies()
clearBridge()
try { window.__udsAuthWsAuthedOnce = false } catch { /* ignore */ }
try { window.__udsAuthResetEmp = null } catch { /* ignore */ }
setUser(null)
setOpen(false)
document.documentElement.setAttribute('data-uds-logged-in', '0')
@ -1713,7 +2014,7 @@ function reloadAfterLogin() {
document.documentElement.setAttribute('data-uds-can-create-ws', '0')
clearSessionIfAnonymous(window.__udsAuthSessions)
window.dispatchEvent(new Event('uds-auth-changed'))
try { softReconnectAuth() } catch { /* ignore */ }
try { softReconnectAuth({ force: true }) } catch { /* ignore */ }
},
}, t('ui.logout')),
),
@ -1785,17 +2086,24 @@ function reloadAfterLogin() {
window.__udsAuthReconnect = () => {
try { cctx.connection.reconnect() } catch { window.location.reload() }
}
// Desktop ownsHost: mux WS often connects before this client patches
// WebSocket. If a bridge already exists, reconnect once so upgrade
// carries ?udsBridge* (history/follow ACL depends on it).
// connection.reconnect() only re-opens streams; it does not emit
// connection/reset, so boot-time caches (settings describe mirror →
// Settings › Models "key missing") keep the anonymous projection.
window.__udsAuthEmitReset = () => {
try { cctx.emit('connection/reset') } catch { /* ignore */ }
}
// Desktop ownsHost: mux WS often connects before tickets are cached.
// Claim the once-flag so AuthBadge refreshUser does not fire a second
// ticketed reconnect (that kills WorkspaceStateStream — see softReconnectAuth).
if (
readBridge()
&& window.__DSH_TRANSPORT__?.ownsHost
&& !window.__udsAuthBridgeMuxPrimed
) {
window.__udsAuthBridgeMuxPrimed = true
window.__udsAuthWsAuthedOnce = true
setTimeout(() => {
try { cctx.connection.reconnect() } catch { /* ignore */ }
try { void reconnectWithWsTicket() } catch { /* ignore */ }
}, 250)
}
})

View file

@ -24,6 +24,11 @@ const DEFAULT_CONFIG = {
retainSkillCredentialsOnLogout: true,
skillCredentialTtlSeconds: 7 * 24 * 60 * 60,
outboundAllowedHosts: 'icenterapi.zte.com.cn,icentermsg.dt.zte.com.cn',
initialAdminEmpNo: '',
allowOpenRegistration: true,
allowRawAgentToken: false,
/** Empty = loopback Host only; reverse proxies must set explicit authorities. */
trustedHosts: '',
}
// 内部常量(不暴露)
@ -56,5 +61,13 @@ export function buildVerifyUrl(uacBaseUrl, uacQrVerifyPath) {
export function validateConfig(config) {
if (!config?.uacBaseUrl) throw new Error('uacBaseUrl is required')
const checkHttps = (v, name) => {
if (v == null || v === '') return
let u
try { u = new URL(String(v)) } catch { throw new Error(`invalid_${name}`) }
if (u.protocol !== 'https:') throw new Error(`${name}_must_be_https`)
}
checkHttps(config.uacBaseUrl, 'uacBaseUrl')
checkHttps(config.userSearchUrl, 'userSearchUrl')
return true
}

View file

@ -0,0 +1,69 @@
// Compatibility with the unmodified DSH Desktop welcome RPC sequence. These
// projections contain no account attempt, real credential reference, or config.
export const DESKTOP_BOOTSTRAP_REF = 'UDS_AUTH_DESKTOP_PROVIDER_CONFIGURED'
const namespace = 'uds-auth-desktop-bootstrap'
const endpoints = new Set(['settings/describe', 'llm/listConfigurableProviders', 'credentials/describe', 'account/getState'])
export function isDesktopBootstrapEndpoint(descriptor) {
return !descriptor.mode && endpoints.has(`${descriptor.namespace}/${descriptor.method}`)
}
function view(ns, value) {
return { ns, value, autoGenerate: false, schema: { type: 'object', dict: {} },
applies: 'live', secrets: [], revision: 0 }
}
/** Read only Host-owned metadata; request arguments can never select a secret. */
export function createDesktopBootstrap({ getService, deny }) {
const settings = () => getService('settings')?.describe?.({ redactSecrets: true }) || []
// Every read is best-effort: one failing provider or reference must not fail the
// whole welcome read (Desktop treats a failed read as "no API key").
const configured = async () => {
let rows = []
try { rows = settings() } catch { rows = [] }
const refs = new Set()
const official = rows.find(row => row.ns === 'llm-deepseek')?.value?.apiKeyEnv
if (typeof official === 'string') refs.add(official)
let providers = []
try { providers = getService('llm')?.listConfigurableProviders?.() || [] } catch { providers = [] }
for (const provider of providers) {
let value = rows.find(row => row.ns === provider?.settingsNs)?.value
for (const key of provider?.settingsPath || []) value = value && Object.hasOwn(value, key) ? value[key] : undefined
if (typeof value?.apiKeyEnv === 'string') refs.add(value.apiKeyEnv)
}
const credentials = getService('credentials')
for (const ref of refs) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(ref)) continue
try {
if ((await credentials?.describe?.(ref))?.configured === true) return true
} catch { /* try the next reference */ }
}
return false
}
return async request => {
const endpoint = `${request.namespace}/${request.method}`
switch (endpoint) {
case 'settings/describe': {
const preference = settings().find(row => row.ns === 'locale')?.value?.preference
return { writable: false, hasDocument: false, namespaces: [
view('locale', { ...(typeof preference === 'string' && /^[A-Za-z-]{2,16}$/.test(preference) ? { preference } : {}) }),
view(namespace, { apiKeyEnv: DESKTOP_BOOTSTRAP_REF }),
] }
}
case 'llm/listConfigurableProviders':
return [{ provider: namespace, displayName: 'Host provider', settingsNs: namespace, settingsPath: [] }]
case 'credentials/describe': {
const refs = request.args?.refs
if (!Array.isArray(refs) || refs.length > 1 || refs.some(ref => ref !== DESKTOP_BOOTSTRAP_REF)) deny('forbidden_settings')
return refs.length ? { [DESKTOP_BOOTSTRAP_REF]: { configured: await configured(), writable: false } } : {}
}
case 'account/getState': {
let state
try { state = await getService('deepseekAccount')?.getState?.() } catch { state = undefined }
return { status: state?.status === 'credential-stored' ? 'credential-stored' : 'signed-out', attempt: null,
links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } }
}
default: return deny('login_required')
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,55 @@
const catalogEvents = new Set(['commands/change', 'llm/adapters-updated', 'permission-presets/catalog-changed'])
/** The Gateway's $events carrier bypasses Remote method dispatch. Fence it too. */
export function installGatewayEvents(gateway, live) {
if (typeof gateway.openRemoteEvents !== 'function' || typeof gateway.receiveRemoteEventResult !== 'function') {
throw new Error('unsupported_gateway_event_contract')
}
const owners = new Map()
const originalOpen = gateway.openRemoteEvents.bind(gateway)
const originalResult = gateway.receiveRemoteEventResult.bind(gateway)
const allowedSession = sid => {
if (typeof sid !== 'string' || !sid) return false
try { live.assertSession(sid); return true } catch { return false }
}
gateway.openRemoteEvents = async function* (...args) {
const principal = live.identity()
if (principal === undefined) { yield* originalOpen(...args); return }
live.assertPrincipal(principal)
let clientId
const delivered = new Set()
try {
for await (const frame of originalOpen(...args)) {
live.assertPrincipal(principal)
if (frame.type === 'ready') {
clientId = frame.clientId
owners.set(clientId, { empNo: principal.empNo, sessionId: principal.sessionId })
yield { ...frame, host: live.permissions(principal).canAccessSettings ? frame.host : { home: '' } }
} else if (frame.type === 'waterfall') {
if (allowedSession(frame.agentId)) { delivered.add(frame.eventId); yield frame }
} else if (frame.type === 'cancel') {
if (delivered.delete(frame.eventId)) yield frame
} else if (frame.type === 'emit') {
const first = frame.args?.[0]
let sid
if (frame.event === 'api-session/added') sid = first?.id ?? first?.sessionId
else if (['api-session/activity', 'api-session/error', 'api-session/removed', 'api-session/status', 'agent-preset/selected'].includes(frame.event)) sid = first
else if (frame.event === 'goal/activation-changed') sid = first?.sessionId
if (sid !== undefined ? allowedSession(sid)
: catalogEvents.has(frame.event) || live.permissions(principal).canAccessSettings) yield frame
}
}
} finally { if (clientId) owners.delete(clientId) }
}
gateway.receiveRemoteEventResult = function(client, result, ...rest) {
const principal = live.identity()
if (principal !== undefined) {
live.assertPrincipal(principal)
const owner = owners.get(result.clientId)
if (!owner || owner.empNo !== principal.empNo || owner.sessionId !== principal.sessionId) live.deny('event_client_forbidden')
const pending = gateway.pendingRemoteEvents?.get(result.eventId)
if (pending && !allowedSession(pending.frame?.agentId)) live.deny('session_forbidden')
}
return originalResult(client, result, ...rest)
}
}

View file

@ -0,0 +1,108 @@
import { installGatewayEvents } from './gateway-events.js'
import { isDesktopBootstrapEndpoint } from './desktop-bootstrap.js'
// Endpoints whose resource boundaries/projections are owned by dsh-acl wrappers.
const delegated = new Set([
'session/list', 'session/search', 'session/create', 'session/selectModel', 'session/rename',
'session/fork', 'session/prompt', 'session/attachment', 'session/updateQueue', 'session/cancel',
'session/page', 'session/follow', 'session/projections', 'session/control', 'session/openWorkspacePath',
'workspace/follow', 'workspace/insertSessionBefore', 'workspace/archiveSession', 'workspace/unarchiveSession',
'workspace/pinSession', 'workspace/unpinSession', 'job/list', 'job/follow', 'job/kill',
'terminal/list', 'terminal/create', 'terminal/follow', 'terminal/retain', 'terminal/write',
'terminal/resize', 'terminal/close',
])
const catalogs = new Set(['session/modelCatalog', 'session/canOpenWorkspacePath', 'permissionPresets/catalog'])
// Anonymous mux must get dsh-acl's empty baseline, not a gateway error: a failed
// opening snapshot kills Desktop WorkspaceStateStream for good (sidebar never repaints
// after the authenticated reconnect).
const anonymousProjected = new Set(['workspace/follow'])
// That empty baseline makes Desktop try to create a default Workspace. Answer the
// anonymous attempt with "nothing created" (a legal result) instead of a denial,
// which surfaces as「无法创建默认工作区」; the real controller is never invoked.
const anonymousNoop = new Set(['workspace/initializeDefault'])
export function gatewayPolicy(descriptor) {
const endpoint = `${descriptor.namespace}/${descriptor.method}`
if (isDesktopBootstrapEndpoint(descriptor)) return 'desktop-bootstrap-projection'
if (catalogs.has(endpoint)) return 'authenticated-catalog'
if (delegated.has(endpoint)) return 'delegated-resource-acl'
if (descriptor.invocation?.kind === 'context' && descriptor.invocation.context === 'agent') return 'session-bound'
if ((descriptor.parameters || []).some(p => p.name === 'sessionId'
|| (p.source === 'lookup' && ['agent', 'workspaceFileScope'].includes(p.name)))) return 'session-bound'
return 'admin-required'
}
/** Guard every unary/stream dispatch, including new or previously unwrapped Remote namespaces. */
export function installGatewayPolicy(gateway, { identity, assertPrincipal, assertSession, permissions, deny, desktopBootstrap }) {
assertGatewayContract(gateway)
const live = gateway.__udsGatewayPolicy || (gateway.__udsGatewayPolicy = {})
Object.assign(live, { identity, assertPrincipal, assertSession, permissions, deny, desktopBootstrap })
if (live.installed) return
installGatewayEvents(gateway, live)
live.installed = true
const original = gateway.prepareInvocation.bind(gateway)
const check = (request, descriptor) => {
const principal = live.identity()
if (principal === undefined) return
const anonymous = !(principal?.empNo || principal?.userContext?.empNo)
const endpoint = `${descriptor.namespace}/${descriptor.method}`
if (anonymous && anonymousProjected.has(endpoint)) return
if (anonymous && anonymousNoop.has(endpoint) && descriptor.mode === undefined) return 'noop'
// Desktop opens its streams (account/watch, session lists, …) before the mux is
// re-authenticated. A RemoteError is terminal for the client's $stream, so the
// surface stays failed forever (e.g. account →「登录失败」). Park anonymous
// streams with no data until the authenticated reconnect closes this carrier;
// the client then reopens them under the real principal.
if (anonymous && descriptor.mode !== undefined) return 'hold'
if (isDesktopBootstrapEndpoint(descriptor) && typeof live.desktopBootstrap === 'function') {
if (principal !== null) live.assertPrincipal(principal)
if (!principal || !live.permissions(principal).canAccessSettings) return 'bootstrap'
}
live.assertPrincipal(principal)
const policy = gatewayPolicy(descriptor)
if (['admin-required', 'desktop-bootstrap-projection'].includes(policy)
&& !live.permissions(principal).canAccessSettings) live.deny('forbidden_settings')
if (policy === 'session-bound') {
const wires = []
if (descriptor.invocation?.kind === 'context') wires.push(descriptor.invocation.wire)
for (const p of descriptor.parameters || []) {
if (p.name === 'sessionId' || (p.source === 'lookup' && ['agent', 'workspaceFileScope'].includes(p.name))) wires.push(p.wire)
}
for (const wire of wires) {
const sid = request.args?.[wire]
if (typeof sid !== 'string' || !sid) live.deny('session_forbidden')
live.assertSession(sid)
}
}
}
gateway.prepareInvocation = async function(request, ...rest) {
const descriptor = gateway.resolveDescriptor(request.namespace, request.method, `${request.namespace}/${request.method}`)
check(request, descriptor)
const prepared = await original(request, ...rest)
const method = prepared.method
prepared.method = function(...args) {
const verdict = check(request, descriptor)
if (verdict === 'noop') return undefined
if (verdict === 'hold') return holdUntilAbort(prepared.invocation?.signal)
if (verdict === 'bootstrap') return live.desktopBootstrap(request)
const value = method.apply(this, args)
if (descriptor.mode === undefined) return value
return (async function* () {
const stream = await value
for await (const frame of stream) { check(request, descriptor); yield frame }
})()
}
return prepared
}
}
/** Empty stream that ends only when its carrier is aborted (no frames, no error). */
async function* holdUntilAbort(signal) {
if (!signal || signal.aborted) return
await new Promise((resolve) => signal.addEventListener('abort', () => resolve(), { once: true }))
}
export function assertGatewayContract(gateway) {
for (const method of ['prepareInvocation', 'resolveDescriptor', 'openRemoteEvents', 'receiveRemoteEventResult']) {
if (typeof gateway?.[method] !== 'function') throw new Error('unsupported_gateway_acl_contract')
}
}

View file

@ -26,7 +26,7 @@ export const MESSAGES = {
'ui.settingsTitle': 'UAC 认证',
'ui.settingsIntro': '工号+token 双校验;UAC 挂死时用应急账号 administrator 密码登录。',
'ui.loginRequiredPage': '请先登录后查看此页',
'ui.roleHint': '当前角色:{role}。超管只是身份标签,与管理员权限相同;首位扫码登录且 roles.json 为空时自动获得超管身份,也可在「用户管理」设为管理员。应急账号 administrator 需先设密码,再在登录面板用账密登录。',
'ui.roleHint': '当前角色:{role}。超管只是身份标签,与管理员权限相同;显式配置的初始管理员在首次登录时获得超管身份,也可在「用户管理」设为管理员。应急账号 administrator 需先设密码,再在登录面板用账密登录。',
'ui.deployConfig': '部署配置',
'ui.userSearchUrl': '用户搜索 URL(token 校验)',
'ui.workspaceRoot': '工作区根目录(空=$DSH_HOME/user-workspaces)',
@ -38,7 +38,7 @@ export const MESSAGES = {
'ui.fallbackStatus': '状态:{status}。可在此改密或关闭。仅在扫码不可用时从登录面板切换。',
'ui.enabled': '已启用',
'ui.disabled': '未启用',
'ui.fallbackPassword': '应急密码(至少 6 位)',
'ui.fallbackPassword': '应急密码(至少 10 位)',
'ui.saveFallbackPassword': '保存应急密码',
'ui.fallbackPasswordSet': '应急密码已设置',
'ui.confirmClearFallback': '确认清除应急密码?',
@ -121,20 +121,50 @@ export const MESSAGES = {
'err.invalid_credentials': '用户名或密码错误',
'err.last_super_admin_demote': '系统至少需要 1 个超级管理员,不能降级最后一个',
'err.last_super_admin_delete': '系统至少需要 1 个超级管理员,不能删除最后一个',
'err.password_too_short': '密码至少 6 位',
'err.password_too_short': '密码至少 10 位',
'err.password_too_common': '不能使用已知默认口令',
'err.account_disabled': '账号已停用',
'err.qr_unknown': '二维码无效或已过期',
'err.qr_replay': '二维码已使用',
'err.qr_expired': '二维码已过期',
'err.qr_mismatch': '二维码校验失败',
'err.upstream_too_large': '上游响应过大',
'err.config_not_ready': '配置未初始化',
'err.request_failed': '请求失败',
'err.method_not_allowed': '方法不允许',
'err.missing_qr_params': '缺少 qrCodeKey 或 qrCodeValue',
'err.missing_emp_token': '缺少 empNo 或 token',
'err.token_in_query_forbidden': '禁止在 URL 传递 token,请改用 POST body 或请求头',
'err.user_search_failed': '用户搜索失败',
'err.not_found': '未找到',
'err.internal': '内部错误',
'err.login_required_cron': '登录后才能使用定时任务',
'err.login_required_session': '登录后才能访问会话',
'err.login_required_create_session': '登录后才能创建会话',
'err.login_required_create_workspace': '登录后才能创建工作区',
'err.login_required_workspace': '登录后才能使用工作区',
'err.workspace_provision_failed': '个人工作区初始化失败,请重试',
'err.plugin_unready': '认证插件未就绪',
'err.origin_forbidden': '请求来源不被允许',
'err.content_type': 'Content-Type 必须为 application/json',
'err.https_required': '出站代理仅允许 HTTPS',
'err.invalid_config': '配置无效',
'err.unknown_config_field': '未知配置字段',
'err.invalid_uac_url': 'uacBaseUrl 必须是 https URL',
'err.invalid_user_search_url': 'userSearchUrl 必须是 https URL',
'err.invalid_workspace_root': 'workspaceRoot 必须是绝对路径',
'err.invalid_ttl': 'TTL 超出允许范围',
'err.invalid_outbound_host': 'outbound 白名单 host 格式无效',
'err.bootstrap_required': '系统尚未初始化管理员,请配置 UDS_AUTH_INITIAL_ADMIN',
'err.registration_closed': '未开放自动注册,请联系管理员',
'err.raw_token_disabled': '原始凭证接口已关闭,请使用 outbound 代理',
'err.caller_unauthenticated': '调用方未通过本地会话认证',
'err.session_id_required': '需要 DSH sessionId',
'err.session_owner_mismatch': '会话归属与调用方不一致',
'err.session_forbidden': '无权访问该会话',
'err.session_revoked': '会话已失效,请重新登录',
'err.fallback_disabled': '应急账号已关闭',
'err.file_forbidden': '无权访问该文件',
'err.session_workspace_only': '只能在自己的工作区创建会话',
'err.workspace_path_only': '只能打开自己的工作区路径',
'err.workspace_create_forbidden': '只有管理员可以创建工作区',
@ -152,6 +182,8 @@ export const MESSAGES = {
'err.key_required': '请输入解密密钥',
'err.decrypt_failed': '密钥无法解密,登录失败',
'err.rate_limited': '尝试过多,请稍后再试',
'err.payload_too_large': '请求体过大',
'err.invalid_json': 'JSON 无效',
// API success
'ok.logged_out': '已退出登录',
@ -179,7 +211,7 @@ export const MESSAGES = {
'ui.settingsTitle': 'UAC Auth',
'ui.settingsIntro': 'EmpNo + token verification; when UAC is down, sign in with emergency account administrator.',
'ui.loginRequiredPage': 'Sign in to view this page',
'ui.roleHint': 'Current role: {role}. Super admin is only an identity label with the same permissions as admin; the first QR login with an empty roles.json gets that identity, or grant admin in User management. Set the emergency password before using administrator on the login panel.',
'ui.roleHint': 'Current role: {role}. Super admin is only an identity label with the same permissions as admin; the explicitly configured initial administrator receives that identity on first login, or grant admin in User management. Set the emergency password before using administrator on the login panel.',
'ui.deployConfig': 'Deploy config',
'ui.userSearchUrl': 'User search URL (token verify)',
'ui.workspaceRoot': 'Workspace root (empty=$DSH_HOME/user-workspaces)',
@ -191,7 +223,7 @@ export const MESSAGES = {
'ui.fallbackStatus': 'Status: {status}. Change or disable here. Switch from the login panel only when QR is unavailable.',
'ui.enabled': 'Enabled',
'ui.disabled': 'Disabled',
'ui.fallbackPassword': 'Emergency password (min 6 chars)',
'ui.fallbackPassword': 'Emergency password (min 10 chars)',
'ui.saveFallbackPassword': 'Save emergency password',
'ui.fallbackPasswordSet': 'Emergency password set',
'ui.confirmClearFallback': 'Clear emergency password?',
@ -269,20 +301,50 @@ export const MESSAGES = {
'err.invalid_credentials': 'Invalid username or password',
'err.last_super_admin_demote': 'At least one super admin is required; cannot demote the last one',
'err.last_super_admin_delete': 'At least one super admin is required; cannot delete the last one',
'err.password_too_short': 'Password must be at least 6 characters',
'err.password_too_short': 'Password must be at least 10 characters',
'err.password_too_common': 'Cannot use the known default password',
'err.account_disabled': 'Account is disabled',
'err.qr_unknown': 'QR challenge unknown or expired',
'err.qr_replay': 'QR challenge already used',
'err.qr_expired': 'QR challenge expired',
'err.qr_mismatch': 'QR challenge mismatch',
'err.upstream_too_large': 'Upstream response too large',
'err.config_not_ready': 'Config not initialized',
'err.request_failed': 'Request failed',
'err.method_not_allowed': 'Method not allowed',
'err.missing_qr_params': 'Missing qrCodeKey or qrCodeValue',
'err.missing_emp_token': 'Missing empNo or token',
'err.token_in_query_forbidden': 'Do not put token in the URL; use POST body or headers',
'err.user_search_failed': 'User search failed',
'err.not_found': 'Not found',
'err.internal': 'Internal error',
'err.login_required_cron': 'Sign in to use scheduled tasks',
'err.login_required_session': 'Sign in to access sessions',
'err.login_required_create_session': 'Sign in to create a session',
'err.login_required_create_workspace': 'Sign in to create a workspace',
'err.login_required_workspace': 'Sign in to use workspaces',
'err.workspace_provision_failed': 'Personal workspace provisioning failed; retry',
'err.plugin_unready': 'Auth plugin not ready',
'err.origin_forbidden': 'Request origin not allowed',
'err.content_type': 'Content-Type must be application/json',
'err.https_required': 'Outbound proxy requires HTTPS',
'err.invalid_config': 'Invalid config',
'err.unknown_config_field': 'Unknown config field',
'err.invalid_uac_url': 'uacBaseUrl must be an https URL',
'err.invalid_user_search_url': 'userSearchUrl must be an https URL',
'err.invalid_workspace_root': 'workspaceRoot must be an absolute path',
'err.invalid_ttl': 'TTL out of allowed range',
'err.invalid_outbound_host': 'Invalid outbound host entry',
'err.bootstrap_required': 'Admin not initialized; set UDS_AUTH_INITIAL_ADMIN',
'err.registration_closed': 'Open registration is disabled; contact an admin',
'err.raw_token_disabled': 'Raw credential API disabled; use outbound proxy',
'err.caller_unauthenticated': 'Caller lacks a verified local session',
'err.session_id_required': 'DSH sessionId required',
'err.session_owner_mismatch': 'Session owner does not match caller',
'err.session_forbidden': 'No access to this session',
'err.session_revoked': 'Session expired; please sign in again',
'err.fallback_disabled': 'Emergency account is disabled',
'err.file_forbidden': 'No access to this file',
'err.session_workspace_only': 'Sessions can only be created in your own workspace',
'err.workspace_path_only': 'You can only open your own workspace path',
'err.workspace_create_forbidden': 'Only admins can create workspaces',
@ -300,6 +362,8 @@ export const MESSAGES = {
'err.key_required': 'Decryption key required',
'err.decrypt_failed': 'Key could not decrypt — sign-in failed',
'err.rate_limited': 'Too many attempts, try later',
'err.payload_too_large': 'Payload too large',
'err.invalid_json': 'Invalid JSON',
'ok.logged_out': 'Signed out',
'ok.config_saved': 'Config saved',

View file

@ -1,22 +1,16 @@
/**
* Last verified browser identity (bridge or cookie).
* Desktop mux follow/history often runs with empty ALS; fall back here after
* a successful /uds-auth HTTP auth or WS upgrade bind.
* Deprecated global last-user cache (SEC-06).
* Kept as no-ops so older imports do not crash; never restores a principal.
*/
/** @type {object|null} */
let lastBrowserIdentity = null
export function rememberBrowserIdentity(identity) {
const emp = identity?.empNo || identity?.userContext?.empNo
if (!emp) return
lastBrowserIdentity = identity
export function rememberBrowserIdentity(_identity) {
/* intentionally no-op — connection-bound identity only */
}
export function clearBrowserIdentity() {
lastBrowserIdentity = null
/* intentionally no-op */
}
export function peekBrowserIdentity() {
return lastBrowserIdentity
return null
}

File diff suppressed because it is too large Load diff

View file

@ -1,15 +1,16 @@
/**
* Local admin via sealed box (decrypt-to-unlock).
*
* Env holds only ciphertext:
* UDS_AUTH_LOCAL_ADMIN_BOX=<base64url sealed blob>
* Ciphertext sources (first match wins):
* 1) UDS_AUTH_LOCAL_ADMIN_BOX env
* 2) $UDS_AUTH_DATA_DIR/local-admin.box or ~/.uds-auth/local-admin.box
*
* Operator keeps the passphrase offline. Unlock API tries AES-GCM open;
* success → administrator session. Setting/replacing env alone does not
* log anyone in — the key must decrypt the box.
*
* Generate a box:
* node -e "import('./lib/local-admin.js').then(m => console.log(m.sealLocalAdminBox(process.argv[1])))" -- "your-passphrase"
* node scripts/seal-local-admin.mjs
*/
import {
createCipheriv,
@ -19,15 +20,27 @@ import {
timingSafeEqual,
createHash,
} from 'node:crypto'
import { readFileSync, existsSync } from 'node:fs'
import { homedir } from 'node:os'
import { join, resolve } from 'node:path'
import { ROLES, computePermissions, DEFAULT_FALLBACK_USERNAME } from './roles.js'
import { derivePasswordKey } from './utils/password-kdf.js'
export const LOCAL_ADMIN_BOX_ENV = 'UDS_AUTH_LOCAL_ADMIN_BOX'
/** @deprecated presence of KEY no longer grants access; use BOX + unlock */
export const LOCAL_ADMIN_ENV = 'UDS_AUTH_LOCAL_ADMIN_KEY'
export const LOCAL_ADMIN_BOX_FILENAME = 'local-admin.box'
export const LOCAL_ADMIN_COOKIE = 'UDS_LOCAL_ADMIN'
export const LOCAL_ADMIN_COOKIE_MAX_AGE = 7 * 24 * 60 * 60
/** Profile dir used when env box is missing (Desktop Host often lacks User env). */
export function resolveLocalAdminBoxFile(dataDir) {
const root = String(dataDir || process.env.UDS_AUTH_DATA_DIR || '').trim()
|| join(homedir(), '.uds-auth')
return resolve(root, LOCAL_ADMIN_BOX_FILENAME)
}
const MAGIC = 'uds-local-admin-v1'
const SCRYPT_N = 16384
const SCRYPT_R = 8
@ -116,8 +129,42 @@ export function openLocalAdminBox(box, passphrase) {
}
}
function readLocalAdminBoxFile() {
try {
const path = resolveLocalAdminBoxFile()
if (!existsSync(path)) return null
const raw = readFileSync(path, 'utf8')
const line = String(raw || '').split(/\r?\n/).map((l) => l.trim()).find((l) => l && !l.startsWith('#'))
if (!line) return null
// Allow either bare box or KEY=value lines copied from seal CLI output.
const m = line.match(/^(?:export\s+)?UDS_AUTH_LOCAL_ADMIN_BOX=(.+)$/)
return String(m ? m[1] : line).trim().replace(/^["']|["']$/g, '') || null
} catch {
return null
}
}
export function readLocalAdminBox() {
return String(process.env[LOCAL_ADMIN_BOX_ENV] || '').trim() || null
const fromEnv = String(process.env[LOCAL_ADMIN_BOX_ENV] || '').trim()
if (fromEnv) return fromEnv
return readLocalAdminBoxFile()
}
/** Runtime unlock uses the same bounded, asynchronous KDF as password login. */
export async function openLocalAdminBoxAsync(box, passphrase) {
if (!box || typeof passphrase !== 'string' || !passphrase) return { ok: false, reason: 'missing' }
const raw = b64urlDecode(box)
if (raw.length < 46 || raw.length > 256 || raw[0] !== 1) return { ok: false, reason: 'bad_box' }
const key = await derivePasswordKey(passphrase, raw.subarray(1, 17))
try {
const decipher = createDecipheriv('aes-256-gcm', key, raw.subarray(17, 29))
decipher.setAuthTag(raw.subarray(29, 45))
const plain = Buffer.concat([decipher.update(raw.subarray(45)), decipher.final()]).toString('utf8')
return plain === `${MAGIC}|${DEFAULT_FALLBACK_USERNAME}`
? { ok: true, empNo: DEFAULT_FALLBACK_USERNAME } : { ok: false, reason: 'bad_payload' }
} catch {
return { ok: false, reason: 'decrypt_failed' }
}
}
export function isLocalAdminBoxConfigured() {
@ -197,7 +244,7 @@ export function buildLocalAdminIdentity(rolesStore) {
role,
permissions,
userContext: buildLocalAdminUserContext(),
kind: 'fallback',
kind: 'sealed_box',
}
}

View file

@ -2,26 +2,30 @@ import { UdsClient } from '../uds/client.js'
import { UdsValidator } from '../uds/validator.js'
import { ROLES } from '../roles.js'
import { searchUserByEmpNoToken } from '../uds/user-search.js'
import { readBridgeFromRequest } from '../session-bridge.js'
import { rememberBrowserIdentity } from '../identity-cache.js'
import {
extractSessionBearerAsync,
extractPortalCredentials,
buildSessionCookie,
} from '../session/request-auth.js'
import { isLocalAdminBoxConfigured } from '../local-admin.js'
/**
* auth-middleware
*
* 正常登录:Cookie 中必须同时有 empNo + token,并用 userSearchUrl
* (带 X-Emp-No / X-Auth-Value)直连内网拉用户详情;成功才建会话。
* 出站请求绕过 HTTP(S)_PROXY。
* Principal only from verified local session (UDS_SESSION / bridge bearer)
* or a fresh UAC login exchange that mints a new local session.
*
* 兜底登录:仅认可已由 /api/fallback/login 或 /api/local-admin/unlock 写好的
* administrator 会话;重启后若仅有 UDS_FALLBACK_USER cookie,会重建内存会话。
*
* 可选 onSkillCredentials(empNo, token):UI 会话写入成功后并行写入 skill 凭证缓存。
* UDS_FALLBACK_USER / UDS_FALLBACK_UI are display hints only — never auth.
* empNo-keyed profile cache hits never prove authentication.
*/
export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {}) {
const onSkillCredentials = typeof hooks.onSkillCredentials === 'function'
? hooks.onSkillCredentials
: null
const sessionBridge = hooks.sessionBridge || null
const onSessionMinted = typeof hooks.onSessionMinted === 'function'
? hooks.onSessionMinted
: null
const udsClient = new UdsClient(config.udsAuth)
const validatorConfig = {
...config.udsAuth,
@ -29,35 +33,47 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
}
const udsValidator = new UdsValidator(validatorConfig)
const { cookieMaxAge, slidingExpiration } = config.session
const ttlSeconds = Math.floor(cookieMaxAge / 1000)
const userSearchUrl = config.userSearchUrl || config.udsAuth?.userSearchUrl
const uacBaseUrl = config.udsAuth?.baseUrl || ''
function extractEmpNo(cookieHeader) {
const udsMatch = cookieHeader?.match(/(?:PORTALSSOUser|ZTEDPGSSOUser)=([^;]+)/)
if (udsMatch) return { empNo: decodeURIComponent(udsMatch[1].trim()), kind: 'uds' }
const fbMatch = cookieHeader?.match(/UDS_FALLBACK_USER=([^;]+)/)
if (fbMatch) return { empNo: decodeURIComponent(fbMatch[1].trim()), kind: 'fallback' }
// Non-HttpOnly UI cookie (still valid when Set-Cookie works).
const fbUi = cookieHeader?.match(/UDS_FALLBACK_UI=([^;]+)/)
if (fbUi) return { empNo: decodeURIComponent(fbUi[1].trim()), kind: 'fallback' }
return null
}
/** Cookie first (http/web), then Desktop bridge headers. */
function extractIdentity(req) {
const fromCookie = extractEmpNo(req?.headers?.cookie || '')
if (fromCookie) return { ...fromCookie, via: 'cookie' }
if (!sessionBridge) return null
const hdr = readBridgeFromRequest(req)
if (!hdr) return null
const ok = sessionBridge.verify(hdr.empNo, hdr.token)
if (!ok) return null
return {
empNo: ok.empNo,
kind: ok.kind === 'uds' ? 'uds' : 'fallback',
via: 'bridge',
ssoToken: ok.ssoToken,
/** R05: read live — do not freeze allowOpenRegistration at middleware create time. */
const isOpenRegistration = () => {
if (typeof config.getAllowOpenRegistration === 'function') {
return config.getAllowOpenRegistration() !== false
}
if (typeof config.allowOpenRegistration === 'function') {
return config.allowOpenRegistration() !== false
}
return config.allowOpenRegistration !== false
}
const resolveInitialAdmin = () => String(
(typeof config.getInitialAdminEmpNo === 'function'
? config.getInitialAdminEmpNo()
: config.initialAdminEmpNo)
|| process.env.UDS_AUTH_INITIAL_ADMIN
|| '',
).trim()
const initialAdminEmpNo = resolveInitialAdmin()
async function verifyEmpNoAndToken(empNo, token) {
const out = await searchUserByEmpNoToken({
userSearchUrl,
empNo,
token,
empNoHeader: config.udsAuth?.empNoHeader || 'X-Emp-No',
authValueHeader: config.udsAuth?.authValueHeader || 'X-Auth-Value',
origin: uacBaseUrl || undefined,
})
if (!out.ok) {
console.warn('[uds-auth] verifyEmpNoAndToken failed:', out)
return null
}
const subject = String(out.profile?.empNo || '').trim()
if (!subject || subject !== String(empNo).trim()) {
console.warn('[uds-auth] verifyEmpNoAndToken subject mismatch')
return null
}
return out.profile
}
function applyProfile(userContext, profile, credentials) {
@ -77,150 +93,208 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
return userContext
}
async function resolveRole(empNo, kind) {
if (kind === 'fallback' || empNo === 'administrator') {
/**
* Empty roles table cannot be claimed by arbitrary UAC users unless empNo
* matches initialAdminEmpNo (explicit bootstrap).
*/
async function resolveRoleOnLogin(empNo) {
if (empNo === 'administrator') {
return rolesStore.getRole(empNo)
}
// 角色表空 → 首位登录者升为 super_admin;新用户默认 user;已有则返回原角色
// 注意:getRole() 对未知用户也会返回 'user',绝不能用 !role 判断是否已 bootstrap
const { role, bootstrapped } = await rolesStore.bootstrapFirstUser(empNo)
if (bootstrapped) {
console.info('[uds-auth] BOOTSTRAP: ' + empNo + ' is now super_admin')
// R12: disabled accounts cannot re-enter via SSO / open registration.
if (typeof rolesStore.isDisabled === 'function' && rolesStore.isDisabled(empNo)) {
const err = new Error('account_disabled')
err.code = 'account_disabled'
throw err
}
if (rolesStore.hasRole(empNo)) {
return rolesStore.getRole(empNo)
}
const adminEmp = resolveInitialAdmin()
if (rolesStore.isEmpty()) {
if (adminEmp && empNo === adminEmp) {
const { role, bootstrapped } = await rolesStore.bootstrapFirstUser(empNo)
if (bootstrapped) {
console.info('[uds-auth] BOOTSTRAP: ' + empNo + ' is now super_admin (initialAdminEmpNo)')
}
return role
}
const err = new Error('bootstrap_required')
err.code = 'bootstrap_required'
throw err
}
if (!isOpenRegistration()) {
const err = new Error('registration_closed')
err.code = 'registration_closed'
throw err
}
const { role } = await rolesStore.bootstrapFirstUser(empNo)
return role
}
async function verifyEmpNoAndToken(empNo, token) {
const out = await searchUserByEmpNoToken({
userSearchUrl,
empNo,
token,
empNoHeader: config.udsAuth?.empNoHeader || 'X-Emp-No',
authValueHeader: config.udsAuth?.authValueHeader || 'X-Auth-Value',
origin: uacBaseUrl || undefined,
})
if (!out.ok) {
console.warn('[uds-auth] verifyEmpNoAndToken failed:', out)
return null
function isHttpsRequest(req) {
if (req?.socket?.encrypted) return true
const xf = String(req?.headers?.['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase()
return xf === 'https'
}
function classifySessionKind(session) {
// Keep sealed_box independent of emergency-password enablement (A08).
const mode = String(
session?.kind
|| session?.authMode
|| session?.userData?.authMode
|| '',
)
if (mode.includes('local-admin') || mode === 'sealed_box' || mode === 'local_admin') {
return 'sealed_box'
}
return out.profile
if (
mode === 'fallback'
|| mode.includes('fallback')
|| (session?.empNo === 'administrator' && (!mode || mode === 'uds'))
) {
return 'fallback'
}
return 'uds'
}
function needsProfileUpgrade(userContext) {
if (!userContext) return true
if (userContext.authMode === 'trust') return true
if (userContext.authMode !== 'token+profile') return true
const name = userContext.displayName || userContext.username || ''
if (!name || name === userContext.empNo) return true
return false
}
async function attachUser(ctx, empNo, userContext, kind, { persist = false } = {}) {
if (persist || slidingExpiration) {
async function attachFromSession(ctx, session, { persistTouch = false, res = null, req = null } = {}) {
const empNo = session.empNo
if (typeof rolesStore.isDisabled === 'function' && rolesStore.isDisabled(empNo)) {
const err = new Error('account_disabled')
err.code = 'account_disabled'
throw err
}
const kind = classifySessionKind(session)
// A01/V25: password-fallback sessions die when emergency password is cleared.
if (kind === 'fallback'
&& typeof rolesStore.isFallbackEnabled === 'function'
&& !rolesStore.isFallbackEnabled()) {
const err = new Error('fallback_disabled')
err.code = 'fallback_disabled'
throw err
}
// F02/E11: sealed-box sessions require live box configuration.
if (kind === 'sealed_box' && !isLocalAdminBoxConfigured()) {
const err = new Error('local_admin_not_configured')
err.code = 'local_admin_not_configured'
throw err
}
// F02: reject password sessions minted before the latest rotation.
if (kind === 'fallback' && typeof rolesStore.getFallbackCredVersion === 'function') {
const liveVer = rolesStore.getFallbackCredVersion()
const sessVer = session.userData?.fallbackCredVersion ?? session.fallbackCredVersion
if (sessVer != null && Number(sessVer) !== Number(liveVer)) {
const err = new Error('fallback_rotated')
err.code = 'fallback_rotated'
throw err
}
}
const userContext = { ...session.userData, empNo, _sessionId: session.sessionId }
if (persistTouch || slidingExpiration) {
await sessionStore.touch(session.sessionId, ttlSeconds)
userContext.lastActiveAt = new Date().toISOString()
await sessionStore.setex(
empNo,
Math.floor(cookieMaxAge / 1000),
userContext,
)
}
const role = kind === 'fallback' || kind === 'sealed_box' || rolesStore.hasRole(empNo)
? rolesStore.getRole(empNo)
: ROLES.USER
if (userContext.token) {
try { onSkillCredentials?.(empNo, userContext.token) } catch { /* ignore */ }
}
const role = await resolveRole(empNo, kind)
ctx.userContext = userContext
ctx.empNo = empNo
ctx.role = role
ctx.permissions = rolesStore.resolvePermissions(empNo, role)
// Stamp for Desktop session.follow when mux ALS is empty.
rememberBrowserIdentity({
empNo,
role,
permissions: ctx.permissions,
userContext,
kind: kind === 'uds' ? 'uds' : 'fallback',
})
ctx.sessionId = session.sessionId
ctx.authVia = 'session'
ctx.authKind = kind
if (res && req && session._bearerForCookie) {
const cookie = buildSessionCookie(session._bearerForCookie, ttlSeconds, {
secure: isHttpsRequest(req),
})
const prev = res.getHeader?.('Set-Cookie')
const list = Array.isArray(prev) ? [...prev, cookie] : (prev ? [prev, cookie] : [cookie])
res.setHeader?.('Set-Cookie', list)
}
}
async function mintSession(ctx, userContext, kind, { res, req } = {}) {
const { sessionId, bearer, record } = await sessionStore.create(userContext, ttlSeconds)
if (userContext.token) {
try { onSkillCredentials?.(userContext.empNo, userContext.token) } catch { /* ignore */ }
}
try { await sessionStore.setProfile?.(userContext.empNo, userContext) } catch { /* ignore */ }
onSessionMinted?.({ sessionId, bearer, empNo: userContext.empNo, kind })
const session = { ...record, _bearerForCookie: bearer }
await attachFromSession(ctx, session, { res, req })
return { sessionId, bearer }
}
async function authMiddleware(ctx, next) {
const { req } = ctx
const extracted = extractIdentity(req)
const { req, res } = ctx
if (!extracted) return next()
let userContext = await sessionStore.get(extracted.empNo)
// 旧 trust 会话 / 无姓名部门:强制用 token 重查用户信息
if (userContext && extracted.kind === 'uds' && needsProfileUpgrade(userContext)) {
const credentials = udsValidator.extractCredentials(req)
|| (extracted.ssoToken
? { empNo: extracted.empNo, token: extracted.ssoToken, lang: 'zh-CN' }
: null)
if (credentials && credentials.empNo === extracted.empNo && udsValidator.validateCredentials(credentials)) {
const profile = await verifyEmpNoAndToken(credentials.empNo, credentials.token)
if (profile) {
userContext = applyProfile(userContext, profile, credentials)
await sessionStore.setex(
profile.empNo,
Math.floor(cookieMaxAge / 1000),
userContext,
)
try { onSkillCredentials?.(profile.empNo, credentials.token) } catch { /* ignore */ }
} else {
// 查不到资料则作废 trust 会话,避免“假登录”
await sessionStore.delete(extracted.empNo)
userContext = null
// 1) Local session bearer (cookie / header / verified bridge)
const extracted = await extractSessionBearerAsync(req, {
sessionBridge,
isLiveBearer: async (bearer) => !!(await sessionStore.getByBearer(bearer)),
})
if (extracted?.bearer) {
const session = await sessionStore.getByBearer(extracted.bearer)
if (session) {
if (
extracted.bridgeEmpNo
&& String(extracted.bridgeEmpNo) !== String(session.empNo)
) {
return next()
}
} else if (userContext.authMode === 'trust') {
await sessionStore.delete(extracted.empNo)
userContext = null
try {
await attachFromSession(ctx, session, { persistTouch: true })
} catch (err) {
// Disabled / fallback-closed sessions must not authenticate management APIs.
if (err?.code === 'account_disabled' || err?.code === 'fallback_disabled'
|| err?.code === 'fallback_rotated' || err?.code === 'local_admin_not_configured') {
return next()
}
throw err
}
// Keep the Desktop bridge alive as long as its session (same bearer).
try { sessionBridge?.touch?.(extracted.bearer, ttlSeconds * 1000) } catch { /* ignore */ }
return next()
}
// Explicit bearer presented but invalid — do not mix with portal cookies.
if (extracted.via === 'header' || extracted.via === 'bridge') {
return next()
}
}
if (userContext) {
await attachUser(ctx, extracted.empNo, userContext, extracted.kind)
// 2) Fresh UAC login exchange from portal cookies → mint local session
const portal = extractPortalCredentials(req)
if (!portal) return next()
if (!udsValidator.validateCredentials({
empNo: portal.empNo,
token: portal.token,
lang: 'zh-CN',
})) {
return next()
}
// Fallback cookie/bridge survives process restart; memory session does not — rebuild.
if (extracted.kind === 'fallback') {
const empNo = extracted.empNo || 'administrator'
userContext = {
empNo,
userId: empNo,
username: 'Fallback Administrator',
displayName: 'Fallback Administrator',
isAuthenticated: true,
role: ROLES.FALLBACK_ADMIN,
authMode: extracted.via === 'bridge' ? 'fallback-bridge' : 'fallback-cookie',
authenticatedAt: new Date().toISOString(),
lastActiveAt: new Date().toISOString(),
sessionCreatedAt: new Date().toISOString(),
}
await attachUser(ctx, empNo, userContext, 'fallback', { persist: true })
const profile = await verifyEmpNoAndToken(portal.empNo, portal.token)
if (!profile) return next()
let role
try {
role = await resolveRoleOnLogin(profile.empNo)
} catch (err) {
ctx.authError = err?.code || err?.message
return next()
}
const credentials = udsValidator.extractCredentials(req)
|| (extracted.ssoToken
? { empNo: extracted.empNo, token: extracted.ssoToken, lang: 'zh-CN' }
: null)
if (!credentials || !udsValidator.validateCredentials(credentials)) {
return next()
}
if (credentials.empNo !== extracted.empNo) {
return next()
}
const profile = await verifyEmpNoAndToken(credentials.empNo, credentials.token)
if (!profile) {
return next()
}
userContext = udsValidator.buildUserContext({
const userContext = udsValidator.buildUserContext({
empNo: profile.empNo,
token: credentials.token,
lang: credentials.lang,
token: portal.token,
lang: 'zh-CN',
username: profile.username,
displayName: profile.username,
department: profile.department,
@ -228,20 +302,11 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
email: profile.email,
phone: profile.phone,
}, false)
applyProfile(userContext, profile, credentials)
applyProfile(userContext, profile, { empNo: profile.empNo, token: portal.token, lang: 'zh-CN' })
userContext.authenticatedAt = new Date().toISOString()
userContext.sessionCreatedAt = new Date().toISOString()
await sessionStore.setex(
profile.empNo,
Math.floor(cookieMaxAge / 1000),
userContext,
)
try { onSkillCredentials?.(profile.empNo, credentials.token) } catch { /* ignore */ }
const role = await resolveRole(profile.empNo, 'uds')
ctx.userContext = userContext
ctx.empNo = profile.empNo
await mintSession(ctx, userContext, 'uds', { res, req })
ctx.role = role
ctx.permissions = rolesStore.resolvePermissions(profile.empNo, role)
return next()
@ -250,6 +315,8 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
authMiddleware.udsClient = udsClient
authMiddleware.udsValidator = udsValidator
authMiddleware.verifyEmpNoAndToken = verifyEmpNoAndToken
authMiddleware.mintSession = mintSession
authMiddleware.resolveRoleOnLogin = resolveRoleOnLogin
return authMiddleware
}

View file

@ -0,0 +1,74 @@
import { lstat, chmod } from 'node:fs/promises'
import { resolve } from 'node:path'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { SECURITY_STATE_FILES } from './state-migration.js'
const run = promisify(execFile)
/** Restrict the owned profile, including legacy files with explicit broad ACLs. */
export async function protectProfileDirectory(dataDir) {
const root = resolve(dataDir)
const dir = await lstat(root)
if (!dir.isDirectory() || dir.isSymbolicLink()) throw new Error('profile_directory_unsafe')
const files = []
for (const name of [...SECURITY_STATE_FILES, '.uds-auth-migrated-from-install']) {
const path = resolve(root, name)
try {
const stat = await lstat(path)
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('profile_state_file_unsafe')
files.push(name)
} catch (err) { if (err.code !== 'ENOENT') throw err }
}
if (process.platform !== 'win32') {
await chmod(root, 0o700)
for (const name of files) await chmod(resolve(root, name), 0o600)
return { platform: process.platform, directoryMode: '0700', fileMode: '0600', files: files.length }
}
// LiteralPath + child-only environment avoids shell/path interpolation.
const script = `
$ErrorActionPreference = 'Stop'
$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
$system = [System.Security.Principal.SecurityIdentifier]::new('S-1-5-18')
$paths = @($env:UDS_ACL_ROOT)
$names = ConvertFrom-Json $env:UDS_ACL_FILES
foreach ($name in $names) { $paths += Join-Path $env:UDS_ACL_ROOT $name }
foreach ($path in $paths) {
$isDir = [System.IO.Directory]::Exists($path)
if ($isDir) {
$acl = [System.Security.AccessControl.DirectorySecurity]::new()
$inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
} else {
$acl = [System.Security.AccessControl.FileSecurity]::new()
$inherit = [System.Security.AccessControl.InheritanceFlags]::None
}
$acl.SetAccessRuleProtection($true, $false)
$acl.SetOwner($sid)
foreach ($principal in @($sid, $system)) {
$rule = [System.Security.AccessControl.FileSystemAccessRule]::new($principal, 'FullControl', $inherit, 'None', 'Allow')
$acl.AddAccessRule($rule)
}
if ($isDir) {
[System.IO.Directory]::SetAccessControl($path, $acl)
$check = [System.IO.Directory]::GetAccessControl($path)
} else {
[System.IO.File]::SetAccessControl($path, $acl)
$check = [System.IO.File]::GetAccessControl($path)
}
if (!$check.AreAccessRulesProtected) { throw 'profile_acl_inheritance' }
$rules = $check.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])
if ($rules.Count -ne 2) { throw 'profile_acl_rule_count' }
foreach ($rule in $rules) {
if ($rule.IdentityReference.Value -notin @($sid.Value, $system.Value) -or $rule.AccessControlType -ne 'Allow') { throw 'profile_acl_unexpected_principal' }
}
}
@{ platform = 'win32'; protected = $true; principals = @('current-user', 'SYSTEM'); files = $names.Count } | ConvertTo-Json -Compress
`
try {
const { stdout } = await run('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], {
windowsHide: true, timeout: 30_000, env: { ...process.env, UDS_ACL_ROOT: root, UDS_ACL_FILES: JSON.stringify(files) },
})
return JSON.parse(stdout.trim())
} catch (cause) {
throw Object.assign(new Error('profile_acl_failed'), { code: 'profile_acl_failed', cause })
}
}

View file

@ -0,0 +1,48 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
const digest = value => createHash('sha256').update(String(value || '')).digest()
export class QrChallengeStore {
constructor({ ttlMs = 120_000, maxEntries = 5000 } = {}) {
this.rows = new Map()
this.ttlMs = ttlMs
this.maxEntries = maxEntries
}
create({ loginSystemCode, originSystemCode }) {
for (const [key, row] of this.rows) if (row.expiresAt <= Date.now()) this.rows.delete(key)
if (this.rows.size >= this.maxEntries) throw Object.assign(new Error('qr_capacity'), { code: 'qr_capacity' })
const qrCodeKey = randomBytes(16).toString('hex')
const qrCodeValue = randomBytes(16).toString('hex')
// The QR contains only public scan coordinates, never the polling browser proof.
const browserBinding = randomBytes(32).toString('hex')
const expiresAt = Date.now() + this.ttlMs
this.rows.set(qrCodeKey, { qrCodeValue, bindingHash: digest(browserBinding), expiresAt,
loginSystemCode, originSystemCode, consumed: false })
return { qrCodeKey, qrCodeValue, browserBinding, expiresAt, loginSystemCode, originSystemCode,
qrCodeStr: `TwoDIMAuth:${qrCodeKey}:${qrCodeValue}` }
}
peek(key, value, binding) {
const row = this.rows.get(String(key || ''))
if (!row) return { ok: false, reason: 'qr_unknown' }
if (row.expiresAt <= Date.now()) { this.rows.delete(String(key)); return { ok: false, reason: 'qr_expired' } }
if (row.consumed) return { ok: false, reason: 'qr_replay' }
if (!timingSafeEqual(digest(value), digest(row.qrCodeValue))) return { ok: false, reason: 'qr_mismatch' }
if (typeof binding !== 'string' || !/^[a-f0-9]{64}$/.test(binding)
|| !timingSafeEqual(digest(binding), row.bindingHash)) return { ok: false, reason: 'qr_binding_required' }
return { ok: true, row }
}
consume(key, value, binding) {
const result = this.peek(key, value, binding)
if (!result.ok) return result
result.row.consumed = true
return { ok: true }
}
}
/** Known UAC contract: outer code + bo.code and credentials in other. */
export function qrLoginCredentials(payload) {
if (String(payload?.code?.code ?? payload?.code) !== '0000' || String(payload?.bo?.code) !== '0000') return null
const empNo = String(payload?.other?.account || payload?.other?.empNo || '').trim()
const token = String(payload?.other?.token || payload?.other?.authValue || '').trim()
if (!empNo || !token || empNo.length > 128 || token.length > 16384) return null
return { empNo, token }
}

View file

@ -1,21 +1,22 @@
/**
* uds-auth 角色存储 + 权限管理
*
* 角色(身份标签;admin 级权限相同):
* super_admin 身份:首位扫码 bootstrap / 显式提权;权限 = admin 级
* fallback_admin 身份:应急账号 administrator;权限 = admin 级
* 角色(身份标签;admin 级权限相同 — SEC-26 产品决策:当前同权):
* super_admin 身份:显式初始管理员 / 管理 API 提权;权限 = admin 级
* fallback_admin 身份:仅应急账号 administrator(不可经管理 API 分配);权限 = admin 级
* admin 身份:用户管理中提权;权限 = admin 级
* user 仅看自己会话,无设置 / 不可建工作区
*
* admin 级(isAdminClass)权限相同:用户管理、设置、建工作区、默认可看全部会话。
* 超管只是身份;默认可持有该身份的包括 admin,以及首位扫码加入者(bootstrap)。
* prefs.viewAllSessions === false 时关闭全览。
* 「超管」是身份标签,不是更强权限隔离;viewAllSessions 是显示偏好,非安全边界。
* 持久化: roles.json (roles + prefs + fallbackPasswordHash)
*/
import { createHash, randomBytes } from 'node:crypto'
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { createHash, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { ROLE_LABELS_ZH } from './i18n.js'
import { atomicWriteJson } from './utils/atomic-write.js'
import { hashPasswordAsync, verifyScryptAsync } from './utils/password-kdf.js'
function codedError(code) {
const err = new Error(code)
@ -23,6 +24,73 @@ function codedError(code) {
return err
}
const ACCOUNT_STATUS = {
ACTIVE: 'active',
DISABLED: 'disabled',
}
/** Known shared default from pre-0.3 installs — must never remain usable (R10). */
const LEGACY_DEFAULT_SHA256 = createHash('sha256').update('Admin@123').digest('hex')
/**
* R10: versioned scrypt password hash.
* Format: scrypt$N$r$p$saltB64$urlHashB64
* Legacy bare SHA-256 hex still verifies once, then must be rotated (except known default).
*/
function hashPasswordScrypt(password, saltBuf = randomBytes(16)) {
const N = 16384
const r = 8
const p = 1
const derived = scryptSync(String(password), saltBuf, 32, { N, r, p })
return [
'scrypt',
String(N),
String(r),
String(p),
saltBuf.toString('base64url'),
derived.toString('base64url'),
].join('$')
}
function verifyPasswordHash(password, stored) {
const s = String(stored || '')
if (!s) return false
if (s.startsWith('scrypt$')) {
const parts = s.split('$')
if (parts.length !== 6) return false
const N = Number(parts[1])
const r = Number(parts[2])
const p = Number(parts[3])
const salt = Buffer.from(parts[4], 'base64url')
const expect = Buffer.from(parts[5], 'base64url')
if (N !== 16384 || r !== 8 || p !== 1 || salt.length !== 16 || expect.length !== 32) return false
let derived
try {
derived = scryptSync(String(password), salt, expect.length, { N, r, p })
} catch {
return false
}
if (derived.length !== expect.length) return false
return timingSafeEqual(derived, expect)
}
// Legacy unsalted SHA-256 — reject known shared default outright.
if (/^[0-9a-f]{64}$/i.test(s)) {
if (s.toLowerCase() === LEGACY_DEFAULT_SHA256) return false
const got = createHash('sha256').update(String(password)).digest('hex')
try {
return timingSafeEqual(Buffer.from(got, 'hex'), Buffer.from(s, 'hex'))
} catch {
return false
}
}
return false
}
/** @deprecated keep name for call sites that still import hashPassword */
function hashPassword(password) {
return hashPasswordScrypt(password)
}
export const ROLES = {
SUPER_ADMIN: 'super_admin',
ADMIN: 'admin',
@ -73,11 +141,7 @@ export function canToggleViewAllSessions(role) {
return isAdminClass(role)
}
function hashPassword(password) {
return createHash('sha256').update(password).digest('hex')
}
/** 初始部署默认兜底密码(扫码不可用时用);可在设置里改密或关闭。 */
/** Historical default — never enable automatically (R10). */
export const DEFAULT_FALLBACK_PASSWORD = 'Admin@123'
export const DEFAULT_FALLBACK_USERNAME = 'administrator'
@ -85,35 +149,39 @@ export const DEFAULT_FALLBACK_USERNAME = 'administrator'
* RolesStore — 角色存储
* 内存 Map + 可选 JSON 文件持久化
*
* fallback admin: 默认启用,密码 Admin@123;roles.json 显式 null 表示已关闭。
* fallback admin: 默认关闭;须在设置中显式设置 scrypt 口令(≥10,禁 Admin@123)。
* 登录路径: POST /uds-auth/api/fallback/login { username, password }
*/
export class RolesStore {
constructor(options = {}) {
this._roles = new Map() // empNo → role
this._prefs = new Map() // empNo → { viewAllSessions?: boolean }
this._status = new Map() // empNo → active|disabled (R12)
this._firstBootLock = Promise.resolve()
this._mutateChain = Promise.resolve() // R09: serialize role mutations
this._saveChain = Promise.resolve()
this._rolesFile = options.rolesFile ? resolve(options.rolesFile) : null
this._fallbackPasswordHash = null // SHA-256 hex,null = 未启用
this._fallbackPasswordHash = null // scrypt$… or legacy hex;null = 未启用
this._fallbackNeedsRehash = false
this._fallbackCredVersion = 0 // F02: bumps on set/clear password
this._committedFallback = { hash: null, needs: false, ver: 0 }
this._fallbackRateLimit = new Map() // ip → { count, resetAt }
this._dirty = false
this._saveTimer = null
}
/**
* New installs do NOT enable a shared default password (SEC-08).
* Admins must set a password explicitly via settings / setFallbackPassword.
*/
_ensureDefaultFallback() {
if (this._fallbackPasswordHash) return
this._fallbackPasswordHash = hashPassword(DEFAULT_FALLBACK_PASSWORD)
this._markDirty()
console.info(
'[uds-auth] fallback_admin enabled by default'
+ ` (user=${DEFAULT_FALLBACK_USERNAME}, change password in settings)`,
)
// no-op — kept for call-site compatibility
}
// === 持久化 ===
async init() {
let loadedHash = undefined // undefined = missing / new file; null = explicitly cleared
let loadedOk = false
if (this._rolesFile) {
try {
const raw = await readFile(this._rolesFile, 'utf-8')
@ -127,27 +195,60 @@ export class RolesStore {
}
}
if (Object.prototype.hasOwnProperty.call(data, 'fallbackPasswordHash')) {
loadedHash = data.fallbackPasswordHash || null
if (loadedHash) this._fallbackPasswordHash = loadedHash
this._fallbackPasswordHash = data.fallbackPasswordHash || null
}
this._fallbackCredVersion = Number.isSafeInteger(data.fallbackCredVersion)
&& data.fallbackCredVersion >= 0 ? data.fallbackCredVersion : 0
for (const [empNo, st] of Object.entries(data.status || {})) {
if (st === ACCOUNT_STATUS.DISABLED || st === ACCOUNT_STATUS.ACTIVE) {
this._status.set(String(empNo), st)
}
}
loadedOk = true
} catch (err) {
if (err.code === 'ENOENT') {
// 首次启动,文件不存在 — 走默认兜底
// First boot — empty roles, fallback disabled until explicitly configured.
console.info(
'[uds-auth] roles file missing; fallback password disabled until configured'
+ ` (set initialAdmin via UDS_AUTH_INITIAL_ADMIN)`,
)
} else {
console.warn('[uds-auth:RolesStore] Failed to load roles file:', err.message)
// Corrupt roles must NOT restore a default password (SEC-08/21).
console.error('[uds-auth:RolesStore] Failed to load roles file — refusing default password:', err.message)
this._loadFailed = true
}
}
}
// 无持久化哈希(新部署或旧文件未写该字段)→ 默认开启;显式 null 表示超管已关闭
if (loadedHash === undefined && !this._fallbackPasswordHash) {
this._ensureDefaultFallback()
// R10: known shared default hash cannot remain enabled after upgrade.
if (loadedOk && this._fallbackPasswordHash
&& String(this._fallbackPasswordHash).toLowerCase() === LEGACY_DEFAULT_SHA256) {
console.warn('[uds-auth] refusing legacy shared default fallback password — disabled until reset')
this._fallbackPasswordHash = null
this._dirty = true
await this._save()
} else if (loadedOk && this._fallbackPasswordHash
&& /^[0-9a-f]{64}$/i.test(String(this._fallbackPasswordHash))) {
this._fallbackNeedsRehash = true
console.warn('[uds-auth] legacy SHA-256 fallback hash loaded — will upgrade on next successful login')
}
if (loadedOk && this._fallbackPasswordHash) {
console.info('[uds-auth] fallback_admin password loaded from roles file')
}
this._captureCommittedFallback()
}
get loadFailed() {
return !!this._loadFailed
}
_markDirty() {
this._dirty = true
if (this._saveTimer) return
this._saveTimer = setTimeout(() => { void this._save() }, 2000)
this._saveTimer = setTimeout(() => {
this._saveTimer = null
void this._withMutationLock(() => this.flush()).catch(() => {})
}, 2000)
this._saveTimer.unref?.()
}
/** Flush pending roles/prefs to disk immediately (e.g. view-all toggle). */
@ -160,26 +261,106 @@ export class RolesStore {
}
async _save() {
const run = this._saveChain.then(() => this._saveNow())
this._saveChain = run.catch(() => {})
return run
}
async _saveNow() {
this._saveTimer = null
if (!this._dirty || !this._rolesFile) return
const snapshot = {
roles: Object.fromEntries(this._roles),
prefs: Object.fromEntries(this._prefs),
status: Object.fromEntries(this._status),
fallbackPasswordHash: this._fallbackPasswordHash,
fallbackCredVersion: this._fallbackCredVersion,
savedAt: new Date().toISOString(),
}
this._dirty = false
try {
const data = {
roles: Object.fromEntries(this._roles),
prefs: Object.fromEntries(this._prefs),
fallbackPasswordHash: this._fallbackPasswordHash,
savedAt: new Date().toISOString(),
}
await mkdir(dirname(this._rolesFile), { recursive: true })
await writeFile(this._rolesFile, JSON.stringify(data, null, 2), 'utf-8')
await atomicWriteJson(this._rolesFile, snapshot, { mode: 0o600 })
} catch (err) {
this._dirty = true
console.warn('[uds-auth:RolesStore] Failed to save roles file:', err.message)
console.error('[uds-auth:RolesStore] Failed to save roles file:', err.message)
const e = new Error('roles_persist_failed')
e.code = 'roles_persist_failed'
e.cause = err
throw e
}
// Changes during await writeFile — schedule another save.
if (this._dirty) this._markDirty()
}
/** R09: serialize mutations that check last-admin invariants. */
_withMutationLock(fn) {
const transaction = async () => {
const previous = {
roles: new Map(this._roles), prefs: new Map(this._prefs), status: new Map(this._status),
hash: this._fallbackPasswordHash, needs: this._fallbackNeedsRehash,
ver: this._fallbackCredVersion,
}
this._transactionSnapshot = previous
try {
return await fn()
} catch (err) {
this._roles = previous.roles
this._prefs = previous.prefs
this._status = previous.status
this._fallbackPasswordHash = previous.hash
this._fallbackNeedsRehash = previous.needs
this._fallbackCredVersion = previous.ver
this._dirty = false
if (this._saveTimer) clearTimeout(this._saveTimer)
this._saveTimer = null
throw err
} finally {
this._transactionSnapshot = null
}
}
const run = this._mutateChain.then(transaction, transaction)
this._mutateChain = run.then(() => {}, () => {})
return run
}
getAccountStatus(empNo) {
const e = String(empNo || '').trim()
if (!e) return ACCOUNT_STATUS.ACTIVE
return (this._transactionSnapshot?.status || this._status).get(e) || ACCOUNT_STATUS.ACTIVE
}
isDisabled(empNo) {
return this.getAccountStatus(empNo) === ACCOUNT_STATUS.DISABLED
}
async setAccountStatus(empNo, status, currentAdminRole) {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) throw codedError('forbidden_set_role')
const e = String(empNo || '').trim()
if (!e) throw codedError('invalid_role_params')
if (status !== ACCOUNT_STATUS.ACTIVE && status !== ACCOUNT_STATUS.DISABLED) {
throw codedError('invalid_role_params')
}
return this._withMutationLock(async () => {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) throw codedError('forbidden_set_role')
if (status === ACCOUNT_STATUS.DISABLED && this._roles.get(e) === ROLES.SUPER_ADMIN
&& !this.isDisabled(e)) {
const n = await this.countActiveByRole(ROLES.SUPER_ADMIN)
if (n <= 1) throw codedError('last_super_admin_demote')
}
const prevStatus = this._status.get(e)
this._status.set(e, status)
this._dirty = true
try {
await this.flush()
} catch (err) {
if (prevStatus === undefined) this._status.delete(e)
else this._status.set(e, prevStatus)
throw err
}
return true
})
}
// === 首次部署 bootstrap ===
/**
@ -187,28 +368,28 @@ export class RolesStore {
* 返回 { role, bootstrapped } bootstrapped=true 表示本次是 bootstrap
*/
async bootstrapFirstUser(empNo) {
// 用锁保证原子性
this._firstBootLock = this._firstBootLock.then(async () => {
return this._withMutationLock(async () => {
if (this._roles.size === 0) {
this._roles.set(empNo, ROLES.SUPER_ADMIN)
this._markDirty()
this._dirty = true
await this.flush()
return { role: ROLES.SUPER_ADMIN, bootstrapped: true }
}
if (!this._roles.has(empNo)) {
this._roles.set(empNo, ROLES.USER)
this._markDirty()
this._dirty = true
await this.flush()
return { role: ROLES.USER, bootstrapped: false }
}
return { role: this._roles.get(empNo), bootstrapped: false }
})
return this._firstBootLock
}
// === 角色查询 ===
getRole(empNo) {
if (empNo === 'administrator') return ROLES.FALLBACK_ADMIN
return this._roles.get(empNo) || ROLES.USER
return (this._transactionSnapshot?.roles || this._roles).get(empNo) || ROLES.USER
}
/**
@ -217,7 +398,7 @@ export class RolesStore {
*/
isViewAllSessionsEnabled(empNo) {
if (!empNo) return false
const prefs = this._prefs.get(String(empNo))
const prefs = (this._transactionSnapshot?.prefs || this._prefs).get(String(empNo))
if (prefs && Object.prototype.hasOwnProperty.call(prefs, 'viewAllSessions')) {
return !!prefs.viewAllSessions
}
@ -244,6 +425,14 @@ export class RolesStore {
return true
}
async setViewAllSessionsDurable(empNo, enabled) {
return this._withMutationLock(async () => {
this.setViewAllSessions(empNo, enabled)
await this.flush()
return true
})
}
/** 角色 + 个人偏好 → 有效权限 */
resolvePermissions(empNo, role) {
const id = empNo != null ? String(empNo) : ''
@ -255,16 +444,16 @@ export class RolesStore {
}
hasRole(empNo) {
return this._roles.has(empNo)
return (this._transactionSnapshot?.roles || this._roles).has(empNo)
}
getAll() {
return Array.from(this._roles.entries()).map(([empNo, role]) => ({ empNo, role }))
return Array.from((this._transactionSnapshot?.roles || this._roles).entries()).map(([empNo, role]) => ({ empNo, role }))
}
/** 角色表是否为空(用于 bootstrap) */
isEmpty() {
return this._roles.size === 0
return (this._transactionSnapshot?.roles || this._roles).size === 0
}
/**
@ -276,7 +465,7 @@ export class RolesStore {
const pageSize = Math.min(200, Math.max(1, Number(opts.pageSize) || 50))
const q = String(opts.q || '').trim().toLowerCase()
let rows = Array.from(this._roles.entries()).map(([empNo, role]) => ({ empNo, role }))
let rows = Array.from((this._transactionSnapshot?.roles || this._roles).entries()).map(([empNo, role]) => ({ empNo, role }))
if (q) {
rows = rows.filter((r) => String(r.empNo).toLowerCase().includes(q)
|| String(ROLE_LABELS[r.role] || r.role).toLowerCase().includes(q))
@ -304,86 +493,258 @@ export class RolesStore {
return n
}
/** A07: count only active (non-disabled) accounts with the given role. */
async countActiveByRole(role) {
let n = 0
for (const [empNo, r] of this._roles) {
if (r !== role) continue
if (this.isDisabled(empNo)) continue
n++
}
return n
}
// === 角色管理 (admin 级) ===
/**
* 设置用户角色
* 保护性 invariant: 至少保留 1 个 super_admin 身份(若表中曾有)
* 保护性 invariant: 至少保留 1 个 active super_admin(若表中曾有)
*/
async setRole(empNo, newRole, currentAdminRole) {
if (!isAdminClass(currentAdminRole)) {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) {
throw codedError('forbidden_set_role')
}
// invariant: 不能让系统变成 0 个 super_admin(身份仍保留)
const currentRole = this._roles.get(empNo)
if (currentRole === ROLES.SUPER_ADMIN && newRole !== ROLES.SUPER_ADMIN) {
const superAdmins = await this.countByRole(ROLES.SUPER_ADMIN)
if (superAdmins <= 1) {
throw codedError('last_super_admin_demote')
}
// SEC-26: management API may only assign assignable roles — never fallback_admin.
const assignable = new Set([ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.USER])
if (!assignable.has(newRole)) {
throw codedError('invalid_role_params')
}
this._roles.set(empNo, newRole)
this._markDirty()
return true
return this._withMutationLock(async () => {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) throw codedError('forbidden_set_role')
const currentRole = this._roles.get(empNo)
if (currentRole === ROLES.SUPER_ADMIN && newRole !== ROLES.SUPER_ADMIN
&& !this.isDisabled(empNo)) {
const superAdmins = await this.countActiveByRole(ROLES.SUPER_ADMIN)
if (superAdmins <= 1) {
throw codedError('last_super_admin_demote')
}
}
const prev = this._roles.get(empNo)
const prevStatus = this._status.get(empNo)
this._roles.set(empNo, newRole)
// Re-activating via setRole clears disabled when admin assigns a role.
if (this._status.get(empNo) === ACCOUNT_STATUS.DISABLED) {
this._status.set(empNo, ACCOUNT_STATUS.ACTIVE)
}
this._dirty = true
try {
await this.flush()
} catch (err) {
if (prev === undefined) this._roles.delete(empNo)
else this._roles.set(empNo, prev)
// A07: roll back status as well as role on persist failure.
if (prevStatus === undefined) this._status.delete(empNo)
else this._status.set(empNo, prevStatus)
throw err
}
return true
})
}
/** 删除用户 */
async removeUser(empNo, currentAdminRole) {
if (!isAdminClass(currentAdminRole)) {
/**
* R12: disable account (preferred) — keeps record so open registration cannot re-add.
* Pass { hard: true } to delete role entry entirely.
*/
async removeUser(empNo, currentAdminRole, opts = {}) {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) {
throw codedError('forbidden_remove_user')
}
const currentRole = this._roles.get(empNo)
if (currentRole === ROLES.SUPER_ADMIN) {
const superAdmins = await this.countByRole(ROLES.SUPER_ADMIN)
if (superAdmins <= 1) {
throw codedError('last_super_admin_delete')
return this._withMutationLock(async () => {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) throw codedError('forbidden_remove_user')
const currentRole = this._roles.get(empNo)
if (currentRole === ROLES.SUPER_ADMIN && !this.isDisabled(empNo)) {
const superAdmins = await this.countActiveByRole(ROLES.SUPER_ADMIN)
if (superAdmins <= 1) {
throw codedError('last_super_admin_delete')
}
}
}
this._roles.delete(empNo)
this._prefs.delete(empNo)
this._markDirty()
return true
if (opts.hard === true) {
const prevRole = this._roles.get(empNo)
const prevPrefs = this._prefs.get(empNo)
const prevStatus = this._status.get(empNo)
this._roles.delete(empNo)
this._prefs.delete(empNo)
this._status.delete(empNo)
this._dirty = true
try {
await this.flush()
} catch (err) {
if (prevRole !== undefined) this._roles.set(empNo, prevRole)
if (prevPrefs !== undefined) this._prefs.set(empNo, prevPrefs)
if (prevStatus !== undefined) this._status.set(empNo, prevStatus)
throw err
}
} else {
const key = String(empNo)
const prevStatus = this._status.get(key)
this._status.set(key, ACCOUNT_STATUS.DISABLED)
this._dirty = true
try {
await this.flush()
} catch (err) {
if (prevStatus === undefined) this._status.delete(key)
else this._status.set(key, prevStatus)
throw err
}
}
return true
})
}
/** 确保用户存在 (如果不存在设为 user) */
ensureUser(empNo, currentAdminRole) {
if (!isAdminClass(currentAdminRole)) {
/**
* 确保用户存在 (如果不存在设为 user);disabled 账号不会被自动复活。
* F03: await flush before success — no silent delayed-only persist.
*/
async ensureUser(empNo, currentAdminRole) {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) {
throw codedError('forbidden_add_user')
}
if (!this._roles.has(empNo)) {
this._roles.set(empNo, ROLES.USER)
this._markDirty()
if (this.isDisabled(empNo)) {
throw codedError('account_disabled')
}
return true
return this._withMutationLock(async () => {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) throw codedError('forbidden_add_user')
if (this.isDisabled(empNo)) throw codedError('account_disabled')
if (!this._roles.has(empNo)) {
this._roles.set(empNo, ROLES.USER)
this._status.set(String(empNo), ACCOUNT_STATUS.ACTIVE)
this._dirty = true
try {
await this.flush()
} catch (err) {
this._roles.delete(empNo)
this._status.delete(String(empNo))
throw err
}
}
return true
})
}
// === Fallback Administrator ===
setFallbackPassword(password, currentAdminRole) {
if (!isAdminClass(currentAdminRole)) {
throw codedError('forbidden_set_fallback')
_ensureCommittedFallback() {
if (!this._committedFallback) {
this._committedFallback = {
hash: this._fallbackPasswordHash,
needs: this._fallbackNeedsRehash,
ver: this._fallbackCredVersion || 0,
}
}
if (!password || password.length < 6) {
throw codedError('password_too_short')
}
this._fallbackPasswordHash = hashPassword(password)
this._markDirty()
return true
}
clearFallbackPassword(currentAdminRole) {
if (!isAdminClass(currentAdminRole)) {
_restoreCommittedFallback() {
this._ensureCommittedFallback()
this._fallbackPasswordHash = this._committedFallback.hash
this._fallbackNeedsRehash = this._committedFallback.needs
this._fallbackCredVersion = this._committedFallback.ver
}
_captureCommittedFallback() {
this._committedFallback = {
hash: this._fallbackPasswordHash,
needs: this._fallbackNeedsRehash,
ver: this._fallbackCredVersion || 0,
}
}
/**
* D05/D06/F03: persist against last-committed snapshot.
* Failed writes restore the last successful commit (not a concurrent peer's
* uncommitted value), including credVersion.
*/
async setFallbackPassword(password, currentAdminRole) {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) {
throw codedError('forbidden_set_fallback')
}
if (String(password) === 'Admin@123') {
throw codedError('password_too_common')
}
if (typeof password !== 'string' || password.length < 10) {
throw codedError('password_too_short')
}
if (Buffer.byteLength(password) > 1024) throw codedError('password_invalid')
return this._withMutationLock(async () => {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) throw codedError('forbidden_set_fallback')
const newHash = await hashPasswordAsync(password)
this._fallbackPasswordHash = newHash
this._fallbackNeedsRehash = false
this._fallbackCredVersion++
this._dirty = true
await this.flush()
this._captureCommittedFallback()
return true
})
}
async clearFallbackPassword(currentAdminRole) {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) {
throw codedError('forbidden_clear_fallback')
}
this._fallbackPasswordHash = null
this._markDirty()
return true
return this._withMutationLock(async () => {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) throw codedError('forbidden_clear_fallback')
this._fallbackPasswordHash = null
this._fallbackNeedsRehash = false
this._fallbackCredVersion++
this._dirty = true
await this.flush()
this._captureCommittedFallback()
return true
})
}
/** Auth material version for password-fallback sessions (F02). */
getFallbackCredVersion() {
return this._transactionSnapshot?.ver ?? this._fallbackCredVersion ?? 0
}
isFallbackEnabled() {
return this._fallbackPasswordHash !== null
return (this._transactionSnapshot ? this._transactionSnapshot.hash : this._fallbackPasswordHash) !== null
}
/** Runtime path: asynchronous bounded KDF and recheck after a concurrent rotation. */
async verifyFallbackAsync(password, ip) {
if (typeof password !== 'string' || Buffer.byteLength(password) > 1024) return false
const hash = this._transactionSnapshot ? this._transactionSnapshot.hash : this._fallbackPasswordHash
const version = this.getFallbackCredVersion()
if (!hash) return false
const now = Date.now()
for (const [key, value] of this._fallbackRateLimit) {
if (value.resetAt <= now) this._fallbackRateLimit.delete(key)
}
let bucket = this._fallbackRateLimit.get(ip)
if (!bucket) {
if (this._fallbackRateLimit.size >= 5000) return false
this._fallbackRateLimit.set(ip, bucket = { count: 0, resetAt: now + 60_000 })
}
if (++bucket.count > 5) return false
const ok = String(hash).startsWith('scrypt$')
? await verifyScryptAsync(password, hash) : verifyPasswordHash(password, hash)
if (!ok) return false
// Serialize the final check with all durable credential changes.
return this._withMutationLock(async () => {
if (this._fallbackPasswordHash !== hash || this._fallbackCredVersion !== version) return false
if (!String(hash).startsWith('scrypt$')) {
this._fallbackPasswordHash = await hashPasswordAsync(password)
this._fallbackNeedsRehash = false
this._dirty = true
await this.flush()
this._captureCommittedFallback()
}
return true
})
}
/**
@ -405,6 +766,16 @@ export class RolesStore {
return false // rate limited
}
return hashPassword(password) === this._fallbackPasswordHash
const ok = verifyPasswordHash(password, this._fallbackPasswordHash)
if (ok && (this._fallbackNeedsRehash || !String(this._fallbackPasswordHash).startsWith('scrypt$'))) {
try {
this._fallbackPasswordHash = hashPasswordScrypt(password)
this._fallbackNeedsRehash = false
this._markDirty()
} catch { /* keep legacy until next save */ }
}
return ok
}
}
export { ACCOUNT_STATUS, hashPasswordScrypt, verifyPasswordHash, LEGACY_DEFAULT_SHA256 }

View file

@ -4,10 +4,11 @@
* Used with workspace/cwd checks in dsh-acl.js (owner OR own workspace).
* Missing owner must NOT deny access by itself (legacy sessions).
*/
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { getUserContext } from './context.js'
import { ROLES, computePermissions } from './roles.js'
import { atomicWriteJson } from './utils/atomic-write.js'
export class SessionAclStore {
/**
@ -29,12 +30,17 @@ export class SessionAclStore {
this._owners.set(String(sessionId), String(empNo))
}
} catch (err) {
if (err.code !== 'ENOENT') {
console.warn('[uds-auth:SessionAcl] load failed:', err.message)
}
if (err.code === 'ENOENT') return
// R09: corrupt ownership must fail closed — do not treat as empty/unowned.
console.error('[uds-auth:SessionAcl] load failed — fail closed:', err.message)
this._loadFailed = true
}
}
get loadFailed() {
return !!this._loadFailed
}
_markDirty() {
this._dirty = true
if (this._saveTimer) return
@ -46,16 +52,12 @@ export class SessionAclStore {
if (!this._dirty || !this._ownersFile) return
this._dirty = false
try {
await mkdir(dirname(this._ownersFile), { recursive: true })
await writeFile(
this._ownersFile,
JSON.stringify({
owners: Object.fromEntries(this._owners),
savedAt: new Date().toISOString(),
}, null, 2),
'utf-8',
)
await atomicWriteJson(this._ownersFile, {
owners: Object.fromEntries(this._owners),
savedAt: new Date().toISOString(),
}, { mode: 0o600 })
} catch (err) {
this._dirty = true
console.warn('[uds-auth:SessionAcl] save failed:', err.message)
}
}
@ -64,10 +66,21 @@ export class SessionAclStore {
return this._owners.get(String(sessionId)) || null
}
setOwner(sessionId, empNo) {
if (!sessionId || !empNo) return
this._owners.set(String(sessionId), String(empNo))
/**
* Stamp owner. Existing foreign owner is NOT overwritten unless force=true (SEC-13).
* @returns {boolean} true if written / already same owner
*/
setOwner(sessionId, empNo, { force = false } = {}) {
if (!sessionId || !empNo) return false
const id = String(sessionId)
const next = String(empNo)
const existing = this._owners.get(id)
if (existing && String(existing) !== next && !force) {
return false
}
this._owners.set(id, next)
this._markDirty()
return true
}
/** All stamped owners for export / analytics. */

View file

@ -5,8 +5,8 @@
* localStorage and sends X-UDS-Bridge-* headers on subsequent /uds-auth requests.
*/
import { randomBytes, timingSafeEqual, createHash } from 'node:crypto'
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { dirname } from 'node:path'
import { readFile } from 'node:fs/promises'
import { atomicWriteJson } from './utils/atomic-write.js'
export const BRIDGE_EMPNO_HEADER = 'x-uds-bridge-empno'
export const BRIDGE_TOKEN_HEADER = 'x-uds-bridge-token'
@ -30,6 +30,8 @@ export class SessionBridgeStore {
this._ttlMs = opts.ttlMs || DEFAULT_TTL_MS
/** @type {Map<string, { empNo: string, kind: string, exp: number, ssoToken?: string }>} */
this._byToken = new Map()
/** @type {Map<string, { empNo: string, bearer: string, exp: number }>} T01: one-time WS tickets */
this._wsTickets = new Map()
this._saveTimer = null
}
@ -72,30 +74,31 @@ export class SessionBridgeStore {
this._byToken.delete(token)
continue
}
// Persist bridge metadata only — strip upstream SSO token from disk (SEC-17).
tokens[token] = {
empNo: row.empNo,
kind: row.kind,
exp: row.exp,
...(row.ssoToken ? { ssoToken: row.ssoToken } : {}),
}
}
try {
await mkdir(dirname(this._file), { recursive: true })
await writeFile(this._file, JSON.stringify({ tokens }, null, 2), 'utf-8')
await atomicWriteJson(this._file, { tokens }, { mode: 0o600 })
} catch (err) {
console.warn('[uds-auth:SessionBridge] save failed:', err.message)
}
}
/**
* @param {{ empNo: string, kind?: string, ssoToken?: string, ttlMs?: number }} row
* @param {{ empNo: string, kind?: string, ssoToken?: string, ttlMs?: number, token?: string }} row
* @returns {{ empNo: string, kind: string, token: string, exp: number }}
*/
mint(row) {
const empNo = String(row.empNo || '').trim()
if (!empNo) throw new Error('bridge_empNo_required')
const kind = String(row.kind || 'fallback')
const token = randomBytes(32).toString('hex')
// Prefer caller-supplied token (local session bearer) so bridge ≡ session proof.
const token = row.token ? String(row.token) : randomBytes(32).toString('hex')
if (token.length < 32) throw new Error('bridge_token_too_short')
const exp = Date.now() + (row.ttlMs || this._ttlMs)
this._byToken.set(token, {
empNo,
@ -127,6 +130,33 @@ export class SessionBridgeStore {
return { empNo: row.empNo, kind: row.kind, ssoToken: row.ssoToken }
}
/**
* Slide bridge expiry with its local session (bridge token ≡ session bearer).
* Without this the bridge died at mint+TTL even while the session stayed active,
* leaving cookie-less Desktop (and emergency admins, who have no portal SSO
* re-mint path) anonymous on remote.mux.
* @param {string} token
* @param {number} [ttlMs]
* @returns {boolean}
*/
touch(token, ttlMs) {
const t = String(token || '').trim()
if (!t) return false
const row = this._byToken.get(t)
if (!row) return false
const now = Date.now()
if (row.exp <= now) {
this._byToken.delete(t)
this._scheduleSave()
return false
}
const next = now + (Number(ttlMs) > 0 ? Number(ttlMs) : this._ttlMs)
if (next <= row.exp) return true
row.exp = next
this._scheduleSave()
return true
}
/** Clear all bridge tokens for an empNo (logout). */
revokeEmpNo(empNo) {
const e = String(empNo || '').trim()
@ -141,11 +171,63 @@ export class SessionBridgeStore {
if (changed) this._scheduleSave()
}
/**
* D04: revoke only bridges whose kind matches (password vs sealed_box).
* @param {string} empNo
* @param {string|string[]} kinds
*/
revokeEmpNoKinds(empNo, kinds) {
const e = String(empNo || '').trim()
const allow = new Set(Array.isArray(kinds) ? kinds.map(String) : [String(kinds)])
if (!e || !allow.size) return 0
let n = 0
for (const [token, row] of this._byToken) {
if (row.empNo === e && allow.has(String(row.kind || 'fallback'))) {
this._byToken.delete(token)
n++
}
}
if (n) this._scheduleSave()
return n
}
revokeToken(token) {
const t = String(token || '').trim()
if (!t) return
if (this._byToken.delete(t)) this._scheduleSave()
}
/**
* T01: short-TTL, single-use WS handshake ticket (not a reusable session bearer).
* @param {{ empNo: string, bearer: string, ttlMs?: number }} input
*/
mintWsTicket(input) {
const empNo = String(input?.empNo || '').trim()
const bearer = String(input?.bearer || '').trim()
if (!empNo || bearer.length < 16) throw new Error('ws_ticket_input_required')
const now = Date.now()
for (const [k, v] of this._wsTickets) if (v.exp <= now) this._wsTickets.delete(k)
if (this._wsTickets.size >= 5000) throw new Error('ws_ticket_capacity')
const ttl = Math.min(120_000, Math.max(15_000, Number(input.ttlMs) || 60_000))
const ticket = randomBytes(24).toString('hex')
const exp = Date.now() + ttl
this._wsTickets.set(ticket, { empNo, bearer, exp })
return { ticket, empNo, expiresAt: exp, expiresIn: Math.floor(ttl / 1000) }
}
/**
* Atomically consume a WS ticket. Replay returns null.
* @returns {{ empNo: string, bearer: string } | null}
*/
consumeWsTicket(ticket) {
const id = String(ticket || '').trim()
if (!id) return null
const row = this._wsTickets.get(id)
if (!row) return null
this._wsTickets.delete(id)
if (row.exp <= Date.now()) return null
return { empNo: row.empNo, bearer: row.bearer }
}
}
/**
@ -163,34 +245,25 @@ export function readBridgeHeaders(req) {
}
/**
* Headers first, then WebSocket upgrade query
* (`?udsBridgeEmpNo=&udsBridgeToken=&udsBridgeKind=`).
* Browser WS cannot set custom headers; Desktop also drops cookies.
* Headers only for reusable bridge credentials.
* T01: WS upgrade must use one-time `udsWsTicket` (see extractSessionBearer) —
* long-lived bridge tokens in the URL query are ignored.
* @param {any} req
* @returns {{ empNo: string, token: string, kind: string } | null}
*/
export function readBridgeFromRequest(req) {
const fromHdr = readBridgeHeaders(req)
if (fromHdr) return fromHdr
return readBridgeHeaders(req)
}
/**
* Read one-time WS ticket id from upgrade URL (does not consume).
* @param {any} req
* @returns {string|null}
*/
export function readWsTicketFromRequest(req) {
try {
const url = new URL(req?.url || '/', 'http://uds-auth.local')
const empNo = String(
url.searchParams.get('udsBridgeEmpNo')
|| url.searchParams.get('x-uds-bridge-empno')
|| '',
).trim()
const token = String(
url.searchParams.get('udsBridgeToken')
|| url.searchParams.get('x-uds-bridge-token')
|| '',
).trim()
const kind = String(
url.searchParams.get('udsBridgeKind')
|| url.searchParams.get('x-uds-bridge-kind')
|| 'fallback',
).trim() || 'fallback'
if (!empNo || !token) return null
return { empNo, token, kind }
return String(url.searchParams.get('udsWsTicket') || '').trim() || null
} catch {
return null
}

View file

@ -4,9 +4,10 @@ import { RedisStore } from './redis-store.js'
/**
* Factory function to create appropriate session store based on config
* @param {object} config - Session configuration
* @param {{ file?: string, dataDir?: string, logger?: any }} [persist] - MemoryStore disk persistence
* @returns {Promise<SessionStore>}
*/
export async function createSessionStore(config) {
export async function createSessionStore(config, persist = {}) {
const { storeType, redisUrl } = config
if (storeType === 'redis') {
@ -18,10 +19,14 @@ export async function createSessionStore(config) {
} catch (err) {
console.error('[uds-auth] Failed to connect to Redis:', err.message)
console.warn('[uds-auth] Falling back to MemoryStore')
return new MemoryStore()
const store = new MemoryStore(persist)
await store.init()
return store
}
}
// Default to memory store
return new MemoryStore()
// Default to memory store (persisted to the profile data dir when configured)
const store = new MemoryStore(persist)
await store.init()
return store
}

View file

@ -1,61 +1,440 @@
import { SessionStore } from './store.js'
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { SessionStore, SESSION_ABSOLUTE_MAX_SECONDS } from './store.js'
import { atomicWriteJson } from '../utils/atomic-write.js'
import { loadOrCreateSecretKey, sealSecret, openSecret } from '../utils/secret-box.js'
function hashBearer(bearer) {
return createHash('sha256').update(String(bearer)).digest('hex')
}
function safeEqualHex(a, b) {
if (!a || !b || a.length !== b.length) return false
try {
return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'))
} catch {
return false
}
}
/**
* In-memory session store — 单索引 empNo → userData
* 一个工号 = 一个会话,多终端同工号共享同一条目。
* In-memory auth session store, optionally persisted so a Host restart does not
* log everyone out (emergency admins have no SSO re-mint path).
* Primary key: sessionId. Auth proof: bearer (stored as SHA-256 hash only).
* empNo is a secondary index for logout-all / listing — not an auth credential.
*
* Persistence (opts.file + opts.dataDir): bearer hashes and expiry in clear,
* userData (may carry the UAC token) sealed with the plugin AES-GCM key.
*/
export class MemoryStore extends SessionStore {
constructor() {
/**
* @param {{ file?: string, dataDir?: string, logger?: any }} [opts]
*/
constructor(opts = {}) {
super()
this._sessions = new Map() // Map<empNo, { userData, expiresAt }>
this._timers = new Map() // Map<empNo, Timeout>
this._file = opts.file || null
this._dataDir = opts.dataDir || null
this._logger = opts.logger || console
this._key = null
this._saveTimer = null
/** @type {Map<string, object>} sessionId → record */
this._sessions = new Map()
/** @type {Map<string, string>} bearerHash → sessionId */
this._byBearer = new Map()
/** @type {Map<string, Set<string>>} empNo → sessionIds */
this._byEmpNo = new Map()
/** @type {Map<string, object>} empNo → profile (cache only) */
this._profiles = new Map()
/** @type {Map<string, NodeJS.Timeout>} */
this._timers = new Map()
}
async get(empNo) {
const session = this._sessions.get(empNo)
if (!session) return null
if (session.expiresAt && Date.now() > session.expiresAt) {
await this.delete(empNo)
/** Load persisted sessions (if configured). Never throws: bad state is dropped. */
async init() {
if (!this._file) return
let raw = null
try {
raw = JSON.parse(await readFile(this._file, 'utf-8'))
} catch (err) {
if (err?.code !== 'ENOENT') this._logger.warn?.('[uds-auth:sessions] load failed, starting empty:', err.message)
}
const rows = Array.isArray(raw?.sessions) ? raw.sessions : []
try {
this._key = await loadOrCreateSecretKey(this._dataDir || '.', { hasEncryptedData: rows.length > 0 })
} catch (err) {
this._logger.warn?.('[uds-auth:sessions] secret key unavailable, sessions will not persist:', err.code || err.message)
this._file = null
return
}
const now = Date.now()
let restored = 0
for (const r of rows) {
if (!r?.sessionId || !r.bearerHash || !r.empNo) continue
if (!(r.expiresAt > now) || !(r.absoluteExpiresAt > now)) continue
const plain = openSecret(this._key, r.userData)
if (plain == null) continue
let userData
try { userData = JSON.parse(plain) } catch { continue }
const record = {
sessionId: String(r.sessionId),
bearerHash: String(r.bearerHash),
empNo: String(r.empNo),
kind: String(r.kind || 'uds'),
userData,
createdAt: Number(r.createdAt) || now,
expiresAt: Number(r.expiresAt),
absoluteExpiresAt: Number(r.absoluteExpiresAt),
}
this._sessions.set(record.sessionId, record)
this._byBearer.set(record.bearerHash, record.sessionId)
if (!this._byEmpNo.has(record.empNo)) this._byEmpNo.set(record.empNo, new Set())
this._byEmpNo.get(record.empNo).add(record.sessionId)
this._scheduleExpiry(record.sessionId, Math.ceil((record.expiresAt - now) / 1000))
if (userData.displayName || userData.username) {
this._profiles.set(record.empNo, {
empNo: record.empNo,
username: userData.username,
displayName: userData.displayName,
department: userData.department,
organization: userData.organization,
email: userData.email,
phone: userData.phone,
})
}
restored++
}
if (restored !== rows.length) this._scheduleSave()
if (restored) this._logger.info?.('[uds-auth:sessions] restored %d session(s)', restored)
}
_scheduleSave() {
if (!this._file || !this._key || this._saveTimer) return
this._saveTimer = setTimeout(() => {
this._saveTimer = null
void this.flush()
}, 200)
if (typeof this._saveTimer.unref === 'function') this._saveTimer.unref()
}
/** Write current sessions now (also used on shutdown / tests). */
async flush() {
if (!this._file || !this._key) return
if (this._saveTimer) {
clearTimeout(this._saveTimer)
this._saveTimer = null
}
const now = Date.now()
const sessions = []
for (const row of this._sessions.values()) {
if (!(row.expiresAt > now) || !(row.absoluteExpiresAt > now)) continue
sessions.push({
sessionId: row.sessionId,
bearerHash: row.bearerHash,
empNo: row.empNo,
kind: row.kind,
createdAt: row.createdAt,
expiresAt: row.expiresAt,
absoluteExpiresAt: row.absoluteExpiresAt,
userData: sealSecret(this._key, JSON.stringify(row.userData || {})),
})
}
try {
await atomicWriteJson(this._file, { version: 1, sessions }, { mode: 0o600 })
} catch (err) {
this._logger.warn?.('[uds-auth:sessions] save failed:', err.message)
}
}
_purgeExpired(sessionId) {
const row = this._sessions.get(sessionId)
if (!row) return null
if (row.expiresAt && Date.now() > row.expiresAt) {
this._removeRecord(sessionId, row)
return null
}
return { ...session.userData }
return row
}
async set(empNo, userData) {
this._sessions.set(empNo, { userData })
}
async setex(empNo, seconds, userData) {
this._sessions.set(empNo, { userData, expiresAt: Date.now() + seconds * 1000 })
if (this._timers.has(empNo)) clearTimeout(this._timers.get(empNo))
this._timers.set(empNo, setTimeout(() => this._sessions.delete(empNo), seconds * 1000))
}
async delete(empNo) {
if (this._timers.has(empNo)) {
clearTimeout(this._timers.get(empNo))
this._timers.delete(empNo)
_removeRecord(sessionId, row) {
if (this._sessions.has(sessionId)) this._scheduleSave()
this._sessions.delete(sessionId)
if (row?.bearerHash) this._byBearer.delete(row.bearerHash)
if (row?.empNo) {
const set = this._byEmpNo.get(row.empNo)
if (set) {
set.delete(sessionId)
if (set.size === 0) this._byEmpNo.delete(row.empNo)
}
}
if (this._timers.has(sessionId)) {
clearTimeout(this._timers.get(sessionId))
this._timers.delete(sessionId)
}
this._sessions.delete(empNo)
}
async has(empNo) {
const session = this._sessions.get(empNo)
if (!session) return false
if (session.expiresAt && Date.now() > session.expiresAt) {
await this.delete(empNo)
return false
_scheduleExpiry(sessionId, ttlSeconds) {
if (this._timers.has(sessionId)) {
clearTimeout(this._timers.get(sessionId))
this._timers.delete(sessionId)
}
if (!ttlSeconds || ttlSeconds <= 0) return
const timer = setTimeout(() => {
const row = this._sessions.get(sessionId)
if (row) this._removeRecord(sessionId, row)
}, ttlSeconds * 1000)
// Do not keep the process alive solely for session TTLs (tests / short-lived hosts).
if (typeof timer.unref === 'function') timer.unref()
this._timers.set(sessionId, timer)
}
_publicRecord(row) {
if (!row) return null
return {
sessionId: row.sessionId,
empNo: row.empNo,
kind: row.kind,
userData: { ...row.userData },
createdAt: row.createdAt,
expiresAt: row.expiresAt,
absoluteExpiresAt: row.absoluteExpiresAt,
}
}
async create(userData, ttlSeconds = 1800) {
const empNo = String(userData?.empNo || '').trim()
if (!empNo) throw new Error('session_empNo_required')
const sessionId = randomBytes(16).toString('hex')
const bearer = randomBytes(32).toString('hex')
const bearerHash = hashBearer(bearer)
const now = Date.now()
const ttl = Math.max(60, Number(ttlSeconds) || 1800)
// Idle TTL slides via touch(); absolute lifetime is a fixed 7 days. (Was min(ttl, 7d),
// which capped every session at its 30-min idle TTL and made sliding a no-op.)
const absoluteCap = SESSION_ABSOLUTE_MAX_SECONDS
const record = {
sessionId,
bearerHash,
empNo,
kind: (() => {
const mode = String(userData?.authMode || '')
if (mode.includes('local-admin') || mode === 'sealed_box' || mode === 'local_admin') {
return 'sealed_box'
}
if (mode.includes('fallback') || mode === 'fallback-password' || mode === 'fallback-login') {
return 'fallback'
}
if (empNo === 'administrator') return 'fallback'
return 'uds'
})(),
userData: { ...userData },
createdAt: now,
expiresAt: now + ttl * 1000,
absoluteExpiresAt: now + absoluteCap * 1000,
}
this._sessions.set(sessionId, record)
this._byBearer.set(bearerHash, sessionId)
if (!this._byEmpNo.has(empNo)) this._byEmpNo.set(empNo, new Set())
this._byEmpNo.get(empNo).add(sessionId)
this._scheduleExpiry(sessionId, ttl)
this._scheduleSave()
if (userData.displayName || userData.username) {
this._profiles.set(empNo, {
empNo,
username: userData.username,
displayName: userData.displayName,
department: userData.department,
organization: userData.organization,
email: userData.email,
phone: userData.phone,
})
}
return { sessionId, bearer, record: this._publicRecord(record) }
}
_lookupBearer(bearer) {
const t = String(bearer || '').trim()
if (!t || t.length < 16) return null
const sessionId = this._byBearer.get(hashBearer(t))
if (!sessionId) return null
const row = this._purgeExpired(sessionId)
if (!row) return null
if (!safeEqualHex(row.bearerHash, hashBearer(t))) return null
if (row.absoluteExpiresAt && Date.now() > row.absoluteExpiresAt) {
this._removeRecord(sessionId, row)
return null
}
return row
}
async getByBearer(bearer) {
return this._publicRecord(this._lookupBearer(bearer))
}
getByBearerSync(bearer) {
return this._publicRecord(this._lookupBearer(bearer))
}
async getBySessionId(sessionId) {
const id = String(sessionId || '').trim()
if (!id) return null
return this._publicRecord(this._purgeExpired(id))
}
getBySessionIdSync(sessionId) {
const id = String(sessionId || '').trim()
if (!id) return null
return this._publicRecord(this._purgeExpired(id))
}
async touch(sessionId, ttlSeconds) {
const row = this._purgeExpired(String(sessionId || ''))
if (!row) return null
const ttl = Math.max(60, Number(ttlSeconds) || 1800)
const absLeft = row.absoluteExpiresAt
? Math.max(0, Math.floor((row.absoluteExpiresAt - Date.now()) / 1000))
: ttl
const nextTtl = Math.min(ttl, absLeft || ttl)
if (nextTtl <= 0) {
this._removeRecord(row.sessionId, row)
return null
}
row.expiresAt = Date.now() + nextTtl * 1000
row.userData = {
...row.userData,
lastActiveAt: new Date().toISOString(),
}
this._scheduleExpiry(row.sessionId, nextTtl)
this._scheduleSave()
return this._publicRecord(row)
}
async revokeSession(sessionId) {
const row = this._sessions.get(String(sessionId || ''))
if (!row) return false
this._removeRecord(row.sessionId, row)
return true
}
async revokeBearer(bearer) {
const row = this._lookupBearer(bearer)
if (!row) return false
this._removeRecord(row.sessionId, row)
return true
}
async revokeEmpNo(empNo) {
const e = String(empNo || '').trim()
const set = this._byEmpNo.get(e)
if (!set) return 0
let n = 0
for (const sid of [...set]) {
const row = this._sessions.get(sid)
if (row) {
this._removeRecord(sid, row)
n++
}
}
return n
}
/**
* D04: revoke only sessions matching predicate (e.g. password-mode, not sealed-box).
* @param {string} empNo
* @param {(row: object) => boolean} predicate
*/
async revokeEmpNoMatching(empNo, predicate) {
const e = String(empNo || '').trim()
const set = this._byEmpNo.get(e)
if (!set || typeof predicate !== 'function') return 0
let n = 0
for (const sid of [...set]) {
const row = this._sessions.get(sid)
if (row && predicate(row)) {
this._removeRecord(sid, row)
n++
}
}
return n
}
async setProfile(empNo, profile) {
const e = String(empNo || '').trim()
if (!e || !profile) return
this._profiles.set(e, { ...profile, empNo: e })
}
async getProfile(empNo) {
const row = this._profiles.get(String(empNo || '').trim())
return row ? { ...row } : null
}
async clear() {
this._scheduleSave()
for (const t of this._timers.values()) clearTimeout(t)
this._timers.clear()
this._sessions.clear()
this._byBearer.clear()
this._byEmpNo.clear()
this._profiles.clear()
}
/**
* Legacy: return first active session userData for empNo (profile-like).
* Does NOT prove the caller is authenticated as that empNo.
*/
async get(empNo) {
const e = String(empNo || '').trim()
const set = this._byEmpNo.get(e)
if (!set || set.size === 0) return null
for (const sid of set) {
const row = this._purgeExpired(sid)
if (row) return { ...row.userData, _sessionId: row.sessionId }
}
return null
}
/**
* Legacy compat: create a new session for empNo (multi-device safe).
* Returns the public userData; caller should also capture bearer via create().
*/
async setex(empNo, seconds, userData) {
const data = { ...userData, empNo: String(empNo) }
const { bearer, sessionId, record } = await this.create(data, seconds)
// Stash last mint so login handlers can read bearer without API break.
this._lastMint = { empNo: String(empNo), bearer, sessionId }
return record.userData
}
takeLastMint() {
const m = this._lastMint || null
this._lastMint = null
return m
}
async delete(empNo) {
await this.revokeEmpNo(empNo)
}
async has(empNo) {
const e = String(empNo || '').trim()
const set = this._byEmpNo.get(e)
if (!set) return false
for (const sid of set) {
if (this._purgeExpired(sid)) return true
}
return false
}
async keys() {
return Array.from(this._sessions.keys())
const out = new Set()
for (const [empNo, set] of this._byEmpNo) {
for (const sid of set) {
if (this._purgeExpired(sid)) {
out.add(empNo)
break
}
}
}
return [...out]
}
}

View file

@ -1,47 +1,226 @@
import { SessionStore } from './store.js'
import { createHash, randomBytes } from 'node:crypto'
import { SessionStore, SESSION_ABSOLUTE_MAX_SECONDS } from './store.js'
function hashBearer(bearer) {
return createHash('sha256').update(String(bearer)).digest('hex')
}
/**
* Redis-backed session store (for production / 多实例)
* Requires 'redis' package: npm install redis
* Key: uds-session:{empNo}
* Redis-backed auth session store.
* Keys: uds-sess:{sessionId}, uds-bearer:{hash} → sessionId, uds-emp:{empNo} set
*/
export class RedisStore extends SessionStore {
constructor(redisClient) {
super()
if (!redisClient) throw new Error('Redis client is required')
this._client = redisClient
this._lastMint = null
}
_key(empNo) { return `uds-session:${empNo}` }
_sessKey(id) { return `uds-sess:${id}` }
_bearerKey(hash) { return `uds-bearer:${hash}` }
_empKey(empNo) { return `uds-emp:${empNo}` }
_profileKey(empNo) { return `uds-profile:${empNo}` }
async get(empNo) {
const data = await this._client.get(this._key(empNo))
return data ? JSON.parse(data) : null
async create(userData, ttlSeconds = 1800) {
const empNo = String(userData?.empNo || '').trim()
if (!empNo) throw new Error('session_empNo_required')
const sessionId = randomBytes(16).toString('hex')
const bearer = randomBytes(32).toString('hex')
const bearerHash = hashBearer(bearer)
const now = Date.now()
const ttl = Math.max(60, Number(ttlSeconds) || 1800)
const record = {
sessionId,
bearerHash,
empNo,
kind: (() => {
const mode = String(userData?.authMode || '')
if (mode.includes('local-admin') || mode === 'sealed_box' || mode === 'local_admin') {
return 'sealed_box'
}
if (mode.includes('fallback') || mode === 'fallback-password' || mode === 'fallback-login') {
return 'fallback'
}
if (empNo === 'administrator') return 'fallback'
return 'uds'
})(),
userData: { ...userData },
createdAt: now,
expiresAt: now + ttl * 1000,
absoluteExpiresAt: now + SESSION_ABSOLUTE_MAX_SECONDS * 1000,
}
const payload = JSON.stringify(record)
await this._client.setEx(this._sessKey(sessionId), ttl, payload)
await this._client.setEx(this._bearerKey(bearerHash), ttl, sessionId)
await this._client.sAdd(this._empKey(empNo), sessionId)
await this._client.expire(this._empKey(empNo), ttl)
this._lastMint = { empNo, bearer, sessionId }
return {
sessionId,
bearer,
record: {
sessionId,
empNo,
kind: record.kind,
userData: { ...userData },
createdAt: record.createdAt,
expiresAt: record.expiresAt,
absoluteExpiresAt: record.absoluteExpiresAt,
},
}
}
async set(empNo, userData) {
await this._client.set(this._key(empNo), JSON.stringify(userData))
async _load(sessionId) {
if (!sessionId) return null
const raw = await this._client.get(this._sessKey(sessionId))
if (!raw) return null
try {
const row = JSON.parse(raw)
if (row.absoluteExpiresAt && Date.now() > row.absoluteExpiresAt) {
await this.revokeSession(sessionId)
return null
}
return row
} catch {
return null
}
}
async setex(empNo, seconds, userData) {
await this._client.setEx(this._key(empNo), seconds, JSON.stringify(userData))
_public(row) {
if (!row) return null
return {
sessionId: row.sessionId,
empNo: row.empNo,
kind: row.kind,
userData: { ...row.userData },
createdAt: row.createdAt,
expiresAt: row.expiresAt,
absoluteExpiresAt: row.absoluteExpiresAt,
}
}
async delete(empNo) {
await this._client.del(this._key(empNo))
async getByBearer(bearer) {
const t = String(bearer || '').trim()
if (!t || t.length < 16) return null
const sessionId = await this._client.get(this._bearerKey(hashBearer(t)))
return this._public(await this._load(sessionId))
}
async has(empNo) {
return (await this._client.exists(this._key(empNo))) === 1
getByBearerSync() {
// Redis cannot sync-read; callers must use async path or MemoryStore.
return null
}
async getBySessionId(sessionId) {
return this._public(await this._load(String(sessionId || '').trim()))
}
async touch(sessionId, ttlSeconds) {
const row = await this._load(sessionId)
if (!row) return null
const ttl = Math.max(60, Number(ttlSeconds) || 1800)
const absLeft = row.absoluteExpiresAt
? Math.max(0, Math.floor((row.absoluteExpiresAt - Date.now()) / 1000))
: ttl
const nextTtl = Math.min(ttl, absLeft || ttl)
if (nextTtl <= 0) {
await this.revokeSession(sessionId)
return null
}
row.expiresAt = Date.now() + nextTtl * 1000
row.userData = { ...row.userData, lastActiveAt: new Date().toISOString() }
await this._client.setEx(this._sessKey(sessionId), nextTtl, JSON.stringify(row))
await this._client.setEx(this._bearerKey(row.bearerHash), nextTtl, sessionId)
return this._public(row)
}
async revokeSession(sessionId) {
const row = await this._load(sessionId)
if (!row) {
await this._client.del(this._sessKey(sessionId))
return false
}
await this._client.del(this._sessKey(sessionId))
await this._client.del(this._bearerKey(row.bearerHash))
await this._client.sRem(this._empKey(row.empNo), sessionId)
return true
}
async revokeBearer(bearer) {
const t = String(bearer || '').trim()
if (!t) return false
const sessionId = await this._client.get(this._bearerKey(hashBearer(t)))
if (!sessionId) return false
return this.revokeSession(sessionId)
}
async revokeEmpNo(empNo) {
const e = String(empNo || '').trim()
const members = await this._client.sMembers(this._empKey(e))
let n = 0
for (const sid of members || []) {
if (await this.revokeSession(sid)) n++
}
await this._client.del(this._empKey(e))
return n
}
async setProfile(empNo, profile) {
const e = String(empNo || '').trim()
if (!e || !profile) return
await this._client.set(this._profileKey(e), JSON.stringify({ ...profile, empNo: e }))
}
async getProfile(empNo) {
const raw = await this._client.get(this._profileKey(String(empNo || '').trim()))
if (!raw) return null
try { return JSON.parse(raw) } catch { return null }
}
async clear() {
const keys = await this._client.keys('uds-session:*')
if (keys.length > 0) await this._client.del(keys)
const keys = await this._client.keys('uds-sess:*')
const bkeys = await this._client.keys('uds-bearer:*')
const ekeys = await this._client.keys('uds-emp:*')
const pkeys = await this._client.keys('uds-profile:*')
const all = [...keys, ...bkeys, ...ekeys, ...pkeys]
if (all.length) await this._client.del(all)
}
async get(empNo) {
const members = await this._client.sMembers(this._empKey(String(empNo || '').trim()))
for (const sid of members || []) {
const row = await this._load(sid)
if (row) return { ...row.userData, _sessionId: row.sessionId }
}
return null
}
async setex(empNo, seconds, userData) {
const { bearer, sessionId, record } = await this.create(
{ ...userData, empNo: String(empNo) },
seconds,
)
this._lastMint = { empNo: String(empNo), bearer, sessionId }
return record.userData
}
takeLastMint() {
const m = this._lastMint
this._lastMint = null
return m
}
async delete(empNo) {
await this.revokeEmpNo(empNo)
}
async has(empNo) {
return !!(await this.get(empNo))
}
async keys() {
const all = await this._client.keys('uds-session:*')
return all.map(k => k.replace(/^uds-session:/, ''))
const ekeys = await this._client.keys('uds-emp:*')
return ekeys.map((k) => k.replace(/^uds-emp:/, ''))
}
}

View file

@ -0,0 +1,167 @@
/**
* Extract local session bearer from Cookie / Header / Desktop bridge.
* EmpNo cookies (PORTALSSO*, UDS_FALLBACK_*) are never auth proofs.
*/
import { SESSION_COOKIE, SESSION_HEADER } from './store.js'
import { readBridgeFromRequest, readWsTicketFromRequest } from '../session-bridge.js'
import { requestIsLoopback } from '../skill-credentials.js'
function parseCookie(header, name) {
if (!header || typeof header !== 'string') return null
for (const part of header.split(';')) {
const idx = part.indexOf('=')
if (idx < 0) continue
if (part.slice(0, idx).trim() !== name) continue
try {
return decodeURIComponent(part.slice(idx + 1).trim())
} catch {
return part.slice(idx + 1).trim()
}
}
return null
}
/**
* @param {any} req
* @param {{ sessionBridge?: any, isLiveBearer?: (bearer: string) => boolean }} [deps]
* isLiveBearer: when given, a cookie bearer with no live session no longer shadows
* a valid WS ticket / bridge (stale UDS_SESSION → anonymous remote.mux).
* @returns {{ bearer: string, via: 'cookie'|'header'|'bridge', bridgeEmpNo?: string } | null}
*/
export function extractSessionBearer(req, deps = {}) {
const headers = req?.headers || {}
const fromHeader = String(headers[SESSION_HEADER] || headers['X-UDS-Session'] || '').trim()
if (fromHeader) return { bearer: fromHeader, via: 'header' }
const fromCookie = parseCookie(headers.cookie || '', SESSION_COOKIE)
if (fromCookie) {
const cookieHit = { bearer: fromCookie, via: 'cookie' }
if (typeof deps.isLiveBearer !== 'function') return cookieHit
let live = false
try { live = !!deps.isLiveBearer(fromCookie) } catch { live = false }
if (live) return cookieHit
return extractBridgeBearer(req, deps) || cookieHit
}
return extractBridgeBearer(req, deps)
}
/**
* Async variant for stores that can only read sessions asynchronously (Redis).
* @param {any} req
* @param {{ sessionBridge?: any, isLiveBearer?: (bearer: string) => Promise<boolean>|boolean }} [deps]
*/
export async function extractSessionBearerAsync(req, deps = {}) {
const headers = req?.headers || {}
const fromHeader = String(headers[SESSION_HEADER] || headers['X-UDS-Session'] || '').trim()
if (fromHeader) return { bearer: fromHeader, via: 'header' }
const fromCookie = parseCookie(headers.cookie || '', SESSION_COOKIE)
if (fromCookie) {
const cookieHit = { bearer: fromCookie, via: 'cookie' }
if (typeof deps.isLiveBearer !== 'function') return cookieHit
let live = false
try { live = !!(await deps.isLiveBearer(fromCookie)) } catch { live = false }
if (live) return cookieHit
return extractBridgeBearer(req, deps) || cookieHit
}
return extractBridgeBearer(req, deps)
}
/** WS ticket (upgrade only) → loopback bridge query (upgrade only) → bridge headers. */
function extractBridgeBearer(req, deps = {}) {
const headers = req?.headers || {}
if (deps.sessionBridge) {
try {
// D07/T01: one-time WS ticket only on WebSocket upgrade to remote.mux — never normal HTTP.
// 0.3.9 accepted Upgrade: websocket alone. 0.3.10 also required Connection to list
// "upgrade"; some Desktop/Electron stacks omit that and then never consume the ticket
// → workspace follow stays anonymous →「未分组」. Keep path+method+Upgrade gate.
const pathname = new URL(req?.url || '/', 'http://uds-auth.local').pathname
const upgrade = String(headers.upgrade || '').toLowerCase()
const connection = String(headers.connection || '').toLowerCase()
const connectionOk = !connection
|| connection.split(',').map((x) => x.trim()).includes('upgrade')
const isUpgrade = String(req?.method || 'GET').toUpperCase() === 'GET'
&& upgrade === 'websocket'
&& connectionOk
&& pathname === '/api/remote.mux'
const wsTicket = isUpgrade ? readWsTicketFromRequest(req) : null
if (wsTicket && typeof deps.sessionBridge.consumeWsTicket === 'function') {
const consumed = deps.sessionBridge.consumeWsTicket(wsTicket)
if (consumed?.bearer) {
return {
bearer: consumed.bearer,
via: 'ws_ticket',
bridgeEmpNo: consumed.empNo,
}
}
}
// Desktop 0.3.9 fallback: loopback remote.mux may carry bridge in the query
// when one-time ticket mint fails (sync XHR/CORS). Not accepted off-loopback.
if (isUpgrade && requestIsLoopback(req)) {
try {
const q = new URL(req?.url || '/', 'http://uds-auth.local').searchParams
const empNo = String(q.get('udsBridgeEmpNo') || '').trim()
const token = String(q.get('udsBridgeToken') || '').trim()
if (empNo && token && typeof deps.sessionBridge.verify === 'function') {
const ok = deps.sessionBridge.verify(empNo, token)
if (ok) {
return { bearer: token, via: 'bridge_query', bridgeEmpNo: ok.empNo }
}
}
} catch { /* ignore */ }
}
const hdr = readBridgeFromRequest(req)
if (hdr?.token) {
const ok = deps.sessionBridge.verify(hdr.empNo, hdr.token)
if (ok) {
// Bridge token itself is the local bearer (minted at login). Headers only.
return { bearer: hdr.token, via: 'bridge', bridgeEmpNo: ok.empNo }
}
}
} catch { /* ignore */ }
}
return null
}
/**
* Portal SSO cookies for login exchange only — not sufficient for principal.
*/
export function extractPortalCredentials(req) {
const cookie = req?.headers?.cookie || ''
const empNo = parseCookie(cookie, 'PORTALSSOUser')
|| parseCookie(cookie, 'ZTEDPGSSOUser')
const token = parseCookie(cookie, 'PORTALSSOCookie')
|| parseCookie(cookie, 'ZTEDPGSSOCookie')
if (!empNo || !token) return null
return { empNo: String(empNo), token: String(token) }
}
/** Display hint only — never authenticate. */
export function extractDisplayEmpNoHint(req) {
const cookie = req?.headers?.cookie || ''
return parseCookie(cookie, 'UDS_FALLBACK_UI')
|| parseCookie(cookie, 'UDS_FALLBACK_USER')
|| parseCookie(cookie, 'PORTALSSOUser')
|| parseCookie(cookie, 'ZTEDPGSSOUser')
|| null
}
export function buildSessionCookie(bearer, maxAgeSeconds, { secure = false } = {}) {
const parts = [
`${SESSION_COOKIE}=${encodeURIComponent(bearer)}`,
`Max-Age=${Math.max(60, Number(maxAgeSeconds) || 1800)}`,
'Path=/',
'HttpOnly',
'SameSite=Lax',
]
if (secure) parts.push('Secure')
return parts.join('; ')
}
export function clearSessionCookie({ secure = false } = {}) {
const base = `${SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax`
return secure ? `${base}; Secure` : base
}
export { SESSION_COOKIE, SESSION_HEADER, parseCookie }

View file

@ -1,22 +1,89 @@
/**
* Session store — 单索引: empNo → userData
*
* 一个工号 = 一个会话,不需要 sessionId,不需要额外 cookie。
* 多终端同工号自动共享同一个会话条目。
* Auth session store — opaque sessionId + random bearer.
* empNo is an attribute / secondary index, never the auth credential.
*/
export class SessionStore {
/** 按 empNo 获取会话 */
async get(empNo) { throw new Error('SessionStore#get must be implemented') }
/** 按 empNo 存储会话 */
async set(empNo, userData) { throw new Error('SessionStore#set must be implemented') }
/** 按 empNo 存储并设置过期(秒) */
async setex(empNo, seconds, userData) { throw new Error('SessionStore#setex must be implemented') }
/** 按 empNo 删除会话 */
async delete(empNo) { throw new Error('SessionStore#delete must be implemented') }
/** 检查 empNo 是否存在 */
async has(empNo) { throw new Error('SessionStore#has must be implemented') }
/** 清空所有会话 */
async clear() { throw new Error('SessionStore#clear must be implemented') }
/** 列出所有活跃会话 */
async keys() { throw new Error('SessionStore#keys must be implemented') }
/**
* Create a verified session.
* @param {object} userData
* @param {number} ttlSeconds
* @returns {Promise<{ sessionId: string, bearer: string, record: object }>}
*/
async create(userData, ttlSeconds) {
throw new Error('SessionStore#create must be implemented')
}
/** Lookup by bearer token (proof of possession). */
async getByBearer(bearer) {
throw new Error('SessionStore#getByBearer must be implemented')
}
/** Sync lookup for WS upgrade paths. */
getByBearerSync(bearer) {
throw new Error('SessionStore#getByBearerSync must be implemented')
}
async getBySessionId(sessionId) {
throw new Error('SessionStore#getBySessionId must be implemented')
}
async touch(sessionId, ttlSeconds) {
throw new Error('SessionStore#touch must be implemented')
}
async revokeSession(sessionId) {
throw new Error('SessionStore#revokeSession must be implemented')
}
async revokeBearer(bearer) {
throw new Error('SessionStore#revokeBearer must be implemented')
}
/** Revoke all devices for an empNo. */
async revokeEmpNo(empNo) {
throw new Error('SessionStore#revokeEmpNo must be implemented')
}
/** Profile cache only — never proves authentication. */
async setProfile(empNo, profile) {
throw new Error('SessionStore#setProfile must be implemented')
}
async getProfile(empNo) {
throw new Error('SessionStore#getProfile must be implemented')
}
async clear() {
throw new Error('SessionStore#clear must be implemented')
}
// --- legacy compat shims (empNo-keyed) — prefer create/getByBearer ---
/** @deprecated use getByBearer */
async get(empNo) {
throw new Error('SessionStore#get must be implemented')
}
/** @deprecated use create */
async setex(empNo, seconds, userData) {
throw new Error('SessionStore#setex must be implemented')
}
/** @deprecated use revokeEmpNo */
async delete(empNo) {
throw new Error('SessionStore#delete must be implemented')
}
async has(empNo) {
throw new Error('SessionStore#has must be implemented')
}
async keys() {
throw new Error('SessionStore#keys must be implemented')
}
}
export const SESSION_COOKIE = 'UDS_SESSION'
export const SESSION_HEADER = 'x-uds-session'
/** Absolute session lifetime; idle expiry (cookieMaxAge) slides within it. */
export const SESSION_ABSOLUTE_MAX_SECONDS = 7 * 24 * 60 * 60

View file

@ -1,28 +1,47 @@
/**
* Skill credential cache — separate from UI sessionStore.
* empNo → { token, updatedAt }; long TTL for cron/skills after UI logout.
* SEC-17: upstream tokens encrypted at rest (AES-256-GCM); plaintext only in memory.
*/
import { readFile, writeFile } from 'node:fs/promises'
import { readFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import { mkdir } from 'node:fs/promises'
import { loadOrCreateSecretKey, sealSecret, openSecret } from './utils/secret-box.js'
import { atomicWriteJson } from './utils/atomic-write.js'
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000
export class SkillCredentialCache {
/**
* @param {{ file?: string, ttlMs?: number, logger?: Console }} opts
* @param {{ file?: string, ttlMs?: number, logger?: Console, dataDir?: string }} opts
*/
constructor(opts = {}) {
this._file = opts.file || null
this._dataDir = opts.dataDir || (opts.file ? dirname(opts.file) : null)
this._ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS
this._logger = opts.logger || console
/** @type {Map<string, { token: string, updatedAt: string, expiresAt: number }>} */
this._map = new Map()
this._dirty = false
this._flushTimer = null
/** @type {Buffer|null} */
this._key = null
}
async init() {
let hasEncryptedData = false
if (this._file) {
try {
const peek = await readFile(this._file, 'utf-8')
hasEncryptedData = /tokenEnc/.test(peek) && peek.trim().length > 2
} catch { /* absent */ }
}
if (this._dataDir) {
try {
this._key = await loadOrCreateSecretKey(this._dataDir, { hasEncryptedData })
} catch (err) {
this._logger?.error?.('[uds-auth] secret key unavailable:', err.message)
throw err
}
}
if (!this._file) return
try {
const raw = await readFile(this._file, 'utf-8')
@ -30,16 +49,35 @@ export class SkillCredentialCache {
const entries = data?.entries && typeof data.entries === 'object' ? data.entries : data
if (!entries || typeof entries !== 'object') return
const now = Date.now()
let decryptFailures = 0
for (const [empNo, row] of Object.entries(entries)) {
if (!row?.token) continue
if (!row) continue
const expiresAt = Number(row.expiresAt) || (now + this._ttlMs)
if (expiresAt <= now) continue
let token = null
if (row.tokenEnc && this._key) {
token = openSecret(this._key, row.tokenEnc)
if (!token) decryptFailures++
} else if (row.token) {
// Legacy plaintext — accept once, rewrite encrypted on next flush.
token = String(row.token)
this._dirty = true
}
if (!token) continue
this._map.set(String(empNo), {
token: String(row.token),
token,
updatedAt: row.updatedAt || new Date().toISOString(),
expiresAt,
})
}
if (decryptFailures > 0) {
this._logger?.error?.(
'[uds-auth] skill credential decrypt failed for',
decryptFailures,
'entries — check secret key (not silently replaced)',
)
}
if (this._dirty) this._scheduleFlush()
} catch {
/* optional file */
}
@ -95,23 +133,29 @@ export class SkillCredentialCache {
this._flushTimer = null
void this._flush()
}, 250)
if (typeof this._flushTimer.unref === 'function') this._flushTimer.unref()
}
async _flush() {
if (!this._file || !this._dirty) return
this._dirty = false
if (!this._key) {
this._logger?.error?.('[uds-auth] skillCredentialCache flush skipped: no key')
this._dirty = true
return
}
const entries = {}
for (const [empNo, row] of this._map.entries()) {
entries[empNo] = {
token: row.token,
tokenEnc: sealSecret(this._key, row.token),
updatedAt: row.updatedAt,
expiresAt: row.expiresAt,
}
}
try {
await mkdir(dirname(this._file), { recursive: true })
await writeFile(this._file, JSON.stringify({ entries }, null, 2), 'utf-8')
await atomicWriteJson(this._file, { entries, v: 2 }, { mode: 0o600 })
} catch (err) {
this._dirty = true
this._logger?.warn?.('[uds-auth] skillCredentialCache flush failed:', err.message)
}
}
@ -123,10 +167,10 @@ export function isLoopbackAddress(addr) {
return a === '127.0.0.1' || a === '::1' || a === 'localhost'
}
/**
* Real socket peer only — never trust X-Forwarded-For / X-Real-IP (SEC-05).
*/
export function requestIsLoopback(req) {
const ra = req?.socket?.remoteAddress
if (isLoopbackAddress(ra)) return true
const xf = String(req?.headers?.['x-forwarded-for'] || '').split(',')[0].trim()
if (xf && isLoopbackAddress(xf)) return true
return false
return isLoopbackAddress(ra)
}

View file

@ -0,0 +1,59 @@
import { readFile, copyFile, unlink, lstat } from 'node:fs/promises'
import { constants } from 'node:fs'
import { resolve } from 'node:path'
import { atomicWriteJson } from './utils/atomic-write.js'
export const SECURITY_STATE_FILES = ['roles.json', 'session-owners.json', 'user-workspaces.json',
'session-bridge.json', 'skill-credentials.json', 'config.runtime.json', '.uds-auth-secret-key']
async function readOptional(path) {
try {
const stat = await lstat(path)
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('legacy_migrate_unsafe_file')
return await readFile(path)
} catch (err) { if (err.code === 'ENOENT') return null; throw err }
}
/** Exclusive copies, no overwrite, identical partial copies can resume after a crash. */
export async function migrateState(dataDir, sourceDir, logger = console, io = {}) {
const marker = resolve(dataDir, '.uds-auth-migrated-from-install')
if (await readOptional(marker)) return { migrated: false, reason: 'already' }
const present = []
for (const name of SECURITY_STATE_FILES) {
const bytes = await readOptional(resolve(sourceDir, name))
if (bytes) present.push({ name, bytes })
}
if (!present.length) return { migrated: false, reason: 'no_legacy' }
for (const { name, bytes } of present) {
const existing = await readOptional(resolve(dataDir, name))
if (existing && !existing.equals(bytes)) {
throw Object.assign(new Error('legacy_migrate_conflict'), { code: 'legacy_migrate_conflict', conflicts: [name] })
}
}
const copied = []
try {
for (const { name, bytes } of present) {
const dest = resolve(dataDir, name)
try {
await (io.copyFile || copyFile)(resolve(sourceDir, name), dest, constants.COPYFILE_EXCL)
copied.push(name)
} catch (err) {
if (err.code !== 'EEXIST' || !(await readOptional(dest))?.equals(bytes)) throw err
}
if (!(await readOptional(dest))?.equals(bytes)) throw new Error('legacy_migrate_source_changed')
}
await atomicWriteJson(marker, { at: new Date().toISOString(), from: resolve(sourceDir),
copied, seen: present.map(row => row.name) })
} catch (err) {
// Remove only files created by this attempt which still equal the source snapshot.
for (const name of copied) {
const dest = resolve(dataDir, name)
const bytes = present.find(row => row.name === name).bytes
try { if ((await readOptional(dest))?.equals(bytes)) await unlink(dest) } catch { /* preserve evidence */ }
}
logger.error?.('[uds-auth] legacy migration failed; no success marker written')
throw err
}
logger.info?.('[uds-auth] legacy state migration committed')
return { migrated: true, copied, present: present.map(row => row.name) }
}

View file

@ -0,0 +1,145 @@
/**
* R06: short-lived task capabilities for skill/cron (not browser session bearers).
* Bound to empNo + optional dshSessionId + audience + scope + expiry; revocable.
*/
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
function hashToken(token) {
return createHash('sha256').update(String(token)).digest('hex')
}
function safeEqualHex(a, b) {
if (!a || !b || a.length !== b.length) return false
try {
return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'))
} catch {
return false
}
}
export class TaskCapabilityStore {
/**
* @param {{ defaultTtlSeconds?: number, maxTtlSeconds?: number }} [opts]
*/
constructor(opts = {}) {
this._defaultTtl = Math.max(60, Number(opts.defaultTtlSeconds) || 3600)
this._maxTtl = Math.max(this._defaultTtl, Number(opts.maxTtlSeconds) || 24 * 3600)
/** @type {Map<string, object>} id → record */
this._byId = new Map()
/** @type {Map<string, string>} tokenHash → id */
this._byHash = new Map()
}
/**
* @param {{
* empNo: string,
* dshSessionId?: string|null,
* audience?: string,
* scopes?: string[],
* ttlSeconds?: number,
* mintedBy?: string,
* }} input
*/
mint(input) {
for (const [id, row] of this._byId) {
if (row.expiresAt <= Date.now()) this.revoke(id)
}
if (this._byId.size >= 5000) throw new Error('capability_capacity')
const empNo = String(input?.empNo || '').trim()
if (!empNo) throw new Error('capability_empNo_required')
const ttl = Math.min(
this._maxTtl,
Math.max(30, Number(input.ttlSeconds) || this._defaultTtl),
)
const id = randomBytes(12).toString('hex')
const token = randomBytes(32).toString('hex')
const tokenHash = hashToken(token)
const now = Date.now()
const record = {
id,
tokenHash,
empNo,
dshSessionId: input.dshSessionId ? String(input.dshSessionId) : null,
audience: String(input.audience || 'uds-auth-agent'),
scopes: Array.isArray(input.scopes) ? input.scopes.map(String) : ['outbound', 'credentials'],
mintedBy: input.mintedBy || null,
createdAt: now,
expiresAt: now + ttl * 1000,
revoked: false,
}
this._byId.set(id, record)
this._byHash.set(tokenHash, id)
return {
id,
token,
empNo: record.empNo,
dshSessionId: record.dshSessionId,
audience: record.audience,
scopes: record.scopes,
expiresAt: record.expiresAt,
expiresIn: ttl,
}
}
/**
* @param {string} token
* @param {{ audience?: string, scope?: string, dshSessionId?: string|null }} [opts]
*/
verify(token, opts = {}) {
const t = String(token || '').trim()
if (!t || t.length < 16) return null
const id = this._byHash.get(hashToken(t))
if (!id) return null
const row = this._byId.get(id)
if (!row || row.revoked) return null
if (row.expiresAt && Date.now() > row.expiresAt) {
this.revoke(id)
return null
}
if (!safeEqualHex(row.tokenHash, hashToken(t))) return null
if (opts.audience && row.audience !== opts.audience) return null
if (opts.scope && !(row.scopes || []).includes(opts.scope)) return null
if (opts.dshSessionId != null && row.dshSessionId
&& String(opts.dshSessionId) !== String(row.dshSessionId)) {
return null
}
return {
id: row.id,
empNo: row.empNo,
dshSessionId: row.dshSessionId,
audience: row.audience,
scopes: [...(row.scopes || [])],
expiresAt: row.expiresAt,
}
}
revoke(id) {
const row = this._byId.get(String(id || ''))
if (!row) return false
row.revoked = true
this._byHash.delete(row.tokenHash)
this._byId.delete(row.id)
return true
}
revokeEmpNo(empNo) {
const e = String(empNo || '').trim()
let n = 0
for (const [id, row] of [...this._byId]) {
if (row.empNo === e) {
this.revoke(id)
n++
}
}
return n
}
extractFromRequest(req) {
const h = req?.headers || {}
return (
h['x-uds-task-capability']
|| h['X-UDS-Task-Capability']
|| null
)
}
}

View file

@ -0,0 +1,60 @@
/** Host shellEnv adapter. Values are rebuilt after ordinary environment entries. */
export function createTaskEnvironment({ getOwner, rolesStore, capabilities, getWebServer }) {
const issued = new Map()
const release = execution => {
const key = execution?.token || execution
const row = issued.get(key)
if (!row) return
capabilities.revoke(row.id)
row.signal?.removeEventListener('abort', row.abort)
clearTimeout(row.timer)
row.off?.()
issued.delete(key)
}
const contributor = {
name: 'uds-auth',
variables: {
DSH_UDS_TASK_CAPABILITY: { description: 'Short-lived session-bound UDS outbound capability; never print or persist.' },
DSH_UDS_AUTH_BASE: { description: 'Owned loopback uds-auth endpoint of this Host.' },
},
resolve(execution) {
release(execution)
const empty = { DSH_UDS_TASK_CAPABILITY: '', DSH_UDS_AUTH_BASE: '' }
const sid = execution?.agent?.session?.header?.id
const owner = sid ? getOwner(String(sid)) : null
const server = getWebServer()
const port = Number(server?.port)
if (!owner || rolesStore.isDisabled(owner) || !Number.isInteger(port) || port <= 0 || port > 65535
|| execution?.signal?.aborted) return empty
const cap = capabilities.mint({
empNo: owner, dshSessionId: String(sid), audience: 'uds-auth-agent',
scopes: ['outbound'], ttlSeconds: 600, mintedBy: 'host:shellEnv',
})
const row = { id: cap.id, sessionId: String(sid), signal: execution.signal, abort: () => release(execution) }
issued.set(execution.token || execution, row)
row.signal?.addEventListener('abort', row.abort, { once: true })
row.timer = setTimeout(() => release(execution), cap.expiresAt - Date.now())
row.timer.unref?.()
return { DSH_UDS_TASK_CAPABILITY: cap.token, DSH_UDS_AUTH_BASE: `http://127.0.0.1:${port}/uds-auth` }
},
}
return { contributor, release, finish(execution, result, jobs) {
const row = issued.get(execution?.token || execution)
const value = result?.value
if (row && !result?.isError && ['background', 'promoted'].includes(value?.kind)
&& typeof value.jobId === 'string' && jobs?.events?.subscribe) {
const job = jobs.list(row.sessionId).find(job => job.id === value.jobId)
if (job && ['running', 'stopping'].includes(job.status)) {
row.signal?.removeEventListener('abort', row.abort)
row.signal = null
row.off = jobs.events.subscribe({ owner: row.sessionId }, event => {
if (event.job?.id === value.jobId && ['settled', 'stopping', 'removed'].includes(event.type)) release(execution)
})
return
}
}
release(execution)
}, dispose() {
for (const key of [...issued.keys()]) release({ token: key })
} }
}

View file

@ -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 },
}
}

View file

@ -0,0 +1,51 @@
/**
* SEC-21: atomic JSON replace — write temp in same dir, then rename.
*/
import { open, rename, mkdir, unlink } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { randomBytes } from 'node:crypto'
/**
* @param {string} filePath
* @param {string|Buffer} data
* @param {{ mode?: number }} [opts]
*/
export async function atomicWriteFile(filePath, data, opts = {}) {
const dir = dirname(filePath)
await mkdir(dir, { recursive: true })
const tmp = join(dir, `.${randomBytes(8).toString('hex')}.tmp`)
const mode = opts.mode ?? 0o600
let handle, committed = false
try {
handle = await open(tmp, 'wx', mode)
await handle.writeFile(data, typeof data === 'string' ? 'utf-8' : undefined)
await handle.sync()
await handle.close()
handle = null
await rename(tmp, filePath)
committed = true
if (process.platform !== 'win32') {
// Rename is the commit point. A filesystem that cannot fsync directories
// must not make callers roll back memory after the replacement succeeded.
let directory
try { directory = await open(dir, 'r'); await directory.sync() }
catch { /* directory durability is best effort on unsupported filesystems */ }
finally { await directory?.close().catch(() => {}) }
}
} finally {
await handle?.close()
if (!committed) await unlink(tmp).catch(err => { if (err.code !== 'ENOENT') throw err })
}
}
/**
* @param {string} filePath
* @param {any} obj
* @param {{ mode?: number, pretty?: boolean }} [opts]
*/
export async function atomicWriteJson(filePath, obj, opts = {}) {
const text = opts.pretty === false
? JSON.stringify(obj)
: JSON.stringify(obj, null, 2)
await atomicWriteFile(filePath, text + '\n', { mode: opts.mode })
}

View file

@ -0,0 +1,59 @@
/**
* SEC-25: structured security audit events (no secrets).
*/
import { randomBytes } from 'node:crypto'
const SECRET_KEYS = /^(token|password|passphrase|authorization|cookie|authvalue|sso|bearer|secret|key)$/i
/**
* @param {unknown} value
* @returns {unknown}
*/
export function redactValue(value) {
if (value == null) return value
if (typeof value === 'string') {
if (value.length > 64) return value.slice(0, 8) + '…(' + value.length + ')'
return value
}
if (Array.isArray(value)) return value.map(redactValue)
if (typeof value === 'object') {
const out = {}
for (const [k, v] of Object.entries(value)) {
if (SECRET_KEYS.test(k) || /token|password|cookie|secret|auth/i.test(k)) {
out[k] = '[redacted]'
} else {
out[k] = redactValue(v)
}
}
return out
}
return value
}
/**
* @param {object} event
* @param {{ logger?: Console }} [opts]
*/
export function audit(event, opts = {}) {
const logger = opts.logger || console
const row = {
ts: new Date().toISOString(),
type: 'uds-auth.audit',
correlationId: event.correlationId || randomBytes(8).toString('hex'),
action: event.action,
decision: event.decision || 'info',
reasonCode: event.reasonCode || null,
actor: event.actor || null,
target: event.target || null,
resource: event.resource || null,
service: event.service || 'uds-auth',
meta: event.meta ? redactValue(event.meta) : undefined,
}
const line = JSON.stringify(row)
if (event.decision === 'deny' || event.decision === 'error') {
logger.warn?.(line)
} else {
logger.info?.(line)
}
return row
}

View file

@ -0,0 +1,136 @@
/**
* SEC-19 / R13 / A09: Origin / Host checks for /uds-auth routes.
* trustedHosts allowlist (exact host[:port]). Empty allowlist = loopback Host only.
* Desktop (null / missing Origin) allowed only for loopback on mutating methods.
*/
import { requestIsLoopback } from '../skill-credentials.js'
function normalizeHost(h, protocolHint) {
const lower = String(h || '').toLowerCase()
if (lower.endsWith(':443') && (protocolHint === 'https:' || !protocolHint)) {
return lower.slice(0, -4)
}
if (lower.endsWith(':80') && (protocolHint === 'http:' || !protocolHint)) {
return lower.slice(0, -3)
}
return lower
}
function hostIsLoopback(host) {
const raw = String(host || '').trim().toLowerCase()
// F08: parse bracketed IPv6 authority ([::1]:3000) — do not split on ':'.
let hostname = raw
try {
hostname = new URL('http://' + raw).hostname
} catch {
if (raw.startsWith('[')) {
const end = raw.indexOf(']')
hostname = end > 0 ? raw.slice(0, end + 1) : raw
} else {
hostname = normalizeHost(raw).split(':')[0]
}
}
// Node may return hostname as ::1 or [::1] depending on version.
const bare = hostname.startsWith('[') && hostname.endsWith(']')
? hostname.slice(1, -1)
: hostname
return bare === 'localhost' || bare === '127.0.0.1' || bare === '::1'
}
/**
* @param {import('node:http').IncomingMessage} req
* @param {{ allowNullOrigin?: boolean, trustedHosts?: string[]|(() => string[]) }} [opts]
* @returns {{ ok: boolean, reason?: string }}
*/
export function checkRequestOrigin(req, opts = {}) {
const method = opts.upgrade ? 'POST' : String(req?.method || 'GET').toUpperCase()
const host = String(req?.headers?.host || '').trim().toLowerCase()
if (!host) {
return { ok: false, reason: 'missing_host' }
}
if (host.includes('@') || host.includes('\\') || /[\s\0/?#]/.test(host)) {
return { ok: false, reason: 'invalid_host' }
}
try {
const parsedHost = new URL('http://' + host)
if (!parsedHost.hostname || parsedHost.username || parsedHost.password || parsedHost.pathname !== '/') {
return { ok: false, reason: 'invalid_host' }
}
} catch { return { ok: false, reason: 'invalid_host' } }
const trustedRaw = typeof opts.trustedHosts === 'function'
? opts.trustedHosts()
: opts.trustedHosts
const trusted = Array.isArray(trustedRaw)
? trustedRaw.map((h) => normalizeHost(h)).filter(Boolean)
: []
const reqHost = normalizeHost(host)
if (trusted.length) {
if (!trusted.includes(reqHost)) {
return { ok: false, reason: 'host_not_trusted' }
}
} else if (!hostIsLoopback(host)) {
// A09: empty trustedHosts = loopback-only default (no silent open allowlist).
return { ok: false, reason: 'host_not_trusted' }
}
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') {
return { ok: true }
}
const origin = String(req?.headers?.origin || '').trim()
const fetchSite = String(req?.headers?.['sec-fetch-site'] || '').toLowerCase()
if (fetchSite === 'cross-site') {
return { ok: false, reason: 'cross_site' }
}
if (!origin || origin === 'null') {
if (opts.allowNullOrigin !== false && requestIsLoopback(req)) {
return { ok: true }
}
if (!origin && requestIsLoopback(req)) return { ok: true }
return { ok: false, reason: 'missing_origin' }
}
let originUrl
try {
originUrl = new URL(origin)
} catch {
return { ok: false, reason: 'invalid_origin' }
}
// Only the renderer's owned scheme authority may represent Desktop.
if (originUrl.protocol === 'dsh-app:') {
return requestIsLoopback(req) && originUrl.hostname === 'app' && !originUrl.port
&& !originUrl.username && !originUrl.password && originUrl.pathname === ''
? { ok: true } : { ok: false, reason: 'origin_mismatch' }
}
if (!['http:', 'https:'].includes(originUrl.protocol) || originUrl.username || originUrl.password
|| !['', '/'].includes(originUrl.pathname) || originUrl.search || originUrl.hash) {
return { ok: false, reason: 'invalid_origin' }
}
const originHost = normalizeHost(originUrl.host, originUrl.protocol)
const hostForOrigin = normalizeHost(host, originUrl.protocol)
if (originHost !== hostForOrigin) {
return { ok: false, reason: 'origin_mismatch' }
}
if (trusted.length && !trusted.includes(originHost)) {
return { ok: false, reason: 'origin_not_trusted' }
}
return { ok: true }
}
/**
* Reject non-JSON mutating Content-Types for state-changing APIs.
*/
export function requireJsonContentType(req) {
const method = String(req?.method || 'GET').toUpperCase()
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return { ok: true }
const ct = String(req?.headers?.['content-type'] || '').toLowerCase()
if (!ct.includes('application/json')) {
return { ok: false, reason: 'content_type' }
}
return { ok: true }
}

View file

@ -0,0 +1,47 @@
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
// A shared bound across password login, rotation and sealed-box unlock. Reject
// overload instead of keeping unbounded request/password buffers in memory.
const MAX_RUNNING = 2
const MAX_WAITING = 8
let running = 0
const waiting = []
export async function derivePasswordKey(password, salt, length = 32) {
if (typeof password !== 'string' || Buffer.byteLength(password) > 1024) {
throw Object.assign(new Error('password_invalid'), { code: 'password_invalid' })
}
if (running >= MAX_RUNNING) {
if (waiting.length >= MAX_WAITING) {
throw Object.assign(new Error('kdf_busy'), { code: 'kdf_busy' })
}
await new Promise(resolve => waiting.push(resolve))
} else running++
try {
return await new Promise((resolve, reject) => {
scrypt(password, salt, length, { N: 16384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 },
(err, key) => err ? reject(err) : resolve(key))
})
} finally {
const next = waiting.shift()
if (next) next()
else running--
}
}
export async function hashPasswordAsync(password) {
const salt = randomBytes(16)
const key = await derivePasswordKey(password, salt)
return `scrypt$16384$8$1$${salt.toString('base64url')}$${key.toString('base64url')}`
}
export async function verifyScryptAsync(password, stored) {
const parts = String(stored).split('$')
// Never accept attacker-controlled KDF work factors or output sizes.
if (parts.length !== 6 || parts.slice(0, 4).join('$') !== 'scrypt$16384$8$1') return false
const salt = Buffer.from(parts[4], 'base64url')
const expected = Buffer.from(parts[5], 'base64url')
if (salt.length !== 16 || expected.length !== 32) return false
const actual = await derivePasswordKey(password, salt)
return timingSafeEqual(actual, expected)
}

View file

@ -0,0 +1,58 @@
/**
* SEC-23: simple in-memory rate limiter with bucket cleanup.
* Keyed by real socket address (not XFF).
*/
export class RateLimiter {
/**
* @param {{ windowMs?: number, max?: number, maxBuckets?: number }} [opts]
*/
constructor(opts = {}) {
this._windowMs = opts.windowMs ?? 60_000
this._max = opts.max ?? 30
this._maxBuckets = opts.maxBuckets ?? 10_000
/** @type {Map<string, { count: number, resetAt: number }>} */
this._buckets = new Map()
this._sweepTimer = setInterval(() => this._sweep(), Math.max(this._windowMs, 30_000))
if (typeof this._sweepTimer.unref === 'function') this._sweepTimer.unref()
}
_sweep() {
const now = Date.now()
for (const [k, b] of this._buckets) {
if (now > b.resetAt) this._buckets.delete(k)
}
}
/**
* @param {string} key
* @returns {boolean} true if allowed
*/
allow(key) {
const id = String(key || 'unknown')
const now = Date.now()
let bucket = this._buckets.get(id)
if (!bucket || now > bucket.resetAt) {
if (this._buckets.size >= this._maxBuckets) this._sweep()
if (this._buckets.size >= this._maxBuckets) {
// Evict oldest-ish entry
const first = this._buckets.keys().next().value
if (first != null) this._buckets.delete(first)
}
bucket = { count: 0, resetAt: now + this._windowMs }
this._buckets.set(id, bucket)
}
bucket.count++
return bucket.count <= this._max
}
dispose() {
clearInterval(this._sweepTimer)
this._buckets.clear()
}
}
export function clientKey(req) {
const ra = req?.socket?.remoteAddress || 'unknown'
return String(ra).replace(/^::ffff:/i, '')
}

View file

@ -0,0 +1,49 @@
/**
* SEC-23: bounded body readers.
*/
export const DEFAULT_MAX_BODY = 256 * 1024
export const MAX_QR_PROXY_BODY = 64 * 1024
export const MAX_QR_DATA_LEN = 2048
/**
* @param {import('node:http').IncomingMessage} req
* @param {{ maxBytes?: number }} [opts]
* @returns {Promise<string>}
*/
export async function readBodyLimited(req, opts = {}) {
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BODY
const chunks = []
let total = 0
for await (const chunk of req) {
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
total += buf.length
if (total > maxBytes) {
const err = new Error('payload_too_large')
err.code = 'payload_too_large'
err.statusCode = 413
// Drain remaining to free the socket (best-effort).
try { req.resume() } catch { /* ignore */ }
throw err
}
chunks.push(buf)
}
return Buffer.concat(chunks).toString('utf8')
}
/**
* @param {import('node:http').IncomingMessage} req
* @param {{ maxBytes?: number }} [opts]
* @returns {Promise<object>}
*/
export async function readJsonBodyLimited(req, opts = {}) {
const raw = await readBodyLimited(req, opts)
if (!raw) return {}
try {
return JSON.parse(raw)
} catch {
const err = new Error('invalid_json')
err.code = 'invalid_json'
err.statusCode = 400
throw err
}
}

View file

@ -0,0 +1,52 @@
/**
* SEC-14: normalize empNo / user directory segment — reject path semantics.
*/
import { resolve, join, sep } from 'node:path'
const MAX_LEN = 64
const SAFE_RE = /^[A-Za-z0-9._@+-]+$/
const WIN_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i
/**
* @param {unknown} empNo
* @returns {string|null} sanitized id or null if rejected
*/
export function sanitizeUserId(empNo) {
if (empNo == null) return null
const s = String(empNo).trim()
if (!s || s.length > MAX_LEN) return null
if (s.includes('\0') || s.includes('..') || s.includes('/') || s.includes('\\') || s.includes(':')) {
return null
}
if (!SAFE_RE.test(s)) return null
if (WIN_RESERVED.test(s)) return null
return s
}
/**
* Resolve user workspace directory under root; returns null if escape would occur.
* @param {string} workspaceRootAbs
* @param {string} empNo
* @returns {string|null}
*/
export function resolveUserDir(workspaceRootAbs, empNo) {
const safe = sanitizeUserId(empNo)
if (!safe) return null
const root = resolve(String(workspaceRootAbs))
const userPath = resolve(join(root, safe))
if (userPath !== root && !userPath.startsWith(root + sep)) return null
return userPath
}
/**
* True when candidate is equal to or under base (after resolve).
*/
export function isPathInside(baseAbs, candidateAbs) {
try {
const base = resolve(String(baseAbs))
const cand = resolve(String(candidateAbs))
return cand === base || cand.startsWith(base + sep)
} catch {
return false
}
}

View file

@ -0,0 +1,135 @@
/**
* SEC-17 / R11: encrypt upstream secrets at rest (AES-256-GCM).
* Key from UDS_AUTH_SECRET_KEY (64 hex) or plugin-local key file.
* Invalid env / corrupt key file fail closed — never silently replace.
*/
import { createCipheriv, createDecipheriv, randomBytes, createHash, scryptSync } from 'node:crypto'
import { readFile, writeFile, mkdir, chmod, access } from 'node:fs/promises'
import { constants as fsConstants } from 'node:fs'
import { dirname, join } from 'node:path'
const MAGIC = 'uds-secret-v1'
function keyFilePath(dataDir) {
return join(dataDir, '.uds-auth-secret-key')
}
/**
* @param {string} dataDir plugin data directory
* @param {{ hasEncryptedData?: boolean }} [opts]
* @returns {Promise<Buffer>} 32-byte key
*/
export async function loadOrCreateSecretKey(dataDir, opts = {}) {
const env = String(process.env.UDS_AUTH_SECRET_KEY || '').trim()
if (env) {
if (!/^[0-9a-fA-F]{64}$/.test(env)) {
const err = new Error('invalid_secret_key_env')
err.code = 'invalid_secret_key_env'
throw err
}
return Buffer.from(env, 'hex')
}
const keyFile = keyFilePath(dataDir)
let fileExists = false
try {
await access(keyFile, fsConstants.F_OK)
fileExists = true
} catch { /* absent */ }
if (fileExists) {
let raw
try {
raw = (await readFile(keyFile, 'utf-8')).trim()
} catch (err) {
const e = new Error('secret_key_unreadable')
e.code = 'secret_key_unreadable'
e.cause = err
throw e
}
if (!/^[0-9a-fA-F]{64}$/.test(raw)) {
const e = new Error('secret_key_corrupt')
e.code = 'secret_key_corrupt'
throw e
}
return Buffer.from(raw, 'hex')
}
// Only mint a new key on a confirmed empty deployment (no prior key file).
if (opts.hasEncryptedData) {
const e = new Error('secret_key_missing')
e.code = 'secret_key_missing'
throw e
}
const key = randomBytes(32)
await mkdir(dirname(keyFile), { recursive: true })
// Exclusive create — avoid concurrent overwrite races.
const { open } = await import('node:fs/promises')
let fh
try {
fh = await open(keyFile, 'wx')
await fh.writeFile(key.toString('hex') + '\n', { encoding: 'utf8' })
} catch (err) {
if (err.code === 'EEXIST') {
const raw = (await readFile(keyFile, 'utf-8')).trim()
if (/^[0-9a-fA-F]{64}$/.test(raw)) return Buffer.from(raw, 'hex')
const e = new Error('secret_key_corrupt')
e.code = 'secret_key_corrupt'
throw e
}
throw err
} finally {
try { await fh?.close() } catch { /* ignore */ }
}
try { await chmod(keyFile, 0o600) } catch { /* win */ }
return key
}
/**
* @param {Buffer} key
* @param {string} plaintext
* @returns {string} sealed blob
*/
export function sealSecret(key, plaintext) {
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', key, iv)
const enc = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return [
MAGIC,
iv.toString('base64url'),
tag.toString('base64url'),
enc.toString('base64url'),
].join('.')
}
/**
* @param {Buffer} key
* @param {string} blob
* @returns {string|null}
*/
export function openSecret(key, blob) {
const parts = String(blob || '').split('.')
if (parts.length !== 4 || parts[0] !== MAGIC) return null
try {
const iv = Buffer.from(parts[1], 'base64url')
const tag = Buffer.from(parts[2], 'base64url')
const enc = Buffer.from(parts[3], 'base64url')
const decipher = createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8')
} catch {
return null
}
}
/** Stable fingerprint for logs — never the secret itself. */
export function secretFingerprint(value) {
return createHash('sha256').update(String(value)).digest('hex').slice(0, 12)
}
/** Derive a key from passphrase (for tests / migration helpers). */
export function deriveKeyFromPassphrase(passphrase, salt = 'uds-auth-skill') {
return scryptSync(String(passphrase), String(salt), 32)
}

View file

@ -2,11 +2,13 @@
* Per-user workspace provisioning under workspaceRoot/<empNo>.
*/
import { mkdir } from 'node:fs/promises'
import { join, resolve, sep } from 'node:path'
import { join, resolve } from 'node:path'
import { homedir } from 'node:os'
import { readFile, writeFile } from 'node:fs/promises'
import { readFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import { withUserContext, getUserContext } from './context.js'
import { sanitizeUserId, resolveUserDir, isPathInside } from './utils/safe-userid.js'
import { atomicWriteJson } from './utils/atomic-write.js'
function defaultWorkspaceRoot() {
const home = process.env.DSH_HOME || process.env.DSH_PROFILE_DIR || join(homedir(), '.dsh')
@ -56,16 +58,12 @@ export class UserWorkspaceStore {
if (!this._dirty || !this._mapFile) return
this._dirty = false
try {
await mkdir(dirname(this._mapFile), { recursive: true })
await writeFile(
this._mapFile,
JSON.stringify({
users: Object.fromEntries(this._map),
savedAt: new Date().toISOString(),
}, null, 2),
'utf-8',
)
await atomicWriteJson(this._mapFile, {
users: Object.fromEntries(this._map),
savedAt: new Date().toISOString(),
}, { mode: 0o600 })
} catch (err) {
this._dirty = true
console.warn('[uds-auth:UserWorkspace] save failed:', err.message)
}
}
@ -90,8 +88,15 @@ export class UserWorkspaceStore {
// Fallback admin: still get a dedicated folder
empNo = empNo || 'administrator'
}
const safeId = sanitizeUserId(empNo)
if (!safeId) {
console.warn('[uds-auth] reject unsafe empNo for workspace:', String(empNo).slice(0, 32))
return null
}
empNo = safeId
const root = resolveWorkspaceRoot(workspaceRoot)
const userPath = join(root, String(empNo))
const userPath = resolveUserDir(root, empNo)
if (!userPath) return null
await mkdir(userPath, { recursive: true })
// Cordis throws on ctx.workspaceRegistry without inject.
@ -131,8 +136,12 @@ export class UserWorkspaceStore {
isUserPath(empNo, candidatePath, workspaceRoot) {
if (!empNo || !candidatePath) return false
const root = resolveWorkspaceRoot(workspaceRoot)
const userPath = resolve(join(root, String(empNo)))
const cand = resolve(String(candidatePath))
return cand === userPath || cand.startsWith(userPath + sep)
const userPath = resolveUserDir(root, empNo)
if (!userPath) return false
// Candidate must stay under user dir; user dir must stay under workspace root.
if (!isPathInside(root, userPath)) return false
return isPathInside(userPath, candidatePath)
}
}
export { sanitizeUserId, resolveUserDir }