From df97c5e0425ca56a624abc00c47aacc5251bf6f6 Mon Sep 17 00:00:00 2001 From: oliver Date: Mon, 12 Oct 2026 02:11:09 +0800 Subject: [PATCH] fix(uds-auth): emergency login workspaces, Desktop key prompts, persistent sessions Emergency (fallback / sealed-box) admins lost the workspace sidebar and saw "sign-in failed / add API key" on DSH Desktop because the remote.mux opens anonymously at boot and every early call was rejected terminally. - gateway: anonymous workspace/follow reaches dsh-acl's empty baseline; anonymous streams are parked until the carrier aborts instead of failing; anonymous workspace/initializeDefault answers "nothing created" - request-auth: a dead UDS_SESSION cookie no longer shadows a valid WS ticket or bridge (sync + async variants used by all callers) - dsh-acl: /api/* waits (bounded 15s) for the gateway ACL during Host startup instead of 403, so the Desktop welcome read no longer fails - desktop-bootstrap: key/account projection is best-effort per reference - client: emit connection/reset after login so boot-time caches (settings describe mirror -> Settings > Models) re-read under the real principal - sessions: absolute lifetime fixed at 7 days (was min(ttl, 7d) = 30 min); bridge expiry slides with its session; MemoryStore persisted to sessions.json (bearer hash only, userData AES-256-GCM sealed) - README: session lifetime/persistence and DSH compatibility notes Also includes previously uncommitted uds-auth 0.3.x work in this tree. Co-Authored-By: Claude Opus 5.5 --- uds-auth/.gitignore | 3 + uds-auth/.npmignore | 18 + uds-auth/LICENSE | 21 + uds-auth/README.md | 78 +- uds-auth/README.zh.md | 108 +- uds-auth/config.default.yaml | 33 +- uds-auth/docs/THIRD-PARTY-NOTICES.txt | 35 + uds-auth/docs/sbom.cdx.json | 109 ++ uds-auth/docs/skill-auth-standard.zh.md | 19 +- uds-auth/docs/uds-skill-auth.zh.md | 10 +- uds-auth/docs/upgrade-0.3.10.zh.md | 41 + uds-auth/docs/upgrade-0.3.11.zh.md | 18 + uds-auth/lib/agent-auth.js | 188 +++- uds-auth/lib/api.js | 158 ++- uds-auth/lib/client.js | 526 ++++++++-- uds-auth/lib/config.js | 13 + uds-auth/lib/desktop-bootstrap.js | 69 ++ uds-auth/lib/dsh-acl.js | 991 +++++++++++++----- uds-auth/lib/gateway-events.js | 55 + uds-auth/lib/gateway-policy.js | 108 ++ uds-auth/lib/i18n.js | 76 +- uds-auth/lib/identity-cache.js | 18 +- uds-auth/lib/index.js | 919 +++++++++++++--- uds-auth/lib/local-admin.js | 57 +- uds-auth/lib/middleware/auth-middleware.js | 381 ++++--- uds-auth/lib/profile-security.js | 74 ++ uds-auth/lib/qr-challenge.js | 48 + uds-auth/lib/roles.js | 569 ++++++++-- uds-auth/lib/session-acl.js | 47 +- uds-auth/lib/session-bridge.js | 131 ++- uds-auth/lib/session/factory.js | 13 +- uds-auth/lib/session/memory-store.js | 447 +++++++- uds-auth/lib/session/redis-store.js | 219 +++- uds-auth/lib/session/request-auth.js | 167 +++ uds-auth/lib/session/store.js | 103 +- uds-auth/lib/skill-credentials.js | 70 +- uds-auth/lib/state-migration.js | 59 ++ uds-auth/lib/task-capability.js | 145 +++ uds-auth/lib/task-environment.js | 60 ++ uds-auth/lib/uds/user-search.js | 91 +- uds-auth/lib/utils/atomic-write.js | 51 + uds-auth/lib/utils/audit-log.js | 59 ++ uds-auth/lib/utils/origin-guard.js | 136 +++ uds-auth/lib/utils/password-kdf.js | 47 + uds-auth/lib/utils/rate-limit.js | 58 + uds-auth/lib/utils/read-body.js | 49 + uds-auth/lib/utils/safe-userid.js | 52 + uds-auth/lib/utils/secret-box.js | 135 +++ uds-auth/lib/workspace-provision.js | 39 +- uds-auth/package-lock.json | 6 +- uds-auth/package.json | 20 +- uds-auth/scripts/generate-sbom.mjs | 45 + uds-auth/scripts/pack-check.mjs | 110 ++ uds-auth/scripts/seal-local-admin.mjs | 83 +- .../skill-helpers/python/uds_skill_auth.py | 101 +- uds-auth/skills/uds-skill-auth/SKILL.md | 9 +- .../uds-skill-auth/scripts/uds_skill_auth.py | 101 +- uds-auth/test/acceptance-batch1.test.js | 170 +++ uds-auth/test/acceptance-batch2.test.js | 193 ++++ uds-auth/test/auth-session-p0.test.js | 276 +++++ uds-auth/test/desktop-bootstrap.test.js | 111 ++ .../test/emergency-workspace-follow.test.js | 353 +++++++ uds-auth/test/host-context.test.js | 10 +- uds-auth/test/local-admin.test.js | 18 + uds-auth/test/phase2-acl.test.js | 104 ++ uds-auth/test/phase3-security.test.js | 165 +++ uds-auth/test/phase4-ops.test.js | 65 ++ uds-auth/test/remediation-0310.test.js | 353 +++++++ uds-auth/test/sealed-workspace-follow.test.js | 130 +++ uds-auth/test/session-persistence.test.js | 129 +++ .../test/startup-and-session-lifetime.test.js | 69 ++ uds-auth/test/ws-ticket-userinfo.test.js | 86 ++ 72 files changed, 8436 insertions(+), 1192 deletions(-) create mode 100644 uds-auth/.npmignore create mode 100644 uds-auth/LICENSE create mode 100644 uds-auth/docs/THIRD-PARTY-NOTICES.txt create mode 100644 uds-auth/docs/sbom.cdx.json create mode 100644 uds-auth/docs/upgrade-0.3.10.zh.md create mode 100644 uds-auth/docs/upgrade-0.3.11.zh.md create mode 100644 uds-auth/lib/desktop-bootstrap.js create mode 100644 uds-auth/lib/gateway-events.js create mode 100644 uds-auth/lib/gateway-policy.js create mode 100644 uds-auth/lib/profile-security.js create mode 100644 uds-auth/lib/qr-challenge.js create mode 100644 uds-auth/lib/session/request-auth.js create mode 100644 uds-auth/lib/state-migration.js create mode 100644 uds-auth/lib/task-capability.js create mode 100644 uds-auth/lib/task-environment.js create mode 100644 uds-auth/lib/utils/atomic-write.js create mode 100644 uds-auth/lib/utils/audit-log.js create mode 100644 uds-auth/lib/utils/origin-guard.js create mode 100644 uds-auth/lib/utils/password-kdf.js create mode 100644 uds-auth/lib/utils/rate-limit.js create mode 100644 uds-auth/lib/utils/read-body.js create mode 100644 uds-auth/lib/utils/safe-userid.js create mode 100644 uds-auth/lib/utils/secret-box.js create mode 100644 uds-auth/scripts/generate-sbom.mjs create mode 100644 uds-auth/scripts/pack-check.mjs create mode 100644 uds-auth/test/acceptance-batch1.test.js create mode 100644 uds-auth/test/acceptance-batch2.test.js create mode 100644 uds-auth/test/auth-session-p0.test.js create mode 100644 uds-auth/test/desktop-bootstrap.test.js create mode 100644 uds-auth/test/emergency-workspace-follow.test.js create mode 100644 uds-auth/test/phase2-acl.test.js create mode 100644 uds-auth/test/phase3-security.test.js create mode 100644 uds-auth/test/phase4-ops.test.js create mode 100644 uds-auth/test/remediation-0310.test.js create mode 100644 uds-auth/test/sealed-workspace-follow.test.js create mode 100644 uds-auth/test/session-persistence.test.js create mode 100644 uds-auth/test/startup-and-session-lifetime.test.js create mode 100644 uds-auth/test/ws-ticket-userinfo.test.js diff --git a/uds-auth/.gitignore b/uds-auth/.gitignore index c654a6be..876a3daf 100644 --- a/uds-auth/.gitignore +++ b/uds-auth/.gitignore @@ -19,3 +19,6 @@ scripts/merge-sessions-into-workspace.ps1 session-bridge.json roles.json +sessions.json +local-admin.box +.uds-auth-secret-key diff --git a/uds-auth/.npmignore b/uds-auth/.npmignore new file mode 100644 index 00000000..886fc80c --- /dev/null +++ b/uds-auth/.npmignore @@ -0,0 +1,18 @@ +# SEC-24: keep secrets, caches, and local state out of the published tarball +node_modules/ +test/ +*.tgz +**/__pycache__/ +**/*.pyc +**/*.pyo +.uds-auth-secret-key +skill-credentials.json +session-bridge.json +session-owners.json +session-owners.json.bak* +user-workspaces.json +roles.json +config.runtime.json +*.log +.DS_Store +pnpm-lock.yaml diff --git a/uds-auth/LICENSE b/uds-auth/LICENSE new file mode 100644 index 00000000..bea416e9 --- /dev/null +++ b/uds-auth/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 uds-auth contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/uds-auth/README.md b/uds-auth/README.md index f2e37561..b6a10c09 100644 --- a/uds-auth/README.md +++ b/uds-auth/README.md @@ -1,53 +1,83 @@ # uds-auth -UDS authentication plugin for DeepSeek Harness: fixed sidebar login badge + host session/role APIs. +0.3.10: [升级与协议变更说明](docs/upgrade-0.3.10.zh.md) · [SBOM](docs/sbom.cdx.json) · [第三方许可证](docs/THIRD-PARTY-NOTICES.txt) + +UDS authentication plugin for DeepSeek Harness (**trusted single-instance** access control — **not** strong multi-tenant isolation). + +See `package.json` for the current 0.3.x version (breaking auth protocol vs 0.2.x — re-login required). + +## Support boundary + +| Item | Notes | +|------|--------| +| Runtime | Node `^22.19.0 \|\| >=24` (aligned with DSH Host) | +| Mode | Single Host, trusted enterprise users, controlled skills | +| Out of scope | Untrusted multi-tenant execution isolation; multi-instance without shared session store | ## Install ```bash -dsh plugin --profile web add -w "D:/project/chatgpt/oclaw/uds-auth" +dsh plugin --profile web add -w "/oclaw/uds-auth" +# or clean tarball +dsh plugin add -w "/uds-auth-0.3.9.tgz" ``` -Restart Harness after install. The badge mounts on `sidebar.footer.action` (root scope). +Empty roles table requires `UDS_AUTH_INITIAL_ADMIN` or `initialAdminEmpNo`. Restart Harness after install. -Version `0.2.21` provides `ctx.get('udsAuth').runAsHost(callback)` for trusted Host plugins. It isolates UDS browser identity during synchronous and asynchronous background operations and restores the caller afterward. Use with `dsh-im-ops >= 4.9.1-ops.38` to fix WhatsApp background Session creation rejected as signed out; update both plugins and restart the Host. The callback adds no remote endpoint and preserves browser login and workspace ACL checks. +`ctx.get('udsAuth').runAsHost(cb)` — Host background ALS. +`ctx.get('udsAuth').mintTaskCapability({ empNo, dshSessionId, scopes, ttlSeconds })` — inject `UDS_TASK_CAPABILITY` for cron/skill. ## Config ```yaml -uacBaseUrl: https://uac.zte.com.cn -userSearchUrl: https://icenterapi.zte.com.cn/zte-km-icenter-addresearch/user/plain/docs/search +uacBaseUrl: https://uac.zte.com.cn # HTTPS required at startup/merge +userSearchUrl: https://icenterapi.zte.com.cn/... loginSystemCode: '100000455558' -originSystemCode: '' +initialAdminEmpNo: '' +allowOpenRegistration: true +allowRawAgentToken: false +outboundAllowedHosts: icenterapi.zte.com.cn,icentermsg.dt.zte.com.cn +trustedHosts: '' # empty = loopback Host only; proxies must set allowlist retainSkillCredentialsOnLogout: true skillCredentialTtlSeconds: 604800 -outboundAllowedHosts: icenterapi.zte.com.cn,icentermsg.dt.zte.com.cn ``` -Skill auth standard (Chinese): [docs/skill-auth-standard.zh.md](docs/skill-auth-standard.zh.md). +Durable state defaults to `~/.uds-auth/` (roles / owners / credentials / bridge / sessions / `config.runtime.json`; override with `UDS_AUTH_DATA_DIR`). Security toggles take effect on the next request after Settings save. Empty outbound allowlist denies all. -Bundled skill: [skills/uds-skill-auth](skills/uds-skill-auth). Handoff notes: [docs/uds-skill-auth.zh.md](docs/uds-skill-auth.zh.md). +## Sessions -Loopback agent APIs: `GET|POST /uds-auth/agent-credentials`, `POST /uds-auth/outbound`. +- **Lifetime**: 30-minute idle timeout that slides on activity, 7-day absolute cap. The Desktop bridge token slides with its session. +- **Survives restarts**: the default in-memory store is persisted to `~/.uds-auth/sessions.json` and restored on Host start (emergency admins have no SSO re-login, so they depend on this). + - Only the SHA-256 hash of each session token is stored; user data (may include the UAC token) is AES-256-GCM encrypted with the plugin key `.uds-auth-secret-key`. + - Expired, undecryptable or tampered rows are dropped on load; disabled-account and emergency-password rotation checks still run on every request. + - **Delete `sessions.json` and restart to sign everyone out.** If the key file is unavailable, sessions are simply not persisted. +- Not used when a Redis session store is configured. -### Local admin unlock (optional, decrypt-to-login) +## Admin / emergency -1. `node scripts/seal-local-admin.mjs "your-passphrase"` -2. Set printed `UDS_AUTH_LOCAL_ADMIN_BOX=...` on the Harness process (ciphertext only) -3. Login panel → “Unlock with local key” → enter passphrase +- `super_admin` / `admin` / `fallback_admin` share the same admin-class permissions (identity labels, not stronger isolation). +- No shared default password. Set emergency password in Settings (≥10 chars; `Admin@123` rejected). Legacy default hashes are cleared on upgrade. +- Local unlock: `node scripts/seal-local-admin.mjs` (interactive / `--stdin` — **no argv passphrase**) → `UDS_AUTH_LOCAL_ADMIN_BOX`. +- Removing a user disables the account by default (open registration cannot auto-revive). -Env alone does **not** grant admin. Legacy `UDS_AUTH_LOCAL_ADMIN_KEY` is ignored. +## DSH compatibility -## Layout +Verified on **DeepSeek Harness Desktop 0.2.0-rc.2**. The plugin relies on DSH internals that are not a public API; after a DSH upgrade run `npm test` and check login, workspaces and Settings → Models on a real install: -| Piece | Path | Role | -|-------|------|------| -| Host | `lib/index.js` | Cordis `apply`: settings, RPC, `/uds-auth/*` | -| Client | `lib/client.js` | ModuleLoader + React footer login card | -| Bundle | `cordis.patch.yml` | layer `insert` only | -| Meta | `package.json` `dsh.client` | `./client` + slots inject | +- Gateway `prepareInvocation` / `resolveDescriptor` / `openRemoteEvents` / `receiveRemoteEventResult` (if missing, the ACL refuses to install instead of failing open); +- the client `connection/reset` event (emitted after login so boot-time caches such as the settings mirror re-read); +- endpoint names `workspace/follow`, `workspace/initializeDefault`, and the Desktop welcome reads `settings/describe`, `llm/listConfigurableProviders`, `credentials/describe`, `account/getState`. -The login panel uses `position: fixed` with a measured trigger anchor (same pattern as CordisPanel) so the sidebar overflow clip cannot hide it. +Desktop opens its connection anonymously at boot and reconnects after login. The plugin therefore: +- parks anonymous stream calls (e.g. account state) without data instead of failing them (a failure is terminal for DSH and shows "sign-in failed / add API key"); +- answers anonymous `workspace/follow` with an empty list and anonymous `workspace/initializeDefault` with "nothing created"; +- holds `/api/*` requests for up to 15 s while the gateway ACL is still installing, instead of returning 403. + +## Skill / cron + +Prefer Host-minted `UDS_TASK_CAPABILITY` + `DSH_SESSION_ID` with `/uds-auth/outbound`. Raw `/agent-credentials` stays off unless explicitly enabled. Python helper: `skill-helpers/python/uds_skill_auth.py`. + +Chinese docs: [README.zh.md](README.zh.md), [docs/skill-auth-standard.zh.md](docs/skill-auth-standard.zh.md). ## License diff --git a/uds-auth/README.zh.md b/uds-auth/README.zh.md index 5703ac07..6fffe0a8 100644 --- a/uds-auth/README.zh.md +++ b/uds-auth/README.zh.md @@ -1,71 +1,95 @@ # uds-auth -DeepSeek Harness 的 UDS 统一认证插件:右上角登录徽章 + 设置页用户管理。 +0.3.10: [升级与协议变更说明](docs/upgrade-0.3.10.zh.md) · [SBOM](docs/sbom.cdx.json) · [第三方许可证](docs/THIRD-PARTY-NOTICES.txt) + +DeepSeek Harness 的 UDS 统一认证插件(**受信任单实例**访问控制增强,**不支持**互不信任多租户 / 任意代码执行隔离)。 + +当前包版本见 `package.json`(0.3.x 起为安全协议破坏性变更:须重新登录)。 + +## 支持范围 + +| 项 | 说明 | +|----|------| +| 运行时 | Node `^22.19.0 \|\| >=24`(与 DSH Host engines 对齐) | +| 部署 | 单 Host、受信任企业用户、受控 skill | +| 不支持 | 不可信多租户、用户自上传任意代码的强隔离、多实例未共享 session store 时的会话一致性 | ## 安装 ```bash -dsh plugin --profile web add -w "D:/project/chatgpt/oclaw/uds-auth" +# 开发树 +dsh plugin --profile web add -w "<绝对路径>/oclaw/uds-auth" + +# 或干净 tgz +dsh plugin add -w "<绝对路径>/uds-auth-0.3.9.tgz" ``` -安装后重启 Harness。登录徽章在 `shell.overlay`(右上角);用户管理/部署配置在 **设置 → UDS 认证**(`settings.section`)。 +首次空角色表须设置初始超管:环境变量 `UDS_AUTH_INITIAL_ADMIN=<工号>` 或配置 `initialAdminEmpNo`。 -`0.2.21` 提供仅供 Host 插件使用的 `ctx.get('udsAuth').runAsHost(callback)`:在回调及其异步操作内使用 Host 后台上下文,并恢复调用者的网页身份。配合 `dsh-im-ops >= 4.9.1-ops.38` 修复 WhatsApp 后台建会话误报“登录后才能创建会话”;更新两者后重启 Host。该方法不增加远程端点,网页登录及工作区 ACL 保持生效。 +安装后重启 Harness。登录徽章在 `shell.overlay`;用户管理在 **设置 → UDS 认证**。 + +`ctx.get('udsAuth').runAsHost(callback)`:Host 后台上下文。`mintTaskCapability({ empNo, dshSessionId, scopes, ttlSeconds })` 为 cron/skill 签发短期能力令牌(注入 `UDS_TASK_CAPABILITY`)。 ## 配置 -在 **设置 → UDS 认证** 或 `cordis.patch.yml` 中修改: - ```yaml -uacBaseUrl: https://uac.zte.com.cn -userSearchUrl: https://icenterapi.zte.com.cn/zte-km-icenter-addresearch/user/plain/docs/search +uacBaseUrl: https://uac.zte.com.cn # 启动/合并时强制 HTTPS +userSearchUrl: https://icenterapi.zte.com.cn/... loginSystemCode: '100000455558' -originSystemCode: '' +initialAdminEmpNo: '' # 或环境变量 UDS_AUTH_INITIAL_ADMIN +allowOpenRegistration: true # false 时禁止未知用户自动注册 +allowRawAgentToken: false # 原始 agent-credentials 默认关闭 +outboundAllowedHosts: icenterapi.zte.com.cn,icentermsg.dt.zte.com.cn +trustedHosts: '' # 精确 Host 白名单;空=仅 loopback Host +retainSkillCredentialsOnLogout: true +skillCredentialTtlSeconds: 604800 ``` -## 标准 DSH 插件结构 +状态文件默认在 `~/.uds-auth/`(roles / owners / credentials / bridge / sessions / `config.runtime.json`)。可用环境变量 `UDS_AUTH_DATA_DIR` 覆盖。从安装目录升级时,若新数据目录为空且安装根仍有旧 `roles.json` 等,启动会一次性迁移(幂等、不覆盖已有目标文件)。公网或反代必须配置 `trustedHosts`。默认目录为**用户级**共享路径,不是按 DSH profile 隔离;单实例部署须知悉该边界。 -| 部分 | 路径 | 说明 | -|------|------|------| -| Host | `lib/index.js` | Cordis `apply`:HTTP `/uds-auth/*`、可选 schema 设置 | -| Client | `lib/client.js` | 右上角登录 + 设置页用户管理 | -| Bundle | `cordis.patch.yml` | `insert` 插件层(不含 client.entry) | -| Meta | `package.json` → `dsh.client` | `exports["./client"]` + slots inject | +安全开关经设置保存后**立即生效**(无需重启)。空 `outboundAllowedHosts` 表示拒绝全部出站。 -## API +## 会话 -- `POST /uds-auth/qr-start` — 服务端生成扫码挑战 -- `GET /uds-auth/qr?data=` — 二维码 SVG -- `POST /uds-auth/qr-proxy` — 代理 UAC 扫码校验 -- `GET /uds-auth/api/me` — 当前用户 -- `POST /uds-auth/api/logout` — 登出(UI 会话;skill 凭证是否保留见配置) -- `GET|POST /uds-auth/agent-credentials` — **loopback**:按 `DSH_SESSION_ID` 取 skill 用 empNo+token -- `POST /uds-auth/outbound` — **loopback**:白名单出站并注入鉴权头 -- 用户管理 / 兜底管理员:见 `/uds-auth/api/users*`、`/uds-auth/api/fallback/*` -- **默认兜底账号**(扫码不可用时):用户名 `administrator`,密码 `Admin@123`(首次启动自动启用;可在设置中改密或关闭) -- **本机密钥解锁(可选,解密才登录)**: - 1. 生成密封盒:`node scripts/seal-local-admin.mjs "你的口令"` - 2. 把输出的 `UDS_AUTH_LOCAL_ADMIN_BOX=...` 设到 **Harness 进程环境**(这是密文,不是口令) - 3. 登录面板 →「本机密钥解锁」→ 输入口令;**必须解密成功才有 admin** - 仅设置环境变量、不知道口令 → **无法登录**。旧变量 `UDS_AUTH_LOCAL_ADMIN_KEY` 已忽略。 -- **ACL / 侧栏**:未登录与普通用户看不到设置齿轮;**仅超管/应急**可见设置。`admin` 无设置齿轮,但可看渠道/系统会话。`super_admin` / 兜底默认可见全部会话(可关)。布局:设置在左、登录在右。 +- **有效期**:30 分钟无操作过期,有操作自动续期;从登录起最长 7 天。Desktop 的 bridge 令牌随会话一起续期。 +- **重启不掉线**:默认内存会话会写入 `~/.uds-auth/sessions.json`,Host / Harness 重启后自动恢复(应急账号没有 SSO 自动重登,尤其依赖这一点)。 + - 文件只保存会话令牌的 SHA-256 哈希,不保存令牌本身;用户信息(可能含 UAC token)用插件密钥 `.uds-auth-secret-key` 做 AES-256-GCM 加密。 + - 加载时丢弃已过期、解密失败或被篡改的记录;停用账号、应急口令轮换等检查仍在每次请求时执行。 + - **删除 `sessions.json` 并重启 = 让所有人重新登录**。密钥文件丢失时会话不再持久化(不影响插件启动)。 +- 配置 Redis 会话存储时不使用该文件。 -## Desktop 离线 tgz(无 npm 外网) +## 管理员与应急 -**≥0.2.20**:`qrcode` 已打进 `lib/qrcode-bundled.cjs`,`dependencies` 为空。 -打包机:`npm pack`(会跑 `prepack` 重打 bundle)。现场 Desktop「添加插件」填 **绝对路径** `D:\…\uds-auth-0.2.20.tgz`,装包日志不应再出现 `registry.npmjs.org/qrcode`。 +- **三类管理员同权**(`super_admin` / `admin` / `fallback_admin`):用户管理、设置、建工作区;标签不是更强隔离。 +- **无共享默认密码**。须在设置中显式设置应急口令(≥10 位,禁止 `Admin@123`)。旧安装若仍存该默认 hash,升级后自动禁用直至重置。 +- **本机密钥解锁**:`node scripts/seal-local-admin.mjs`(交互输入,勿把口令放 argv)→ 设置 `UDS_AUTH_LOCAL_ADMIN_BOX` → 登录面板解密。 +- **停用账号**:删除用户默认写入 disabled 状态,不会因开放注册自动复活。 -## Skill 认证(给他人改造 skill 时) +## Skill / cron -发整个 **uds-auth 插件** 即可,内含: +1. 标准说明:`docs/skill-auth-standard.zh.md`、`docs/uds-skill-auth.zh.md` +2. Python helper:`skill-helpers/python/uds_skill_auth.py`(与 `skills/...` 同源) +3. 推荐:Host 在任务创建时 `mintTaskCapability`,注入 `UDS_TASK_CAPABILITY` + `DSH_SESSION_ID`,调用 `outbound`;不要依赖浏览器长会话。 +4. `resolve(apply_env_aliases=False)` 默认不写环境变量。 -1. **标准**:[docs/skill-auth-standard.zh.md](docs/skill-auth-standard.zh.md) -2. **公共 skill 说明**:[docs/uds-skill-auth.zh.md](docs/uds-skill-auth.zh.md) -3. **公共 skill 本体**:[skills/uds-skill-auth/](skills/uds-skill-auth/)(随插件提交) +## DSH 兼容性 -现场:装插件 → 把 `skills/uds-skill-auth` 配进 skills 路径 → 扫码 → 再装业务 skill。 +已在 **DeepSeek Harness Desktop 0.2.0-rc.2** 上验证。插件依赖以下 DSH 内部行为(非公开 API),升级 DSH 后请先跑 `npm test` 并实机检查登录、工作区、设置 → 模型: -可配置:`retainSkillCredentialsOnLogout`(默认 true)、`skillCredentialTtlSeconds`、`outboundAllowedHosts`。 +- Gateway 的 `prepareInvocation` / `resolveDescriptor` / `openRemoteEvents` / `receiveRemoteEventResult`(缺失时插件拒绝启动 ACL,不会静默放行); +- 客户端 `connection/reset` 事件(登录后用它让设置缓存等重新读取); +- 端点名 `workspace/follow`、`workspace/initializeDefault`,以及 Desktop 欢迎页使用的 `settings/describe`、`llm/listConfigurableProviders`、`credentials/describe`、`account/getState`。 + +Desktop 启动时连接会先以匿名身份建立,登录后再重连。插件对此的处理: +- 匿名的数据流请求(如账号状态)挂起等待,不返回错误(否则 DSH 会把账号永久标记为失败,弹出「登录失败 / 添加 API Key」); +- 匿名的工作区订阅返回空列表,匿名的「创建默认工作区」返回空结果(不报「无法创建默认工作区」,也不会真的创建); +- Gateway ACL 尚未就绪时,`/api/*` 请求最多等待 15 秒,而不是直接 403。 + +## 升级注意 + +- 旧 USER/UI Cookie、旧 bridge 失效,须重新扫码/登录。 +- 回滚到 0.2.x **不能**作为安全方案。 +- `npm run pack:check` / `npm test` 发布前必跑。 ## License diff --git a/uds-auth/config.default.yaml b/uds-auth/config.default.yaml index 1e5442f1..d61686fb 100644 --- a/uds-auth/config.default.yaml +++ b/uds-auth/config.default.yaml @@ -1,28 +1,19 @@ -# uds-auth 部署配置 -# 在 DSH 设置面板中可修改以下字段 - -# UAC 基础 URL (生产: https://uac.zte.com.cn, 测试: http://uactest.zte.com.cn:8080) +# uds-auth defaults — trusted single-instance (not strong multi-tenant) uacBaseUrl: https://uac.zte.com.cn - -# icenterapi 用户搜索接口 (完整 URL;须内网直连,禁止走 HTTP(S)_PROXY) userSearchUrl: https://icenterapi.zte.com.cn/zte-km-icenter-addresearch/user/plain/docs/search - -# 登录时传给 UAC 的业务系统 Code loginSystemCode: '100000455558' - -# 登录时传给 UAC 的来源系统 Code (留空则不传) originSystemCode: '' - -# Per-user workspaces under workspaceRoot/; empty => $DSH_HOME/user-workspaces -workspaceRoot: "" - -# Skill 凭证:UI 退出时是否保留(默认 true,供 cron/skill 继续用) +workspaceRoot: '' +# Explicit first super_admin when roles.json is empty (also: UDS_AUTH_INITIAL_ADMIN) +initialAdminEmpNo: '' +# When roles already exist, auto-register unknown UAC users as role=user +allowOpenRegistration: true +# Raw /agent-credentials token response (loopback) — off by default; prefer outbound + task capability +allowRawAgentToken: false retainSkillCredentialsOnLogout: true - -# Skill 凭证 TTL(秒),默认 7 天 skillCredentialTtlSeconds: 604800 - -# outbound 白名单 hosts(逗号分隔) outboundAllowedHosts: icenterapi.zte.com.cn,icentermsg.dt.zte.com.cn - -# 兜底管理员默认在首次启动启用:administrator / Admin@123(roles.json 可改密或关闭) +# Optional exact Host allowlist for /uds-auth writes; empty = Origin must match Host only +trustedHosts: '' +# Emergency fallback password is NOT enabled by default. Set via Settings (min 10 chars). +# Legacy Admin@123 hashes are refused on upgrade. diff --git a/uds-auth/docs/THIRD-PARTY-NOTICES.txt b/uds-auth/docs/THIRD-PARTY-NOTICES.txt new file mode 100644 index 00000000..ba3994d2 --- /dev/null +++ b/uds-auth/docs/THIRD-PARTY-NOTICES.txt @@ -0,0 +1,35 @@ +dijkstrajs 1.0.3 (MIT) + +``` +Dijkstra path-finding functions. Adapted from the Dijkstar Python project. + +Copyright (C) 2008 + Wyatt Baldwin + All rights reserved + +Licensed under the MIT license. + + http://www.opensource.org/licenses/mit-license.php + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +``` + +--- + +qrcode 1.5.4 (MIT) + +The MIT License (MIT) + +Copyright (c) 2012 Ryan Day + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/uds-auth/docs/sbom.cdx.json b/uds-auth/docs/sbom.cdx.json new file mode 100644 index 00000000..d846d9c4 --- /dev/null +++ b/uds-auth/docs/sbom.cdx.json @@ -0,0 +1,109 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "metadata": { + "component": { + "type": "application", + "name": "uds-auth", + "version": "0.3.11", + "bom-ref": "pkg:npm/uds-auth@0.3.11", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ] + }, + "properties": [ + { + "name": "uds-auth:bundle-sha256", + "value": "1ca76135efb5f93975793da04024409ef215d8b595d5b5194707e3acc693a15f" + }, + { + "name": "uds-auth:external-peer", + "value": "React supplied by DSH; audit its exact version in the Host SBOM" + }, + { + "name": "uds-auth:scope", + "value": "Actual vendored runtime bundle; development dependency audit is a separate npm audit --include=dev gate" + } + ] + }, + "components": [ + { + "type": "library", + "name": "dijkstrajs", + "version": "1.0.3", + "purl": "pkg:npm/dijkstrajs@1.0.3", + "bom-ref": "pkg:npm/dijkstrajs@1.0.3", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "properties": [ + { + "name": "uds-auth:delivery", + "value": "bundled in lib/qrcode-bundled.cjs" + }, + { + "name": "npm:integrity", + "value": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==" + }, + { + "name": "uds-auth:source-files", + "value": "[{\"file\":\"dijkstrajs/dijkstra.js\",\"sha256\":\"7f5721e77332370a71314f1536b8c552f4f9dbd99bbc6c5aa10df3253eacfd5c\"}]" + } + ] + }, + { + "type": "library", + "name": "qrcode", + "version": "1.5.4", + "purl": "pkg:npm/qrcode@1.5.4", + "bom-ref": "pkg:npm/qrcode@1.5.4", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "properties": [ + { + "name": "uds-auth:delivery", + "value": "bundled in lib/qrcode-bundled.cjs" + }, + { + "name": "npm:integrity", + "value": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==" + }, + { + "name": "uds-auth:source-files", + "value": "[{\"file\":\"qrcode/lib/can-promise.js\",\"sha256\":\"fd18906803bd4cfe4f23801a7032c5a1e10be31274ba25dc34541932cdd8f253\"},{\"file\":\"qrcode/lib/core/utils.js\",\"sha256\":\"1b66145cb9afddadf46ea7314b168e0b8369b2ab4938256fe64bd1c465a473bd\"},{\"file\":\"qrcode/lib/core/error-correction-level.js\",\"sha256\":\"7f31b2250223cb7549bf3c305cce948ad9b0ee67cb9ee93b6c08352b7fa2b7f6\"},{\"file\":\"qrcode/lib/core/bit-buffer.js\",\"sha256\":\"c6f982814aa373da6e44183a61f75762c8559909087fff76603885b180bac0ad\"},{\"file\":\"qrcode/lib/core/bit-matrix.js\",\"sha256\":\"77e051929fbc9d3b3ba91bd9b190f9a1ab3ccea162d627696ad2c01bf41e51bd\"},{\"file\":\"qrcode/lib/core/alignment-pattern.js\",\"sha256\":\"7e642a801c5c3d114bcceeeb08a3930369ec16fe874bdb617f02efb43aaea9ba\"},{\"file\":\"qrcode/lib/core/finder-pattern.js\",\"sha256\":\"e5808e9b8ed06fd7c3ed201a03f7e7341a5c80f6a8b4cc78c9a94c3ee0ddd6e2\"},{\"file\":\"qrcode/lib/core/mask-pattern.js\",\"sha256\":\"c8cfdab7b8d8276f9c1128ab8e5d930759df7594f03211f72b80c3002af91aa9\"},{\"file\":\"qrcode/lib/core/error-correction-code.js\",\"sha256\":\"03683cae6cc410b83f8ad0e87fa394df5102b52ca4ce843e507aa9f6acce13e5\"},{\"file\":\"qrcode/lib/core/galois-field.js\",\"sha256\":\"d487207ce5040b49f95441bedafb6dbc9c010b245a0f0f47b9e3f10f8d9530a6\"},{\"file\":\"qrcode/lib/core/polynomial.js\",\"sha256\":\"34cb4e4216fc51fe75ac1c4bb00c4f4fc118a51da50c8250aec951062ff3a3c4\"},{\"file\":\"qrcode/lib/core/reed-solomon-encoder.js\",\"sha256\":\"080273fcf5a73c2348330f3edb55f3968c78853e298dc55a074becd7cee3faa2\"},{\"file\":\"qrcode/lib/core/version-check.js\",\"sha256\":\"9cffe3a6f9cbb978022c0d0b1652fb2f3d381b9e48b7e01dfc744ded28736da2\"},{\"file\":\"qrcode/lib/core/regex.js\",\"sha256\":\"974d9358148d11c8e48713ef2da8fc12e597b76f4e6fc6bde00cfe7c8094efa1\"},{\"file\":\"qrcode/lib/core/mode.js\",\"sha256\":\"25d3574cf4b0cdbd62363ff2c57eb46da41d21124e6d37eb0e84f979799bd530\"},{\"file\":\"qrcode/lib/core/version.js\",\"sha256\":\"1221665a29e6927d3823912bde8bd711c114c61188d7ce862997a33d47fcfba9\"},{\"file\":\"qrcode/lib/core/format-info.js\",\"sha256\":\"889b133cd6a86f4079a3a5d1dfd94c495ca71d4fde4ec88ce9e9a5de358629d0\"},{\"file\":\"qrcode/lib/core/numeric-data.js\",\"sha256\":\"3e506376e4c84b88162024c1d840b5bc3721da3b0d02f18784161adea5e3b2e8\"},{\"file\":\"qrcode/lib/core/alphanumeric-data.js\",\"sha256\":\"cf8f81197e02c0db069155a2e82c098d26329019cfd06cc0e4479124636fa1ea\"},{\"file\":\"qrcode/lib/core/byte-data.js\",\"sha256\":\"24614cb942165dcc5378117a951cc1d68fbfc2e60043830111ae99283cd9aa94\"},{\"file\":\"qrcode/lib/core/kanji-data.js\",\"sha256\":\"2dcd7d5aee492392e0c47976ae718e9783c5b13234487804ed21558fbd4a62de\"},{\"file\":\"qrcode/lib/core/segments.js\",\"sha256\":\"abd08a6ddd778c92e36e2a303d2b4a3b4ad1d9089d2a7605bee8b9d06da7846a\"},{\"file\":\"qrcode/lib/core/qrcode.js\",\"sha256\":\"216e490908ed95df74992a05d44efa35ea7bc8dcab27b49b446725964704e833\"},{\"file\":\"qrcode/lib/renderer/utils.js\",\"sha256\":\"03ffb1b5f7e629bef61a83835107b391efc0b7a71ab9329a950708b20f361b87\"},{\"file\":\"qrcode/lib/renderer/canvas.js\",\"sha256\":\"0ce857661e234786295c9b4042cad6dc1f4d7a496c4990f52870d2bc4ab1e6a9\"},{\"file\":\"qrcode/lib/renderer/svg-tag.js\",\"sha256\":\"2f733e88d0b0155fae4aa4827de0fb9ae73cbbfdfc0cae52dc9d5ed17ae0447d\"},{\"file\":\"qrcode/lib/browser.js\",\"sha256\":\"868da79917b05db1f3d4402f4875148fb06ce843cbd74c65429cd6ef960b1638\"}]" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:npm/uds-auth@0.3.11", + "dependsOn": [ + "pkg:npm/dijkstrajs@1.0.3", + "pkg:npm/qrcode@1.5.4" + ] + }, + { + "ref": "pkg:npm/dijkstrajs@1.0.3", + "dependsOn": [] + }, + { + "ref": "pkg:npm/qrcode@1.5.4", + "dependsOn": [] + } + ] +} diff --git a/uds-auth/docs/skill-auth-standard.zh.md b/uds-auth/docs/skill-auth-standard.zh.md index 9fd32c23..7a201a69 100644 --- a/uds-auth/docs/skill-auth-standard.zh.md +++ b/uds-auth/docs/skill-auth-standard.zh.md @@ -35,12 +35,15 @@ ## Agent 环境 -Shell 已注入(非密钥): +Shell / 任务 env(非密钥除非 Host 显式注入 capability): - `DSH_SESSION_ID` — 当前 agent 会话 - `DSH_WEB_URL` — 可选,用于拼 Host 地址 +- `UDS_TASK_CAPABILITY` — **推荐**:Host/`mintTaskCapability` 签发的短期令牌(scopes: `outbound` 和/或 `credentials`) -Host 在 `session/created` 时会 `stampSessionOwner(sessionId, empNo)`。Cron 可按 `ownerEmpNo` 解析。 +Host 在 `session/created` 时会 `stampSessionOwner(sessionId, empNo)`。Cron 可按 `ownerEmpNo` 解析并 mint capability;**不要**依赖浏览器 Cookie。 + +账号停用/删除会撤销该用户全部 task capability。 ## 两种合法模式 @@ -49,13 +52,15 @@ Host 在 `session/created` 时会 `stampSessionOwner(sessionId, empNo)`。Cron ```http POST /uds-auth/outbound X-DSH-Session-Id: +X-UDS-Task-Capability: # scope 须含 outbound { "url": "https://icenterapi.zte.com.cn/...", "method": "POST", "headers": {...}, "body": {...} } ``` - 仅 **loopback** - Host 注入 `X-Emp-No` / `X-Auth-Value` - 剥离客户端自带鉴权头 -- host 必须在白名单内 +- host 必须在白名单内;URL 必须 **HTTPS** +- capability 仅 `credentials` scope 时 **不能** 调 outbound Python(先把 `uds-skill-auth/scripts` 加入 `sys.path`): @@ -67,16 +72,20 @@ out = request("POST", url, headers={...}, body={...}) ### Mode Creds(必须自管 HTTP 时) +默认 `allowRawAgentToken=false`。开启后: + ```http POST /uds-auth/agent-credentials X-DSH-Session-Id: +X-UDS-Task-Capability: # scope 须含 credentials ``` -返回 `{ empNo, token }`。可设进程内 `EMP_NO` / `AUTH_VALUE` 别名,**禁止**写进 SKILL.md 教模型 `printenv`。 +返回 `{ empNo, token }`。可设进程内 `EMP_NO` / `AUTH_VALUE` 别名,**禁止**写进 SKILL.md 教模型 `printenv`。仅 `outbound` scope 的 capability **不能**取 raw token。 ```python from uds_skill_auth import resolve -creds = resolve() # empNo + token;默认写入 EMP_NO/AUTH_VALUE +# 优先用 request()/outbound;仅在 allowRawAgentToken=true 时使用 resolve +creds = resolve(apply_env_aliases=False) ``` Helpers 来源:插件内 skill `uds-auth/skills/uds-skill-auth/scripts/`(装到 skills 路径后,或环境变量 `UDS_AUTH_HELPERS`)。 diff --git a/uds-auth/docs/uds-skill-auth.zh.md b/uds-auth/docs/uds-skill-auth.zh.md index d547387d..ec4e90ce 100644 --- a/uds-auth/docs/uds-skill-auth.zh.md +++ b/uds-auth/docs/uds-skill-auth.zh.md @@ -12,9 +12,11 @@ uds-auth/skills/uds-skill-auth/ 1. 安装 uds-auth 插件 2. 把 `uds-auth/skills/uds-skill-auth` 配进 Harness 的 skills 目录(或复制到工作区 `skills/uds-skill-auth`) -3. 用户扫码登录 +3. 用户扫码登录(或由 Host 为 cron 注入 `UDS_TASK_CAPABILITY`) 4. 业务 skill 通过 sibling / skills 根找到本 skill 的 `scripts/` +推荐:**优先** `request()` / outbound;原始 `resolve()` 依赖 `allowRawAgentToken`(默认关闭)。后台任务使用 `UDS_TASK_CAPABILITY`,不要依赖浏览器 Cookie。 + ## 业务 skill 引用 ```python @@ -33,8 +35,10 @@ def load_uds_skill_auth(): | 函数 | 用途 | |------|------| -| `resolve()` | `POST /uds-auth/agent-credentials` → empNo+token | -| `request(method, url, ...)` | `POST /uds-auth/outbound` 出站代贴鉴权 | +| `resolve()` | 原始凭证(需显式开启 raw;可带 `UDS_TASK_CAPABILITY`) | +| `request(method, url, ...)` | `POST /uds-auth/outbound`(推荐;支持 task capability) | | `UdsAuthError` | 未登录 / 连不上 Host 等 | +环境变量:`DSH_SESSION_ID`、`UDS_TASK_CAPABILITY`(Host `mintTaskCapability` 签发)。 + 完整契约:[skill-auth-standard.zh.md](./skill-auth-standard.zh.md) diff --git a/uds-auth/docs/upgrade-0.3.10.zh.md b/uds-auth/docs/upgrade-0.3.10.zh.md new file mode 100644 index 00000000..5ec7d248 --- /dev/null +++ b/uds-auth/docs/upgrade-0.3.10.zh.md @@ -0,0 +1,41 @@ +# 升级到 0.3.10 + +本版修复密码并发写入、Desktop 凭据附带范围、Gateway 全局接口遗漏、扫码浏览器绑定、任务授权注入和安全状态迁移。先备份 profile 中的安全状态及密钥,再替换插件并重启 Host;不要删除 roles/owners 文件,也不要恢复历史共享默认密码。 + +## 登录和 Desktop + +应急密码入口始终显示;未设置密码时保持禁用。已登录的授权管理员可在「账号 / 权限」设置至少 10 位的新密码。Local Admin 密封盒解锁保持独立,轮换或关闭应急密码不撤销密封盒会话。 + +Desktop 只把 bridge 附到 `dsh-app://app` 或 Host 启动时提供的 `__DSH_TRANSPORT__.streamBaseUrl` 对应 authority。启动信息缺失时绝不向其他本机端口外发凭据;需要支持提供这个正式 transport 字段的 DSH 版本。带 bridge 的 fetch 拒绝重定向,保留原 Request headers。一次性 WS ticket 只用于 GET `/api/remote.mux` 的真正 WebSocket upgrade;普通 HTTP、其他路径和重放均无效。构造 WebSocket 仍采用同步 XHR 取票,需真实 Desktop 验证该协议桥。 + +## 扫码协议变更 + +`qr-start` 返回 `browserBinding`,浏览器仅在内存中保存,轮询 `qr-proxy` 时通过 `X-UDS-QR-Binding` 发送。该秘密不出现在二维码、URL 或转发给 UAC 的 body 中。服务端固定发送挑战绑定的系统代码并计算 verifyCode。 + +当前明确支持的 UAC 契约是 outer `code.code=0000`、`bo.code=0000`,凭据来自 `other.account/empNo` 和 `other.token/authValue`;`4002` 保持等待,`1002` 表示过期。成功结果还必须通过严格工号匹配的用户资料校验。服务端在异步校验前原子预留成功兑换,直接返回 `auth` 本地会话;前端不再保存 SSO cookie,也不再依次调用 user-info 和 bridge/bind。无法识别的响应拒绝登录,不返回上游原文。外部 QR 客户端须同步适配此变更;真实 UAC 响应仍需现场核验。 + +## Skill、cron 和后台 shell + +Host 加载 `shellEnv` 时自动注册 uds-auth contributor。每次 shell 执行根据真实 `execution.agent.session.header.id` 和服务端 owner 签发仅有 `outbound` scope 的 capability,默认 10 分钟,注入受保护的 `DSH_UDS_TASK_CAPABILITY` / `DSH_UDS_AUTH_BASE`。Python helper 优先读取这两个变量;兼容的手工 `UDS_TASK_CAPABILITY` / `UDS_AUTH_BASE` 仅在没有 Host 管理变量时使用。所有 base,包括旧 `DSH_WEB_URL`,都必须是 loopback HTTP(S)。不再猜测 8787 端口,也不允许 remote override 携带凭据。 + +普通执行结束或取消即撤销。官方 shell 交给 jobs 的 background/promoted 执行绑定该 job,停止、结算、移除或 TTL 到期撤销;不跨任务复用。账号停用、owner 改变和 scope 不符均拒绝代理调用。长于 10 分钟的后台进程不能自动续期;应拆为新的受控调用。前台/后台都不会向 shell 注入 UAC 原始 token。需要 raw credentials 的受信 Host 调用必须显式申请 scope,且仍受 `allowRawAgentToken` 开关控制。 + +配套 `dsh-ops-cron` 已把 owner 校验和盖章前移到 agents.create/followup 之前,使用 uds-auth 时 owner 缺失/停用/冲突会拒绝执行。单独无 uds-auth 的 cron 使用方式保持可用。请同时部署本次 cron 变更。 + +## Gateway、持久化与权限 + +统一 Gateway 检查覆盖实际 `prepareInvocation` 路径:包括 unary、HTTP RPC 和 WS stream;每帧复核会话有效性、账号状态及 owner。现有资源接口继续调用各自 ACL;带 agent/workspaceFileScope/sessionId 的接口校验目标会话;其余全局接口默认要求管理员(包括账户、插件管理和未知新接口)。session/control baseline 和增量按可见会话投影。普通用户需要新增全局功能时,应先审查接口,再显式加入策略。 + +Gateway 内置 `$events` 同样校验登录和每帧有效性,按 owner 过滤会话事件、goal、Agent 审批和问答;普通用户只接收显式允许的公共目录变更,其他全局事件要求管理员。`$events/result` 必须来自创建该事件流的同一个本地登录 session,且仍能访问目标 Agent,避免跨用户代答审批。 + +该统一检查依赖当前 DSH `0.2.0-rc.2` 的 Gateway 内部方法;不兼容时拒绝加载,不能静默略过。升级 Host 前须重跑真实 Loader/Gateway 集成检查和完整 inventory。 + +roles 全部运行时写入串行化,未提交的权限不对读者生效。密码 generation 落盘并在重启时恢复;失败事务恢复角色、状态、偏好及口令,不让后台计时器写回失败状态。运行时 scrypt 使用异步线程池,最多 2 个并发、8 个等待,超载返回错误。 + +迁移使用排他复制、不覆盖冲突状态;相同的中断副本可继续,失败清理本次已创建且仍与源相同的文件,成功 marker 原子落盘。原子写入失败清理临时文件。 + +Unix profile 目录/文件为 0700/0600。Windows profile 及已知安全状态文件 DACL 仅允许当前 Host 账号和 SYSTEM,关闭继承,并实际读取 ACL 验证;失败拒绝初始化。服务账号部署须使用该账号执行初始化;换账号前由授权运维迁移所有权和 ACL。此机制不限制管理员提升权限后的访问。临时目录 DACL 测试不等于完整的第二个 Windows 普通账号读取测试。 + +## 交付检查 + +仓库提供 `npm test`(自然退出)、`npm run pack:check`、`npm run sbom`;CI 覆盖 Windows/Linux、Node 22.19/24、重建二维码、包检查及含开发依赖的 npm audit。`docs/sbom.cdx.json` 精确记录实际 bundled qrcode/dijkstrajs 和源文件哈希;`THIRD-PARTY-NOTICES.txt` 保留其许可证。DSH/React/Electron 属于 Host 的独立供应链,须结合正式 Host SBOM 检查。CI 配置落地不代表远端任务已经运行。 diff --git a/uds-auth/docs/upgrade-0.3.11.zh.md b/uds-auth/docs/upgrade-0.3.11.zh.md new file mode 100644 index 00000000..9a5d0eaa --- /dev/null +++ b/uds-auth/docs/upgrade-0.3.11.zh.md @@ -0,0 +1,18 @@ +# 0.3.11:原版 Desktop 启动兼容修复 + +无需修改、重新打包或替换 DSH。修复全部在 uds-auth npm 包内。 + +0.3.10 的 Gateway 默认权限策略阻止了 Desktop 原生欢迎页在 UDS 登录前读取 `settings/describe`,宿主将业务拒绝当作启动失败,显示 `desktop welcome: Web RPC failed`。 + +0.3.11 对四个已有的、非流式 RPC 提供最小启动投影: + +- `settings/describe`:仅语言偏好和虚拟启动命名空间,声明不可写且无本地文档。 +- `llm/listConfigurableProviders`:仅虚拟启动条目,不枚举实际供应商配置。 +- `credentials/describe`:仅接受固定虚拟引用 `UDS_AUTH_DESKTOP_PROVIDER_CONFIGURED`,返回“是否配置过供应商密钥”的合并布尔值,永远不可写。其他引用拒绝。 +- `account/getState`:仅 Host 平台凭据是否存在的状态,登录尝试为 null,链接固定为公开平台页面。 + +这些投影不构造 UDS 身份,不赋予角色,不开放设置写入、凭据读写、会话或 WS 流。经过登录的管理员和可信 Host 调用仍获得原始权限对应的结果;普通用户只有投影,已撤销的登录身份仍拒绝。 + +如果 Host 还没有任何供应商凭据,原版 Desktop 可能仍显示自身的欢迎页;点击 Skip/跳过进入工作区后由 UDS 登录界面接管。原生欢迎页的账户登录和密钥写入仍须管理权限;请通过 UDS 管理员登录后的设置配置供应商。 + +安装 0.3.11 后重启 Desktop。请保持 0.3.10 的安全配置和角色数据,不要通过停用 uds-auth 或放行整个设置命名空间绕过此问题。 diff --git a/uds-auth/lib/agent-auth.js b/uds-auth/lib/agent-auth.js index 2af999cc..eb7e1193 100644 --- a/uds-auth/lib/agent-auth.js +++ b/uds-auth/lib/agent-auth.js @@ -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) { diff --git a/uds-auth/lib/api.js b/uds-auth/lib/api.js index 0a75bb54..5d943c1b 100644 --- a/uds-auth/lib/api.js +++ b/uds-auth/lib/api.js @@ -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 + } } diff --git a/uds-auth/lib/client.js b/uds-auth/lib/client.js index ad25cfd0..2c0e2f53 100644 --- a/uds-auth/lib/client.js +++ b/uds-auth/lib/client.js @@ -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) } }) diff --git a/uds-auth/lib/config.js b/uds-auth/lib/config.js index 1550c114..004ea644 100644 --- a/uds-auth/lib/config.js +++ b/uds-auth/lib/config.js @@ -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 } diff --git a/uds-auth/lib/desktop-bootstrap.js b/uds-auth/lib/desktop-bootstrap.js new file mode 100644 index 00000000..d6157647 --- /dev/null +++ b/uds-auth/lib/desktop-bootstrap.js @@ -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') + } + } +} diff --git a/uds-auth/lib/dsh-acl.js b/uds-auth/lib/dsh-acl.js index 73e15a30..c863c07b 100644 --- a/uds-auth/lib/dsh-acl.js +++ b/uds-auth/lib/dsh-acl.js @@ -5,11 +5,16 @@ import { createRequire } from 'node:module' import { resolve as resolvePath, sep as pathSep } from 'node:path' import { withUserContext, getUserContext, runWithUserContext, enterUserContext } from './context.js' import { resolveLocale, t } from './i18n.js' -import { readBridgeFromRequest } from './session-bridge.js' -import { rememberBrowserIdentity, peekBrowserIdentity } from './identity-cache.js' +import { extractSessionBearer, extractSessionBearerAsync } from './session/request-auth.js' +import { isLocalAdminBoxConfigured } from './local-admin.js' +import { installGatewayPolicy } from './gateway-policy.js' +import { createDesktopBootstrap } from './desktop-bootstrap.js' const require = createRequire(import.meta.url) +/** Max time an /api/* request waits for the gateway ACL during Host startup. */ +const ACL_READY_WAIT_MS = 15_000 + /** True when path is outside per-user workspace root (channel/bot/harness cwd). */ export function isOutsideUserWorkspaceRoot(candidatePath, workspaceRoot) { if (!candidatePath) return true @@ -23,26 +28,6 @@ export function isOutsideUserWorkspaceRoot(candidatePath, workspaceRoot) { } } -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 {import('node:http').IncomingMessage} req - * @param {{ sessionStore: any, rolesStore: any }} deps - */ - /** @type {WeakMap} */ const upgradeSocketIdentity = new WeakMap() @@ -50,10 +35,6 @@ function empNoOfIdentity(identity) { return identity?.empNo || identity?.userContext?.empNo || null } -function fallbackBrowserIdentity() { - return peekBrowserIdentity() -} - /** Re-resolve when prior bind left null/empty (do not treat null as final). */ function resolveUpgradeIdentity(req, socket, resolveIdentitySync) { let identity @@ -130,19 +111,15 @@ function bindWebSocketListenersToIdentity(ws, identity) { if (!ws || ws.__udsAuthBound) return ws.__udsAuthBound = true try { ws.__udsAuthIdentity = identity || null } catch { /* ignore */ } - if (empNoOfIdentity(identity)) rememberBrowserIdentity(identity) // Gateway RemoteStreamMuxConnection registers sync `message` listeners that // kick off async pump()/session.follow after the listener returns. - // als.run() exits too early → empNo dropped → "登录后才能访问会话". - // enterWith keeps upgrade-time identity for deferred stream / history work. + // enterWith keeps THIS connection's upgrade-time identity — never a global last-user. const wrap = (listener) => { if (typeof listener !== 'function') return listener return function udsAuthBoundListener(...args) { - // Prefer live socket identity; fall back to last HTTP/WS bridged login. - const live = empNoOfIdentity(identity) ? identity : fallbackBrowserIdentity() - // enterWith only — do NOT wrap in als.run(): run() restores the previous - // store when the sync listener returns, which drops empNo before - // pump()/session.follow (history) continues on the same connection. + const live = empNoOfIdentity(ws.__udsAuthIdentity) + ? ws.__udsAuthIdentity + : (empNoOfIdentity(identity) ? identity : null) enterUserContext(live || null) return listener.apply(this, args) } @@ -158,14 +135,17 @@ function bindWebSocketListenersToIdentity(ws, identity) { * remote.mux streams lose HTTP ALS after upgrade. Bind identity onto ws listeners. * Lazy-load `ws` from Host module cache / cwd — plugin folder cannot require it directly. */ -function patchOneWebSocketServer(WebSocketServer, resolveIdentitySync) { +/** A10: shared live sync resolver for WS prototype upgrade (survives reload). */ +const _wsIdentityLive = { resolveIdentitySync: null } + +function patchOneWebSocketServer(WebSocketServer) { if (!WebSocketServer?.prototype?.handleUpgrade) return false if (WebSocketServer.prototype.handleUpgrade.__udsAuthPatched) return true const orig = WebSocketServer.prototype.handleUpgrade function udsAuthHandleUpgrade(req, socket, head, cb) { let identity try { - identity = resolveUpgradeIdentity(req, socket, resolveIdentitySync) + identity = resolveUpgradeIdentity(req, socket, _wsIdentityLive.resolveIdentitySync) } catch { identity = null } @@ -183,125 +163,163 @@ function patchOneWebSocketServer(WebSocketServer, resolveIdentitySync) { } function patchWebSocketServerForUdsIdentity(resolveIdentitySync) { + if (typeof resolveIdentitySync === 'function') { + _wsIdentityLive.resolveIdentitySync = resolveIdentitySync + } const mods = loadWsModules(typeof require === 'function' ? require : null) if (!mods.length) return false let any = false for (const { mod } of mods) { const WebSocketServer = mod.WebSocketServer || mod.Server - if (patchOneWebSocketServer(WebSocketServer, resolveIdentitySync)) any = true + if (patchOneWebSocketServer(WebSocketServer)) any = true } return any } /** - * Sync ACL identity from cookies + roles (no sessionStore). Used on WS upgrade/messages. + * Normalize session/identity auth mode → sealed_box | fallback | uds. + * Sealed-box and password-fallback must stay independent (A08) — emergency + * password off must not kill a live sealed_box principal (workspace follow). */ -function identityFromCookieOrBridge(req, deps) { - const cookie = req?.headers?.cookie || '' - let empNo = parseCookie(cookie, 'PORTALSSOUser') - || parseCookie(cookie, 'ZTEDPGSSOUser') - || parseCookie(cookie, 'UDS_FALLBACK_USER') - || parseCookie(cookie, 'UDS_FALLBACK_UI') - let token = parseCookie(cookie, 'PORTALSSOCookie') - || parseCookie(cookie, 'ZTEDPGSSOCookie') - let via = empNo ? 'cookie' : null - let isFallback = empNo === 'administrator' - || !!parseCookie(cookie, 'UDS_FALLBACK_USER') - || !!parseCookie(cookie, 'UDS_FALLBACK_UI') - - // Desktop dsh-app://: cookies are dropped — accept verified bridge - // (HTTP headers or WS upgrade query params). - if (!empNo && deps?.sessionBridge) { - try { - const hdr = readBridgeFromRequest(req) - if (hdr) { - const ok = deps.sessionBridge.verify(hdr.empNo, hdr.token) - if (ok) { - empNo = ok.empNo - token = ok.ssoToken || token - via = 'bridge' - isFallback = ok.kind !== 'uds' || empNo === 'administrator' - } - } - } catch { /* ignore */ } +export function classifyAuthKind(input, empNoHint) { + const empNo = String( + empNoHint + || input?.empNo + || input?.userContext?.empNo + || '', + ) + const mode = String( + input?.kind + || input?.authMode + || input?.userContext?.authMode + || input?.userData?.authMode + || '', + ) + if ( + mode === 'local_admin' + || mode === 'sealed_box' + || mode === 'local-admin-unlock' + || mode.includes('local-admin') + ) { + return 'sealed_box' } + if ( + mode === 'fallback' + || mode === 'fallback_password' + || mode === 'fallback-password' + || mode === 'fallback-login' + || mode.includes('fallback') + ) { + return 'fallback' + } + // Legacy administrator sessions without an explicit mode were password-fallback. + // Do not invent fallback when mode is an unknown non-empty string. + if (empNo === 'administrator' && (!mode || mode === 'uds')) { + return 'fallback' + } + return mode || 'uds' +} - if (!empNo) return null - return { empNo: String(empNo), token, via, isFallback } +/** + * Build ACL identity from a verified local session record only. + * Never constructs a principal from empNo cookies or non-empty upstream tokens alone. + */ +export function identityFromSessionRecord(session, rolesStore) { + if (!session?.empNo) return null + const empNo = String(session.empNo) + if (rolesStore && typeof rolesStore.isDisabled === 'function' && rolesStore.isDisabled(empNo)) { + return null + } + // A08: distinguish password-fallback vs sealed-box — do not share one enable switch. + const kind = classifyAuthKind(session, empNo) + const isSealedBox = kind === 'sealed_box' + const isPasswordFallback = kind === 'fallback' + if (isPasswordFallback + && rolesStore && typeof rolesStore.isFallbackEnabled === 'function' + && !rolesStore.isFallbackEnabled()) { + return null + } + // F02/E11: sealed-box sessions require a live configured box (hot disable supported). + if (isSealedBox && !isLocalAdminBoxConfigured()) { + return null + } + // F02: password sessions stamped with older cred version are rejected after rotation. + if (isPasswordFallback && rolesStore && typeof rolesStore.getFallbackCredVersion === 'function') { + const liveVer = rolesStore.getFallbackCredVersion() + const sessVer = session.userData?.fallbackCredVersion ?? session.fallbackCredVersion + if (sessVer != null && Number(sessVer) !== Number(liveVer)) { + return null + } + } + const role = rolesStore.getRole(empNo) + const userContext = { + ...(session.userData || {}), + empNo, + userId: empNo, + isAuthenticated: true, + _sessionId: session.sessionId, + authMode: kind === 'uds' ? (session.userData?.authMode || 'uds') : kind, + } + // Do not expose upstream token on ACL identity objects. + if (userContext.token) delete userContext.token + return { + empNo, + role, + permissions: rolesStore.resolvePermissions(empNo, role), + userContext, + kind, + authMode: userContext.authMode, + sessionId: session.sessionId, + } +} + +function lookupSessionSync(req, deps) { + const sessionStore = deps?.sessionStore + if (!sessionStore || typeof sessionStore.getByBearerSync !== 'function') return null + const extracted = extractSessionBearer(req, { + sessionBridge: deps?.sessionBridge, + isLiveBearer: (bearer) => !!sessionStore.getByBearerSync(bearer), + }) + if (!extracted?.bearer) return null + const session = sessionStore.getByBearerSync(extracted.bearer) + if (!session) return null + if ( + extracted.bridgeEmpNo + && String(extracted.bridgeEmpNo) !== String(session.empNo) + ) { + return null + } + return session } export function resolveIdentityFromRequestSync(req, deps) { - const extracted = identityFromCookieOrBridge(req, deps) - if (!extracted) return null - const { empNo, token, via, isFallback } = extracted - - // Bare portal empNo without token is NOT enough — otherwise logout/未登录 - // still leaks workspace names via leftover SSO cookies on the WebSocket. - if (!isFallback && !token) return null - - const { rolesStore } = deps - const role = rolesStore.getRole(empNo) - return { - empNo: String(empNo), - role, - permissions: rolesStore.resolvePermissions(empNo, role), - userContext: { - empNo: String(empNo), - userId: String(empNo), - isAuthenticated: true, - authMode: isFallback - ? (via === 'bridge' ? 'fallback-bridge' : 'fallback-cookie') - : (via === 'bridge' ? 'bridge-sync' : 'cookie-sync'), - token: token || undefined, - }, - kind: isFallback ? 'fallback' : 'uds', - } + const session = lookupSessionSync(req, deps) + if (!session) return null + return identityFromSessionRecord(session, deps.rolesStore) } export async function resolveIdentityFromRequest(req, deps) { - const extracted = identityFromCookieOrBridge(req, deps) - if (!extracted) return null - const { empNo, token, via, isFallback } = extracted - const { sessionStore, rolesStore } = deps - let userContext = null + const extracted = await extractSessionBearerAsync(req, { + sessionBridge: deps?.sessionBridge, + isLiveBearer: async (bearer) => !!(await sessionStore?.getByBearer?.(bearer)), + }) + if (!extracted?.bearer) return null + let session = null try { - userContext = await sessionStore.get(empNo) + session = await sessionStore.getByBearer(extracted.bearer) } catch { - userContext = null + session = null } - - if (!userContext) { - // Require session, token, or fallback cookie/bridge — never empNo alone. - if (!token && !isFallback) return null - userContext = { - empNo: String(empNo), - userId: String(empNo), - isAuthenticated: true, - authMode: token - ? (via === 'bridge' ? 'bridge-acl' : 'cookie-acl') - : (via === 'bridge' ? 'fallback-bridge' : 'fallback-cookie'), - token: token || undefined, - lastActiveAt: new Date().toISOString(), - } - } - - let role = rolesStore.getRole(empNo) - if (empNo !== 'administrator' && typeof rolesStore.bootstrapFirstUser === 'function') { - try { - const r = await rolesStore.bootstrapFirstUser(empNo) - role = r.role - } catch { /* keep getRole */ } - } - - const permissions = rolesStore.resolvePermissions(empNo, role) - return { - empNo: String(empNo), - role, - permissions, - userContext, - kind: isFallback ? 'fallback' : 'uds', + if (!session) return null + if ( + extracted.bridgeEmpNo + && String(extracted.bridgeEmpNo) !== String(session.empNo) + ) { + return null } + // Never bootstrap roles from ACL identity path. + return identityFromSessionRecord(session, rolesStore) } /** @@ -310,19 +328,55 @@ export async function resolveIdentityFromRequest(req, deps) { * @param {(req: any) => Promise} resolveIdentity * @param {(req: any) => object|null} resolveIdentitySync */ -export function patchWebServerWithIdentity(server, resolveIdentity, resolveIdentitySync) { - if (!server || server.__udsAuthPatched) return () => {} +export function patchWebServerWithIdentity(server, resolveIdentity, resolveIdentitySync, validateRequest) { + if (!server) return () => {} + // R14: keep live resolver refs so reload/re-patch swaps identity store without + // leaving the first patch's closed-over resolvers permanently attached. + const live = server.__udsAuthIdentityLive || (server.__udsAuthIdentityLive = { + resolveIdentity: null, + resolveIdentitySync: null, + }) + live.resolveIdentity = resolveIdentity + live.resolveIdentitySync = resolveIdentitySync + live.validateRequest = validateRequest + if (server.__udsAuthPatched) { + return () => { + /* resolvers already live-updated */ + } + } server.__udsAuthPatched = true + /** + * Desktop main reads settings/llm/credentials/account over /api/* the moment the + * Host reports ready, which can precede our gateway ACL install. A hard 403 there + * makes the native welcome read fail → hasApiKey=false → "add an API Key" prompt + * even with keys stored. Hold (bounded) until the ACL is in place instead. + */ + const validateWhenAclReady = async (req, upgrade) => { + let checked = live.validateRequest?.(req, upgrade) + const deadline = Date.now() + ACL_READY_WAIT_MS + while (checked && !checked.ok && checked.reason === 'gateway_acl_not_ready' && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 50)) + checked = live.validateRequest?.(req, upgrade) + } + return checked + } + const wrap = (handler) => { if (typeof handler !== 'function' || handler.__udsWrapped) return handler const wrapped = async (req, res, ...rest) => { - const syncIdentity = typeof resolveIdentitySync === 'function' - ? resolveIdentitySync(req) - : null + const checked = await validateWhenAclReady(req, false) + if (checked && !checked.ok) { + res.writeHead(403, { 'content-type': 'application/json; charset=utf-8' }) + res.end(JSON.stringify({ ok: false, error: checked.reason })) + return + } + const syncFn = live.resolveIdentitySync + const asyncFn = live.resolveIdentity + const syncIdentity = typeof syncFn === 'function' ? syncFn(req) : null let identity = syncIdentity try { - identity = (await resolveIdentity(req)) || syncIdentity + identity = (typeof asyncFn === 'function' ? await asyncFn(req) : null) || syncIdentity } catch { identity = syncIdentity } @@ -374,28 +428,31 @@ export function patchWebServerWithIdentity(server, resolveIdentity, resolveIdent } const bindUpgradeIdentity = async (req, socket, head, prev) => { - // Retry ws patch until Host has loaded the module. - patchWebSocketServerForUdsIdentity(resolveIdentitySync) - const syncIdentity = typeof resolveIdentitySync === 'function' - ? resolveIdentitySync(req) - : null + const checked = await validateWhenAclReady(req, true) + if (checked && !checked.ok) { + socket?.end?.('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n') + return + } + // A10: always use live bag — never closed-over first-patch resolvers. + const syncFn = live.resolveIdentitySync + const asyncFn = live.resolveIdentity + patchWebSocketServerForUdsIdentity(syncFn) + const syncIdentity = typeof syncFn === 'function' ? syncFn(req) : null let identity = syncIdentity try { - identity = (await resolveIdentity(req)) || syncIdentity + identity = (typeof asyncFn === 'function' ? await asyncFn(req) : null) || syncIdentity } catch { identity = syncIdentity } - // If async path missed bridge query, force a fresh sync resolve. - if (!empNoOfIdentity(identity) && typeof resolveIdentitySync === 'function') { - try { identity = resolveIdentitySync(req) || identity } catch { /* keep */ } + if (!empNoOfIdentity(identity) && typeof syncFn === 'function') { + try { identity = syncFn(req) || identity } catch { /* keep */ } } try { req.__udsAuthIdentity = identity } catch { /* ignore */ } if (socket) upgradeSocketIdentity.set(socket, identity) - if (empNoOfIdentity(identity)) rememberBrowserIdentity(identity) return withUserContext(identity, () => prev(req, socket, head)) } - patchWebSocketServerForUdsIdentity(resolveIdentitySync) + patchWebSocketServerForUdsIdentity(live.resolveIdentitySync) try { const table = server.upgrades @@ -532,9 +589,8 @@ export function createSessionAccess({ } /** - * Owner stamp OR cwd under the caller's provisioned path. - * View-all off means only those — no "unowned outside user-workspaces" leak - * (shared project sessions were incorrectly treated as channel/system). + * SEC-13: authoritative owner wins. Foreign owner is never overridden by cwd. + * Unowned legacy sessions may still match via own provisioned cwd / workspace. */ const canAccessSession = (sessionId, identity, rowHint) => { if (!identity || !empOf(identity)) return false @@ -542,21 +598,21 @@ export function createSessionAccess({ if (sessionId == null) return false const empNo = empOf(identity) const owner = sessionAcl?.getOwner?.(sessionId) || null - if (owner && String(owner) === String(empNo)) return true + if (owner) { + return String(owner) === String(empNo) + } + // No owner stamp — path / workspace membership only (legacy migration path). const root = getWorkspaceRoot() const cwd = resolveSessionCwd(sessionId, rowHint) if (cwd && userWorkspaces.isUserPath(empNo, cwd, root)) return true - const foreignOwner = !!(owner && String(owner) !== String(empNo)) - const registry = resolveRegistry() if (!registry || typeof registry.list !== 'function') return false let workspaces = [] try { workspaces = registry.list() || [] } catch { return false } for (const ws of workspaces) { if (!isVisibleWorkspace(identity, ws)) continue - if (foreignOwner) continue if (workspaceContainsSession(ws, sessionId)) return true } return false @@ -583,6 +639,7 @@ export function installDshAcl(ctx, { getWorkspaceRoot, rolesStore, getRolesStore, + getSessionStore, ensureUserWorkspace, getWorkspaceRegistry, }) { @@ -596,6 +653,12 @@ export function installDshAcl(ctx, { } return rolesStore || null } + const resolveSessionStore = () => { + if (typeof getSessionStore === 'function') { + try { return getSessionStore() } catch { return null } + } + return null + } // Shared live bag: re-install updates this even when Host controllers are already wrapped. const live = installDshAcl._live || (installDshAcl._live = { access: null }) @@ -617,7 +680,9 @@ export function installDshAcl(ctx, { resolveLiveCwd: (sessionId) => { try { const agents = ctx.get('agents') - const agent = agents?.get?.(sessionId) + // F06: use raw lookup — never the ACL-wrapped get (avoids recursion). + const rawGet = agents?.__udsRawGet || agents?.get + const agent = typeof rawGet === 'function' ? rawGet.call(agents, sessionId) : null return agent?.session?.header?.cwd || null } catch { return null @@ -635,6 +700,55 @@ export function installDshAcl(ctx, { live.access.canAccessSession(sessionId, identity, rowHint) ) + /** SEC-10: always read current role/prefs — never trust frozen identity.permissions. */ + const livePermissions = (identity) => { + const emp = empOf(identity) + const store = resolveRolesStore() + if (emp && store && typeof store.resolvePermissions === 'function') { + return store.resolvePermissions(emp) + } + return identity?.permissions || {} + } + + /** + * A01: every ACL entry must re-validate session + account + auth mode. + * When no session store is wired (unit harness), skip the store check. + */ + const assertPrincipalLive = (identity) => { + if (identity === undefined) return + if (!empOf(identity)) throwForbidden('login_required_session') + const roles = resolveRolesStore() + const emp = empOf(identity) + if (roles && typeof roles.isDisabled === 'function' && roles.isDisabled(emp)) { + throwForbidden('account_disabled') + } + const store = resolveSessionStore() + let sessionRow = null + if (store && typeof store.getBySessionIdSync === 'function') { + const sid = identity.sessionId || identity.userContext?._sessionId + if (!sid || !store.getBySessionIdSync(sid)) throwForbidden('session_revoked') + sessionRow = store.getBySessionIdSync(sid) + if (sessionRow?.empNo && String(sessionRow.empNo) !== String(emp)) throwForbidden('session_revoked') + } + // Prefer live session row kind — incomplete ALS identities (empNo-only) used to + // default administrator→fallback and wipe workspace follow when emergency + // password is unset, even though the session is sealed_box. + const kind = classifyAuthKind(sessionRow || identity, emp) + if (kind === 'sealed_box' && !isLocalAdminBoxConfigured()) { + throwForbidden('local_admin_not_configured') + } + if (kind === 'fallback') { + if (roles && typeof roles.isFallbackEnabled === 'function' && !roles.isFallbackEnabled()) { + throwForbidden('fallback_disabled') + } + const generation = identity.userContext?.fallbackCredVersion + ?? sessionRow?.userData?.fallbackCredVersion + if (generation != null && roles?.getFallbackCredVersion?.() !== Number(generation)) { + throwForbidden('fallback_rotated') + } + } + } + // Stamp owner on session create const offCreated = ctx.on('session/created', (session) => { try { @@ -664,7 +778,7 @@ export function installDshAcl(ctx, { const assertCanAccess = (request, rowHint) => { const identity = getUserContext() if (identity === undefined) return - if (!empOf(identity)) throwForbidden('login_required_session') + assertPrincipalLive(identity) if (canSeeAll(identity)) return const sessionId = extractSessionId(request) if (!canAccessSession(sessionId, identity, rowHint)) { @@ -675,13 +789,22 @@ export function installDshAcl(ctx, { const assertSessionReadable = (sessionId, rowHint) => { const identity = getUserContext() if (identity === undefined) return - if (!empOf(identity)) throwForbidden('login_required_session') + assertPrincipalLive(identity) if (canSeeAll(identity)) return if (!canAccessSession(sessionId, identity, rowHint)) { throwForbidden('session_forbidden') } } + ctx.inject(['typertGateway'], (gctx) => { + const desktopBootstrap = createDesktopBootstrap({ + getService: name => { try { return gctx.get(name) } catch { return null } }, + deny: throwForbidden, + }) + installGatewayPolicy(gctx.typertGateway, { identity: getUserContext, assertPrincipal: assertPrincipalLive, + assertSession: assertSessionReadable, permissions: livePermissions, deny: throwForbidden, desktopBootstrap }) + }) + const filterSessionRecords = (records, identity) => (records || []).filter((row) => { const id = row?.header?.id ?? row?.sessionId ?? row?.id return id != null && canAccessSession(id, identity, { @@ -700,9 +823,10 @@ export function installDshAcl(ctx, { if (typeof sq.listSessions === 'function') { const origList = sq.listSessions.bind(sq) sq.listSessions = async (signal) => { - const records = await origList(signal) const identity = getUserContext() - if (identity === undefined) return records + if (identity === undefined) return origList(signal) + assertPrincipalLive(identity) + const records = await origList(signal) if (!empOf(identity)) return [] if (canSeeAll(identity)) return records return filterSessionRecords(records, identity) @@ -712,9 +836,10 @@ export function installDshAcl(ctx, { if (typeof sq.filterSessions === 'function') { const origFilter = sq.filterSessions.bind(sq) sq.filterSessions = async (filters, signal) => { - const records = await origFilter(filters, signal) const identity = getUserContext() - if (identity === undefined) return records + if (identity === undefined) return origFilter(filters, signal) + assertPrincipalLive(identity) + const records = await origFilter(filters, signal) if (!empOf(identity)) return [] if (canSeeAll(identity)) return records return filterSessionRecords(records, identity) @@ -724,9 +849,10 @@ export function installDshAcl(ctx, { if (typeof sq.searchSessions === 'function') { const origSearch = sq.searchSessions.bind(sq) sq.searchSessions = async (request, exec) => { - const page = await origSearch(request, exec) const identity = getUserContext() - if (identity === undefined) return page + if (identity === undefined) return origSearch(request, exec) + assertPrincipalLive(identity) + const page = await origSearch(request, exec) if (!empOf(identity)) { return page && typeof page === 'object' ? { ...page, hits: [], items: [] } @@ -768,9 +894,10 @@ export function installDshAcl(ctx, { if (typeof resolver.listCandidates === 'function') { const origList = resolver.listCandidates.bind(resolver) resolver.listCandidates = async (agent, query, limit, signal) => { - const candidates = await origList(agent, query, limit, signal) const identity = getUserContext() - if (identity === undefined) return candidates + if (identity === undefined) return origList(agent, query, limit, signal) + assertPrincipalLive(identity) + const candidates = await origList(agent, query, limit, signal) if (!empOf(identity)) return [] if (canSeeAll(identity)) return candidates return (candidates || []).filter((c) => canAccessSession(c?.sessionId, identity, { @@ -784,6 +911,7 @@ export function installDshAcl(ctx, { resolver.prepare = async (agent, content, references, signal) => { const identity = getUserContext() if (identity !== undefined) { + assertPrincipalLive(identity) if (!empOf(identity)) throwForbidden('login_required_session') if (!canSeeAll(identity)) { for (const ref of references || []) { @@ -805,7 +933,9 @@ export function installDshAcl(ctx, { sc.__udsAcl = true const assertCreateTargetAllowed = async (req, identity) => { - if (canSeeAll(identity) || identity.permissions?.canCreateWorkspace) return + // R04: live permissions after demotion — never trust frozen identity.permissions. + const perms = livePermissions(identity) + if (canSeeAll(identity) || perms.canCreateWorkspace) return const empNo = empOf(identity) const root = getWorkspaceRoot() if (req.workspaceId !== undefined) { @@ -833,9 +963,10 @@ export function installDshAcl(ctx, { sc.listState.__udsAcl = true const origStateList = sc.listState.list.bind(sc.listState) sc.listState.list = async (signal) => { - const items = await origStateList(signal) const identity = getUserContext() - if (identity === undefined) return items + if (identity === undefined) return origStateList(signal) + assertPrincipalLive(identity) + const items = await origStateList(signal) if (!empOf(identity)) return [] if (canSeeAll(identity)) return items return filterItems(items, identity) @@ -843,9 +974,10 @@ export function installDshAcl(ctx, { if (typeof sc.listState.search === 'function') { const origStateSearch = sc.listState.search.bind(sc.listState) sc.listState.search = async (query, signal) => { - const value = await origStateSearch(query, signal) const identity = getUserContext() - if (identity === undefined) return value + if (identity === undefined) return origStateSearch(query, signal) + assertPrincipalLive(identity) + const value = await origStateSearch(query, signal) if (!empOf(identity)) return { items: [], hasMore: false } if (canSeeAll(identity)) return value return sessionAcl.filterListValue(value, (id, row) => canAccessSession(id, identity, row)) @@ -855,9 +987,10 @@ export function installDshAcl(ctx, { const origList = sc.list.bind(sc) sc.list = async (request, signal) => { - const value = await origList(request, signal) const identity = getUserContext() - if (identity === undefined) return value + if (identity === undefined) return origList(request, signal) + assertPrincipalLive(identity) + const value = await origList(request, signal) if (!empOf(identity)) return { items: [] } if (canSeeAll(identity)) return value return sessionAcl.filterListValue(value, (id, row) => canAccessSession(id, identity, row)) @@ -865,9 +998,10 @@ export function installDshAcl(ctx, { const origSearch = sc.search.bind(sc) sc.search = async (request, signal) => { - const value = await origSearch(request, signal) const identity = getUserContext() - if (identity === undefined) return value + if (identity === undefined) return origSearch(request, signal) + assertPrincipalLive(identity) + const value = await origSearch(request, signal) if (!empOf(identity)) return { items: [], hasMore: false } if (canSeeAll(identity)) return value return sessionAcl.filterListValue(value, (id, row) => canAccessSession(id, identity, row)) @@ -882,17 +1016,29 @@ export function installDshAcl(ctx, { } if (!empOf(identity)) throwForbidden('login_required_create_session') + assertPrincipalLive(identity) let req = { ...(request || {}) } await assertCreateTargetAllowed(req, identity) + // SEC-15: never fall through to Host default cwd when provisioning fails. if (req.workspaceId === undefined && req.cwd === undefined) { const ensured = await ensureUserWorkspace(identity.empNo) if (ensured?.workspaceId) { req = { ...req, workspaceId: ensured.workspaceId } } else if (ensured?.path) { req = { ...req, cwd: ensured.path } + } else { + throwForbidden('workspace_provision_failed') } } + if (req.workspaceId === undefined && req.cwd === undefined) { + throwForbidden('workspace_provision_failed') + } + if (req.workspaceId != null && req.cwd != null) { + // Ambiguous dual target — keep workspaceId, drop cwd. + const { cwd: _drop, ...rest } = req + req = rest + } const result = await origCreate(req) const sid = result?.sessionId ?? result?.id @@ -905,22 +1051,11 @@ export function installDshAcl(ctx, { // Host-internal: do not burn 800ms waiting for a browser cookie that will never appear. if (identity === undefined) return identity if (empOf(identity)) return identity - // Desktop mux: follow/history often sees ALS=null; use last bridged login - // (HTTP /api/me or WS upgrade — see identity-cache.js). - const cached = fallbackBrowserIdentity() - if (cached && empOf(cached)) { - enterUserContext(cached) - return cached - } + // Wait briefly for connection-bound ALS only — never borrow another user's identity. const deadline = Date.now() + ms while (!empOf(identity) && Date.now() < deadline) { await new Promise((r) => setTimeout(r, 40)) identity = getUserContext() - const again = fallbackBrowserIdentity() - if (!empOf(identity) && again && empOf(again)) { - enterUserContext(again) - return again - } } return identity } @@ -935,7 +1070,11 @@ export function installDshAcl(ctx, { assertCanAccess(request) // Long-lived generator: pin ALS so history frames keep empNo after awaits. if (identity !== undefined) enterUserContext(identity) - yield* orig(request, signal) + for await (const frame of orig(request, signal)) { + // SEC-10: re-check before each push after demote/logout. + assertCanAccess(request) + yield frame + } } return } @@ -955,6 +1094,25 @@ export function installDshAcl(ctx, { wrapSessionMethod('updateQueue') wrapSessionMethod('attachment') wrapSessionMethod('selectModel') + wrapSessionMethod('projections') + + if (typeof sc.control === 'function') { + const originalControl = sc.control.bind(sc) + sc.control = async function* (signal) { + for await (const frame of originalControl(signal)) { + const identity = getUserContext() + if (identity === undefined) { yield frame; continue } + assertPrincipalLive(identity) + if (frame.type === 'baseline') { + const projections = Object.fromEntries(Object.entries(frame.value?.projections || {}) + .filter(([id]) => canAccessSession(id, identity))) + yield { ...frame, value: { ...frame.value, projections } } + } else if (frame.type === 'projection' && canAccessSession(frame.sessionId, identity)) { + yield frame + } + } + } + } if (typeof sc.openWorkspacePath === 'function') { const origOpenPath = sc.openWorkspacePath.bind(sc) @@ -962,7 +1120,8 @@ export function installDshAcl(ctx, { const identity = getUserContext() if (identity === undefined) return origOpenPath(request, signal) if (!empOf(identity)) throwForbidden('login_required_session') - if (!canSeeAll(identity) && !identity.permissions?.canCreateWorkspace) { + const perms = livePermissions(identity) + if (!canSeeAll(identity) && !perms.canCreateWorkspace) { const path = request?.path if (!path || !userWorkspaces.isUserPath(empOf(identity), path, getWorkspaceRoot())) { throwForbidden('workspace_path_only') @@ -995,20 +1154,73 @@ ctx.inject(['workspaceController'], (wctx) => { wc.create = async (request) => { const identity = getUserContext() if (identity?._internalProvision) return origCreate(request) - // No ALS identity: Host-side plugins (IM / cron) create workspaces without a - // browser login. Authenticated browser calls always run under user context; - // there admin / super_admin / fallback_admin must have canCreateWorkspace. - if (identity && !identity.permissions?.canCreateWorkspace) { + // undefined = trusted Host (runAsHost); null/missing = anonymous browser — deny. + if (identity === undefined) return origCreate(request) + assertPrincipalLive(identity) + if (!empOf(identity)) throwForbidden('login_required_create_workspace') + if (!livePermissions(identity).canCreateWorkspace) { throwForbidden('workspace_create_forbidden') } return origCreate(request) } - // IMPORTANT: capture identity at follow() entry. Long-lived follow resumes - // after awaits on registry watchers where AsyncLocalStorage is often empty; - // re-reading getUserContext() per frame would treat a logged-in super_admin - // as anonymous and filter away every pre-plugin workspace (sessions fall into - // the ungrouped bucket). + /** SEC-11: guard workspace write / archive / pin Remote methods. */ + const projectIdList = (identity, ids) => { + if (!Array.isArray(ids)) return ids + if (canSeeAll(identity)) return ids + return ids.filter((id) => id != null && canAccessSession(String(id), identity)) + } + + const projectMutationResult = (identity, value) => { + if (value == null || typeof value !== 'object') return value + if (canSeeAll(identity)) return value + const next = { ...value } + for (const key of [ + 'archivedSessionIds', 'pinnedSessionIds', 'sessionIds', 'ids', 'items', + ]) { + if (Array.isArray(next[key])) { + if (key === 'items') { + next[key] = next[key].filter((row) => { + const id = row?.sessionId ?? row?.id + return id == null || canAccessSession(String(id), identity, row) + }) + } else { + next[key] = projectIdList(identity, next[key]) + } + } + } + return next + } + + const wrapWorkspaceWrite = (methodName, { sessionScoped = false } = {}) => { + if (typeof wc[methodName] !== 'function') return + const orig = wc[methodName].bind(wc) + wc[methodName] = async (...args) => { + const identity = getUserContext() + if (identity === undefined) return orig(...args) + assertPrincipalLive(identity) + if (!empOf(identity)) throwForbidden('login_required_workspace') + if (sessionScoped) { + const sid = args[0]?.sessionId ?? args[0]?.id ?? args[0] + if (sid != null && !canSeeAll(identity) && !canAccessSession(sid, identity)) { + throwForbidden('session_forbidden') + } + } else if (!livePermissions(identity).canCreateWorkspace) { + throwForbidden('workspace_create_forbidden') + } + const result = await orig(...args) + return projectMutationResult(identity, result) + } + } + wrapWorkspaceWrite('rename') + wrapWorkspaceWrite('delete') + wrapWorkspaceWrite('insertBefore') + wrapWorkspaceWrite('insertSessionBefore', { sessionScoped: true }) + wrapWorkspaceWrite('archiveSession', { sessionScoped: true }) + wrapWorkspaceWrite('unarchiveSession', { sessionScoped: true }) + wrapWorkspaceWrite('pinSession', { sessionScoped: true }) + wrapWorkspaceWrite('unpinSession', { sessionScoped: true }) + const canSeeAllWorkspaces = (identity) => { if (!identity) return false return canSeeAll(identity) @@ -1025,25 +1237,54 @@ ctx.inject(['workspaceController'], (wctx) => { && String(userWorkspaces.get(empNo).workspaceId) === String(wid))) { return true } - // View-all off: hide shared/channel workspaces from the sidebar partitions. return false } - const filterBaseline = (identity, baseline) => { - if (!baseline?.items) return baseline + const filterSessionIdList = (identity, ids) => { + if (!Array.isArray(ids)) return ids + if (canSeeAllWorkspaces(identity)) return ids + return ids.filter((id) => id != null && canAccessSession(String(id), identity)) + } + + const projectWorkspaceItem = (identity, ws) => { + if (!ws || typeof ws !== 'object') return ws + if (canSeeAllWorkspaces(identity)) return ws + if (!Array.isArray(ws.sessionIds)) return ws return { - ...baseline, - items: baseline.items.filter((ws) => allowWorkspace(identity, ws)), + ...ws, + sessionIds: filterSessionIdList(identity, ws.sessionIds), } } + const filterBaseline = (identity, baseline) => { + if (!baseline) return baseline + const next = { ...baseline } + if (Array.isArray(baseline.items)) { + next.items = baseline.items + .filter((ws) => allowWorkspace(identity, ws)) + .map((ws) => projectWorkspaceItem(identity, ws)) + } + // SEC-12: do not leak foreign archived/pinned session ids. + if (Array.isArray(baseline.archivedSessionIds)) { + next.archivedSessionIds = filterSessionIdList(identity, baseline.archivedSessionIds) + } + if (Array.isArray(baseline.pinnedSessionIds)) { + next.pinnedSessionIds = filterSessionIdList(identity, baseline.pinnedSessionIds) + } + return next + } + const filterFrame = (identity, frame) => { if (!frame) return frame if (frame.type === 'baseline') { return { ...frame, value: filterBaseline(identity, frame.value) } } if (frame.type === 'upsert') { - return allowWorkspace(identity, frame.workspace) ? frame : null + if (!allowWorkspace(identity, frame.workspace)) return null + return { + ...frame, + workspace: projectWorkspaceItem(identity, frame.workspace), + } } if (frame.type === 'order') { if (canSeeAllWorkspaces(identity)) return frame @@ -1056,38 +1297,89 @@ ctx.inject(['workspaceController'], (wctx) => { workspaceIds: (frame.workspaceIds || []).filter((id) => allowed.has(String(id))), } } + // R08: DSH remove frames carry workspaceId (not sessionId). + if (frame.type === 'remove') { + const wid = frame.workspaceId ?? frame.id ?? frame.value?.workspaceId + if (wid != null) { + const ws = { workspaceId: wid, id: wid, path: frame.path } + return allowWorkspace(identity, ws) ? frame : null + } + } + // SEC-12: archive/pin frames — drop if session not accessible. + const sid = frame.sessionId ?? frame.id ?? frame.value?.sessionId + if (sid != null && ['archive', 'unarchive', 'pin', 'unpin', 'archived', 'pinned'].includes(frame.type)) { + return canAccessSession(String(sid), identity) ? frame : null + } + if (Array.isArray(frame.archivedSessionIds) || Array.isArray(frame.pinnedSessionIds)) { + return { + ...frame, + ...(Array.isArray(frame.archivedSessionIds) + ? { archivedSessionIds: filterSessionIdList(identity, frame.archivedSessionIds) } + : {}), + ...(Array.isArray(frame.pinnedSessionIds) + ? { pinnedSessionIds: filterSessionIdList(identity, frame.pinnedSessionIds) } + : {}), + } + } + // Unknown frame types: default deny (do not pass through). + if (!['baseline', 'upsert', 'order'].includes(frame.type)) { + return null + } return frame } const origFollow = wc.follow.bind(wc) + const emptyWorkspaceBaseline = () => ({ + type: 'baseline', + value: { items: [], archivedSessionIds: [], pinnedSessionIds: [] }, + }) + const holdUntilAbort = async (signal) => { + if (!signal || signal.aborted) return + await new Promise((resolve) => { + signal.addEventListener('abort', () => resolve(), { once: true }) + }) + } wc.follow = async function* (signal) { - let identity = getUserContext() - if (!identity?.empNo && !identity?.userContext?.empNo) { - const deadline = Date.now() + 800 - while (!identity?.empNo && !identity?.userContext?.empNo && Date.now() < deadline) { - await new Promise((r) => setTimeout(r, 40)) - identity = getUserContext() - } - } - // Missing ALS on long-lived follow generators is common. Emptying the - // baseline here is what turned real partitions (harness/chatgpt) into - // 未分组 for fallback_admin. Pass through; client hides UI when logged out. - if (!identity?.empNo && !identity?.userContext?.empNo) { + // Identity is fixed at WS upgrade (ticket/bridge). Waiting on ALS cannot + // turn an anonymous mux into an admin — Desktop must soft-reconnect. + // + // Critical Desktop bug: returning with ZERO frames makes + // WorkspaceStateStream throw a non-Carrier Error + // ("ended before its opening snapshot"), which is terminal — later + // authenticated reconnects never repaint and the sidebar stays「未分组」 + // even though a fresh follow with a ticket returns all workspaces. + const identity = getUserContext() + // undefined = trusted Host (runAsHost) — full feed. + if (identity === undefined) { yield* origFollow(signal) return } - if (canSeeAllWorkspaces(identity)) { - yield* origFollow(signal) + // Anonymous browser: empty snapshot + hold until reconnect aborts us. + if (!empOf(identity)) { + yield emptyWorkspaceBaseline() + await holdUntilAbort(signal) return } + // R04: never bypass projection for admin-at-subscribe — re-check every frame after demotion. + let sentBaseline = false for await (const frame of origFollow(signal)) { - const live = getUserContext() || identity - if (canSeeAllWorkspaces(live)) { + const liveId = getUserContext() || identity + try { + assertPrincipalLive(liveId) + } catch { + if (!sentBaseline) yield emptyWorkspaceBaseline() + return + } + if (frame?.type === 'baseline') sentBaseline = true + if (canSeeAllWorkspaces(liveId)) { yield frame continue } - const next = filterFrame(live, frame) - if (next) yield next + const next = filterFrame(liveId, frame) + if (next) { + if (next.type === 'baseline') sentBaseline = true + yield next + } } } }) @@ -1102,9 +1394,9 @@ ctx.inject(['workspaceController'], (wctx) => { const orig = settings[method].bind(settings) settings[method] = async (...args) => { const identity = getUserContext() - // Allow uds-auth namespace writes from our own settings section for admins; - // users cannot touch any settings. - if (!identity?.permissions?.canAccessSettings) { + if (identity === undefined) return orig(...args) + assertPrincipalLive(identity) + if (!empOf(identity) || !livePermissions(identity).canAccessSettings) { throwForbidden('forbidden_settings') } return orig(...args) @@ -1112,7 +1404,213 @@ ctx.inject(['workspaceController'], (wctx) => { } }) - // directory picker: admin / super_admin / fallback_admin (canCreateWorkspace) + // credentialsController — deny browser users from reading/writing Host credentials + ctx.inject(['credentialsController'], (cctx) => { + const cc = cctx.credentialsController + if (!cc || cc.__udsAcl) return + cc.__udsAcl = true + const guard = (methodName) => { + if (typeof cc[methodName] !== 'function') return + const orig = cc[methodName].bind(cc) + cc[methodName] = async (...args) => { + const identity = getUserContext() + if (identity === undefined) return orig(...args) + assertPrincipalLive(identity) + if (!empOf(identity) || !livePermissions(identity).canAccessSettings) { + throwForbidden('forbidden_settings') + } + return orig(...args) + } + } + for (const m of ['get', 'set', 'list', 'delete', 'update', 'read', 'write']) { + guard(m) + } + }) + + // R02: jobController — request.sessionId is a resource id, not caller identity. + ctx.inject(['jobController'], (jctx) => { + const jc = jctx.jobController + if (!jc || jc.__udsAcl) return + jc.__udsAcl = true + for (const method of ['kill', 'list', 'follow']) { + if (typeof jc[method] !== 'function') continue + const orig = jc[method].bind(jc) + if (method === 'follow' || method === 'list') { + jc[method] = async function* (request, signal) { + assertCanAccess(request) + for await (const frame of orig(request, signal)) { + try { assertCanAccess(request) } catch { return } + yield frame + } + } + } else { + jc[method] = async (request, signal) => { + assertCanAccess(request) + return orig(request, signal) + } + } + } + }) + + // A03: terminalController — preserve AsyncIterable for streams; wrap retain. + ctx.inject(['terminalController'], (tctx) => { + const tc = tctx.terminalController + if (!tc || tc.__udsAcl) return + tc.__udsAcl = true + + const terminalSessionId = (agentOrReq, rest) => ( + agentOrReq?.session?.header?.id + ?? agentOrReq?.sessionId + ?? agentOrReq?.id + ?? (typeof agentOrReq === 'string' ? agentOrReq : null) + ?? rest?.[0]?.sessionId + ?? null + ) + + const assertTerminalAccess = (agentOrReq, rest = []) => { + const sid = terminalSessionId(agentOrReq, rest) + if (sid != null) assertSessionReadable(sid) + else { + const identity = getUserContext() + if (identity !== undefined) assertPrincipalLive(identity) + } + } + + if (typeof tc.list === 'function') { + const origList = tc.list.bind(tc) + tc.list = (sessionId, ...rest) => { + assertSessionReadable(sessionId) + return origList(sessionId, ...rest) + } + } + + // Stream methods must return AsyncIterable synchronously (Gateway does not await). + for (const method of ['follow', 'retain']) { + if (typeof tc[method] !== 'function') continue + const orig = tc[method].bind(tc) + tc[method] = function udsAuthTerminalStream(agentOrReq, ...rest) { + try { + assertTerminalAccess(agentOrReq, rest) + } catch { + // Deny as empty stream so callers iterating frames get no data (V11). + return (async function* () {})() + } + const out = orig(agentOrReq, ...rest) + // If Host returns a thenable accidentally, still expose async iteration. + const iterate = async function* () { + const stream = typeof out?.then === 'function' ? await out : out + for await (const frame of stream) { + try { assertTerminalAccess(agentOrReq, rest) } catch { return } + yield frame + } + } + if (out && typeof out[Symbol.asyncIterator] === 'function' && typeof out.then !== 'function') { + return (async function* () { + for await (const frame of out) { + try { assertTerminalAccess(agentOrReq, rest) } catch { return } + yield frame + } + })() + } + return iterate() + } + } + + for (const method of ['create', 'resize', 'write', 'close', 'rename', 'dispose']) { + if (typeof tc[method] !== 'function') continue + const orig = tc[method].bind(tc) + tc[method] = async (agentOrReq, ...rest) => { + assertTerminalAccess(agentOrReq, rest) + return orig(agentOrReq, ...rest) + } + } + }) + + // R02: agents.get — Typert / Agent lookup by sessionId under UDS ACL. + ctx.inject(['agents'], (actx) => { + const agents = actx.agents + if (!agents || agents.__udsAcl) return + agents.__udsAcl = true + if (typeof agents.get === 'function') { + const origGet = agents.get.bind(agents) + // F06: preserve unbound raw get for resolveLiveCwd (no ACL recursion). + agents.__udsRawGet = origGet + agents.get = (sessionId, ...rest) => { + const identity = getUserContext() + if (identity !== undefined) assertSessionReadable(sessionId) + return origGet(sessionId, ...rest) + } + } + }) + + // A02: authorize on real target path/key after resolve — not only displayPath. + // Soft-deny for /api/file: Host serveFile only maps FsError→403; RemoteError escapes. + ctx.inject(['fs'], (fctx) => { + const fs = fctx.fs + if (!fs || fs.__udsAcl) return + fs.__udsAcl = true + const pathFromTarget = (target) => { + if (typeof target === 'string') return target + if (!target || typeof target !== 'object') return null + // Prefer realpath / targetKey over displayPath (junction-safe). + return target.targetKey || target.realpath || target.realPath + || target.path || target.displayPath || target.href || null + } + const fileAllowed = (path) => { + const identity = getUserContext() + if (identity === undefined) return true + assertPrincipalLive(identity) + if (canSeeAll(identity)) return true + const emp = empOf(identity) + const root = getWorkspaceRoot() + return !!(path && userWorkspaces.isUserPath(emp, path, root)) + } + const deniedStub = (displayPath) => ({ + displayPath: displayPath || '', + targetKey: displayPath || '', + path: displayPath || '', + __udsAuthDenied: true, + }) + /** + * F09: Prefer Host FsError → serveFile maps to 403. When the Host package is + * unavailable (unit/probe), soft-deny without leaking foreign bytes. + */ + const softDeny = (method, displayPath = '') => { + try { + const { FsError } = require('@deepseek-ai/dsh-fs') + throw new FsError('file_forbidden', 'FS_PERMISSION_DENIED') + } catch (err) { + if (err?.code === 'FS_PERMISSION_DENIED') throw err + } + if (method === 'readBytes') return new Uint8Array(0) + if (method === 'readText') return '' + if (method === 'stat') return undefined + if (method === 'open') throwForbidden('file_forbidden') + return deniedStub(displayPath) + } + for (const method of ['resolve', 'stat', 'readBytes', 'readText', 'open']) { + if (typeof fs[method] !== 'function') continue + const orig = fs[method].bind(fs) + fs[method] = async (target, ...rest) => { + if (target?.__udsAuthDenied) return softDeny(method, target.displayPath || '') + if (method === 'resolve') { + const input = typeof target === 'string' ? target : pathFromTarget(target) + if (input && !fileAllowed(input)) return deniedStub(input) + const resolved = await orig(target, ...rest) + const real = pathFromTarget(resolved) || input + if (real && !fileAllowed(real)) { + return deniedStub(resolved?.displayPath || input || real) + } + return resolved + } + const path = pathFromTarget(target) + if (path && !fileAllowed(path)) return softDeny(method, path) + return orig(target, ...rest) + } + } + }) + + // directory picker: admin-class (live canCreateWorkspace); wrap capability() too (SEC-11) ctx.inject(['directoryPicker'], (dctx) => { const dp = dctx.directoryPicker if (!dp || dp.__udsAcl) return @@ -1120,31 +1618,38 @@ ctx.inject(['workspaceController'], (wctx) => { const assertCanCreateWorkspace = () => { const identity = getUserContext() - if (!identity?.empNo) throwForbidden('login_required_workspace') - if (!identity?.permissions?.canCreateWorkspace) { + if (identity === undefined) return + assertPrincipalLive(identity) + if (!empOf(identity)) throwForbidden('login_required_workspace') + if (!livePermissions(identity).canCreateWorkspace) { throwForbidden('workspace_create_forbidden') } } - if (typeof dp.pick === 'function') { - const origPick = dp.pick.bind(dp) - dp.pick = async (...args) => { + const wrapDpMethod = (obj, methodName) => { + if (!obj || typeof obj[methodName] !== 'function') return + const orig = obj[methodName].bind(obj) + obj[methodName] = async (...args) => { assertCanCreateWorkspace() - return origPick(...args) + return orig(...args) } } - if (typeof dp.list === 'function') { - const origList = dp.list.bind(dp) - dp.list = async (...args) => { + + wrapDpMethod(dp, 'pick') + wrapDpMethod(dp, 'list') + wrapDpMethod(dp, 'createDirectory') + + if (typeof dp.capability === 'function') { + const origCap = dp.capability.bind(dp) + dp.capability = (...args) => { assertCanCreateWorkspace() - return origList(...args) - } - } - if (typeof dp.createDirectory === 'function') { - const origCreate = dp.createDirectory.bind(dp) - dp.createDirectory = async (...args) => { - assertCanCreateWorkspace() - return origCreate(...args) + const cap = origCap(...args) + if (cap && typeof cap === 'object') { + wrapDpMethod(cap, 'list') + wrapDpMethod(cap, 'createDirectory') + wrapDpMethod(cap, 'pick') + } + return cap } } }) diff --git a/uds-auth/lib/gateway-events.js b/uds-auth/lib/gateway-events.js new file mode 100644 index 00000000..036b5900 --- /dev/null +++ b/uds-auth/lib/gateway-events.js @@ -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) + } +} diff --git a/uds-auth/lib/gateway-policy.js b/uds-auth/lib/gateway-policy.js new file mode 100644 index 00000000..42304313 --- /dev/null +++ b/uds-auth/lib/gateway-policy.js @@ -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') + } +} diff --git a/uds-auth/lib/i18n.js b/uds-auth/lib/i18n.js index d695c250..0c91b1d4 100644 --- a/uds-auth/lib/i18n.js +++ b/uds-auth/lib/i18n.js @@ -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', diff --git a/uds-auth/lib/identity-cache.js b/uds-auth/lib/identity-cache.js index c4d1488a..7a7dab88 100644 --- a/uds-auth/lib/identity-cache.js +++ b/uds-auth/lib/identity-cache.js @@ -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 } diff --git a/uds-auth/lib/index.js b/uds-auth/lib/index.js index 813ae6fd..203c95d9 100644 --- a/uds-auth/lib/index.js +++ b/uds-auth/lib/index.js @@ -5,8 +5,9 @@ import { requirePermission } from './middleware/auth-middleware.js' import { computePermissions } from './roles.js' import { apiError, apiOk, resolveLocale } from './i18n.js' -import { readFile, writeFile } from 'node:fs/promises' -import { resolve, dirname } from 'node:path' +import { readFile, writeFile, mkdir } from 'node:fs/promises' +import { resolve, dirname, isAbsolute, join } from 'node:path' +import { homedir } from 'node:os' import { fileURLToPath } from 'node:url' import { createRequire } from 'node:module' import { createHash, randomBytes } from 'node:crypto' @@ -15,13 +16,29 @@ import { appendLocalAdminCookie, isLocalAdminBoxConfigured, readLocalAdminBox, - openLocalAdminBox, + openLocalAdminBoxAsync, allowUnlockAttempt, buildLocalAdminUserContext, LOCAL_ADMIN_BOX_ENV, } from './local-admin.js' import { SessionBridgeStore } from './session-bridge.js' import { runAsHost } from './context.js' +import { + buildSessionCookie, + clearSessionCookie, + extractSessionBearerAsync, + SESSION_COOKIE, +} from './session/request-auth.js' +import { checkRequestOrigin, requireJsonContentType } from './utils/origin-guard.js' +import { requestIsLoopback } from './skill-credentials.js' +import { atomicWriteJson } from './utils/atomic-write.js' +import { readBodyLimited, MAX_QR_PROXY_BODY, MAX_QR_DATA_LEN, DEFAULT_MAX_BODY } from './utils/read-body.js' +import { RateLimiter, clientKey } from './utils/rate-limit.js' +import { audit } from './utils/audit-log.js' +import { QrChallengeStore, qrLoginCredentials } from './qr-challenge.js' +import { migrateState } from './state-migration.js' +import { protectProfileDirectory } from './profile-security.js' +import { gatewayPolicy, assertGatewayContract } from './gateway-policy.js' const __dirname = dirname(fileURLToPath(import.meta.url)) const require = createRequire(import.meta.url) @@ -75,12 +92,20 @@ const CONFIG_DEFAULTS = { loginSystemCode: '100000455558', originSystemCode: '', workspaceRoot: '', + /** Explicit first admin when roles.json is empty (also: UDS_AUTH_INITIAL_ADMIN) */ + initialAdminEmpNo: '', + /** When roles already exist, auto-register unknown UAC users as role=user */ + allowOpenRegistration: true, /** UI 退出时是否保留 skill 凭证缓存(默认保留,供 cron/skill) */ retainSkillCredentialsOnLogout: true, /** skill 凭证 TTL(秒),默认 7 天 */ skillCredentialTtlSeconds: 7 * 24 * 60 * 60, /** outbound 白名单 host(逗号分隔或数组) */ outboundAllowedHosts: 'icenterapi.zte.com.cn,icentermsg.dt.zte.com.cn', + /** SEC-05: raw /agent-credentials token response off by default */ + allowRawAgentToken: false, + /** R13: optional exact Host allowlist (comma/array). Empty = compare Origin↔Host only. */ + trustedHosts: '', } // 内部常量(不暴露给用户,UDS 固定协议) @@ -92,11 +117,11 @@ const INTERNAL = { altCookieAuthValue: 'ZTEDPGSSOCookie', empNoHeader: 'X-Emp-No', authValueHeader: 'X-Auth-Value', - // Session: 按 empNo 做 key,不需要额外 cookie + // Session: opaque bearer in UDS_SESSION cookie / Desktop bridge session: { - storeType: 'memory', // 单实例足够;多实例/重启用 redis + storeType: 'memory', // 单实例足够;多实例需共享 store,不可静默切 memory redisUrl: 'redis://localhost:6379', - cookieMaxAge: 1800000, // 30min + cookieMaxAge: 1800000, // 30min idle TTL slidingExpiration: true, slidingInterval: 300000, }, @@ -119,6 +144,10 @@ function loadConfigSchemaAndSettings(ctx) { retainSkillCredentialsOnLogout: z.boolean().default(CONFIG_DEFAULTS.retainSkillCredentialsOnLogout), skillCredentialTtlSeconds: z.number().default(CONFIG_DEFAULTS.skillCredentialTtlSeconds), outboundAllowedHosts: z.string().default(CONFIG_DEFAULTS.outboundAllowedHosts), + initialAdminEmpNo: z.string().default(CONFIG_DEFAULTS.initialAdminEmpNo), + allowOpenRegistration: z.boolean().default(CONFIG_DEFAULTS.allowOpenRegistration), + allowRawAgentToken: z.boolean().default(CONFIG_DEFAULTS.allowRawAgentToken), + trustedHosts: z.string().default(CONFIG_DEFAULTS.trustedHosts), }) _DshSettings = require('@deepseek-ai/dsh-settings') ctx?.logger?.info?.('[uds-auth] schemastery + dsh-settings loaded') @@ -133,6 +162,18 @@ function mergeConfig(custom = {}) { return { ...CONFIG_DEFAULTS, ...custom } } +/** + * A11: durable state lives under a profile data dir, not only the install tree. + * Override with UDS_AUTH_DATA_DIR (absolute) or config.dataDir. + */ +function resolvePluginDataDir(config = _currentConfig) { + const fromEnv = String(process.env.UDS_AUTH_DATA_DIR || '').trim() + if (fromEnv) return isAbsolute(fromEnv) ? fromEnv : resolve(process.cwd(), fromEnv) + const fromCfg = String(config?.dataDir || '').trim() + if (fromCfg) return isAbsolute(fromCfg) ? fromCfg : resolve(process.cwd(), fromCfg) + return join(homedir(), '.uds-auth') +} + /** DSH/schemastery config objects may be frozen — always clone before mutate. */ function asMutableConfig(config) { return { ...(config && typeof config === 'object' ? config : {}) } @@ -151,14 +192,36 @@ let _pluginCtx = null let _logger = console let _skillCredentials = null let _agentAuthHandlers = null +/** @type {import('./task-capability.js').TaskCapabilityStore | null} */ +let _taskCapabilities = null /** @type {SessionBridgeStore | null} */ let _sessionBridge = null +/** SEC-20: false until init completes; routes return 503 when unready. */ +let _pluginReady = false +let _pluginReadyError = null +const _loginLimiter = new RateLimiter({ windowMs: 60_000, max: 20 }) +const _qrLimiter = new RateLimiter({ windowMs: 60_000, max: 60 }) +/** R13: server-side QR challenge TTL + one-time consume */ +const QR_CHALLENGE_TTL_MS = 5 * 60 * 1000 +const _qrChallenges = new QrChallengeStore({ ttlMs: QR_CHALLENGE_TTL_MS }) +const MAX_QR_UPSTREAM_BYTES = 256 * 1024 -/** Mint cookie-less bridge token (Desktop dsh-app://); cookies still set for http. */ -function mintBridge(empNo, kind = 'fallback', ssoToken) { +/** + * Mint Desktop bridge using the same bearer as the local auth session. + * @param {string} empNo + * @param {string} [kind] + * @param {string} [ssoToken] + * @param {string} [bearer] local session bearer — must match session store + */ +function mintBridge(empNo, kind = 'fallback', ssoToken, bearer) { if (!_sessionBridge) return null try { - const minted = _sessionBridge.mint({ empNo, kind, ssoToken }) + const minted = _sessionBridge.mint({ + empNo, + kind, + ssoToken, + token: bearer || undefined, + }) return { empNo: minted.empNo, kind: minted.kind, @@ -171,6 +234,39 @@ function mintBridge(empNo, kind = 'fallback', ssoToken) { } } +/** Create local session + bridge sharing the same bearer. */ +async function mintLoginSession(empNo, userContext, kind = 'uds', ssoToken) { + const live = () => !_rolesStore?.isDisabled(empNo) + && (kind !== 'fallback' || (_rolesStore.isFallbackEnabled() + && userContext.fallbackCredVersion === _rolesStore.getFallbackCredVersion())) + if (!live()) throw Object.assign(new Error('invalid_credentials'), { code: 'invalid_credentials' }) + const ttl = Math.floor(INTERNAL.session.cookieMaxAge / 1000) + const { sessionId, bearer } = await _sessionStore.create( + { ...userContext, empNo }, + ttl, + ) + if (!live()) { + await _sessionStore.revokeSession(sessionId) + throw Object.assign(new Error('invalid_credentials'), { code: 'invalid_credentials' }) + } + const bridge = mintBridge(empNo, kind, ssoToken, bearer) + return { sessionId, bearer, bridge } +} + +function appendSessionCookie(req, res, bearer, extraCookies = []) { + const ttl = Math.floor(INTERNAL.session.cookieMaxAge / 1000) + const secure = isHttpsRequest(req) + const sessionCookie = buildSessionCookie(bearer, ttl, { secure }) + const list = [sessionCookie, ...extraCookies] + const prev = res.getHeader?.('Set-Cookie') + if (prev) { + const p = Array.isArray(prev) ? prev : [prev] + res.setHeader('Set-Cookie', [...p, ...list]) + } else { + res.setHeader('Set-Cookie', list) + } +} + function buildVerifyUrl(uacBaseUrl, uacQrVerifyPath) { if (/^https?:\/\//.test(uacQrVerifyPath)) return uacQrVerifyPath return (uacBaseUrl.replace(/\/$/, '') + '/' + uacQrVerifyPath.replace(/^\//, '')) @@ -198,20 +294,25 @@ async function loadQRCodeLib() { /** Start a UAC TwoDIMAuth QR challenge (no browser jQuery plugins required). */ function createQrChallenge(config = _currentConfig || {}) { - const loginSystemCode = String(config.loginSystemCode || CONFIG_DEFAULTS.loginSystemCode) - const originSystemCode = String(config.originSystemCode || '') - const qrCodeKey = randomBytes(16).toString('hex') - const qrCodeValue = randomBytes(16).toString('hex') - const qrCodeStr = `TwoDIMAuth:${qrCodeKey}:${qrCodeValue}` - return { qrCodeStr, qrCodeKey, qrCodeValue, loginSystemCode, originSystemCode } + return _qrChallenges.create({ loginSystemCode: String(config.loginSystemCode || CONFIG_DEFAULTS.loginSystemCode), + originSystemCode: String(config.originSystemCode || '') }) } +function peekQrChallenge(key, value, binding) { return _qrChallenges.peek(key, value, binding) } +function consumeQrChallenge(key, value, binding) { return _qrChallenges.consume(key, value, binding) } +function isQrLoginSuccessPayload(payload) { return !!qrLoginCredentials(payload) } + async function handleQrStart(req, res) { if (req.method !== 'POST' && req.method !== 'GET') { res.writeHead(405, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(apiError('method_not_allowed', localeOf(req)))) return } + if (!_qrLimiter.allow(clientKey(req))) { + res.writeHead(429, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(apiError('rate_limited', localeOf(req)))) + return + } const challenge = createQrChallenge(_currentConfig) res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', @@ -227,23 +328,41 @@ async function handleQRProxy(req, res) { res.end(JSON.stringify(apiError('method_not_allowed', localeOf(req)))) return } + if (!_qrLimiter.allow(clientKey(req))) { + res.writeHead(429, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(apiError('rate_limited', localeOf(req)))) + return + } - let body = '' - for await (const chunk of req) { - body += chunk + let body + try { + body = await readBodyLimited(req, { maxBytes: MAX_QR_PROXY_BODY }) + } catch (err) { + const status = err.statusCode || 413 + res.writeHead(status, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(apiError(err.code || 'payload_too_large', localeOf(req)))) + return } try { - // 不替换 body 中的 loginClientIp(前端已用 127.0.0.1 计算 verifyCode) - // 但 originSystemCode 如果配置了则替换进 body - if (_currentConfig.originSystemCode) { - try { - const parsed = JSON.parse(body) - if (!parsed.originSystemCode) parsed.originSystemCode = _currentConfig.originSystemCode - body = JSON.stringify(parsed) - } catch { /* body 不是 JSON 就跳过 */ } + let parsedBody = {} + try { parsedBody = JSON.parse(body) } catch { parsedBody = {} } + // A04: peek only — do not consume until upstream confirms login. + const peeked = peekQrChallenge(parsedBody.qrCodeKey, parsedBody.qrCodeValue, req.headers['x-uds-qr-binding']) + if (!peeked.ok) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(apiError(peeked.reason || 'qr_unknown', localeOf(req)))) + return } - + // Only send contract fields. Browser binding and arbitrary body fields stay local. + parsedBody = { + qrCodeKey: parsedBody.qrCodeKey, qrCodeValue: parsedBody.qrCodeValue, + loginClientIp: '127.0.0.1', loginSystemCode: peeked.row.loginSystemCode, + originSystemCode: peeked.row.originSystemCode, + verifyCode: calculateVerifyCode(parsedBody.qrCodeKey, parsedBody.qrCodeValue, '127.0.0.1', + peeked.row.loginSystemCode, peeked.row.originSystemCode), + } + body = JSON.stringify(parsedBody) const https = await import('node:https') const url = new URL(buildVerifyUrl(_currentConfig.uacBaseUrl, INTERNAL.uacQrVerifyPath)) @@ -258,24 +377,84 @@ async function handleQRProxy(req, res) { 'Origin': _currentConfig.uacBaseUrl, 'Referer': _currentConfig.uacBaseUrl + '/', 'X-Requested-With': 'XMLHttpRequest' - } + }, + timeout: 15_000, } const proxyReq = https.request(options, (proxyRes) => { - let data = '' - proxyRes.on('data', chunk => data += chunk) + const chunks = [] + let total = 0 + let aborted = false + proxyRes.on('data', (chunk) => { + if (aborted) return + total += chunk.length + if (total > MAX_QR_UPSTREAM_BYTES) { + aborted = true + try { proxyRes.destroy() } catch { /* ignore */ } + if (!res.headersSent) { + res.writeHead(502, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(apiError('upstream_too_large', localeOf(req)))) + } + return + } + chunks.push(chunk) + }) proxyRes.on('end', () => { - res.writeHead(200, { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*' + if (aborted || res.headersSent) return + void (async () => { + let payload + try { payload = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { + return sendErr(res, req, 502, 'invalid_upstream_response') + } + if (proxyRes.statusCode < 200 || proxyRes.statusCode >= 300) { + return sendErr(res, req, 502, 'upstream_error') + } + const credentials = qrLoginCredentials(payload) + if (!credentials) { + // Pending polls preserve the challenge. Do not return upstream secrets/errors verbatim. + const top = String(payload?.code?.code ?? payload?.code ?? '') + const boCode = String(payload?.bo?.code ?? '') + if (top === '0000' && ['4002', '1002'].includes(boCode)) { + return sendJSON(res, 200, { code: { code: top }, bo: { code: boCode } }) + } + return sendErr(res, req, 502, 'invalid_upstream_response') + } + const consumed = consumeQrChallenge(parsedBody.qrCodeKey, parsedBody.qrCodeValue, + req.headers['x-uds-qr-binding']) + if (!consumed.ok) return sendErr(res, req, 409, consumed.reason) + // Reserve once before asynchronous profile validation or session minting. + const profile = await _authMiddleware.verifyEmpNoAndToken(credentials.empNo, credentials.token) + if (!profile) return sendErr(res, req, 401, 'invalid_credentials') + const id = profile.empNo || credentials.empNo + const role = await _authMiddleware.resolveRoleOnLogin(id) + const now = new Date().toISOString() + const userContext = { empNo: id, userId: id, username: profile.username || id, + displayName: profile.username || id, department: profile.department || '', + organization: profile.organization || profile.department || '', + email: profile.email || '', phone: profile.phone || '', token: credentials.token, + isAuthenticated: true, authMode: 'token+profile', authenticatedAt: now, + lastActiveAt: now, sessionCreatedAt: now } + await ensureUserWorkspace(id) + const { bearer, bridge } = await mintLoginSession(id, userContext, 'uds', credentials.token) + appendSessionCookie(req, res, bearer) + sendJSON(res, 200, { code: { code: '0000' }, bo: { code: '0000' }, + auth: { success: true, empNo: id, role, bridge, + userContext: { empNo: id, username: userContext.username, role, isAuthenticated: true } } }) + })().catch(err => { + if (!res.headersSent) sendErr(res, req, 502, err.code || 'upstream_error') }) - res.end(data) + }) + proxyRes.on('error', () => { + if (!res.headersSent) sendErr(res, req, 502, 'upstream_error') }) }) + proxyReq.on('timeout', () => { + try { proxyReq.destroy() } catch { /* ignore */ } + }) + proxyReq.on('error', (err) => { - res.writeHead(502, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: err.message })) + if (!res.headersSent) sendErr(res, req, 502, 'upstream_error') }) proxyReq.write(body) @@ -286,34 +465,15 @@ async function handleQRProxy(req, res) { } } -// QR code endpoint -// 静态资源服务:/uds-auth/lib/* → lib/qrcode-lib/* -// /uds-auth/vendor/* → lib/vendor/* -const MIME = { '.js': 'application/javascript', '.mjs': 'application/javascript', '.json': 'application/json', '.css': 'text/css', '.html': 'text/html' } -function makeStaticHandler(baseDir) { - return async function handleStatic(req, res) { - try { - const url = new URL(req.url, 'http://localhost') - let rel = url.pathname.replace(/^\/uds-auth\/(lib|vendor)\//, '') - rel = rel.replace(/\.\.\//g, '').replace(/\.\./g, '') - const filePath = resolve(baseDir, rel) - const data = await readFile(filePath) - const ext = filePath.substring(filePath.lastIndexOf('.')) - res.writeHead(200, { - 'Content-Type': MIME[ext] || 'application/octet-stream', - 'Cache-Control': 'max-age=3600' - }) - res.end(data) - } catch (e) { - res.writeHead(404, { 'Content-Type': 'text/plain' }) - res.end('Not found') - } - } -} -const handleQrcodeStatic = makeStaticHandler(resolve(__dirname, 'qrcode-lib')) -const handleVendorStatic = makeStaticHandler(resolve(__dirname, 'vendor')) +// SEC-01: generic /uds-auth/lib/* and /vendor/* file serving removed (path traversal). +// QR library is bundled as qrcode-bundled.cjs and loaded in-process only. async function handleQRCode(req, res) { + if (!_qrLimiter.allow(clientKey(req))) { + res.writeHead(429, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(apiError('rate_limited', localeOf(req)))) + return + } const url = new URL(req.url, 'http://localhost') const data = url.searchParams.get('data') @@ -322,6 +482,11 @@ async function handleQRCode(req, res) { res.end('Missing data parameter') return } + if (String(data).length > MAX_QR_DATA_LEN) { + res.writeHead(413, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(apiError('payload_too_large', localeOf(req)))) + return + } try { const qr = await loadQRCodeLib() @@ -397,6 +562,8 @@ async function handleVerifyCode(req, res) { return } + const challenge = peekQrChallenge(qrCodeKey, qrCodeValue, req.headers['x-uds-qr-binding']) + if (!challenge.ok) return sendErr(res, req, 400, challenge.reason) const verifyCode = calculateVerifyCode(qrCodeKey, qrCodeValue, loginClientIp, loginSystemCode, originSystemCode) res.writeHead(200, { @@ -414,17 +581,47 @@ async function handleVerifyCode(req, res) { })) } -// User info proxy — intranet direct (no HTTP_PROXY), empNo+token headers +// User info proxy — intranet direct (no HTTP_PROXY). T03: token never via URL query. async function handleUserInfo(req, res) { - if (req.method !== 'GET') { + const method = (req.method || 'GET').toUpperCase() + if (method !== 'POST' && method !== 'GET') { res.writeHead(405, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(apiError('method_not_allowed', localeOf(req)))) return } const url = new URL(req.url, 'http://localhost') - const empNo = url.searchParams.get('empNo') - const token = url.searchParams.get('token') + // Reject token in query on any method (access logs / Referer leak). + if (url.searchParams.get('token') || url.searchParams.get('authValue')) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(apiError('token_in_query_forbidden', localeOf(req)))) + return + } + + let empNo = null + let token = null + const headers = req.headers || {} + if (method === 'POST') { + let body = {} + try { + const raw = await readBodyLimited(req, { maxBytes: DEFAULT_MAX_BODY }) + try { body = JSON.parse(raw || '{}') } catch { body = {} } + } catch (err) { + res.writeHead(err.statusCode || 413, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(apiError(err.code || 'payload_too_large', localeOf(req)))) + return + } + empNo = body.empNo || headers[INTERNAL.empNoHeader.toLowerCase()] || headers['x-emp-no'] + token = body.token || body.authValue + || headers[INTERNAL.authValueHeader.toLowerCase()] + || headers['x-auth-value'] + } else { + // GET: headers only (no query credentials). + empNo = headers[INTERNAL.empNoHeader.toLowerCase()] || headers['x-emp-no'] + token = headers[INTERNAL.authValueHeader.toLowerCase()] || headers['x-auth-value'] + } + empNo = empNo != null ? String(empNo).trim() : '' + token = token != null ? String(token).trim() : '' if (!empNo || !token) { res.writeHead(400, { 'Content-Type': 'application/json' }) @@ -453,14 +650,51 @@ async function handleUserInfo(req, res) { })) return } + // R07: client expects code + non-empty bo list (not only upstream code). + // Return whitelist profile only — never raw UAC blob / full token. + const profile = out.profile || {} + const safeRow = { + employeeShortId: profile.empNo, + empNo: profile.empNo, + name: profile.username || profile.empNo, + deptFullName: profile.department || '', + orgNamePath: profile.organization || '', + email: profile.email || '', + } res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify(out.result)) + res.end(JSON.stringify({ + code: { code: '0000' }, + bo: [safeRow], + profile: { + empNo: profile.empNo, + username: profile.username || null, + displayName: profile.username || null, + department: profile.department || null, + organization: profile.organization || null, + }, + })) } catch (err) { res.writeHead(502, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: err.message })) } } +/** Desktop renderer (dsh-app://app) talks to loopback Host across origins. */ +function applyDesktopCors(req, res) { + const origin = String(req?.headers?.origin || '').trim() + if (origin !== 'dsh-app://app') return false + if (!requestIsLoopback(req)) return false + res.setHeader('Access-Control-Allow-Origin', 'dsh-app://app') + res.setHeader('Access-Control-Allow-Credentials', 'true') + res.setHeader( + 'Access-Control-Allow-Headers', + 'Content-Type, X-UDS-Bridge-EmpNo, X-UDS-Bridge-Token, X-UDS-Bridge-Kind, X-UDS-Session', + ) + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + res.setHeader('Vary', 'Origin') + return true +} + function sendJSON(res, code, data) { res.statusCode = code >= 200 && code < 300 ? 200 : code res.setHeader('Content-Type', 'application/json; charset=utf-8') @@ -487,33 +721,35 @@ function isHttpsRequest(req) { return xf === 'https' } -function setFallbackAdminCookies(req, res, extraCookies = []) { +/** + * UI display hints only (SEC-02) — never prove login. + * Auth proof is UDS_SESSION / bridge bearer minted separately. + */ +function setFallbackHintCookies(req, res, extraCookies = []) { const fbMaxAge = 7 * 24 * 60 * 60 const secure = isHttpsRequest(req) - const partsUser = [ - 'UDS_FALLBACK_USER=administrator', - `Max-Age=${fbMaxAge}`, - 'Path=/', - 'HttpOnly', - 'SameSite=Lax', - ] const partsUi = [ 'UDS_FALLBACK_UI=administrator', `Max-Age=${fbMaxAge}`, 'Path=/', 'SameSite=Lax', ] - if (secure) { - partsUser.push('Secure') - partsUi.push('Secure') - } - const list = [partsUser.join('; '), partsUi.join('; '), ...extraCookies] + if (secure) partsUi.push('Secure') + const list = [partsUi.join('; '), ...extraCookies] res.setHeader('Set-Cookie', list) } async function handleFallbackLogin(req, res) { - let body = '' - for await (const chunk of req) body += chunk + if (!_loginLimiter.allow(clientKey(req))) { + audit({ action: 'fallback_login', decision: 'deny', reasonCode: 'rate_limited' }) + return sendErr(res, req, 429, 'rate_limited') + } + let body + try { + body = await readBodyLimited(req, { maxBytes: DEFAULT_MAX_BODY }) + } catch (err) { + return sendErr(res, req, err.statusCode || 413, err.code || 'payload_too_large') + } let parsed try { parsed = JSON.parse(body) } catch { parsed = {} } @@ -525,31 +761,45 @@ async function handleFallbackLogin(req, res) { } if (username !== 'administrator') { // 不泄露"administrator"是唯一用户名 + audit({ action: 'fallback_login', decision: 'deny', reasonCode: 'invalid_credentials' }) return sendErr(res, req, 401, 'invalid_credentials') } - if (!_rolesStore.verifyFallback(password, ip)) { + const verifiedGeneration = _rolesStore.getFallbackCredVersion() + let passwordOk + try { passwordOk = await _rolesStore.verifyFallbackAsync(password, ip) } catch (err) { + return sendErr(res, req, err.code === 'kdf_busy' ? 429 : 503, err.code || 'roles_persist_failed') + } + if (!passwordOk || verifiedGeneration !== _rolesStore.getFallbackCredVersion()) { + audit({ action: 'fallback_login', decision: 'deny', reasonCode: 'invalid_credentials' }) return sendErr(res, req, 401, 'invalid_credentials') } - // 登录成功:创建 session,角色 = fallback_admin (等同 super_admin) + // 登录成功:签发随机本地会话;fallback UI cookie 仅提示,不能单独重建登录 const empNo = 'administrator' const userContext = { empNo, username: 'Fallback Administrator', isAuthenticated: true, role: 'fallback_admin', + authMode: 'fallback-password', + fallbackCredVersion: verifiedGeneration, authenticatedAt: new Date().toISOString(), lastActiveAt: new Date().toISOString(), } - await _sessionStore.setex(empNo, Math.floor(INTERNAL.session.cookieMaxAge / 1000), userContext) + const { bearer, bridge } = await mintLoginSession(empNo, userContext, 'fallback') await ensureUserWorkspace(empNo) - // Cookie path (http / web) + bridge path (Desktop dsh-app:// where cookies are dropped). - setFallbackAdminCookies(req, res) - const bridge = mintBridge(empNo, 'fallback') + setFallbackHintCookies(req, res) + appendSessionCookie(req, res, bearer) + audit({ action: 'fallback_login', decision: 'allow', actor: empNo }) - sendOkMsg(res, req, 'fallback_login', null, userContext, { + sendOkMsg(res, req, 'fallback_login', null, { + empNo, + username: userContext.username, + isAuthenticated: true, + role: 'fallback_admin', + }, { success: true, empNo, role: 'fallback_admin', @@ -562,8 +812,12 @@ async function handleFallbackLogin(req, res) { * Used after QR login on Desktop where document.cookie / Set-Cookie are no-ops. */ async function handleBridgeBind(req, res) { - let body = '' - for await (const chunk of req) body += chunk + let body + try { + body = await readBodyLimited(req, { maxBytes: DEFAULT_MAX_BODY }) + } catch (err) { + return sendErr(res, req, err.statusCode || 413, err.code || 'payload_too_large') + } let parsed try { parsed = JSON.parse(body) } catch { parsed = {} } const empNo = String(parsed.empNo || parsed.account || '').trim() @@ -596,10 +850,16 @@ async function handleBridgeBind(req, res) { sessionCreatedAt: now, } const id = userContext.empNo - await _sessionStore.setex(id, Math.floor(INTERNAL.session.cookieMaxAge / 1000), userContext) + let role + try { + role = await _authMiddleware.resolveRoleOnLogin(id) + } catch (err) { + return sendErr(res, req, 403, err?.code || 'bootstrap_required') + } + const { bearer, bridge } = await mintLoginSession(id, userContext, 'uds', token) await ensureUserWorkspace(id) - // Cookie compat for http hosts + // Local session cookie is the auth proof; portal cookies are upstream leftovers only. const secure = isHttpsRequest(req) const maxAge = Math.floor(INTERNAL.session.cookieMaxAge / 1000) const cookieParts = (name, value, httpOnly) => { @@ -614,13 +874,18 @@ async function handleBridgeBind(req, res) { return parts.join('; ') } res.setHeader('Set-Cookie', [ + buildSessionCookie(bearer, maxAge, { secure }), cookieParts(INTERNAL.cookieEmpNo, id, false), - cookieParts(INTERNAL.cookieAuthValue, token, true), ]) - const bridge = mintBridge(id, 'uds', token) - const role = _rolesStore?.getRole?.(id) || 'user' - sendOkMsg(res, req, 'fallback_login', null, userContext, { + sendOkMsg(res, req, 'fallback_login', null, { + empNo: id, + username: userContext.username, + displayName: userContext.displayName, + department: userContext.department, + isAuthenticated: true, + role, + }, { success: true, empNo: id, role, @@ -638,11 +903,16 @@ async function handleLocalAdminUnlock(req, res) { } const ip = req.socket?.remoteAddress || 'unknown' if (!allowUnlockAttempt(ip)) { + audit({ action: 'local_admin_unlock', decision: 'deny', reasonCode: 'rate_limited' }) return sendErr(res, req, 429, 'rate_limited') } - let body = '' - for await (const chunk of req) body += chunk + let body + try { + body = await readBodyLimited(req, { maxBytes: DEFAULT_MAX_BODY }) + } catch (err) { + return sendErr(res, req, err.statusCode || 413, err.code || 'payload_too_large') + } let parsed try { parsed = JSON.parse(body) } catch { parsed = {} } const key = String(parsed.key || parsed.passphrase || parsed.password || '').trim() @@ -650,17 +920,21 @@ async function handleLocalAdminUnlock(req, res) { return sendErr(res, req, 400, 'key_required') } - const opened = openLocalAdminBox(readLocalAdminBox(), key) + let opened + try { opened = await openLocalAdminBoxAsync(readLocalAdminBox(), key) } catch (err) { + return sendErr(res, req, err.code === 'kdf_busy' ? 429 : 400, err.code || 'decrypt_failed') + } if (!opened.ok) { + audit({ action: 'local_admin_unlock', decision: 'deny', reasonCode: 'decrypt_failed' }) return sendErr(res, req, 401, 'decrypt_failed') } const empNo = opened.empNo const userContext = buildLocalAdminUserContext() - await _sessionStore.setex(empNo, Math.floor(INTERNAL.session.cookieMaxAge / 1000), userContext) + const { bearer, bridge } = await mintLoginSession(empNo, userContext, 'sealed_box') await ensureUserWorkspace(empNo) + audit({ action: 'local_admin_unlock', decision: 'allow', actor: empNo }) - // Build local-admin session cookie into the same Set-Cookie batch. const fakeRes = { headersSent: false, _cookies: [], @@ -675,10 +949,15 @@ async function handleLocalAdminUnlock(req, res) { }, } appendLocalAdminCookie(fakeRes, req) - setFallbackAdminCookies(req, res, fakeRes._cookies) - const bridge = mintBridge(empNo, 'fallback') + setFallbackHintCookies(req, res, fakeRes._cookies) + appendSessionCookie(req, res, bearer) - sendOkMsg(res, req, 'local_admin_unlock', null, userContext, { + sendOkMsg(res, req, 'local_admin_unlock', null, { + empNo, + username: userContext.username || 'Local Administrator', + isAuthenticated: true, + role: 'fallback_admin', + }, { success: true, empNo, role: 'fallback_admin', @@ -686,8 +965,80 @@ async function handleLocalAdminUnlock(req, res) { }) } -/** 运行时配置文件路径 — 持久化 _currentConfig 让重启后不丢 */ -const RUNTIME_CONFIG_FILE = resolve(__dirname, '..', 'config.runtime.json') +/** Legacy install-root runtime path (pre-0.3.6); prefer profile data dir. */ +const LEGACY_RUNTIME_CONFIG_FILE = resolve(__dirname, '..', 'config.runtime.json') +const LEGACY_PLUGIN_ROOT = resolve(__dirname, '..') +let _runtimeConfigFile = LEGACY_RUNTIME_CONFIG_FILE + +function runtimeConfigPath(dataDir) { + return dataDir ? resolve(dataDir, 'config.runtime.json') : LEGACY_RUNTIME_CONFIG_FILE +} + +/** + * F05: copy 0.3.5 install-root state into the profile data dir once. + * Refuses silent empty bootstrap when legacy security state exists. + */ +async function migrateLegacyStateIfNeeded(dataDir, logger) { + return migrateState(dataDir, LEGACY_PLUGIN_ROOT, logger) +} + +function validateConfigPatch(partial) { + const err = (code) => { + const e = new Error(code) + e.code = code + return e + } + if (partial == null || typeof partial !== 'object' || Array.isArray(partial)) { + throw err('invalid_config') + } + // Reject unknown security-sensitive fields being smuggled in. + const allowed = new Set([ + 'uacBaseUrl', + 'userSearchUrl', + 'loginSystemCode', + 'originSystemCode', + 'workspaceRoot', + 'retainSkillCredentialsOnLogout', + 'skillCredentialTtlSeconds', + 'outboundAllowedHosts', + 'initialAdminEmpNo', + 'allowOpenRegistration', + 'allowRawAgentToken', + 'trustedHosts', + ]) + for (const k of Object.keys(partial)) { + if (!allowed.has(k)) throw err('unknown_config_field') + } + const checkHttpsUrl = (v, code) => { + if (v === undefined) return + const s = String(v).trim() + if (!s) throw err(code) + let u + try { u = new URL(s) } catch { throw err(code) } + if (u.protocol !== 'https:') throw err(code) + } + checkHttpsUrl(partial.uacBaseUrl, 'invalid_uac_url') + checkHttpsUrl(partial.userSearchUrl, 'invalid_user_search_url') + if (partial.workspaceRoot !== undefined && String(partial.workspaceRoot).trim()) { + const root = String(partial.workspaceRoot).trim() + if (!isAbsolute(root)) throw err('invalid_workspace_root') + } + if (partial.skillCredentialTtlSeconds !== undefined) { + const n = Number(partial.skillCredentialTtlSeconds) + if (!Number.isFinite(n) || n < 60 || n > 30 * 24 * 60 * 60) { + throw err('invalid_ttl') + } + } + if (partial.outboundAllowedHosts !== undefined) { + const raw = partial.outboundAllowedHosts + const list = Array.isArray(raw) + ? raw + : String(raw).split(/[,;\s]+/).map((s) => s.trim()).filter(Boolean) + for (const h of list) { + if (!/^[a-z0-9.-]+$/i.test(h) || h.includes('..')) throw err('invalid_outbound_host') + } + } +} async function saveRuntimeConfig(partial) { if (!_currentConfig) { @@ -695,7 +1046,7 @@ async function saveRuntimeConfig(partial) { err.code = 'config_not_ready' throw err } - // 只允许修改可配置字段;写到新对象上(host 传入的 config 可能是只读的) + validateConfigPatch(partial) const allowed = [ 'uacBaseUrl', 'userSearchUrl', @@ -705,27 +1056,29 @@ async function saveRuntimeConfig(partial) { 'retainSkillCredentialsOnLogout', 'skillCredentialTtlSeconds', 'outboundAllowedHosts', + 'initialAdminEmpNo', + 'allowOpenRegistration', + 'allowRawAgentToken', + 'trustedHosts', ] const next = asMutableConfig(_currentConfig) for (const k of allowed) { if (partial[k] !== undefined) { - if (k === 'retainSkillCredentialsOnLogout') { + if (k === 'retainSkillCredentialsOnLogout' || k === 'allowOpenRegistration' || k === 'allowRawAgentToken') { next[k] = partial[k] === true || partial[k] === 'true' } else if (k === 'skillCredentialTtlSeconds') { - next[k] = Number(partial[k]) || CONFIG_DEFAULTS.skillCredentialTtlSeconds + next[k] = Number(partial[k]) + } else if (k === 'outboundAllowedHosts') { + // Preserve [] / '' as deny-all; do not String([]) → "[]". + next[k] = Array.isArray(partial[k]) ? partial[k].map(String) : String(partial[k]) } else { next[k] = typeof partial[k] === 'string' ? partial[k] : String(partial[k]) } } } + // Persist first — only then swap memory (SEC-22). F05: write under profile data dir. + await atomicWriteJson(_runtimeConfigFile, next, { mode: 0o600 }) _currentConfig = next - // 写运行时配置文件(不覆盖原始 config.default.yaml) - try { - await writeFile(RUNTIME_CONFIG_FILE, JSON.stringify(_currentConfig, null, 2), 'utf-8') - } catch (err) { - // 文件写失败不影响内存配置(服务端仍然生效) - console.warn('[uds-auth] Failed to write runtime config:', err.message) - } } // 统一入口:单个 prefix 路由 handler,内部自行分发 @@ -735,15 +1088,53 @@ async function handleAllRoutes(req, res) { const pathname = url.pathname.replace(/\/+$/, '') || '/' const method = (req.method || 'GET').toUpperCase() - // 静态资源:/uds-auth/vendor/* - if (pathname.startsWith('/uds-auth/vendor/')) { - return await handleVendorStatic(req, res) + // Desktop dsh-app:// → loopback Host: answer CORS preflight before auth. + if (pathname.startsWith('/uds-auth') && applyDesktopCors(req, res) && method === 'OPTIONS') { + res.statusCode = 204 + res.end() + return } - // 静态资源:/uds-auth/lib/* - if (pathname.startsWith('/uds-auth/lib/')) { - return await handleQrcodeStatic(req, res) + + // SEC-20 health: always answer readiness without auth. + if (pathname === '/uds-auth/health' && method === 'GET') { + const ready = _pluginReady && !_rolesStore?.loadFailed + return sendJSON(res, ready ? 200 : 503, { + ok: ready, + ready, + error: ready ? null : (_pluginReadyError || 'unready'), + }) } + if (!_pluginReady) { + return sendJSON(res, 503, { + ...apiError('plugin_unready', localeOf(req)), + detail: _pluginReadyError || 'initializing', + }) + } + + // A09/SEC-19: Host trust on all methods (incl. GET qr-start); Origin/CT on mutating. + const isMutating = !['GET', 'HEAD', 'OPTIONS'].includes(method) + const trustedHostsFn = () => { + const raw = _currentConfig?.trustedHosts + if (raw == null || raw === '') return [] + if (Array.isArray(raw)) return raw.map(String) + return String(raw).split(/[,;\s]+/).map((s) => s.trim()).filter(Boolean) + } + const originCheck = checkRequestOrigin(req, { trustedHosts: trustedHostsFn }) + if (!originCheck.ok) { + return sendJSON(res, 403, { ...apiError('origin_forbidden', localeOf(req)), reason: originCheck.reason }) + } + if (isMutating) { + if (pathname.startsWith('/uds-auth/api/') || pathname === '/uds-auth/outbound') { + const ct = requireJsonContentType(req) + if (!ct.ok) { + return sendJSON(res, 415, { ...apiError('content_type', localeOf(req)), reason: ct.reason }) + } + } + } + + // SEC-01: generic static file routes removed (path traversal). + // QR 生成 if (pathname === '/uds-auth/qr' && method === 'GET') { return await handleQRCode(req, res) @@ -764,8 +1155,8 @@ async function handleAllRoutes(req, res) { return await handleVerifyCode(req, res) } - // 用户信息代理 - if (pathname === '/uds-auth/user-info' && method === 'GET') { + // 用户信息代理(T03: POST body/headers;GET 仅 headers,禁止 query token) + if (pathname === '/uds-auth/user-info' && (method === 'GET' || method === 'POST')) { return await handleUserInfo(req, res) } @@ -876,6 +1267,32 @@ function handleRequest(req, res) { await _apiHandlers.setViewAllSessions(ctx2); return } + // T01: short-TTL one-time WS handshake ticket (never put long-lived bearer in WS URL). + if (url === '/api/ws-ticket' && method === 'POST') { + const extracted = await extractSessionBearerAsync(req, { + sessionBridge: _sessionBridge, + isLiveBearer: async (b) => !!(await _sessionStore?.getByBearer(b)), + }) + const bearer = extracted?.bearer + if (!bearer || !_sessionBridge) { + return sendErr(res, req, 401, 'not_logged_in', null, ctx2.userContext) + } + try { + const minted = _sessionBridge.mintWsTicket({ + empNo: ctx2.empNo, + bearer, + ttlMs: 60_000, + }) + return sendJSON(res, 200, { + ticket: minted.ticket, + expiresAt: minted.expiresAt, + expiresIn: minted.expiresIn, + }) + } catch (err) { + return sendErr(res, req, 400, err.message || 'ws_ticket_failed', null, ctx2.userContext) + } + } + // 配置端点 (admin / super_admin:canAccessSettings) if (url === '/api/config' && method === 'GET') { if (!requirePermission(ctx2, 'canAccessSettings')) { @@ -888,17 +1305,35 @@ function handleRequest(req, res) { if (!requirePermission(ctx2, 'canAccessSettings')) { return sendErr(res, req, 403, 'forbidden_settings', null, ctx2.userContext) } - let body = '' - for await (const chunk of req) body += chunk + let body + try { + body = await readBodyLimited(req, { maxBytes: DEFAULT_MAX_BODY }) + } catch (err) { + return sendErr(res, req, err.statusCode || 413, err.code || 'payload_too_large', null, ctx2.userContext) + } let parsed try { parsed = JSON.parse(body) } catch { parsed = {} } try { await saveRuntimeConfig(parsed) + audit({ + action: 'config_save', + decision: 'allow', + actor: ctx2.empNo, + meta: { keys: Object.keys(parsed || {}) }, + }) sendOkMsg(res, req, 'config_saved', null, ctx2.userContext) } catch (err) { const code = err?.code || err?.message + audit({ + action: 'config_save', + decision: 'deny', + actor: ctx2.empNo, + reasonCode: code, + }) if (code === 'config_not_ready') { sendErr(res, req, 400, 'config_not_ready', null, ctx2.userContext) + } else if (typeof code === 'string' && !code.includes(' ')) { + sendErr(res, req, 400, code, null, ctx2.userContext) } else { sendJSON(res, 400, { error: 'request_failed', message: err.message }) } @@ -924,13 +1359,8 @@ function handleRequest(req, res) { await _apiHandlers.logout(ctx2); return } - sendJSON(res, 200, { - success: true, - user: ctx2.userContext, - empNo: ctx2.empNo, - role: ctx2.role, - permissions: ctx2.permissions, - }) + // SEC-07: unknown API → 404 (do not echo userContext/token) + sendJSON(res, 404, { error: 'not_found' }) }).catch(err => { sendJSON(res, 500, { error: err.message }) }) @@ -945,8 +1375,8 @@ function installSettingsSection(ctx, entry, hooks) { const hooksArg = { setSource: (current) => { + // Validation + assign happen inside hooks.setSource (F04). hooks.setSource?.(current) - _currentConfig = asMutableConfig(current) }, onChange: () => { hooks.onChange?.() @@ -1077,11 +1507,40 @@ export async function apply(ctx, config = {}) { installSettingsSection(ctx, source(), { setSource: (current) => { - source = typeof current === 'function' ? current : () => current - _currentConfig = asMutableConfig(source()) + const next = asMutableConfig(typeof current === 'function' ? current() : current) + // F04: same HTTPS/field validation as config API / startup. + try { + validateConfigPatch({ + uacBaseUrl: next.uacBaseUrl, + userSearchUrl: next.userSearchUrl, + outboundAllowedHosts: next.outboundAllowedHosts, + skillCredentialTtlSeconds: next.skillCredentialTtlSeconds, + workspaceRoot: next.workspaceRoot || undefined, + trustedHosts: next.trustedHosts, + }) + } catch (err) { + ctx.logger?.warn?.('[uds-auth] settings rejected: %s', err.code || err.message) + throw err + } + source = typeof current === 'function' ? current : () => next + _currentConfig = next }, onChange: () => { - _currentConfig = asMutableConfig(source()) + const next = asMutableConfig(source()) + try { + validateConfigPatch({ + uacBaseUrl: next.uacBaseUrl, + userSearchUrl: next.userSearchUrl, + outboundAllowedHosts: next.outboundAllowedHosts, + skillCredentialTtlSeconds: next.skillCredentialTtlSeconds, + workspaceRoot: next.workspaceRoot || undefined, + trustedHosts: next.trustedHosts, + }) + } catch (err) { + ctx.logger?.warn?.('[uds-auth] settings onChange rejected: %s', err.code || err.message) + throw err + } + _currentConfig = next ctx.logger?.info?.('[uds-auth] settings updated') } }) @@ -1099,7 +1558,11 @@ export async function apply(ctx, config = {}) { } async function initServices(ctx, config) { + _pluginReady = false + _pluginReadyError = 'initializing' try { + const existingGateway = tryGet(ctx, 'typertGateway') + if (existingGateway) assertGatewayContract(existingGateway) const { createSessionStore } = await import('./session/factory.js') const { createAuthMiddleware } = await import('./middleware/auth-middleware.js') const { createApiHandlers } = await import('./api.js') @@ -1113,13 +1576,45 @@ async function initServices(ctx, config) { _pluginCtx = ctx _currentConfig = asMutableConfig(config) + // A11/F05: resolve profile data dir first, migrate legacy install-root state, then load runtime. + const pluginDataDir = resolvePluginDataDir(_currentConfig) + await mkdir(pluginDataDir, { recursive: true, mode: 0o700 }) + await protectProfileDirectory(pluginDataDir) + ctx.logger?.info?.('[uds-auth] profile data dir: %s', pluginDataDir) + await migrateLegacyStateIfNeeded(pluginDataDir, ctx.logger || console) + await protectProfileDirectory(pluginDataDir) + _runtimeConfigFile = runtimeConfigPath(pluginDataDir) + try { - const raw = await readFile(RUNTIME_CONFIG_FILE, 'utf-8') + const raw = await readFile(_runtimeConfigFile, 'utf-8') const rt = JSON.parse(raw) if (rt && typeof rt === 'object') { _currentConfig = { ..._currentConfig, ...rt } } - } catch { /* runtime config optional */ } + } catch { + // Fall back to legacy install-root runtime once if profile copy missing. + try { + const raw = await readFile(LEGACY_RUNTIME_CONFIG_FILE, 'utf-8') + const rt = JSON.parse(raw) + if (rt && typeof rt === 'object') { + _currentConfig = { ..._currentConfig, ...rt } + } + } catch { /* runtime config optional */ } + } + + // A09: same HTTPS + field validation as config POST / settings. + try { + validateConfigPatch({ + uacBaseUrl: _currentConfig.uacBaseUrl, + userSearchUrl: _currentConfig.userSearchUrl, + outboundAllowedHosts: _currentConfig.outboundAllowedHosts, + skillCredentialTtlSeconds: _currentConfig.skillCredentialTtlSeconds, + workspaceRoot: _currentConfig.workspaceRoot || undefined, + trustedHosts: _currentConfig.trustedHosts, + }) + } catch (err) { + throw new Error(`invalid_startup_config:${err.code || err.message}`) + } // Normalize skill-related config after merge if (_currentConfig.retainSkillCredentialsOnLogout === undefined) { @@ -1132,48 +1627,64 @@ async function initServices(ctx, config) { if (!_currentConfig.skillCredentialTtlSeconds) { _currentConfig.skillCredentialTtlSeconds = CONFIG_DEFAULTS.skillCredentialTtlSeconds } - if (!_currentConfig.outboundAllowedHosts) { + // R05: only default when field absent — empty string / [] means deny-all. + if (_currentConfig.outboundAllowedHosts === undefined || _currentConfig.outboundAllowedHosts === null) { _currentConfig.outboundAllowedHosts = CONFIG_DEFAULTS.outboundAllowedHosts } - _sessionStore = await createSessionStore(INTERNAL.session) - + _sessionStore = await createSessionStore(INTERNAL.session, { + file: resolve(pluginDataDir, 'sessions.json'), + dataDir: pluginDataDir, + logger: ctx.logger || console, + }) const ttlMs = Math.max(60, Number(_currentConfig.skillCredentialTtlSeconds) || CONFIG_DEFAULTS.skillCredentialTtlSeconds) * 1000 _skillCredentials = new SkillCredentialCache({ - file: resolve(__dirname, '..', 'skill-credentials.json'), + file: resolve(pluginDataDir, 'skill-credentials.json'), + dataDir: pluginDataDir, ttlMs, logger: ctx.logger || console, }) await _skillCredentials.init() - const rolesFile = resolve(__dirname, '..', 'roles.json') + const rolesFile = resolve(pluginDataDir, 'roles.json') _rolesStore = new RolesStore({ rolesFile }) await _rolesStore.init() + if (_rolesStore.loadFailed) { + throw new Error('roles_store_corrupt') + } logLocalAdminStatus(ctx.logger || console) _sessionBridge = new SessionBridgeStore({ - file: resolve(__dirname, '..', 'session-bridge.json'), + file: resolve(pluginDataDir, 'session-bridge.json'), ttlMs: INTERNAL.session.cookieMaxAge, }) await _sessionBridge.init() ctx.logger?.info?.('[uds-auth] session bridge ready (cookie + header dual path)') - _sessionAcl = new SessionAclStore({ ownersFile: resolve(__dirname, '..', 'session-owners.json') }) + _sessionAcl = new SessionAclStore({ ownersFile: resolve(pluginDataDir, 'session-owners.json') }) await _sessionAcl.init() + if (_sessionAcl.loadFailed) { + throw new Error('session_owners_corrupt') + } - _userWorkspaces = new UserWorkspaceStore({ mapFile: resolve(__dirname, '..', 'user-workspaces.json') }) + _userWorkspaces = new UserWorkspaceStore({ mapFile: resolve(pluginDataDir, 'user-workspaces.json') }) await _userWorkspaces.init() + const { TaskCapabilityStore } = await import('./task-capability.js') + _taskCapabilities = new TaskCapabilityStore({ defaultTtlSeconds: 3600 }) + const rememberSkillCreds = (empNo, token) => { try { _skillCredentials?.set(empNo, token) } catch { /* ignore */ } } _authMiddleware = createAuthMiddleware({ - userSearchUrl: _currentConfig.userSearchUrl, + get userSearchUrl() { return _currentConfig?.userSearchUrl }, + getInitialAdminEmpNo: () => _currentConfig?.initialAdminEmpNo || process.env.UDS_AUTH_INITIAL_ADMIN || '', + getAllowOpenRegistration: () => _currentConfig?.allowOpenRegistration !== false, udsAuth: { - baseUrl: _currentConfig.uacBaseUrl, - systemCode: _currentConfig.loginSystemCode, - userSearchUrl: _currentConfig.userSearchUrl, + get baseUrl() { return _currentConfig?.uacBaseUrl }, + get systemCode() { return _currentConfig?.loginSystemCode }, + get userSearchUrl() { return _currentConfig?.userSearchUrl }, empNoHeader: INTERNAL.empNoHeader, authValueHeader: INTERNAL.authValueHeader, }, @@ -1191,16 +1702,23 @@ async function initServices(ctx, config) { skillCredentials: _skillCredentials, retainSkillCredentialsOnLogout: () => _currentConfig?.retainSkillCredentialsOnLogout !== false, sessionBridge: _sessionBridge, + revokeTaskCapabilitiesForEmpNo: (empNo) => _taskCapabilities?.revokeEmpNo(empNo) || 0, }, ) function parseOutboundHosts() { const raw = _currentConfig?.outboundAllowedHosts - if (Array.isArray(raw)) return raw.map(String).filter(Boolean) - return String(raw || CONFIG_DEFAULTS.outboundAllowedHosts) - .split(/[,;\s]+/) - .map((s) => s.trim()) - .filter(Boolean) + // R05: undefined → packaged defaults; '' / [] → deny all (do not revive defaults). + if (raw === undefined || raw === null) { + return String(CONFIG_DEFAULTS.outboundAllowedHosts) + .split(/[,;\s]+/) + .map((s) => s.trim()) + .filter(Boolean) + } + if (Array.isArray(raw)) return raw.map(String).map((s) => s.trim()).filter(Boolean) + const s = String(raw).trim() + if (!s) return [] + return s.split(/[,;\s]+/).map((x) => x.trim()).filter(Boolean) } async function resolveCredentialsForEmpNo(empNo) { @@ -1219,10 +1737,24 @@ async function initServices(ctx, config) { _agentAuthHandlers = createAgentAuthHandlers({ resolveCredentialsForSession, - resolveCredentialsForEmpNo, + getSessionOwner: (sessionId) => _sessionAcl?.getOwner(sessionId) || null, + resolveCallerSession: async (req) => { + const extracted = await extractSessionBearerAsync(req, { + sessionBridge: _sessionBridge, + isLiveBearer: async (b) => !!(await _sessionStore?.getByBearer(b)), + }) + if (!extracted?.bearer) return null + const session = await _sessionStore.getByBearer(extracted.bearer) + if (!session?.empNo) return null + return { empNo: session.empNo, sessionId: session.sessionId } + }, + allowRawTokenResponse: () => _currentConfig?.allowRawAgentToken === true, empNoHeader: INTERNAL.empNoHeader, authValueHeader: INTERNAL.authValueHeader, outboundHosts: () => parseOutboundHosts(), + sessionBridge: _sessionBridge, + taskCapabilities: _taskCapabilities, + isAccountActive: (empNo) => !(_rolesStore?.isDisabled?.(empNo)), }) const identityDeps = { @@ -1234,7 +1766,17 @@ async function initServices(ctx, config) { const resolveIdentitySync = (req) => resolveIdentityFromRequestSync(req, identityDeps) const patchServer = (server) => { - patchWebServerWithIdentity(server, resolveIdentity, resolveIdentitySync) + patchWebServerWithIdentity(server, resolveIdentity, resolveIdentitySync, (req, upgrade) => { + const gateway = tryGet(ctx, 'typertGateway') + const pathname = new URL(req.url || '/', 'http://uds.local').pathname + if (pathname.startsWith('/api/') && gateway && !gateway.__udsGatewayPolicy?.installed) { + return { ok: false, reason: 'gateway_acl_not_ready' } + } + return checkRequestOrigin(req, { upgrade, trustedHosts: () => { + const raw = _currentConfig?.trustedHosts + return Array.isArray(raw) ? raw : String(raw || '').split(/[,;\s]+/).filter(Boolean) + } }) + }) } const present = tryGet(ctx, 'webServer') if (present) patchServer(present) @@ -1253,6 +1795,7 @@ async function initServices(ctx, config) { userWorkspaces: _userWorkspaces, getWorkspaceRoot: () => _currentConfig?.workspaceRoot, getRolesStore: () => _rolesStore, + getSessionStore: () => _sessionStore, ensureUserWorkspace, getWorkspaceRegistry: () => _workspaceRegistry, }) @@ -1269,6 +1812,23 @@ async function initServices(ctx, config) { const udsAuth = { runAsHost, + /** + * R06: mint a short-lived task capability for skill/cron (Host-trusted callers). + * Inject as env UDS_TASK_CAPABILITY or header X-UDS-Task-Capability. + */ + mintTaskCapability(input) { + if (!_taskCapabilities) throw new Error('task_capabilities_unavailable') + const sid = String(input?.dshSessionId || '').trim() + const owner = sid ? _sessionAcl?.getOwner(sid) : null + if (!owner || owner !== input?.empNo || _rolesStore.isDisabled(owner)) { + throw new Error('capability_session_owner_required') + } + return _taskCapabilities.mint({ ...input, empNo: owner, dshSessionId: sid, + audience: 'uds-auth-agent', scopes: input?.scopes || ['outbound'], mintedBy: 'host:udsAuth' }) + }, + revokeTaskCapabilitiesForEmpNo(empNo) { + return _taskCapabilities?.revokeEmpNo(empNo) || 0 + }, async resolveRequestIdentity(req) { const identity = await resolveIdentity(req) if (!identity?.empNo) return null @@ -1350,13 +1910,31 @@ async function initServices(ctx, config) { ) }, stampSessionOwner(sessionId, empNo) { - if (!_sessionAcl || !sessionId || !empNo) return - if (String(empNo).startsWith('__')) return - _sessionAcl.setOwner(sessionId, empNo) + if (!_sessionAcl || !sessionId || !empNo || String(empNo).startsWith('__') + || _rolesStore.isDisabled(empNo)) return false + return _sessionAcl.setOwner(sessionId, empNo) }, getSessionOwner(sessionId) { return _sessionAcl?.getOwner(sessionId) || null }, + getGatewayInventory() { + return runAsHost(() => { + const gateway = tryGet(ctx, 'typertGateway') + if (!gateway) return [] + const endpoints = new Set(gateway.collectSrcClaims?.() || []) + for (const row of gateway.ctx?.get?.('typert')?.local?.list?.() || []) { + endpoints.add(`${row.namespace}/${row.method}`) + } + const rows = [...endpoints].sort().map(endpoint => { + const [namespace, method] = endpoint.split('/') + const descriptor = gateway.resolveDescriptor(namespace, method, endpoint) + return { endpoint, service: descriptor.service, policy: gatewayPolicy(descriptor), mode: descriptor.mode || 'unary', + invocation: descriptor.invocation, parameters: descriptor.parameters.map(p => ({ name: p.name, wire: p.wire, source: p.source })) } + }) + return [...rows, { endpoint: '$events', mode: 'stream', policy: 'authenticated-owner-filtered-events' }, + { endpoint: '$events/result', mode: 'unary', policy: 'login-session-and-agent-bound-result' }] + }) + }, resolveCredentialsForSession, resolveCredentialsForEmpNo, rememberSkillCredentials(empNo, token) { @@ -1365,8 +1943,23 @@ async function initServices(ctx, config) { } ctx.provide('udsAuth', udsAuth) + const { createTaskEnvironment } = await import('./task-environment.js') + ctx.inject(['shellEnv', 'webServer'], (sctx) => { + const env = createTaskEnvironment({ getOwner: sid => _sessionAcl?.getOwner(sid), + rolesStore: _rolesStore, capabilities: _taskCapabilities, + getWebServer: () => tryGet(sctx, 'webServer') }) + sctx.shellEnv.register(env.contributor) + sctx.on('tools/result', (execution, result) => { env.finish(execution, result, tryGet(sctx, 'jobs')) }) + sctx.effect(() => () => env.dispose(), 'uds-auth: task capabilities') + }) + _pluginReady = true + _pluginReadyError = null ctx.logger?.info?.('[uds-auth] Initialized (ACL + workspaces + skill credentials + udsAuth service)') } catch (err) { - ctx.logger?.error?.('[uds-auth] Init failed: ' + (err.message || err)) + _pluginReady = false + _pluginReadyError = String(err?.message || err) + ctx.logger?.error?.('[uds-auth] Init failed: ' + _pluginReadyError) + // SEC-20: do not silently report Host ready with open routes. + throw err } } diff --git a/uds-auth/lib/local-admin.js b/uds-auth/lib/local-admin.js index 89dcb367..abc6e248 100644 --- a/uds-auth/lib/local-admin.js +++ b/uds-auth/lib/local-admin.js @@ -1,15 +1,16 @@ /** * Local admin via sealed box (decrypt-to-unlock). * - * Env holds only ciphertext: - * UDS_AUTH_LOCAL_ADMIN_BOX= + * 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', } } diff --git a/uds-auth/lib/middleware/auth-middleware.js b/uds-auth/lib/middleware/auth-middleware.js index 225b0d85..3dc99ca7 100644 --- a/uds-auth/lib/middleware/auth-middleware.js +++ b/uds-auth/lib/middleware/auth-middleware.js @@ -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 } diff --git a/uds-auth/lib/profile-security.js b/uds-auth/lib/profile-security.js new file mode 100644 index 00000000..f1e1ae53 --- /dev/null +++ b/uds-auth/lib/profile-security.js @@ -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 }) + } +} diff --git a/uds-auth/lib/qr-challenge.js b/uds-auth/lib/qr-challenge.js new file mode 100644 index 00000000..27f6120f --- /dev/null +++ b/uds-auth/lib/qr-challenge.js @@ -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 } +} diff --git a/uds-auth/lib/roles.js b/uds-auth/lib/roles.js index 53b1134a..5ff6650d 100644 --- a/uds-auth/lib/roles.js +++ b/uds-auth/lib/roles.js @@ -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 } diff --git a/uds-auth/lib/session-acl.js b/uds-auth/lib/session-acl.js index aade0871..a4455ef5 100644 --- a/uds-auth/lib/session-acl.js +++ b/uds-auth/lib/session-acl.js @@ -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. */ diff --git a/uds-auth/lib/session-bridge.js b/uds-auth/lib/session-bridge.js index bdbaae1c..e0443ce2 100644 --- a/uds-auth/lib/session-bridge.js +++ b/uds-auth/lib/session-bridge.js @@ -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} */ this._byToken = new Map() + /** @type {Map} 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 } diff --git a/uds-auth/lib/session/factory.js b/uds-auth/lib/session/factory.js index 193b99cb..aa373a80 100644 --- a/uds-auth/lib/session/factory.js +++ b/uds-auth/lib/session/factory.js @@ -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} */ -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 } diff --git a/uds-auth/lib/session/memory-store.js b/uds-auth/lib/session/memory-store.js index dc3f036a..7eef8693 100644 --- a/uds-auth/lib/session/memory-store.js +++ b/uds-auth/lib/session/memory-store.js @@ -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 - this._timers = new Map() // Map + this._file = opts.file || null + this._dataDir = opts.dataDir || null + this._logger = opts.logger || console + this._key = null + this._saveTimer = null + /** @type {Map} sessionId → record */ + this._sessions = new Map() + /** @type {Map} bearerHash → sessionId */ + this._byBearer = new Map() + /** @type {Map>} empNo → sessionIds */ + this._byEmpNo = new Map() + /** @type {Map} empNo → profile (cache only) */ + this._profiles = new Map() + /** @type {Map} */ + 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] } } diff --git a/uds-auth/lib/session/redis-store.js b/uds-auth/lib/session/redis-store.js index 72be3f99..79f15f3b 100644 --- a/uds-auth/lib/session/redis-store.js +++ b/uds-auth/lib/session/redis-store.js @@ -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:/, '')) } } diff --git a/uds-auth/lib/session/request-auth.js b/uds-auth/lib/session/request-auth.js new file mode 100644 index 00000000..1105de67 --- /dev/null +++ b/uds-auth/lib/session/request-auth.js @@ -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 }} [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 } diff --git a/uds-auth/lib/session/store.js b/uds-auth/lib/session/store.js index 359f0dc7..01255ba5 100644 --- a/uds-auth/lib/session/store.js +++ b/uds-auth/lib/session/store.js @@ -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 diff --git a/uds-auth/lib/skill-credentials.js b/uds-auth/lib/skill-credentials.js index e4f7be5b..5753907e 100644 --- a/uds-auth/lib/skill-credentials.js +++ b/uds-auth/lib/skill-credentials.js @@ -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} */ 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) } diff --git a/uds-auth/lib/state-migration.js b/uds-auth/lib/state-migration.js new file mode 100644 index 00000000..94a59309 --- /dev/null +++ b/uds-auth/lib/state-migration.js @@ -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) } +} diff --git a/uds-auth/lib/task-capability.js b/uds-auth/lib/task-capability.js new file mode 100644 index 00000000..696380a4 --- /dev/null +++ b/uds-auth/lib/task-capability.js @@ -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} id → record */ + this._byId = new Map() + /** @type {Map} 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 + ) + } +} diff --git a/uds-auth/lib/task-environment.js b/uds-auth/lib/task-environment.js new file mode 100644 index 00000000..9babb3ca --- /dev/null +++ b/uds-auth/lib/task-environment.js @@ -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 }) + } } +} diff --git a/uds-auth/lib/uds/user-search.js b/uds-auth/lib/uds/user-search.js index e1a70468..bb7c5b37 100644 --- a/uds-auth/lib/uds/user-search.js +++ b/uds-auth/lib/uds/user-search.js @@ -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, body?: string|Buffer, timeoutMs?: number }} opts + * @param {{ method?: string, headers?: Record, 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 }, } } diff --git a/uds-auth/lib/utils/atomic-write.js b/uds-auth/lib/utils/atomic-write.js new file mode 100644 index 00000000..7e3c06de --- /dev/null +++ b/uds-auth/lib/utils/atomic-write.js @@ -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 }) +} diff --git a/uds-auth/lib/utils/audit-log.js b/uds-auth/lib/utils/audit-log.js new file mode 100644 index 00000000..90e6a0a8 --- /dev/null +++ b/uds-auth/lib/utils/audit-log.js @@ -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 +} diff --git a/uds-auth/lib/utils/origin-guard.js b/uds-auth/lib/utils/origin-guard.js new file mode 100644 index 00000000..0eb67fec --- /dev/null +++ b/uds-auth/lib/utils/origin-guard.js @@ -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 } +} diff --git a/uds-auth/lib/utils/password-kdf.js b/uds-auth/lib/utils/password-kdf.js new file mode 100644 index 00000000..5bc9dedb --- /dev/null +++ b/uds-auth/lib/utils/password-kdf.js @@ -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) +} diff --git a/uds-auth/lib/utils/rate-limit.js b/uds-auth/lib/utils/rate-limit.js new file mode 100644 index 00000000..97668edd --- /dev/null +++ b/uds-auth/lib/utils/rate-limit.js @@ -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} */ + 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, '') +} diff --git a/uds-auth/lib/utils/read-body.js b/uds-auth/lib/utils/read-body.js new file mode 100644 index 00000000..883fa55c --- /dev/null +++ b/uds-auth/lib/utils/read-body.js @@ -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} + */ +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} + */ +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 + } +} diff --git a/uds-auth/lib/utils/safe-userid.js b/uds-auth/lib/utils/safe-userid.js new file mode 100644 index 00000000..0aa146d6 --- /dev/null +++ b/uds-auth/lib/utils/safe-userid.js @@ -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 + } +} diff --git a/uds-auth/lib/utils/secret-box.js b/uds-auth/lib/utils/secret-box.js new file mode 100644 index 00000000..b40aa3f8 --- /dev/null +++ b/uds-auth/lib/utils/secret-box.js @@ -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} 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) +} diff --git a/uds-auth/lib/workspace-provision.js b/uds-auth/lib/workspace-provision.js index 0381710c..6c52d14d 100644 --- a/uds-auth/lib/workspace-provision.js +++ b/uds-auth/lib/workspace-provision.js @@ -2,11 +2,13 @@ * Per-user workspace provisioning under workspaceRoot/. */ 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 } diff --git a/uds-auth/package-lock.json b/uds-auth/package-lock.json index 26da796e..95a0ee62 100644 --- a/uds-auth/package-lock.json +++ b/uds-auth/package-lock.json @@ -1,18 +1,18 @@ { "name": "uds-auth", - "version": "0.2.21", + "version": "0.3.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "uds-auth", - "version": "0.2.21", + "version": "0.3.11", "license": "MIT", "devDependencies": { "qrcode": "1.5.4" }, "engines": { - "node": ">=18.0.0" + "node": "^22.19.0 || >=24.0.0" }, "peerDependencies": { "react": "^18.2.0 || ^19.0.0" diff --git a/uds-auth/package.json b/uds-auth/package.json index a0b5009c..bee93037 100644 --- a/uds-auth/package.json +++ b/uds-auth/package.json @@ -1,9 +1,12 @@ { "name": "uds-auth", - "version": "0.2.21", - "description": "UDS authentication plugin for DeepSeek Harness with multi-tenant session isolation", + "version": "0.3.15", + "description": "UDS authentication plugin for DeepSeek Harness — trusted single-instance access control (not strong multi-tenant isolation)", "type": "module", "main": "lib/index.js", + "bin": { + "uds-auth-seal-local-admin": "./scripts/seal-local-admin.mjs" + }, "exports": { ".": "./lib/index.js", "./client": "./lib/client.js", @@ -18,8 +21,10 @@ }, "scripts": { "bundle:qrcode": "bun run scripts/bundle-qrcode.mjs", - "prepack": "bun run scripts/bundle-qrcode.mjs", - "test": "node --test test/**/*.test.js" + "prepack": "bun run scripts/bundle-qrcode.mjs && node scripts/generate-sbom.mjs", + "pack:check": "node scripts/pack-check.mjs", + "test": "node --test test/**/*.test.js", + "sbom": "node scripts/generate-sbom.mjs" }, "keywords": [ "dsh-plugin", @@ -28,13 +33,14 @@ "authentication", "uds", "sso", - "session-management", - "multi-tenant" + "session-management" ], "files": [ "lib", "skills", "skill-helpers", + "scripts/seal-local-admin.mjs", + "scripts/pack-check.mjs", "config.default.yaml", "cordis.patch.yml", "docs", @@ -45,7 +51,7 @@ "author": "", "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": "^22.19.0 || >=24.0.0" }, "dependencies": {}, "devDependencies": { diff --git a/uds-auth/scripts/generate-sbom.mjs b/uds-auth/scripts/generate-sbom.mjs new file mode 100644 index 00000000..b4a0dca8 --- /dev/null +++ b/uds-auth/scripts/generate-sbom.mjs @@ -0,0 +1,45 @@ +import { readFile, writeFile, mkdir } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { resolve, join } from 'node:path' +const root = resolve(import.meta.dirname, '..') +const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) +const lock = JSON.parse(await readFile(join(root, 'package-lock.json'), 'utf8')) +const bundle = await readFile(join(root, 'lib/qrcode-bundled.cjs')) +const sourceFiles = [...bundle.toString().matchAll(/^\/\/ node_modules\/(.+)$/gm)].map(m => m[1].trim()) +if (!sourceFiles.length) throw new Error('bundle source inventory missing') +const names = [...new Set(sourceFiles.map(path => path.startsWith('@') ? path.split('/').slice(0, 2).join('/') : path.split('/')[0]))].sort() +const components = [], notices = [] +for (const name of names) { + const row = lock.packages['node_modules/' + name] + const manifest = JSON.parse(await readFile(join(root, 'node_modules', name, 'package.json'), 'utf8')) + if (!row?.integrity || row.version !== manifest.version || !manifest.license) throw new Error('unlocked/unlicensed bundle component: ' + name) + let license + for (const file of ['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'LICENSE-MIT', 'license']) { + try { license = await readFile(join(root, 'node_modules', name, file), 'utf8'); break } catch (err) { if (err.code !== 'ENOENT') throw err } + } + if (!license) throw new Error('missing third-party license: ' + name) + notices.push(`${name} ${row.version} (${manifest.license})\n\n${license.trim()}\n`) + const hashes = [] + for (const file of sourceFiles.filter(file => file.startsWith(name + '/'))) { + const data = await readFile(join(root, 'node_modules', file)) + hashes.push({ file, sha256: createHash('sha256').update(data).digest('hex') }) + } + const purl = `pkg:npm/${name.replace('@', '%40')}@${row.version}` + components.push({ type: 'library', name, version: row.version, purl, 'bom-ref': purl, + licenses: [{ license: { id: manifest.license } }], + properties: [{ name: 'uds-auth:delivery', value: 'bundled in lib/qrcode-bundled.cjs' }, + { name: 'npm:integrity', value: row.integrity }, { name: 'uds-auth:source-files', value: JSON.stringify(hashes) }] }) +} +const mainRef = `pkg:npm/uds-auth@${pkg.version}` +const bom = { bomFormat: 'CycloneDX', specVersion: '1.5', version: 1, + metadata: { component: { type: 'application', name: pkg.name, version: pkg.version, 'bom-ref': mainRef, + licenses: [{ license: { id: pkg.license } }] }, properties: [ + { name: 'uds-auth:bundle-sha256', value: createHash('sha256').update(bundle).digest('hex') }, + { name: 'uds-auth:external-peer', value: 'React supplied by DSH; audit its exact version in the Host SBOM' }, + { name: 'uds-auth:scope', value: 'Actual vendored runtime bundle; development dependency audit is a separate npm audit --include=dev gate' }, + ] }, components, dependencies: [{ ref: mainRef, dependsOn: components.map(c => c['bom-ref']) }, + ...components.map(c => ({ ref: c['bom-ref'], dependsOn: [] }))] } +await mkdir(join(root, 'docs'), { recursive: true }) +await writeFile(join(root, 'docs/sbom.cdx.json'), JSON.stringify(bom, null, 2) + '\n') +await writeFile(join(root, 'docs/THIRD-PARTY-NOTICES.txt'), notices.join('\n---\n\n')) +console.log(`SBOM: ${components.length} bundled components, ${sourceFiles.length} source files`) diff --git a/uds-auth/scripts/pack-check.mjs b/uds-auth/scripts/pack-check.mjs new file mode 100644 index 00000000..390d2783 --- /dev/null +++ b/uds-auth/scripts/pack-check.mjs @@ -0,0 +1,110 @@ +#!/usr/bin/env node +/** + * SEC-24: verify npm pack file list before publish. + * Usage: node scripts/pack-check.mjs + */ +import { execSync } from 'node:child_process' +import { readFileSync, existsSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createHash } from 'node:crypto' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const forbidden = [ + /(?:^|\/)node_modules\//, + /(?:^|\/)__pycache__\//, + /\.pyc$/, + /(?:^|\/)skill-credentials\.json$/, + /(?:^|\/)session-bridge\.json$/, + /(?:^|\/)session-owners\.json/, + /(?:^|\/)user-workspaces\.json$/, + /(?:^|\/)roles\.json$/, + /(?:^|\/)config\.runtime\.json$/, + /\.uds-auth-secret-key$/, + /\.tgz$/, +] + +const required = [ + 'package.json', + 'LICENSE', + 'lib/index.js', + 'lib/qrcode-bundled.cjs', + 'scripts/seal-local-admin.mjs', + 'skills/uds-skill-auth/scripts/uds_skill_auth.py', + 'skill-helpers/python/uds_skill_auth.py', + 'README.md', + 'README.zh.md', + 'docs/sbom.cdx.json', + 'docs/THIRD-PARTY-NOTICES.txt', + 'docs/upgrade-0.3.10.zh.md', + 'docs/upgrade-0.3.11.zh.md', + 'lib/desktop-bootstrap.js', +] + +function listPackFiles() { + // --ignore-scripts avoids prepack (bun bundle) polluting stdout / requiring bun. + const raw = execSync('npm pack --dry-run --json --ignore-scripts', { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, npm_config_loglevel: 'error' }, + }) + + const start = raw.indexOf('[') + const end = raw.lastIndexOf(']') + if (start < 0 || end <= start) { + throw new Error('npm pack --json produced no array') + } + const parsed = JSON.parse(raw.slice(start, end + 1)) + const entry = Array.isArray(parsed) ? parsed[0] : parsed + return (entry?.files || []).map((f) => String(f.path || f).replace(/\\/g, '/')) +} + +const files = listPackFiles() +let failed = false + +if (!files.length) { + console.error('[pack-check] empty pack file list') + process.exit(1) +} + +for (const f of files) { + for (const re of forbidden) { + if (re.test(f)) { + console.error('[pack-check] FORBIDDEN in package:', f) + failed = true + } + } +} +for (const need of required) { + const norm = need.replace(/\\/g, '/') + const ok = files.some((f) => f === norm || f.endsWith('/' + norm)) + if (!ok && !existsSync(join(root, need))) { + console.error('[pack-check] MISSING required path on disk:', need) + failed = true + } else if (!ok) { + console.error('[pack-check] MISSING from pack list:', need) + failed = true + } +} + +const a = readFileSync(join(root, 'skills/uds-skill-auth/scripts/uds_skill_auth.py'), 'utf8') +const b = readFileSync(join(root, 'skill-helpers/python/uds_skill_auth.py'), 'utf8') +if (a !== b) { + console.error('[pack-check] Python helpers diverged') + failed = true +} + +const sbom = JSON.parse(readFileSync(join(root, 'docs/sbom.cdx.json'), 'utf8')) +const metadata = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) +const bundleHash = createHash('sha256').update(readFileSync(join(root, 'lib/qrcode-bundled.cjs'))).digest('hex') +if (sbom.metadata?.component?.version !== metadata.version + || !sbom.metadata?.properties?.some(p => p.name === 'uds-auth:bundle-sha256' && p.value === bundleHash)) { + console.error('[pack-check] SBOM version/bundle hash differs; rebuild bundle and SBOM') + failed = true +} + +if (failed) { + process.exit(1) +} +console.log('[pack-check] OK —', files.length, 'files') diff --git a/uds-auth/scripts/seal-local-admin.mjs b/uds-auth/scripts/seal-local-admin.mjs index fd54338d..88a0832f 100644 --- a/uds-auth/scripts/seal-local-admin.mjs +++ b/uds-auth/scripts/seal-local-admin.mjs @@ -3,22 +3,93 @@ * Generate UDS_AUTH_LOCAL_ADMIN_BOX for local decrypt-to-unlock admin. * * Usage: - * node scripts/seal-local-admin.mjs "your-passphrase-here" + * node scripts/seal-local-admin.mjs + * # prompts for passphrase on stdin (hidden when TTY) + * node scripts/seal-local-admin.mjs --stdin # read one line from stdin * - * Then set the printed env on the Harness process (keep the passphrase offline). + * Passphrase on argv is rejected (R10) so it does not land in shell history. */ +import { createInterface } from 'node:readline' +import { stdin as input, stdout as output, stderr } from 'node:process' import { sealLocalAdminBox, LOCAL_ADMIN_BOX_ENV } from '../lib/local-admin.js' -const passphrase = process.argv[2] -if (!passphrase) { - console.error('Usage: node scripts/seal-local-admin.mjs ""') +async function readPassphrase() { + if (process.argv.includes('--stdin') || !process.stdin.isTTY) { + const rl = createInterface({ input, crlfDelay: Infinity }) + const line = await new Promise((resolve) => { + rl.once('line', (l) => resolve(l)) + rl.once('close', () => resolve('')) + }) + rl.close() + return String(line || '').replace(/\r$/, '') + } + // Interactive: do not echo + stderr.write('Passphrase (input hidden): ') + return await new Promise((resolve, reject) => { + const wasRaw = input.isRaw + try { input.setRawMode?.(true) } catch { /* ignore */ } + input.resume() + let buf = '' + const onData = (chunk) => { + const s = chunk.toString('utf8') + for (const ch of s) { + if (ch === '\n' || ch === '\r' || ch === '\u0004') { + cleanup() + stderr.write('\n') + resolve(buf) + return + } + if (ch === '\u0003') { + cleanup() + reject(new Error('cancelled')) + return + } + if (ch === '\u007f' || ch === '\b') { + buf = buf.slice(0, -1) + continue + } + buf += ch + } + } + const cleanup = () => { + input.off('data', onData) + try { input.setRawMode?.(!!wasRaw) } catch { /* ignore */ } + input.pause() + } + input.on('data', onData) + }) +} + +if (process.argv[2] && process.argv[2] !== '--stdin') { + console.error('Do not pass the passphrase on argv (shell history risk).') + console.error('Usage: node scripts/seal-local-admin.mjs') + console.error(' or: echo passphrase | node scripts/seal-local-admin.mjs --stdin') process.exit(1) } try { + const passphrase = await readPassphrase() + if (!passphrase || passphrase.length < 12) { + console.error('Passphrase required (min 12 characters).') + process.exit(1) + } const box = sealLocalAdminBox(passphrase) - console.log(`# Keep the passphrase secret. Only the box goes into the process env.`) + console.log('# Keep the passphrase secret. Only the box goes into env and/or profile file.') console.log(`${LOCAL_ADMIN_BOX_ENV}=${box}`) + console.log('# Also durable for Desktop Host (often misses User env after reinstall):') + console.log(`# write the box string into %USERPROFILE%\\.uds-auth\\local-admin.box`) + try { + const { writeFileSync, mkdirSync } = await import('node:fs') + const { homedir } = await import('node:os') + const { join } = await import('node:path') + const dir = join(homedir(), '.uds-auth') + mkdirSync(dir, { recursive: true }) + const file = join(dir, 'local-admin.box') + writeFileSync(file, box + '\n', { encoding: 'utf8', mode: 0o600 }) + console.log(`# Wrote ${file}`) + } catch (writeErr) { + console.error('# Could not write ~/.uds-auth/local-admin.box:', writeErr.message || writeErr) + } } catch (err) { console.error(err.message || err) process.exit(1) diff --git a/uds-auth/skill-helpers/python/uds_skill_auth.py b/uds-auth/skill-helpers/python/uds_skill_auth.py index c096992b..8eb66adb 100644 --- a/uds-auth/skill-helpers/python/uds_skill_auth.py +++ b/uds-auth/skill-helpers/python/uds_skill_auth.py @@ -6,9 +6,10 @@ Resolve SSO credentials via loopback Host APIs, or call intranet APIs through the outbound proxy. Environment: - DSH_SESSION_ID — agent session id (injected by DSH shell-env) - DSH_WEB_URL — optional base URL of the Harness web server - UDS_AUTH_BASE — optional override, e.g. http://127.0.0.1:PORT/uds-auth + DSH_SESSION_ID — agent session id (injected by DSH shell-env) + UDS_TASK_CAPABILITY — short-lived Host-minted capability (preferred for cron/skill) + DSH_WEB_URL — optional base URL of the Harness web server + UDS_AUTH_BASE — optional override, e.g. http://127.0.0.1:PORT/uds-auth """ from __future__ import annotations @@ -29,19 +30,50 @@ class UdsAuthError(RuntimeError): def _base_url() -> str: - explicit = (os.environ.get("UDS_AUTH_BASE") or "").strip().rstrip("/") - if explicit: - return explicit + """Prefer loopback Host. Env override cannot point at non-loopback without explicit allow.""" + managed = os.environ.get("DSH_UDS_AUTH_BASE") + explicit = (managed if managed is not None else os.environ.get("UDS_AUTH_BASE") or "").strip().rstrip("/") web = (os.environ.get("DSH_WEB_URL") or "").strip().rstrip("/") - if web: - return web + "/uds-auth" - return "http://127.0.0.1:8787/uds-auth" + base = explicit or (web + "/uds-auth" if web else "") + if managed is not None and not explicit: + raise UdsAuthError("Host did not authorize this session", code="no_session") + if not base: + raise UdsAuthError("Missing Host endpoint; load uds-auth shellEnv adapter", code="no_base") + parsed = urlparse(base) + if parsed.scheme not in ("http", "https") or parsed.hostname not in ("127.0.0.1", "localhost", "::1") or parsed.username or parsed.password or parsed.query or parsed.fragment: + raise UdsAuthError("UDS auth endpoint must be an owned loopback HTTP(S) URL", code="unsafe_base") + return base def _session_id() -> str: return (os.environ.get("DSH_SESSION_ID") or "").strip() +def _task_capability() -> str: + managed = os.environ.get("DSH_UDS_TASK_CAPABILITY") + return (managed if managed is not None else os.environ.get("UDS_TASK_CAPABILITY") or os.environ.get("UDS_AUTH_TASK_CAPABILITY") or "").strip() + + +def _auth_headers(extra: Optional[Dict[str, str]] = None) -> Dict[str, str]: + hdrs = dict(extra or {}) + sid = _session_id() + if sid: + hdrs.setdefault("X-DSH-Session-Id", sid) + cap = _task_capability() + if cap: + hdrs["X-UDS-Task-Capability"] = cap + hdrs.setdefault("Accept", "application/json") + return hdrs + + +def _is_loopback_url(url: str) -> bool: + try: + host = (urlparse(url).hostname or "").lower() + except Exception: + return False + return host in ("127.0.0.1", "localhost", "::1") + + def _opener_no_proxy(): return urllib.request.build_opener(urllib.request.ProxyHandler({})) @@ -68,9 +100,10 @@ def _http_json( data = json.dumps(body, ensure_ascii=False).encode("utf-8") hdrs.setdefault("Content-Type", "application/json;charset=UTF-8") req = urllib.request.Request(url, data=data, headers=hdrs, method=method.upper()) - ctx = ssl._create_unverified_context() + # SEC-18: always use standard TLS verification — never unverified context. + ctx = ssl.create_default_context() try: - if "127.0.0.1" in url or "localhost" in url.lower(): + if _is_loopback_url(url): opener = _opener_no_proxy() with opener.open(req, timeout=timeout) as resp: raw = resp.read().decode("utf-8", errors="replace") @@ -94,26 +127,43 @@ def _http_json( return status, parsed, raw -def resolve(*, apply_env_aliases: bool = True) -> Dict[str, str]: +def resolve(*, apply_env_aliases: bool = False) -> Dict[str, str]: """ Fetch {empNo, token} for the current DSH session from Host. - Optionally set process-local EMP_NO / AUTH_VALUE (and coclaw_* aliases). + Prefer UDS_TASK_CAPABILITY (Host-minted) over a browser session cookie. + apply_env_aliases defaults False (SEC-18) — subprocesses inherit secrets if True. + Prefer request()/outbound instead of exposing raw tokens when possible. """ sid = _session_id() - if not sid: - raise UdsAuthError("缺少 DSH_SESSION_ID,请在 Agent shell 中运行", code="no_session") + cap = _task_capability() + if not sid and not cap: + raise UdsAuthError( + "缺少 DSH_SESSION_ID 或 UDS_TASK_CAPABILITY", + code="no_session", + ) url = _base_url() + "/agent-credentials" + body: Dict[str, Any] = {} + if sid: + body["sessionId"] = sid + if cap: + body["taskCapability"] = cap status, parsed, raw = _http_json( "POST", url, - headers={"X-DSH-Session-Id": sid, "Accept": "application/json"}, - body={"sessionId": sid}, + headers=_auth_headers(), + body=body, timeout=15.0, ) if status == 401 or (isinstance(parsed, dict) and parsed.get("error") == "no_skill_credentials"): msg = (parsed or {}).get("message") if isinstance(parsed, dict) else None raise UdsAuthError(msg or "请先完成 UDS 扫码登录", code="no_credentials", status=status) + if status == 403 and isinstance(parsed, dict) and parsed.get("error") == "raw_token_disabled": + raise UdsAuthError( + "原始凭证接口已关闭,请使用 outbound()", + code="raw_token_disabled", + status=403, + ) if status != 200 or not isinstance(parsed, dict): raise UdsAuthError( f"获取凭证失败 (HTTP {status})", @@ -147,22 +197,29 @@ def request( Returns {statusCode, headers, body, json}. """ sid = _session_id() - if not sid: - raise UdsAuthError("缺少 DSH_SESSION_ID,请在 Agent shell 中运行", code="no_session") + cap = _task_capability() + if not sid and not cap: + raise UdsAuthError( + "缺少 DSH_SESSION_ID 或 UDS_TASK_CAPABILITY", + code="no_session", + ) host = urlparse(url).hostname or "" - payload = { - "sessionId": sid, + payload: Dict[str, Any] = { "method": method.upper(), "url": url, "headers": headers or {}, "body": body, "timeoutMs": int(timeout * 1000), } + if sid: + payload["sessionId"] = sid + if cap: + payload["taskCapability"] = cap status, parsed, raw = _http_json( "POST", _base_url() + "/outbound", - headers={"X-DSH-Session-Id": sid, "Accept": "application/json"}, + headers=_auth_headers(), body=payload, timeout=timeout + 5.0, ) diff --git a/uds-auth/skills/uds-skill-auth/SKILL.md b/uds-auth/skills/uds-skill-auth/SKILL.md index 2d7a64ab..a3707398 100644 --- a/uds-auth/skills/uds-skill-auth/SKILL.md +++ b/uds-auth/skills/uds-skill-auth/SKILL.md @@ -23,12 +23,12 @@ uds-auth/skills/uds-skill-auth/ ## 给其它 Skill 用 -业务 skill 的 Python 脚本里,把本 skill 的 `scripts/` 加入 `sys.path` 后: +业务 skill 的 Python 脚本里,把本 skill 的 `scripts/` 加入 `sys.path` 后。**推荐**走 `request()`(outbound);`resolve()` 仅在 Host 开启 `allowRawAgentToken` 且任务注入了 `credentials` scope 的 `UDS_TASK_CAPABILITY` 时可用。 ```python -from uds_skill_auth import resolve, request, UdsAuthError +from uds_skill_auth import request, UdsAuthError -creds = resolve() +# 需要环境:DSH_SESSION_ID +(推荐)UDS_TASK_CAPABILITY out = request("POST", "https://icenterapi.zte.com.cn/...", headers={...}, body={...}) ``` @@ -51,10 +51,11 @@ def load_uds_skill_auth(): 也可设置 `UDS_AUTH_HELPERS` 指向本 skill 的 `scripts` 绝对路径。 -## 环境变量(Agent 已注入,勿教模型打印密钥) +## 环境变量(Agent / 任务注入,勿教模型打印密钥) - `DSH_SESSION_ID` - `DSH_WEB_URL` +- `UDS_TASK_CAPABILITY`(Host `mintTaskCapability`;scopes 按任务最小授权) ## 标准文档 diff --git a/uds-auth/skills/uds-skill-auth/scripts/uds_skill_auth.py b/uds-auth/skills/uds-skill-auth/scripts/uds_skill_auth.py index c096992b..8eb66adb 100644 --- a/uds-auth/skills/uds-skill-auth/scripts/uds_skill_auth.py +++ b/uds-auth/skills/uds-skill-auth/scripts/uds_skill_auth.py @@ -6,9 +6,10 @@ Resolve SSO credentials via loopback Host APIs, or call intranet APIs through the outbound proxy. Environment: - DSH_SESSION_ID — agent session id (injected by DSH shell-env) - DSH_WEB_URL — optional base URL of the Harness web server - UDS_AUTH_BASE — optional override, e.g. http://127.0.0.1:PORT/uds-auth + DSH_SESSION_ID — agent session id (injected by DSH shell-env) + UDS_TASK_CAPABILITY — short-lived Host-minted capability (preferred for cron/skill) + DSH_WEB_URL — optional base URL of the Harness web server + UDS_AUTH_BASE — optional override, e.g. http://127.0.0.1:PORT/uds-auth """ from __future__ import annotations @@ -29,19 +30,50 @@ class UdsAuthError(RuntimeError): def _base_url() -> str: - explicit = (os.environ.get("UDS_AUTH_BASE") or "").strip().rstrip("/") - if explicit: - return explicit + """Prefer loopback Host. Env override cannot point at non-loopback without explicit allow.""" + managed = os.environ.get("DSH_UDS_AUTH_BASE") + explicit = (managed if managed is not None else os.environ.get("UDS_AUTH_BASE") or "").strip().rstrip("/") web = (os.environ.get("DSH_WEB_URL") or "").strip().rstrip("/") - if web: - return web + "/uds-auth" - return "http://127.0.0.1:8787/uds-auth" + base = explicit or (web + "/uds-auth" if web else "") + if managed is not None and not explicit: + raise UdsAuthError("Host did not authorize this session", code="no_session") + if not base: + raise UdsAuthError("Missing Host endpoint; load uds-auth shellEnv adapter", code="no_base") + parsed = urlparse(base) + if parsed.scheme not in ("http", "https") or parsed.hostname not in ("127.0.0.1", "localhost", "::1") or parsed.username or parsed.password or parsed.query or parsed.fragment: + raise UdsAuthError("UDS auth endpoint must be an owned loopback HTTP(S) URL", code="unsafe_base") + return base def _session_id() -> str: return (os.environ.get("DSH_SESSION_ID") or "").strip() +def _task_capability() -> str: + managed = os.environ.get("DSH_UDS_TASK_CAPABILITY") + return (managed if managed is not None else os.environ.get("UDS_TASK_CAPABILITY") or os.environ.get("UDS_AUTH_TASK_CAPABILITY") or "").strip() + + +def _auth_headers(extra: Optional[Dict[str, str]] = None) -> Dict[str, str]: + hdrs = dict(extra or {}) + sid = _session_id() + if sid: + hdrs.setdefault("X-DSH-Session-Id", sid) + cap = _task_capability() + if cap: + hdrs["X-UDS-Task-Capability"] = cap + hdrs.setdefault("Accept", "application/json") + return hdrs + + +def _is_loopback_url(url: str) -> bool: + try: + host = (urlparse(url).hostname or "").lower() + except Exception: + return False + return host in ("127.0.0.1", "localhost", "::1") + + def _opener_no_proxy(): return urllib.request.build_opener(urllib.request.ProxyHandler({})) @@ -68,9 +100,10 @@ def _http_json( data = json.dumps(body, ensure_ascii=False).encode("utf-8") hdrs.setdefault("Content-Type", "application/json;charset=UTF-8") req = urllib.request.Request(url, data=data, headers=hdrs, method=method.upper()) - ctx = ssl._create_unverified_context() + # SEC-18: always use standard TLS verification — never unverified context. + ctx = ssl.create_default_context() try: - if "127.0.0.1" in url or "localhost" in url.lower(): + if _is_loopback_url(url): opener = _opener_no_proxy() with opener.open(req, timeout=timeout) as resp: raw = resp.read().decode("utf-8", errors="replace") @@ -94,26 +127,43 @@ def _http_json( return status, parsed, raw -def resolve(*, apply_env_aliases: bool = True) -> Dict[str, str]: +def resolve(*, apply_env_aliases: bool = False) -> Dict[str, str]: """ Fetch {empNo, token} for the current DSH session from Host. - Optionally set process-local EMP_NO / AUTH_VALUE (and coclaw_* aliases). + Prefer UDS_TASK_CAPABILITY (Host-minted) over a browser session cookie. + apply_env_aliases defaults False (SEC-18) — subprocesses inherit secrets if True. + Prefer request()/outbound instead of exposing raw tokens when possible. """ sid = _session_id() - if not sid: - raise UdsAuthError("缺少 DSH_SESSION_ID,请在 Agent shell 中运行", code="no_session") + cap = _task_capability() + if not sid and not cap: + raise UdsAuthError( + "缺少 DSH_SESSION_ID 或 UDS_TASK_CAPABILITY", + code="no_session", + ) url = _base_url() + "/agent-credentials" + body: Dict[str, Any] = {} + if sid: + body["sessionId"] = sid + if cap: + body["taskCapability"] = cap status, parsed, raw = _http_json( "POST", url, - headers={"X-DSH-Session-Id": sid, "Accept": "application/json"}, - body={"sessionId": sid}, + headers=_auth_headers(), + body=body, timeout=15.0, ) if status == 401 or (isinstance(parsed, dict) and parsed.get("error") == "no_skill_credentials"): msg = (parsed or {}).get("message") if isinstance(parsed, dict) else None raise UdsAuthError(msg or "请先完成 UDS 扫码登录", code="no_credentials", status=status) + if status == 403 and isinstance(parsed, dict) and parsed.get("error") == "raw_token_disabled": + raise UdsAuthError( + "原始凭证接口已关闭,请使用 outbound()", + code="raw_token_disabled", + status=403, + ) if status != 200 or not isinstance(parsed, dict): raise UdsAuthError( f"获取凭证失败 (HTTP {status})", @@ -147,22 +197,29 @@ def request( Returns {statusCode, headers, body, json}. """ sid = _session_id() - if not sid: - raise UdsAuthError("缺少 DSH_SESSION_ID,请在 Agent shell 中运行", code="no_session") + cap = _task_capability() + if not sid and not cap: + raise UdsAuthError( + "缺少 DSH_SESSION_ID 或 UDS_TASK_CAPABILITY", + code="no_session", + ) host = urlparse(url).hostname or "" - payload = { - "sessionId": sid, + payload: Dict[str, Any] = { "method": method.upper(), "url": url, "headers": headers or {}, "body": body, "timeoutMs": int(timeout * 1000), } + if sid: + payload["sessionId"] = sid + if cap: + payload["taskCapability"] = cap status, parsed, raw = _http_json( "POST", _base_url() + "/outbound", - headers={"X-DSH-Session-Id": sid, "Accept": "application/json"}, + headers=_auth_headers(), body=payload, timeout=timeout + 5.0, ) diff --git a/uds-auth/test/acceptance-batch1.test.js b/uds-auth/test/acceptance-batch1.test.js new file mode 100644 index 00000000..daef5c40 --- /dev/null +++ b/uds-auth/test/acceptance-batch1.test.js @@ -0,0 +1,170 @@ +/** + * Acceptance batch-1 regressions (R01–R05). + */ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' +import { MemoryStore } from '../lib/session/memory-store.js' +import { createAgentAuthHandlers } from '../lib/agent-auth.js' +import { RolesStore, ROLES } from '../lib/roles.js' +import { identityFromSessionRecord } from '../lib/dsh-acl.js' +import { runWithUserContext } from '../lib/context.js' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') + +test('R01/A05: client attaches bridge only to same-host http↔ws / https↔wss', () => { + const src = readFileSync(join(root, 'lib/client.js'), 'utf8') + assert.match(src, /isTrustedHostUrl/) + assert.match(src, /locSecure !== urlSecure/) + assert.match(src, /!isTrustedHostUrl\(url\)/) + assert.match(src, /!isTrustedHostUrl\(input\)/) +}) + +test('R03: revoked session fails getBySessionIdSync', async () => { + const store = new MemoryStore() + const { sessionId, bearer } = await store.create({ empNo: 'A001', displayName: 'A' }, 600) + assert.ok(await store.getByBearer(bearer)) + assert.ok(store.getBySessionIdSync(sessionId)) + await store.revokeSession(sessionId) + assert.equal(store.getBySessionIdSync(sessionId), null) + assert.equal(await store.getByBearer(bearer), null) +}) + +test('R03/Q10: fallback identity rejected when emergency password disabled', () => { + const roles = new RolesStore({}) + roles._roles.set('administrator', ROLES.FALLBACK_ADMIN || 'fallback_admin') + roles._fallbackPasswordHash = null + assert.equal(roles.isFallbackEnabled(), false) + const id = identityFromSessionRecord({ + sessionId: 's1', + empNo: 'administrator', + kind: 'fallback', + userData: { empNo: 'administrator' }, + }, roles) + assert.equal(id, null) +}) + +test('R03/Q10: fallback identity accepted when emergency password enabled', () => { + const roles = new RolesStore({}) + roles._roles.set('administrator', 'fallback_admin') + roles._fallbackPasswordHash = 'abc' + assert.equal(roles.isFallbackEnabled(), true) + const id = identityFromSessionRecord({ + sessionId: 's1', + empNo: 'administrator', + kind: 'fallback', + userData: { empNo: 'administrator' }, + }, roles) + assert.equal(id?.empNo, 'administrator') + assert.equal(id?.kind, 'fallback') +}) + +test('R04: create/follow use live permissions; no admin follow bypass', () => { + const src = readFileSync(join(root, 'lib/dsh-acl.js'), 'utf8') + assert.match(src, /assertCreateTargetAllowed[\s\S]*?livePermissions\(identity\)/) + assert.match(src, /never bypass projection for admin-at-subscribe/) + assert.equal(/if \(canSeeAllWorkspaces\(identity\)\) \{\s*yield\* origFollow/.test(src), false) +}) + +test('R05: closing allowRawAgentToken takes effect immediately', async () => { + let allowRaw = true + const handlers = createAgentAuthHandlers({ + allowRawTokenResponse: () => allowRaw, + outboundHosts: () => ['example.com'], + resolveCallerSession: async () => ({ empNo: 'A', sessionId: 's' }), + getSessionOwner: () => 'A', + resolveCredentialsForSession: async () => ({ empNo: 'A', token: 'secret' }), + }) + allowRaw = false + const chunks = [] + const res = { + statusCode: 200, + setHeader() {}, + end(s) { chunks.push(String(s)) }, + } + await handlers.handleAgentCredentials({ + method: 'GET', + headers: {}, + socket: { remoteAddress: '127.0.0.1' }, + }, res) + assert.equal(res.statusCode, 403) + assert.match(chunks.join(''), /raw_token_disabled/) +}) + +test('R05: empty outbound list does not revive defaults', async () => { + const handlers = createAgentAuthHandlers({ + allowRawTokenResponse: () => false, + outboundHosts: () => [], + resolveCallerSession: async () => ({ empNo: 'A', sessionId: 's' }), + getSessionOwner: () => 'A', + resolveCredentialsForSession: async () => ({ empNo: 'A', token: 'secret' }), + }) + const chunks = [] + const res = { + statusCode: 200, + setHeader() {}, + end(s) { chunks.push(String(s)) }, + } + const body = JSON.stringify({ + sessionId: 's', + url: 'https://icenterapi.zte.com.cn/x', + method: 'GET', + }) + const req = { + method: 'POST', + headers: { 'content-type': 'application/json' }, + socket: { remoteAddress: '127.0.0.1' }, + async *[Symbol.asyncIterator]() { + yield Buffer.from(body) + }, + } + await handlers.handleOutbound(req, res) + const text = chunks.join('') + assert.ok( + /host_not_allowed|outbound_denied|forbidden|not_allowed/i.test(text) + || res.statusCode >= 400, + text, + ) +}) + +test('R05: live config getters wired in index', () => { + const src = readFileSync(join(root, 'lib/index.js'), 'utf8') + assert.match(src, /getAllowOpenRegistration/) + assert.match(src, /allowRawTokenResponse: \(\) =>/) + assert.match(src, /empty string \/ \[\] means deny-all|deny-all/) +}) + +test('R02: job/terminal/fs ACL wraps and principal live check', () => { + const src = readFileSync(join(root, 'lib/dsh-acl.js'), 'utf8') + assert.match(src, /jobController/) + assert.match(src, /terminalController/) + assert.match(src, /file_forbidden/) + assert.match(src, /assertPrincipalLive/) + assert.match(src, /getSessionStore/) +}) + +test('R03: assertCanAccess rejects revoked principal via session store', async () => { + const store = new MemoryStore() + const { sessionId } = await store.create({ empNo: 'A001' }, 600) + const roles = new RolesStore({}) + roles._roles.set('A001', 'user') + const identity = identityFromSessionRecord({ + sessionId, + empNo: 'A001', + kind: 'uds', + userData: { empNo: 'A001' }, + }, roles) + assert.ok(identity) + await store.revokeSession(sessionId) + assert.equal(store.getBySessionIdSync(sessionId), null) + + // Simulate installDshAcl assertPrincipalLive gate + const sid = identity.sessionId + assert.ok(sid) + assert.equal(store.getBySessionIdSync(sid), null) +}) + +// silence unused in some runners +void runWithUserContext diff --git a/uds-auth/test/acceptance-batch2.test.js b/uds-auth/test/acceptance-batch2.test.js new file mode 100644 index 00000000..c2e02773 --- /dev/null +++ b/uds-auth/test/acceptance-batch2.test.js @@ -0,0 +1,193 @@ +/** + * Acceptance batch-2/3 regressions (R06–R14 fragments). + */ +import assert from 'node:assert/strict' +import test from 'node:test' +import { mkdtemp, writeFile, readFile, mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { TaskCapabilityStore } from '../lib/task-capability.js' +import { createAgentAuthHandlers } from '../lib/agent-auth.js' +import { RolesStore, ROLES, ACCOUNT_STATUS, LEGACY_DEFAULT_SHA256, hashPasswordScrypt, verifyPasswordHash } from '../lib/roles.js' +import { loadOrCreateSecretKey, sealSecret } from '../lib/utils/secret-box.js' +import { checkRequestOrigin } from '../lib/utils/origin-guard.js' +import { SessionAclStore } from '../lib/session-acl.js' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname } from 'node:path' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') + +test('R06: task capability authorizes outbound without browser session', async () => { + const caps = new TaskCapabilityStore({ defaultTtlSeconds: 600 }) + const minted = caps.mint({ + empNo: 'A001', + dshSessionId: 'sess-a', + scopes: ['outbound', 'credentials'], + }) + const handlers = createAgentAuthHandlers({ + taskCapabilities: caps, + allowRawTokenResponse: () => false, + outboundHosts: () => ['example.com'], + getSessionOwner: () => 'A001', + resolveCredentialsForSession: async () => ({ empNo: 'A001', token: 'tok' }), + resolveCallerSession: async () => null, + }) + const chunks = [] + const res = { statusCode: 200, setHeader() {}, end(s) { chunks.push(String(s)) } } + // credentials still blocked when raw disabled + await handlers.handleAgentCredentials({ + method: 'POST', + headers: { + 'x-uds-task-capability': minted.token, + 'x-dsh-session-id': 'sess-a', + 'content-type': 'application/json', + }, + socket: { remoteAddress: '127.0.0.1' }, + async *[Symbol.asyncIterator]() { + yield Buffer.from(JSON.stringify({ sessionId: 'sess-a', taskCapability: minted.token })) + }, + }, res) + assert.equal(res.statusCode, 403) + assert.match(chunks.join(''), /raw_token_disabled/) + + // verify() binds empNo + const v = caps.verify(minted.token, { audience: 'uds-auth-agent', dshSessionId: 'sess-a' }) + assert.equal(v?.empNo, 'A001') + assert.equal(caps.verify('deadbeefdeadbeefdeadbeefdeadbeef', {}), null) +}) + +test('R07: user-info response includes bo whitelist (source)', () => { + const src = readFileSync(join(root, 'lib/index.js'), 'utf8') + assert.match(src, /bo: \[safeRow\]/) + assert.match(src, /profile:\s*\{/) +}) + +test('R08: nested sessionIds and workspace remove projection (source)', () => { + const src = readFileSync(join(root, 'lib/dsh-acl.js'), 'utf8') + assert.match(src, /projectWorkspaceItem/) + assert.match(src, /frame\.type === 'remove'/) + assert.match(src, /projectMutationResult/) +}) + +test('R09: concurrent last-admin demotion keeps >=1 super_admin', async () => { + const dir = await mkdtemp(join(tmpdir(), 'uds-roles-')) + const file = join(dir, 'roles.json') + const store = new RolesStore({ rolesFile: file }) + await store.init() + store._roles.set('a', ROLES.SUPER_ADMIN) + store._roles.set('b', ROLES.SUPER_ADMIN) + store._dirty = true + await store.flush() + + const results = await Promise.allSettled([ + store.setRole('a', ROLES.USER, ROLES.SUPER_ADMIN), + store.setRole('b', ROLES.USER, ROLES.SUPER_ADMIN), + ]) + const fulfilled = results.filter((r) => r.status === 'fulfilled').length + const rejected = results.filter((r) => r.status === 'rejected').length + assert.equal(fulfilled + rejected, 2) + assert.ok(rejected >= 1, 'at least one demote must fail') + const supers = await store.countByRole(ROLES.SUPER_ADMIN) + assert.ok(supers >= 1, `remaining super_admins=${supers}`) +}) + +test('R09: corrupt session owners fail closed', async () => { + const dir = await mkdtemp(join(tmpdir(), 'uds-own-')) + const file = join(dir, 'owners.json') + await writeFile(file, '{not-json', 'utf8') + const store = new SessionAclStore({ ownersFile: file }) + await store.init() + assert.equal(store.loadFailed, true) +}) + +test('R10: scrypt hash roundtrip; legacy default refused', () => { + const h = hashPasswordScrypt('CorrectHorseBattery') + assert.ok(h.startsWith('scrypt$')) + assert.equal(verifyPasswordHash('CorrectHorseBattery', h), true) + assert.equal(verifyPasswordHash('wrong', h), false) + assert.equal(verifyPasswordHash('Admin@123', LEGACY_DEFAULT_SHA256), false) +}) + +test('R10: setFallbackPassword rejects short and known default', async () => { + const store = new RolesStore({}) + store._roles.set('admin', ROLES.SUPER_ADMIN) + await assert.rejects(() => store.setFallbackPassword('short', ROLES.SUPER_ADMIN), /password_too_short/) + await assert.rejects(() => store.setFallbackPassword('Admin@123', ROLES.SUPER_ADMIN), /password_too_common/) + await store.setFallbackPassword('LongEnoughPass1', ROLES.SUPER_ADMIN) + assert.equal(store.verifyFallback('LongEnoughPass1', '127.0.0.1'), true) +}) + +test('R11: invalid env key fails closed', async () => { + const dir = await mkdtemp(join(tmpdir(), 'uds-key-')) + const prev = process.env.UDS_AUTH_SECRET_KEY + process.env.UDS_AUTH_SECRET_KEY = 'not-hex' + try { + await assert.rejects(() => loadOrCreateSecretKey(dir), (err) => err.code === 'invalid_secret_key_env') + } finally { + if (prev === undefined) delete process.env.UDS_AUTH_SECRET_KEY + else process.env.UDS_AUTH_SECRET_KEY = prev + } +}) + +test('R11: corrupt key file is not replaced', async () => { + const dir = await mkdtemp(join(tmpdir(), 'uds-key2-')) + await writeFile(join(dir, '.uds-auth-secret-key'), 'garbage\n', 'utf8') + const prev = process.env.UDS_AUTH_SECRET_KEY + delete process.env.UDS_AUTH_SECRET_KEY + try { + await assert.rejects(() => loadOrCreateSecretKey(dir), (err) => err.code === 'secret_key_corrupt') + const still = await readFile(join(dir, '.uds-auth-secret-key'), 'utf8') + assert.match(still, /garbage/) + } finally { + if (prev !== undefined) process.env.UDS_AUTH_SECRET_KEY = prev + } +}) + +test('R12: disabled account cannot be ensureUser / open-register revived', async () => { + const store = new RolesStore({}) + store._roles.set('u1', ROLES.USER) + store._roles.set('admin', ROLES.SUPER_ADMIN) + await store.removeUser('u1', ROLES.SUPER_ADMIN) + assert.equal(store.isDisabled('u1'), true) + assert.equal(store.getAccountStatus('u1'), ACCOUNT_STATUS.DISABLED) + await assert.rejects(() => store.ensureUser('u1', ROLES.SUPER_ADMIN), /account_disabled/) +}) + +test('R13: trustedHosts rejects unlisted Host', () => { + const req = { + method: 'POST', + headers: { + host: 'evil.example:8787', + origin: 'https://evil.example:8787', + }, + socket: { remoteAddress: '1.2.3.4' }, + } + const denied = checkRequestOrigin(req, { trustedHosts: ['good.example:8787'] }) + assert.equal(denied.ok, false) + assert.equal(denied.reason, 'host_not_trusted') + const ok = checkRequestOrigin({ + method: 'POST', + headers: { host: 'good.example:8787', origin: 'https://good.example:8787' }, + socket: { remoteAddress: '1.2.3.4' }, + }, { trustedHosts: ['good.example:8787'] }) + assert.equal(ok.ok, true) +}) + +test('R13/R14: QR challenge store + live identity patch + docs (source)', () => { + const index = readFileSync(join(root, 'lib/index.js'), 'utf8') + assert.match(index, /consumeQrChallenge/) + assert.match(index, /MAX_QR_UPSTREAM_BYTES/) + assert.match(index, /mintTaskCapability/) + const acl = readFileSync(join(root, 'lib/dsh-acl.js'), 'utf8') + assert.match(acl, /__udsAuthIdentityLive/) + const readme = readFileSync(join(root, 'README.zh.md'), 'utf8') + assert.doesNotMatch(readme, /Admin@123.*首次启动自动启用/) + assert.match(readme, /mintTaskCapability|UDS_TASK_CAPABILITY/) +}) + +test('R06 python helper mentions task capability', () => { + const py = readFileSync(join(root, 'skill-helpers/python/uds_skill_auth.py'), 'utf8') + assert.match(py, /UDS_TASK_CAPABILITY/) + assert.match(py, /X-UDS-Task-Capability/) +}) diff --git a/uds-auth/test/auth-session-p0.test.js b/uds-auth/test/auth-session-p0.test.js new file mode 100644 index 00000000..daf09950 --- /dev/null +++ b/uds-auth/test/auth-session-p0.test.js @@ -0,0 +1,276 @@ +/** + * Phase-1 P0 negative tests (SEC-01..06, partial 07-09). + */ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createHash } from 'node:crypto' +import { MemoryStore } from '../lib/session/memory-store.js' +import { SESSION_COOKIE } from '../lib/session/store.js' +import { + extractSessionBearer, + extractPortalCredentials, + buildSessionCookie, +} from '../lib/session/request-auth.js' +import { createAuthMiddleware } from '../lib/middleware/auth-middleware.js' +import { + resolveIdentityFromRequest, + resolveIdentityFromRequestSync, +} from '../lib/dsh-acl.js' +import { requestIsLoopback } from '../lib/skill-credentials.js' +import { createAgentAuthHandlers } from '../lib/agent-auth.js' +import { RolesStore, ROLES, DEFAULT_FALLBACK_PASSWORD } from '../lib/roles.js' +import { SessionBridgeStore } from '../lib/session-bridge.js' +import { peekBrowserIdentity, rememberBrowserIdentity } from '../lib/identity-cache.js' + +function mockRoles(seed = {}) { + const store = new RolesStore({}) + for (const [empNo, role] of Object.entries(seed)) { + store._roles.set(empNo, role) + } + return store +} + +test('SEC-01: static generic handlers are not exported from index route helpers', async () => { + // Regression: makeStaticHandler / open path resolve must not exist. + const src = await import('node:fs/promises').then((fs) => + fs.readFile(new URL('../lib/index.js', import.meta.url), 'utf-8'), + ) + assert.equal(src.includes('makeStaticHandler'), false) + assert.equal(src.includes('handleQrcodeStatic'), false) + assert.equal(src.includes('handleVendorStatic'), false) + assert.match(src, /static file routes removed/) +}) + +test('SEC-02/04: MemoryStore keys by bearer — empNo alone cannot prove auth', async () => { + const store = new MemoryStore() + const { bearer, sessionId } = await store.create({ + empNo: '10001', + username: 'Alice', + isAuthenticated: true, + token: 'upstream-secret', + }, 600) + assert.ok(bearer.length >= 32) + assert.ok(sessionId) + + const byBearer = await store.getByBearer(bearer) + assert.equal(byBearer.empNo, '10001') + assert.equal(store.getByBearerSync(bearer).empNo, '10001') + + assert.equal(await store.getByBearer('deadbeef'.repeat(8)), null) + assert.equal(await store.getByBearer(''), null) + + // Profile-like get(empNo) must not be treated as auth by middleware (documented). + const cached = await store.get('10001') + assert.equal(cached.empNo, '10001') + assert.ok(cached.token, 'cache may hold token but must not grant request auth alone') +}) + +test('SEC-02: fallback USER/UI cookies are not session bearers', () => { + const req = { + headers: { + cookie: 'UDS_FALLBACK_USER=administrator; UDS_FALLBACK_UI=administrator', + }, + } + assert.equal(extractSessionBearer(req), null) + assert.equal(extractPortalCredentials(req), null) +}) + +test('SEC-02/03: sync/async ACL identity requires verified local session', async () => { + const store = new MemoryStore() + const roles = mockRoles({ administrator: ROLES.FALLBACK_ADMIN, '10001': ROLES.SUPER_ADMIN }) + const deps = { sessionStore: store, rolesStore: roles, sessionBridge: null } + + // Forged fallback cookies + arbitrary portal token → no principal + const forged = { + headers: { + cookie: 'UDS_FALLBACK_USER=administrator; PORTALSSOUser=10001; PORTALSSOCookie=arbitrary', + }, + } + assert.equal(resolveIdentityFromRequestSync(forged, deps), null) + assert.equal(await resolveIdentityFromRequest(forged, deps), null) + + // EmpNo cache hit alone is not enough + await store.create({ empNo: '10001', username: 'A', isAuthenticated: true }, 600) + assert.equal(resolveIdentityFromRequestSync({ + headers: { cookie: 'PORTALSSOUser=10001' }, + }, deps), null) + + // Valid bearer → principal (no upstream token on identity) + const { bearer } = await store.create({ + empNo: '10001', + username: 'A', + token: 'uac-secret', + isAuthenticated: true, + }, 600) + const req = { headers: { cookie: `${SESSION_COOKIE}=${bearer}` } } + const sync = resolveIdentityFromRequestSync(req, deps) + assert.equal(sync.empNo, '10001') + assert.equal(sync.userContext.token, undefined) + const asyncId = await resolveIdentityFromRequest(req, deps) + assert.equal(asyncId.empNo, '10001') + assert.equal(asyncId.userContext.token, undefined) +}) + +test('SEC-03: ACL path does not bootstrap first user', async () => { + const store = new MemoryStore() + const roles = mockRoles({}) // empty + const { bearer } = await store.create({ + empNo: 'attacker00001', + isAuthenticated: true, + }, 600) + const identity = await resolveIdentityFromRequest({ + headers: { cookie: `${SESSION_COOKIE}=${bearer}` }, + }, { sessionStore: store, rolesStore: roles }) + assert.equal(identity.empNo, 'attacker00001') + assert.equal(roles.isEmpty(), true, 'roles table must stay empty') + assert.equal(identity.role, ROLES.USER) +}) + +test('SEC-04: two devices — wrong bearer / mismatched bridge rejected', async () => { + const store = new MemoryStore() + const bridge = new SessionBridgeStore({ ttlMs: 60_000 }) + const a = await store.create({ empNo: 'A', isAuthenticated: true }, 600) + const b = await store.create({ empNo: 'B', isAuthenticated: true }, 600) + bridge.mint({ empNo: 'A', kind: 'uds', token: a.bearer }) + bridge.mint({ empNo: 'B', kind: 'uds', token: b.bearer }) + + // A bearer with B empNo bridge headers + const bad = extractSessionBearer({ + headers: { + 'x-uds-bridge-empno': 'B', + 'x-uds-bridge-token': a.bearer, + 'x-uds-bridge-kind': 'uds', + }, + }, { sessionBridge: bridge }) + // verify fails (empNo mismatch) → null + assert.equal(bad, null) + + const ok = extractSessionBearer({ + headers: { + cookie: buildSessionCookie(a.bearer, 600), + }, + }, { sessionBridge: bridge }) + assert.equal(ok.bearer, a.bearer) + assert.equal((await store.getByBearer(ok.bearer)).empNo, 'A') +}) + +test('SEC-05: requestIsLoopback ignores X-Forwarded-For', () => { + assert.equal(requestIsLoopback({ + socket: { remoteAddress: '10.1.2.3' }, + headers: { 'x-forwarded-for': '127.0.0.1' }, + }), false) + assert.equal(requestIsLoopback({ + socket: { remoteAddress: '127.0.0.1' }, + headers: {}, + }), true) + assert.equal(requestIsLoopback({ + socket: { remoteAddress: '::ffff:127.0.0.1' }, + headers: { 'x-forwarded-for': '8.8.8.8' }, + }), true) +}) + +test('SEC-05: agent-credentials rejects empNo-only and disables raw token by default', async () => { + const handlers = createAgentAuthHandlers({ + resolveCredentialsForSession: async () => ({ empNo: 'A', token: 't' }), + getSessionOwner: () => 'A', + resolveCallerSession: async () => null, + allowRawTokenResponse: false, + }) + const res = { + statusCode: 0, + headers: {}, + body: '', + setHeader(k, v) { this.headers[k] = v }, + end(s) { this.body = s }, + } + await handlers.handleAgentCredentials({ + method: 'POST', + socket: { remoteAddress: '127.0.0.1' }, + headers: {}, + async *[Symbol.asyncIterator]() { yield Buffer.from(JSON.stringify({ empNo: 'A' })) }, + }, res) + assert.equal(res.statusCode, 403) + assert.match(res.body, /raw_token_disabled|error/) +}) + +test('SEC-05: outbound requires caller session matching session owner', async () => { + let called = false + const handlers = createAgentAuthHandlers({ + resolveCredentialsForSession: async () => { + called = true + return { empNo: 'A', token: 't' } + }, + getSessionOwner: (sid) => (sid === 'sess-a' ? 'A' : 'B'), + resolveCallerSession: async () => ({ empNo: 'A', sessionId: 'local' }), + allowRawTokenResponse: true, + outboundHosts: ['example.com'], + }) + const res = { + statusCode: 0, + headers: {}, + body: '', + setHeader(k, v) { this.headers[k] = v }, + end(s) { this.body = s }, + } + // Caller A + session owned by B + await handlers.handleOutbound({ + method: 'POST', + socket: { remoteAddress: '127.0.0.1' }, + headers: {}, + async *[Symbol.asyncIterator]() { + yield Buffer.from(JSON.stringify({ + sessionId: 'sess-b', + url: 'https://example.com/x', + empNo: 'B', + })) + }, + }, res) + assert.equal(res.statusCode, 401) + assert.equal(called, false) +}) + +test('SEC-06: identity-cache never restores a principal', () => { + rememberBrowserIdentity({ empNo: 'admin', role: 'super_admin' }) + assert.equal(peekBrowserIdentity(), null) +}) + +test('SEC-08: new RolesStore does not enable shared default password', async () => { + const store = new RolesStore({}) + await store.init() + assert.equal(store.isFallbackEnabled(), false) + assert.equal(store.verifyFallback(DEFAULT_FALLBACK_PASSWORD, '127.0.0.1'), false) +}) + +test('SEC-09: empty roles + non-initial admin cannot bootstrap via middleware helper', async () => { + const store = new MemoryStore() + const roles = mockRoles({}) + const mw = createAuthMiddleware({ + userSearchUrl: 'http://127.0.0.1:9/never', + initialAdminEmpNo: '99999', + allowOpenRegistration: true, + udsAuth: { baseUrl: 'http://127.0.0.1:9' }, + session: { cookieMaxAge: 600000, slidingExpiration: true }, + }, store, roles, {}) + await assert.rejects( + () => mw.resolveRoleOnLogin('11111'), + (err) => err.code === 'bootstrap_required', + ) + const role = await mw.resolveRoleOnLogin('99999') + assert.equal(role, ROLES.SUPER_ADMIN) + assert.equal(roles.getRole('99999'), ROLES.SUPER_ADMIN) +}) + +test('SEC-07: session cookie helper is HttpOnly', () => { + const c = buildSessionCookie('abc123def456abc123def456abc123de', 600, { secure: false }) + assert.match(c, /^UDS_SESSION=/) + assert.match(c, /HttpOnly/) + assert.match(c, /SameSite=Lax/) +}) + +test('bearer hash is not reversible from store maps', async () => { + const store = new MemoryStore() + const { bearer } = await store.create({ empNo: 'x', isAuthenticated: true }, 60) + const hash = createHash('sha256').update(bearer).digest('hex') + assert.ok(store._byBearer.has(hash)) + assert.equal([...store._byBearer.keys()].includes(bearer), false) +}) diff --git a/uds-auth/test/desktop-bootstrap.test.js b/uds-auth/test/desktop-bootstrap.test.js new file mode 100644 index 00000000..21f35c64 --- /dev/null +++ b/uds-auth/test/desktop-bootstrap.test.js @@ -0,0 +1,111 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createDesktopBootstrap, DESKTOP_BOOTSTRAP_REF } from '../lib/desktop-bootstrap.js' +import { installGatewayPolicy } from '../lib/gateway-policy.js' + +function fixture() { + let configured = true, invoked = 0 + const services = { + settings: { describe: () => [ + { ns: 'locale', value: { preference: 'zh', hidden: 'LOCALE-SECRET' } }, + { ns: 'llm-deepseek', value: { apiKeyEnv: 'REAL_KEY_NAME', endpoint: 'PRIVATE-ENDPOINT' } }, + { ns: 'uds-auth', value: { initialAdminEmpNo: 'PRIVATE-ADMIN' } }, + ] }, + llm: { listConfigurableProviders: () => [] }, + credentials: { describe: async ref => { assert.equal(ref, 'REAL_KEY_NAME'); return { configured, writable: true, value: 'RAW-KEY' } } }, + deepseekAccount: { getState: async () => ({ status: 'credential-stored', attempt: { authorizeUrl: 'SECRET-LOGIN-LINK' }, token: 'ACCOUNT-TOKEN' }) }, + } + const deny = code => { throw new Error(code) } + const project = createDesktopBootstrap({ getService: name => services[name], deny }) + let principal = null, admin = false, live = true + const gateway = { + async *openRemoteEvents() {}, receiveRemoteEventResult() {}, + resolveDescriptor: (namespace, method) => ({ namespace, method, parameters: [] }), + prepareInvocation: async () => ({ method: () => { invoked++; return 'full-admin-result' } }), + } + installGatewayPolicy(gateway, { identity: () => principal, assertPrincipal: p => { if (!p || !live) deny('login_required') }, + assertSession: () => {}, permissions: () => ({ canAccessSettings: admin }), deny, desktopBootstrap: project }) + const invoke = async (namespace, method, args = {}) => (await gateway.prepareInvocation({ namespace, method, args })).method() + return { invoke, services, setConfigured: value => { configured = value }, + setPrincipal: value => { principal = value }, setAdmin: value => { admin = value }, + revoke: () => { live = false }, invocations: () => invoked } +} + +test('unmodified Desktop welcome reads a bounded projection before UDS login', async () => { + const f = fixture() + const settings = await f.invoke('settings', 'describe') + assert.equal(settings.writable, false); assert.equal(settings.hasDocument, false) + assert.equal(settings.namespaces.find(row => row.ns === 'locale').value.preference, 'zh') + const providers = await f.invoke('llm', 'listConfigurableProviders') + const refs = providers.map(provider => settings.namespaces.find(row => row.ns === provider.settingsNs).value.apiKeyEnv) + const credentials = await f.invoke('credentials', 'describe', { refs }) + assert.deepEqual(credentials, { [DESKTOP_BOOTSTRAP_REF]: { configured: true, writable: false } }) + const account = await f.invoke('account', 'getState') + assert.equal(account.status, 'credential-stored'); assert.equal(account.attempt, null) + assert.equal(f.invocations(), 0, 'anonymous calls never invoke full Remote controllers') + const data = JSON.stringify([settings, providers, credentials, account]) + for (const secret of ['REAL_KEY_NAME', 'PRIVATE-ENDPOINT', 'PRIVATE-ADMIN', 'RAW-KEY', 'ACCOUNT-TOKEN', 'SECRET-LOGIN-LINK', 'LOCALE-SECRET']) assert.equal(data.includes(secret), false) + f.setConfigured(false) + assert.equal((await f.invoke('credentials', 'describe', { refs }))[DESKTOP_BOOTSTRAP_REF].configured, false) +}) + +test('bootstrap cannot enumerate credential refs or access settings writes and sessions', async () => { + const f = fixture() + for (const args of [{ refs: ['REAL_KEY_NAME'] }, { refs: [DESKTOP_BOOTSTRAP_REF, 'OTHER'] }, {}, { refs: 'REAL_KEY_NAME' }]) { + await assert.rejects(f.invoke('credentials', 'describe', args), /forbidden_settings/) + } + for (const [ns, method] of [['settings', 'update'], ['credentials', 'set'], ['account', 'startSignIn'], ['session', 'list'], ['newPlugin', 'describe']]) { + await assert.rejects(f.invoke(ns, method), /login_required/) + } + assert.equal(f.invocations(), 0) +}) + +test('admin and trusted Host retain full reads; ordinary or revoked principals never receive them', async () => { + const f = fixture() + f.setPrincipal({ empNo: 'u' }) + assert.equal((await f.invoke('settings', 'describe')).writable, false) + f.setAdmin(true) + assert.equal(await f.invoke('settings', 'describe'), 'full-admin-result') + f.setAdmin(false) + assert.equal((await f.invoke('settings', 'describe')).writable, false) + f.revoke() + await assert.rejects(f.invoke('settings', 'describe'), /login_required/) + f.setPrincipal(undefined) + assert.equal(await f.invoke('settings', 'describe'), 'full-admin-result') +}) + +test('custom providers are detected without exposing their namespace or credential names', async () => { + const f = fixture() + f.services.settings.describe = () => [{ ns: 'private-provider', value: { profiles: { one: { apiKeyEnv: 'REAL_KEY_NAME' } } } }] + f.services.llm.listConfigurableProviders = () => [{ settingsNs: 'private-provider', settingsPath: ['profiles', 'one'] }] + const value = await f.invoke('credentials', 'describe', { refs: [DESKTOP_BOOTSTRAP_REF] }) + assert.equal(value[DESKTOP_BOOTSTRAP_REF].configured, true) + assert.equal(JSON.stringify(await f.invoke('settings', 'describe')).includes('private-provider'), false) +}) + +test('projection still reports a stored key when another provider or reference throws', async () => { + const deny = code => { throw new Error(code) } + const services = { + settings: { describe: () => [ + { ns: 'locale', value: {} }, + { ns: 'llm-deepseek', value: { apiKeyEnv: 'BROKEN_REF' } }, + { ns: 'llm-pi-ai', value: { providers: { suanlidao: { apiKeyEnv: 'SUANLIDAO_API_KEY' } } } }, + ] }, + llm: { listConfigurableProviders: () => [ + { provider: 'suanlidao', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'suanlidao'] }, + ] }, + credentials: { describe: async ref => { + if (ref === 'BROKEN_REF') throw new Error('provider exploded') + return { configured: ref === 'SUANLIDAO_API_KEY', writable: true } + } }, + deepseekAccount: { getState: async () => { throw new Error('account backend down') } }, + } + const project = createDesktopBootstrap({ getService: name => services[name], deny }) + const described = await project({ namespace: 'credentials', method: 'describe', args: { refs: [DESKTOP_BOOTSTRAP_REF] } }) + assert.equal(described[DESKTOP_BOOTSTRAP_REF].configured, true) + const account = await project({ namespace: 'account', method: 'getState', args: {} }) + assert.equal(account.status, 'signed-out') + services.settings.describe = () => { throw new Error('settings not mounted') } + const none = await project({ namespace: 'credentials', method: 'describe', args: { refs: [DESKTOP_BOOTSTRAP_REF] } }) + assert.equal(none[DESKTOP_BOOTSTRAP_REF].configured, false) +}) diff --git a/uds-auth/test/emergency-workspace-follow.test.js b/uds-auth/test/emergency-workspace-follow.test.js new file mode 100644 index 00000000..c54f2842 --- /dev/null +++ b/uds-auth/test/emergency-workspace-follow.test.js @@ -0,0 +1,353 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { RolesStore } from '../lib/roles.js' +import { MemoryStore } from '../lib/session/memory-store.js' +import { SessionBridgeStore } from '../lib/session-bridge.js' +import { UserWorkspaceStore } from '../lib/workspace-provision.js' +import { + extractSessionBearer, + extractSessionBearerAsync, +} from '../lib/session/request-auth.js' +import { + installDshAcl, + identityFromSessionRecord, + resolveIdentityFromRequest, + resolveIdentityFromRequestSync, +} from '../lib/dsh-acl.js' +import { runWithUserContext } from '../lib/context.js' +import { installGatewayPolicy } from '../lib/gateway-policy.js' +import { createAuthMiddleware } from '../lib/middleware/auth-middleware.js' +import { buildLocalAdminUserContext, sealLocalAdminBox } from '../lib/local-admin.js' + +process.env.UDS_AUTH_LOCAL_ADMIN_BOX = sealLocalAdminBox('test-passphrase-ok') + +const STALE = 'deadbeef'.repeat(8) + +const EMERGENCY_LOGINS = [ + ['password fallback', 'fallback', () => ({ + empNo: 'administrator', + username: 'Fallback Administrator', + isAuthenticated: true, + role: 'fallback_admin', + authMode: 'fallback-password', + fallbackCredVersion: 1, + })], + ['sealed box', 'sealed_box', () => buildLocalAdminUserContext()], +] + +function makeStores() { + const roles = new RolesStore({}) + roles._fallbackPasswordHash = 'scrypt$test' + roles._fallbackCredVersion = 1 + roles._prefs.set('administrator', { viewAllSessions: true }) + return { roles, sessionStore: new MemoryStore(), sessionBridge: new SessionBridgeStore({}) } +} + +async function login(stores, userContext, kind) { + const { bearer } = await stores.sessionStore.create(userContext, 1800) + const bridge = stores.sessionBridge.mint({ empNo: userContext.empNo, kind, token: bearer }) + return { bearer, bridge } +} + +function muxUpgrade(query, headers = {}) { + return { + method: 'GET', + url: '/api/remote.mux?' + query, + socket: { remoteAddress: '127.0.0.1' }, + headers: { upgrade: 'websocket', connection: 'Upgrade', origin: 'dsh-app://app', ...headers }, + } +} + +for (const [label, kind, buildContext] of EMERGENCY_LOGINS) { + test(`${label}: stale UDS_SESSION cookie no longer shadows the WS ticket`, async () => { + const stores = makeStores() + const { bearer } = await login(stores, buildContext(), kind) + const deps = { ...stores, rolesStore: stores.roles } + const cookie = { cookie: `UDS_SESSION=${STALE}` } + + const t1 = stores.sessionBridge.mintWsTicket({ empNo: 'administrator', bearer }) + const sync = resolveIdentityFromRequestSync(muxUpgrade('udsWsTicket=' + t1.ticket, cookie), deps) + assert.equal(sync?.empNo, 'administrator') + assert.equal(sync?.kind, kind) + + const t2 = stores.sessionBridge.mintWsTicket({ empNo: 'administrator', bearer }) + const asyncId = await resolveIdentityFromRequest(muxUpgrade('udsWsTicket=' + t2.ticket, cookie), deps) + assert.equal(asyncId?.empNo, 'administrator') + assert.equal(asyncId?.kind, kind) + }) + + test(`${label}: stale cookie + loopback bridge query still authenticates remote.mux`, async () => { + const stores = makeStores() + const { bridge } = await login(stores, buildContext(), kind) + const req = muxUpgrade( + `udsBridgeEmpNo=administrator&udsBridgeToken=${bridge.token}&udsBridgeKind=${kind}`, + { cookie: `UDS_SESSION=${STALE}` }, + ) + const id = resolveIdentityFromRequestSync(req, { ...stores, rolesStore: stores.roles }) + assert.equal(id?.empNo, 'administrator') + }) + + test(`${label}: workspace follow shows every workspace (view-all on)`, async () => { + const stores = makeStores() + const { bearer } = await login(stores, buildContext(), kind) + const identity = identityFromSessionRecord(stores.sessionStore.getByBearerSync(bearer), stores.roles) + const { wc } = installFollowHarness(stores) + const frames = await collect(wc, identity) + assert.deepEqual(frames[0].value.items.map((w) => w.id), ['ws-admin', 'ws-shared']) + }) +} + +test('live cookie still wins and the WS ticket is not consumed', async () => { + const stores = makeStores() + const a = await login(stores, buildLocalAdminUserContext(), 'sealed_box') + const b = await login(stores, { empNo: '1001', authMode: 'token+profile' }, 'uds') + const ticket = stores.sessionBridge.mintWsTicket({ empNo: '1001', bearer: b.bearer }) + const req = muxUpgrade('udsWsTicket=' + ticket.ticket, { cookie: `UDS_SESSION=${a.bearer}` }) + const got = extractSessionBearer(req, { + sessionBridge: stores.sessionBridge, + isLiveBearer: (x) => !!stores.sessionStore.getByBearerSync(x), + }) + assert.equal(got.via, 'cookie') + assert.equal(got.bearer, a.bearer) + assert.ok(stores.sessionBridge.consumeWsTicket(ticket.ticket), 'ticket left unconsumed') +}) + +test('dead cookie with no other credential keeps legacy cookie result', async () => { + const req = { method: 'GET', url: '/uds-auth/api/me', headers: { cookie: `UDS_SESSION=${STALE}` } } + const got = await extractSessionBearerAsync(req, { + sessionBridge: new SessionBridgeStore({}), + isLiveBearer: async () => false, + }) + assert.deepEqual(got, { bearer: STALE, via: 'cookie' }) +}) + +test('auth middleware: stale cookie falls through to bridge headers for /api/me', async () => { + const stores = makeStores() + const { bridge } = await login(stores, buildLocalAdminUserContext(), 'sealed_box') + const mw = createAuthMiddleware({ + udsAuth: { baseUrl: 'https://uac.example.test', systemCode: 'test' }, + session: { cookieMaxAge: 1_800_000, slidingExpiration: true }, + }, stores.sessionStore, stores.roles, { sessionBridge: stores.sessionBridge }) + const ctx = { + req: { + method: 'GET', + url: '/uds-auth/api/me', + socket: { remoteAddress: '127.0.0.1' }, + headers: { + cookie: `UDS_SESSION=${STALE}`, + 'x-uds-bridge-empno': 'administrator', + 'x-uds-bridge-token': bridge.token, + 'x-uds-bridge-kind': 'sealed_box', + }, + }, + res: { setHeader() {}, getHeader() {} }, + } + await mw(ctx, async () => {}) + assert.equal(ctx.empNo, 'administrator') + assert.equal(ctx.authKind, 'sealed_box') + assert.equal(ctx.permissions.canViewAllSessions, true) +}) + +test('auth middleware slides bridge expiry with the session', async () => { + const stores = makeStores() + const { bridge } = await login(stores, buildLocalAdminUserContext(), 'sealed_box') + const row = stores.sessionBridge._byToken.get(bridge.token) + row.exp = Date.now() + 5_000 + const mw = createAuthMiddleware({ + udsAuth: { baseUrl: 'https://uac.example.test', systemCode: 'test' }, + session: { cookieMaxAge: 1_800_000, slidingExpiration: true }, + }, stores.sessionStore, stores.roles, { sessionBridge: stores.sessionBridge }) + const ctx = { + req: { + method: 'GET', + url: '/uds-auth/api/me', + headers: { 'x-uds-bridge-empno': 'administrator', 'x-uds-bridge-token': bridge.token }, + }, + res: { setHeader() {}, getHeader() {} }, + } + await mw(ctx, async () => {}) + assert.equal(ctx.empNo, 'administrator') + assert.ok(row.exp > Date.now() + 25 * 60_000, 'bridge exp extended to ~30min') +}) + +test('SessionBridgeStore.touch never revives an expired bridge', () => { + const store = new SessionBridgeStore({ ttlMs: 60_000 }) + const live = store.mint({ empNo: 'administrator', kind: 'sealed_box' }) + const dead = store.mint({ empNo: 'administrator', kind: 'sealed_box' }) + store._byToken.get(dead.token).exp = Date.now() - 1 + assert.equal(store.touch(live.token, 600_000), true) + assert.ok(store._byToken.get(live.token).exp > Date.now() + 500_000) + assert.equal(store.touch(dead.token, 600_000), false) + assert.equal(store.verify('administrator', dead.token), null) + assert.equal(store.touch('unknown'), false) +}) + +test('gateway lets anonymous workspace/follow reach dsh-acl (empty baseline, not an error)', async () => { + let principal = null + const descriptors = { + 'workspace/follow': { namespace: 'workspace', method: 'follow', parameters: [], invocation: { kind: 'direct' }, mode: 'stream' }, + 'session/list': { namespace: 'session', method: 'list', parameters: [], invocation: { kind: 'direct' } }, + } + const gateway = { + async *openRemoteEvents() {}, receiveRemoteEventResult() {}, + resolveDescriptor(ns, method) { return descriptors[`${ns}/${method}`] }, + async prepareInvocation(request) { + if (request.method === 'follow') { + return { method: async function* () { yield { type: 'baseline', value: { items: [] } } } } + } + return { method: () => 'ok' } + }, + } + installGatewayPolicy(gateway, { + identity: () => principal, + assertPrincipal: (p) => { if (!p?.empNo) throw new Error('login_required_session') }, + permissions: () => ({ canAccessSettings: false }), + assertSession: () => {}, + deny: (code) => { throw new Error(code) }, + }) + const prepared = await gateway.prepareInvocation({ namespace: 'workspace', method: 'follow', args: {} }) + const frames = [] + for await (const f of prepared.method()) frames.push(f) + assert.equal(frames[0].type, 'baseline') + // Other endpoints stay locked for anonymous principals. + await assert.rejects( + gateway.prepareInvocation({ namespace: 'session', method: 'list', args: {} }), + /login_required_session/, + ) + principal = {} + await assert.doesNotReject(gateway.prepareInvocation({ namespace: 'workspace', method: 'follow', args: {} })) +}) + +test('dsh-acl follow: anonymous gets one empty baseline and holds until abort', async () => { + const stores = makeStores() + const { wc } = installFollowHarness(stores) + const ac = new AbortController() + const frames = [] + const done = runWithUserContext(null, async () => { + for await (const f of wc.follow(ac.signal)) frames.push(f) + }) + await new Promise((r) => setTimeout(r, 20)) + assert.equal(frames.length, 1) + assert.deepEqual(frames[0].value.items, []) + ac.abort() + await done +}) + +function installFollowHarness(stores) { + const items = [ + { id: 'ws-admin', workspaceId: 'ws-admin', path: 'C:/u/administrator', sessionIds: [] }, + { id: 'ws-shared', workspaceId: 'ws-shared', path: 'D:/project/harness', sessionIds: ['s1'] }, + ] + const uw = new UserWorkspaceStore({}) + uw.set('administrator', { path: 'C:/u/administrator', workspaceId: 'ws-admin' }) + const noop = async () => {} + const wc = { + create: noop, rename: noop, delete: noop, insertBefore: noop, insertSessionBefore: noop, + archiveSession: noop, unarchiveSession: noop, pinSession: noop, unpinSession: noop, + async *follow() { + yield { type: 'baseline', value: { items, archivedSessionIds: [], pinnedSessionIds: [] } } + }, + } + installDshAcl({ + inject(deps, cb) { if (deps.includes('workspaceController')) cb({ workspaceController: wc }) }, + on() { return () => {} }, + get() { throw new Error('no service') }, + }, { + sessionAcl: { getOwner: () => null, setOwner() {} }, + userWorkspaces: uw, + getWorkspaceRoot: () => '', + getRolesStore: () => stores.roles, + getSessionStore: () => stores.sessionStore, + ensureUserWorkspace: async () => ({ workspaceId: 'ws-admin' }), + getWorkspaceRegistry: () => ({ list: () => items, get: (id) => items.find((i) => i.id === id) }), + }) + return { wc, items } +} + +async function collect(wc, identity) { + const frames = [] + await runWithUserContext(identity, async () => { + for await (const f of wc.follow(new AbortController().signal)) frames.push(f) + }) + return frames +} + +test('client emits connection/reset after login so boot-time caches (settings mirror) reload', async () => { + const { readFileSync } = await import('node:fs') + const src = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8') + // connection.reconnect() alone never emits connection/reset (DSH only emits it on onConnected). + assert.match(src, /window\.__udsAuthEmitReset = \(\) => \{\s*try \{ cctx\.emit\('connection\/reset'\) \}/) + assert.match(src, /function scheduleAuthReset\(/) + // fires after a soft reconnect and on the first authenticated /api/me per empNo + assert.match(src, /_softReconnectQuietUntil = Date\.now\(\) \+ 2800\s*\n\s*scheduleAuthReset\(/) + assert.match(src, /window\.__udsAuthResetEmp !== resetEmp/) + // cleared on logout / unauthenticated so the next login resets again + assert.equal((src.match(/window\.__udsAuthResetEmp = null/g) || []).length, 2) +}) + +test('anonymous workspace/initializeDefault is a no-op (no default-workspace error toast)', async () => { + let principal = null + let invoked = 0 + const gateway = { + async *openRemoteEvents() {}, receiveRemoteEventResult() {}, + resolveDescriptor: (namespace, method) => ({ namespace, method, parameters: [], invocation: { kind: 'direct' } }), + async prepareInvocation() { return { method: () => { invoked++; return { workspace: { workspaceId: 'w' } } } } }, + } + installGatewayPolicy(gateway, { + identity: () => principal, + assertPrincipal: (p) => { if (!p?.empNo) throw new Error('login_required_session') }, + permissions: () => ({ canAccessSettings: true }), + assertSession: () => {}, + deny: (code) => { throw new Error(code) }, + }) + const anon = await gateway.prepareInvocation({ namespace: 'workspace', method: 'initializeDefault', args: {} }) + assert.equal(anon.method(), undefined) + assert.equal(invoked, 0, 'real controller never runs for anonymous callers') + principal = { empNo: 'administrator' } + const authed = await gateway.prepareInvocation({ namespace: 'workspace', method: 'initializeDefault', args: {} }) + assert.deepEqual(authed.method(), { workspace: { workspaceId: 'w' } }) + assert.equal(invoked, 1) + principal = null + await assert.rejects( + gateway.prepareInvocation({ namespace: 'workspace', method: 'create', args: {} }), + /login_required_session/, + ) +}) + +test('anonymous stream endpoints are parked until the carrier aborts (no terminal error)', async () => { + let principal = null + let invoked = 0 + const control = new AbortController() + const gateway = { + async *openRemoteEvents() {}, receiveRemoteEventResult() {}, + resolveDescriptor: (namespace, method) => ({ namespace, method, parameters: [], invocation: { kind: 'direct' }, mode: 'stream' }), + async prepareInvocation() { + return { + invocation: { signal: control.signal }, + method: async function* () { invoked++; yield { status: 'credential-stored' } }, + } + }, + } + installGatewayPolicy(gateway, { + identity: () => principal, + assertPrincipal: (p) => { if (!p?.empNo) throw new Error('login_required_session') }, + permissions: () => ({ canAccessSettings: true }), + assertSession: () => {}, + deny: (code) => { throw new Error(code) }, + }) + const prepared = await gateway.prepareInvocation({ namespace: 'account', method: 'watch', args: {} }) + const it = prepared.method()[Symbol.asyncIterator]() + let settled = false + const next = it.next().then((r) => { settled = true; return r }) + await new Promise((r) => setTimeout(r, 30)) + assert.equal(settled, false, 'anonymous stream yields nothing and does not fail') + control.abort() + assert.deepEqual(await next, { value: undefined, done: true }) + assert.equal(invoked, 0, 'real stream never opened for anonymous callers') + + principal = { empNo: 'administrator' } + const authed = await gateway.prepareInvocation({ namespace: 'account', method: 'watch', args: {} }) + const frames = [] + for await (const f of authed.method()) frames.push(f) + assert.deepEqual(frames, [{ status: 'credential-stored' }]) +}) diff --git a/uds-auth/test/host-context.test.js b/uds-auth/test/host-context.test.js index 4c4d5d57..c8a15990 100644 --- a/uds-auth/test/host-context.test.js +++ b/uds-auth/test/host-context.test.js @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import { getUserContext, runAsHost, withUserContext } from '../lib/context.js' import { installDshAcl } from '../lib/dsh-acl.js' +import { MemoryStore } from '../lib/session/memory-store.js' test('Host callback context is isolated across concurrent browser identities and exceptions', async () => { const identities = [null, { empNo: 'u1' }, { empNo: 'u2' }] @@ -22,6 +23,8 @@ test('Host callback context is isolated across concurrent browser identities and test('trusted Host creation works from browser ALS while unauthenticated browser creation stays denied', async () => { let creations = 0 const owners = [] + const sessionStore = new MemoryStore() + const minted = await sessionStore.create({ empNo: 'u1' }, 600) const controller = { async list() { return { items: [] } }, async search() { return { items: [] } }, @@ -41,6 +44,7 @@ test('trusted Host creation works from browser ALS while unauthenticated browser userWorkspaces: { isUserPath: (empNo, path) => path === `/ws/${empNo}`, get() { return null } }, getWorkspaceRoot: () => '/ws', getWorkspaceRegistry: () => undefined, + getSessionStore: () => sessionStore, }) await withUserContext(null, async () => { await assert.rejects(controller.create({ cwd: '/bot' }), error => ( @@ -53,7 +57,11 @@ test('trusted Host creation works from browser ALS while unauthenticated browser }) assert.equal(creations, 1) assert.deepEqual(owners, [], 'background sessions must not inherit a browser owner') - await withUserContext({ empNo: 'u1', permissions: {} }, async () => { + await withUserContext({ + empNo: 'u1', + sessionId: minted.sessionId, + permissions: {}, + }, async () => { await assert.rejects(controller.create({ cwd: '/other' }), error => ( error.details.udsError === 'session_workspace_only' )) diff --git a/uds-auth/test/local-admin.test.js b/uds-auth/test/local-admin.test.js index 03ae1d08..c2086b86 100644 --- a/uds-auth/test/local-admin.test.js +++ b/uds-auth/test/local-admin.test.js @@ -1,5 +1,8 @@ import { describe, it, after } from 'node:test' import assert from 'node:assert/strict' +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' import { LOCAL_ADMIN_BOX_ENV, LOCAL_ADMIN_ENV, @@ -38,6 +41,8 @@ describe('local-admin sealed box', () => { it('env box alone does not imply auto-login; only configures unlock', () => { delete process.env[LOCAL_ADMIN_BOX_ENV] + // Isolate from a developer machine's ~/.uds-auth/local-admin.box + process.env.UDS_AUTH_DATA_DIR = join(tmpdir(), `uds-auth-box-${Date.now()}`) assert.equal(isLocalAdminBoxConfigured(), false) const box = sealLocalAdminBox('another-strong-key') @@ -50,4 +55,17 @@ describe('local-admin sealed box', () => { const stillNeedDecrypt = openLocalAdminBox(box, process.env[LOCAL_ADMIN_ENV]) assert.equal(stillNeedDecrypt.ok, false) }) + + it('reads sealed box from profile local-admin.box when env is unset', () => { + delete process.env[LOCAL_ADMIN_BOX_ENV] + const dir = join(tmpdir(), `uds-auth-box-file-${Date.now()}`) + mkdirSync(dir, { recursive: true }) + process.env.UDS_AUTH_DATA_DIR = dir + assert.equal(isLocalAdminBoxConfigured(), false) + + const box = sealLocalAdminBox('file-backed-passphrase') + writeFileSync(join(dir, 'local-admin.box'), box, 'utf8') + assert.equal(isLocalAdminBoxConfigured(), true) + assert.equal(readLocalAdminBox(), box) + }) }) diff --git a/uds-auth/test/phase2-acl.test.js b/uds-auth/test/phase2-acl.test.js new file mode 100644 index 00000000..2dc8d453 --- /dev/null +++ b/uds-auth/test/phase2-acl.test.js @@ -0,0 +1,104 @@ +/** + * Phase-2 ACL tests (SEC-10/12/13/14/15/26 fragments). + */ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createSessionAccess } from '../lib/dsh-acl.js' +import { computePermissions, RolesStore, ROLES } from '../lib/roles.js' +import { SessionAclStore } from '../lib/session-acl.js' +import { UserWorkspaceStore, resolveWorkspaceRoot } from '../lib/workspace-provision.js' +import { sanitizeUserId, resolveUserDir } from '../lib/utils/safe-userid.js' +import { join } from 'node:path' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' + +test('SEC-13: foreign owner is not overridden by own cwd', () => { + const ownersMap = new Map([['s-foreign', 'u2']]) + const { canAccessSession } = createSessionAccess({ + sessionAcl: { getOwner: (id) => ownersMap.get(String(id)) || null }, + userWorkspaces: { + get: (empNo) => (empNo === 'u1' ? { path: '/ws/u1', workspaceId: 'ws-u1' } : null), + isUserPath: (empNo, candidate) => { + if (empNo !== 'u1' || !candidate) return false + const path = String(candidate).replace(/\\/g, '/') + return path === '/ws/u1' || path.startsWith('/ws/u1/') + }, + }, + getWorkspaceRoot: () => '/ws', + getWorkspaceRegistry: () => ({ list: () => [] }), + }) + const user = { empNo: 'u1', role: 'user', permissions: computePermissions('user') } + assert.equal(canAccessSession('s-foreign', user, { cwd: '/ws/u1/project' }), false) + assert.equal(canAccessSession('s-foreign', user, { cwd: '/ws/u2' }), false) +}) + +test('SEC-13: setOwner refuses overwrite of foreign owner without force', () => { + const store = new SessionAclStore({}) + assert.equal(store.setOwner('s1', 'A'), true) + assert.equal(store.setOwner('s1', 'B'), false) + assert.equal(store.getOwner('s1'), 'A') + assert.equal(store.setOwner('s1', 'B', { force: true }), true) + assert.equal(store.getOwner('s1'), 'B') +}) + +test('SEC-14: sanitizeUserId rejects path escape segments', () => { + assert.equal(sanitizeUserId('../escape'), null) + assert.equal(sanitizeUserId('..\\escape'), null) + assert.equal(sanitizeUserId('a/b'), null) + assert.equal(sanitizeUserId('a\\b'), null) + assert.equal(sanitizeUserId('C:'), null) + assert.equal(sanitizeUserId('con'), null) + assert.equal(sanitizeUserId('10001'), '10001') + assert.equal(sanitizeUserId('user.name'), 'user.name') +}) + +test('SEC-14: resolveUserDir / isUserPath block ../ empNo', async () => { + const dir = await mkdtemp(join(tmpdir(), 'uds-ws-')) + try { + const root = resolveWorkspaceRoot(dir) + assert.equal(resolveUserDir(root, '../escape'), null) + assert.equal(resolveUserDir(root, '10001')?.endsWith('10001') || resolveUserDir(root, '10001')?.includes('10001'), true) + + const store = new UserWorkspaceStore({}) + assert.equal(store.isUserPath('../escape', join(root, 'x'), root), false) + assert.equal(store.isUserPath('10001', join(root, '10001', 'a'), root), true) + // Outside user dir + assert.equal(store.isUserPath('10001', join(root, '10002'), root), false) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('SEC-26: setRole cannot assign fallback_admin', async () => { + const store = new RolesStore({}) + store._roles.set('admin1', ROLES.ADMIN) + await assert.rejects( + () => store.setRole('u1', ROLES.FALLBACK_ADMIN, ROLES.ADMIN), + (err) => err.code === 'invalid_role_params', + ) + await store.setRole('u1', ROLES.USER, ROLES.ADMIN) + assert.equal(store.getRole('u1'), ROLES.USER) +}) + +test('SEC-10: live rolesStore demotion removes view-all', () => { + const store = new RolesStore() + store._roles.set('boss', ROLES.SUPER_ADMIN) + const ownersMap = new Map([['s-peer', 'u2']]) + const access = createSessionAccess({ + sessionAcl: { getOwner: (id) => ownersMap.get(String(id)) || null }, + userWorkspaces: { get: () => null, isUserPath: () => false }, + getWorkspaceRoot: () => '/ws', + getWorkspaceRegistry: () => ({ list: () => [] }), + rolesStore: store, + }) + const identity = { + empNo: 'boss', + role: 'super_admin', + permissions: computePermissions('super_admin', { viewAllSessions: true }), + } + assert.equal(access.canAccessSession('s-peer', identity), true) + // Demote in store; frozen identity.permissions still look admin — live check wins. + store._roles.set('boss', ROLES.USER) + assert.equal(access.canSeeAll(identity), false) + assert.equal(access.canAccessSession('s-peer', identity), false) +}) diff --git a/uds-auth/test/phase3-security.test.js b/uds-auth/test/phase3-security.test.js new file mode 100644 index 00000000..95c256a6 --- /dev/null +++ b/uds-auth/test/phase3-security.test.js @@ -0,0 +1,165 @@ +/** + * Phase-3 tests (SEC-16/17/18/19/21 fragments). + */ +import assert from 'node:assert/strict' +import test from 'node:test' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { searchUserByEmpNoToken } from '../lib/uds/user-search.js' +import { sealSecret, openSecret, deriveKeyFromPassphrase } from '../lib/utils/secret-box.js' +import { SkillCredentialCache } from '../lib/skill-credentials.js' +import { checkRequestOrigin, requireJsonContentType } from '../lib/utils/origin-guard.js' +import { createAgentAuthHandlers } from '../lib/agent-auth.js' +import { atomicWriteJson } from '../lib/utils/atomic-write.js' +import { RolesStore, ROLES } from '../lib/roles.js' + +test('SEC-16: substring / digit-contains subject match is rejected', async () => { + // Monkey-patch directRequest via injecting a fake by testing parser path: + // Call search with stubs by temporarily replacing module is hard; unit-test + // the acceptance logic through a local reimplementation of the filter rules. + const want = '12345' + const list = [ + { employeeShortId: '123456', name: 'X' }, // prefix — must NOT match + ] + const matches = list.filter((row) => String(row.employeeShortId || '').trim() === want) + assert.equal(matches.length, 0) + + // Simulate HTTP 500 + biz ok — searchUserByEmpNoToken would need network; + // instead assert the exported function rejects missing url without calling net. + const missing = await searchUserByEmpNoToken({ + userSearchUrl: '', + empNo: '1', + token: 't', + }) + assert.equal(missing.ok, false) + assert.equal(missing.reason, 'missing_args') +}) + +test('SEC-17: seal/open secret roundtrip; wrong key fails', () => { + const key = deriveKeyFromPassphrase('test-pass') + const blob = sealSecret(key, 'upstream-token-secret') + assert.ok(blob.startsWith('uds-secret-v1.')) + assert.equal(openSecret(key, blob), 'upstream-token-secret') + const other = deriveKeyFromPassphrase('other') + assert.equal(openSecret(other, blob), null) +}) + +test('SEC-17: SkillCredentialCache persists encrypted tokens only', async () => { + const dir = await mkdtemp(join(tmpdir(), 'uds-cred-')) + const file = join(dir, 'skill-credentials.json') + try { + const cache = new SkillCredentialCache({ file, dataDir: dir, ttlMs: 60_000 }) + await cache.init() + cache.set('10001', 'plain-uac-token') + // Force flush + await cache._flush() + const raw = await readFile(file, 'utf-8') + assert.equal(raw.includes('plain-uac-token'), false) + assert.match(raw, /tokenEnc/) + const again = new SkillCredentialCache({ file, dataDir: dir, ttlMs: 60_000 }) + await again.init() + assert.equal(again.get('10001')?.token, 'plain-uac-token') + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('SEC-18: outbound hostAllowed is exact match only', async () => { + const handlers = createAgentAuthHandlers({ + resolveCredentialsForSession: async () => ({ empNo: 'A', token: 't' }), + getSessionOwner: () => 'A', + resolveCallerSession: async () => ({ empNo: 'A' }), + outboundHosts: ['icenterapi.zte.com.cn'], + }) + // Probe via hostAllowed closure — call outbound with http and wrong host + const makeRes = () => ({ + statusCode: 0, headers: {}, body: '', + setHeader(k, v) { this.headers[k] = v }, + end(s) { this.body = s }, + }) + const loopbackReq = (payload) => ({ + method: 'POST', + socket: { remoteAddress: '127.0.0.1' }, + headers: { 'content-type': 'application/json' }, + async *[Symbol.asyncIterator]() { + yield Buffer.from(JSON.stringify(payload)) + }, + }) + + const resHttp = makeRes() + await handlers.handleOutbound(loopbackReq({ + sessionId: 's1', + url: 'http://icenterapi.zte.com.cn/x', + }), resHttp) + assert.equal(resHttp.statusCode, 400) + assert.match(resHttp.body, /https_required/) + + const resSub = makeRes() + await handlers.handleOutbound(loopbackReq({ + sessionId: 's1', + url: 'https://evil.icenterapi.zte.com.cn/x', + }), resSub) + assert.equal(resSub.statusCode, 403) + assert.match(resSub.body, /host_not_allowed/) +}) + +test('SEC-19: origin guard rejects cross-site and mismatched Origin', () => { + assert.equal(checkRequestOrigin({ + method: 'POST', + headers: { host: '127.0.0.1:8787', origin: 'https://evil.example', 'sec-fetch-site': 'cross-site' }, + socket: { remoteAddress: '10.0.0.1' }, + }).ok, false) + + // A09: non-loopback Host requires explicit trustedHosts allowlist. + assert.equal(checkRequestOrigin({ + method: 'POST', + headers: { host: 'app.example:443', origin: 'https://app.example:443' }, + socket: { remoteAddress: '10.0.0.1' }, + }).ok, false) + assert.equal(checkRequestOrigin({ + method: 'POST', + headers: { host: 'app.example:443', origin: 'https://app.example:443' }, + socket: { remoteAddress: '10.0.0.1' }, + }, { trustedHosts: ['app.example'] }).ok, true) + + assert.equal(checkRequestOrigin({ + method: 'POST', + headers: { host: '127.0.0.1:8787' }, + socket: { remoteAddress: '127.0.0.1' }, + }).ok, true) + + assert.equal(requireJsonContentType({ + method: 'POST', + headers: { 'content-type': 'text/plain' }, + }).ok, false) +}) + +test('SEC-21: atomicWriteJson replaces file', async () => { + const dir = await mkdtemp(join(tmpdir(), 'uds-atomic-')) + const file = join(dir, 'roles.json') + try { + await atomicWriteJson(file, { a: 1 }) + assert.deepEqual(JSON.parse(await readFile(file, 'utf-8')), { a: 1 }) + await atomicWriteJson(file, { b: 2 }) + assert.deepEqual(JSON.parse(await readFile(file, 'utf-8')), { b: 2 }) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('SEC-21: RolesStore save uses durable path without throwing', async () => { + const dir = await mkdtemp(join(tmpdir(), 'uds-roles-')) + const file = join(dir, 'roles.json') + try { + const store = new RolesStore({ rolesFile: file }) + await store.init() + store._roles.set('u1', ROLES.USER) + store._markDirty() + await store.flush() + const data = JSON.parse(await readFile(file, 'utf-8')) + assert.equal(data.roles.u1, 'user') + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) diff --git a/uds-auth/test/phase4-ops.test.js b/uds-auth/test/phase4-ops.test.js new file mode 100644 index 00000000..1b4519c5 --- /dev/null +++ b/uds-auth/test/phase4-ops.test.js @@ -0,0 +1,65 @@ +/** + * Phase-4 ops tests (SEC-23/25 fragments). + */ +import assert from 'node:assert/strict' +import test from 'node:test' +import { Readable } from 'node:stream' +import { readBodyLimited, readJsonBodyLimited } from '../lib/utils/read-body.js' +import { RateLimiter } from '../lib/utils/rate-limit.js' +import { audit, redactValue } from '../lib/utils/audit-log.js' + +function fakeReq(chunks) { + return Readable.from(chunks) +} + +test('SEC-23: readBodyLimited rejects oversized payload', async () => { + const big = Buffer.alloc(1024, 0x61) + await assert.rejects( + () => readBodyLimited(fakeReq([big, big]), { maxBytes: 1500 }), + (err) => err.code === 'payload_too_large', + ) +}) + +test('SEC-23: readJsonBodyLimited parses small JSON', async () => { + const req = fakeReq([Buffer.from('{"a":1}')]) + assert.deepEqual(await readJsonBodyLimited(req, { maxBytes: 100 }), { a: 1 }) +}) + +test('SEC-23: RateLimiter eventually blocks and cleans buckets', () => { + const lim = new RateLimiter({ windowMs: 60_000, max: 3 }) + assert.equal(lim.allow('ip1'), true) + assert.equal(lim.allow('ip1'), true) + assert.equal(lim.allow('ip1'), true) + assert.equal(lim.allow('ip1'), false) + assert.equal(lim.allow('ip2'), true) + lim.dispose() +}) + +test('SEC-25: audit redacts secrets and returns structured row', () => { + const lines = [] + const row = audit({ + action: 'test', + decision: 'deny', + reasonCode: 'x', + actor: 'u1', + meta: { token: 'secret-value', nested: { password: 'p' }, ok: 'fine' }, + }, { + logger: { + warn(s) { lines.push(s) }, + info() {}, + }, + }) + assert.equal(row.type, 'uds-auth.audit') + assert.equal(row.meta.token, '[redacted]') + assert.equal(row.meta.nested.password, '[redacted]') + assert.equal(row.meta.ok, 'fine') + assert.equal(lines.length, 1) + assert.equal(lines[0].includes('secret-value'), false) +}) + +test('SEC-25: redactValue truncates long strings', () => { + const long = 'x'.repeat(100) + const out = redactValue(long) + assert.ok(String(out).includes('…')) + assert.equal(String(out).includes(long), false) +}) diff --git a/uds-auth/test/remediation-0310.test.js b/uds-auth/test/remediation-0310.test.js new file mode 100644 index 00000000..f7a792ab --- /dev/null +++ b/uds-auth/test/remediation-0310.test.js @@ -0,0 +1,353 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, readFile, writeFile, mkdir, rm, readdir, lstat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { RolesStore, ROLES } from '../lib/roles.js' +import { hashPasswordAsync, derivePasswordKey } from '../lib/utils/password-kdf.js' +import { sealLocalAdminBox, openLocalAdminBoxAsync } from '../lib/local-admin.js' +import { QrChallengeStore, qrLoginCredentials } from '../lib/qr-challenge.js' +import { TaskCapabilityStore } from '../lib/task-capability.js' +import { createTaskEnvironment } from '../lib/task-environment.js' +import { SessionBridgeStore } from '../lib/session-bridge.js' +import { extractSessionBearer } from '../lib/session/request-auth.js' +import { migrateState } from '../lib/state-migration.js' +import { atomicWriteFile } from '../lib/utils/atomic-write.js' +import { checkRequestOrigin } from '../lib/utils/origin-guard.js' +import { protectProfileDirectory } from '../lib/profile-security.js' +import { runInNewContext } from 'node:vm' +import { installGatewayPolicy } from '../lib/gateway-policy.js' +import { installGatewayEvents } from '../lib/gateway-events.js' + +test('Gateway guards new namespaces and lookup owners before invocation, including live streams', async () => { + let principal = { empNo: 'u' }, active = true, viewAll = false, prepared = 0 + const descriptors = { + global: { namespace: 'newPlugin', method: 'global', parameters: [], invocation: { kind: 'direct' } }, + read: { namespace: 'newPlugin', method: 'read', parameters: [{ name: 'workspaceFileScope', source: 'lookup', wire: 'workspaceFileScopeId' }], invocation: { kind: 'direct' } }, + stream: { namespace: 'newPlugin', method: 'stream', parameters: [{ name: 'sessionId', wire: 'sessionId' }], invocation: { kind: 'direct' }, mode: 'stream' }, + } + const gateway = { + async *openRemoteEvents() {}, receiveRemoteEventResult() {}, + resolveDescriptor(ns, method) { return descriptors[method] }, + async prepareInvocation(request) { + prepared++ + return { method: request.method === 'stream' ? async function* () { yield 'first'; yield 'second' } : () => 'ok' } + }, + } + installGatewayPolicy(gateway, { identity: () => principal, + assertPrincipal: p => { if (!p || !active) throw new Error('login_required') }, + permissions: () => ({ canAccessSettings: viewAll }), + assertSession: sid => { if (sid !== 'own') throw new Error('session_forbidden') }, + deny: code => { throw new Error(code) } }) + await assert.rejects(gateway.prepareInvocation({ namespace: 'newPlugin', method: 'global', args: {} }), /forbidden_settings/) + await assert.rejects(gateway.prepareInvocation({ namespace: 'newPlugin', method: 'read', args: { workspaceFileScopeId: 'foreign' } }), /session_forbidden/) + assert.equal(prepared, 0, 'authorization precedes providers and lookups') + const invocation = await gateway.prepareInvocation({ namespace: 'newPlugin', method: 'read', args: { workspaceFileScopeId: 'own' } }) + assert.equal(invocation.method(), 'ok') + const stream = (await gateway.prepareInvocation({ namespace: 'newPlugin', method: 'stream', args: { sessionId: 'own' } })).method() + assert.equal((await stream.next()).value, 'first') + active = false; await assert.rejects(stream.next(), /login_required/) + principal = undefined + assert.equal((await gateway.prepareInvocation({ namespace: 'newPlugin', method: 'global', args: {} })).method(), 'ok') + principal = null; await assert.rejects(gateway.prepareInvocation({ namespace: 'newPlugin', method: 'global', args: {} }), /login_required/) + assert.throws(() => installGatewayPolicy({}, {}), /unsupported_gateway_acl_contract/) +}) + +test('Gateway event stream hides foreign events and binds responses to the issuing local session', async () => { + let principal = { empNo: 'u', sessionId: 'login1' }, active = true + let finish + const gate = new Promise(resolve => { finish = resolve }) + const gateway = { pendingRemoteEvents: new Map([['event-own', { frame: { agentId: 'own' } }]]), + async *openRemoteEvents() { + yield { type: 'ready', clientId: 'client1', host: { home: 'private-home' } } + yield { type: 'emit', event: 'api-session/added', args: [{ id: 'foreign', title: 'secret' }] } + yield { type: 'emit', event: 'api-session/status', args: ['own', true] } + yield { type: 'emit', event: 'credentials/record-updated', args: ['global-secret'] } + yield { type: 'waterfall', eventId: 'event-foreign', agentId: 'foreign', request: { secret: 'hidden' } } + yield { type: 'waterfall', eventId: 'event-own', agentId: 'own', request: {} } + await gate + yield { type: 'emit', event: 'api-session/status', args: ['own', false] } + }, receiveRemoteEventResult() { return 'accepted' } } + installGatewayEvents(gateway, { identity: () => principal, + assertPrincipal: p => { if (!p || !active) throw new Error('login_required') }, + permissions: () => ({ canAccessSettings: false }), assertSession: sid => { if (sid !== 'own') throw new Error('session_forbidden') }, + deny: code => { throw new Error(code) } }) + const stream = gateway.openRemoteEvents() + assert.deepEqual((await stream.next()).value.host, { home: '' }) + assert.equal((await stream.next()).value.event, 'api-session/status') + assert.equal((await stream.next()).value.eventId, 'event-own') + const result = { clientId: 'client1', eventId: 'event-own' } + assert.equal(gateway.receiveRemoteEventResult({}, result), 'accepted') + principal = { empNo: 'u', sessionId: 'login2' } + assert.throws(() => gateway.receiveRemoteEventResult({}, result), /event_client_forbidden/) + active = false; finish(); await assert.rejects(stream.next(), /login_required/) +}) + +test('background shell capability survives foreground completion and ends with its own job', () => { + const caps = new TaskCapabilityStore(), roles = new RolesStore({}), controller = new AbortController() + const env = createTaskEnvironment({ getOwner: () => 'u', rolesStore: roles, capabilities: caps, getWebServer: () => ({ port: 12345 }) }) + const execution = { token: {}, agent: { session: { header: { id: 'own' } } }, signal: controller.signal } + const values = env.contributor.resolve(execution) + let listener, unsubscribed = false + const jobs = { list: () => [{ id: 'job1', status: 'running' }], events: { subscribe(scope, fn) { assert.deepEqual(scope, { owner: 'own' }); listener = fn; return () => { unsubscribed = true } } } } + env.finish(execution, { value: { kind: 'background', jobId: 'job1' } }, jobs) + controller.abort() + assert.ok(caps.verify(values.DSH_UDS_TASK_CAPABILITY)) + listener({ type: 'settled', job: { id: 'other' } }); assert.ok(caps.verify(values.DSH_UDS_TASK_CAPABILITY)) + listener({ type: 'settled', job: { id: 'job1' } }); assert.equal(caps.verify(values.DSH_UDS_TASK_CAPABILITY), null) + assert.equal(unsubscribed, true) +}) + +async function temporary(t) { + const dir = await mkdtemp(join(tmpdir(), 'uds-auth-0310-')) + t.after(() => rm(dir, { recursive: true, force: true })) + return dir +} + +test('password transactions queue set/set/clear and persist a restart-safe generation', async t => { + const dir = await temporary(t), file = join(dir, 'roles.json') + const store = new RolesStore({ rolesFile: file }) + await store.init() + await Promise.all([ + store.setFallbackPassword('password-first-0310', ROLES.SUPER_ADMIN), + store.setFallbackPassword('password-second-0310', ROLES.SUPER_ADMIN), + store.clearFallbackPassword(ROLES.SUPER_ADMIN), + ]) + assert.equal(store.isFallbackEnabled(), false) + assert.equal(store.getFallbackCredVersion(), 3) + const reopened = new RolesStore({ rolesFile: file }); await reopened.init() + assert.equal(reopened.isFallbackEnabled(), false) + assert.equal(reopened.getFallbackCredVersion(), 3) +}) + +test('queued admin writes recheck the actor after a preceding demotion commits', async t => { + const store = new RolesStore({ rolesFile: join(await temporary(t), 'roles.json') }); await store.init() + await store.ensureUser('actor', ROLES.SUPER_ADMIN); await store.setRole('actor', ROLES.ADMIN, ROLES.SUPER_ADMIN) + await store.ensureUser('target', ROLES.SUPER_ADMIN) + const flush = store.flush.bind(store) + let start, finish + const started = new Promise(resolve => { start = resolve }), gate = new Promise(resolve => { finish = resolve }) + store.flush = async () => { start(); await gate; return flush() } + const demotion = store.setRole('actor', ROLES.USER, ROLES.SUPER_ADMIN) + await started + const stale = store.setRole('target', ROLES.ADMIN, () => store.getRole('actor')) + const denied = assert.rejects(stale, { code: 'forbidden_set_role' }) + finish(); await demotion; await denied + assert.equal(store.getRole('target'), ROLES.USER) +}) + +test('failed credential write cannot resurrect an uncommitted peer; next queued write commits', async t => { + const file = join(await temporary(t), 'roles.json') + const store = new RolesStore({ rolesFile: file }); await store.init() + await store.setFallbackPassword('original-password-0310', ROLES.SUPER_ADMIN) + const originalFlush = store.flush.bind(store) + let start, finish + const started = new Promise(r => start = r), gate = new Promise(r => finish = r) + let count = 0 + store.flush = async () => { + if (++count === 1) { start(); await gate; throw new Error('injected-write-failure') } + await originalFlush() + } + const first = store.setFallbackPassword('uncommitted-password-0310', ROLES.SUPER_ADMIN) + const failure = assert.rejects(first, /injected-write-failure/) + await started + const second = store.setFallbackPassword('committed-second-0310', ROLES.SUPER_ADMIN) + assert.equal(store.getFallbackCredVersion(), 1, 'readers see only the durable generation') + finish(); await failure; await second + assert.equal(await store.verifyFallbackAsync('uncommitted-password-0310', 'ip1'), false) + assert.equal(await store.verifyFallbackAsync('committed-second-0310', 'ip2'), true) + const again = new RolesStore({ rolesFile: file }); await again.init() + assert.equal(again.getFallbackCredVersion(), 2) + assert.equal(await again.verifyFallbackAsync('committed-second-0310', 'ip3'), true) +}) + +test('staged role promotion is invisible before commit; failed preferences restore durable state', async t => { + const store = new RolesStore({ rolesFile: join(await temporary(t), 'roles.json') }); await store.init() + await store.ensureUser('u', ROLES.SUPER_ADMIN); await store.ensureUser('boss', ROLES.SUPER_ADMIN) + await store.setRole('boss', ROLES.SUPER_ADMIN, ROLES.SUPER_ADMIN) + let start, finish + const started = new Promise(r => start = r), gate = new Promise(r => finish = r) + store.flush = async () => { start(); await gate; throw new Error('disk-offline') } + const promotion = store.setRole('u', ROLES.ADMIN, ROLES.SUPER_ADMIN) + const failed = assert.rejects(promotion, /disk-offline/) + await started + assert.equal(store.resolvePermissions('u').canAccessSettings, false) + finish(); await failed + assert.equal(store.getRole('u'), ROLES.USER) + await assert.rejects(store.setViewAllSessionsDurable('boss', false), /disk-offline/) + assert.equal(store.resolvePermissions('boss').canViewAllSessions, true) +}) + +test('runtime KDF leaves the event loop responsive and rejects queue overload', async () => { + let heartbeat = false + const timer = setTimeout(() => { heartbeat = true }, 0) + await hashPasswordAsync('responsive-password-0310') + clearTimeout(timer) + assert.equal(heartbeat, true) + const results = await Promise.allSettled(Array.from({ length: 12 }, () => derivePasswordKey('load-password', Buffer.alloc(16)))) + assert.equal(results.filter(r => r.status === 'fulfilled').length, 10) + assert.equal(results.filter(r => r.status === 'rejected' && r.reason.code === 'kdf_busy').length, 2) +}) + +test('async sealed-box unlock uses the existing wire format and rejects wrong passphrase', async () => { + const box = sealLocalAdminBox('offline-passphrase-0310') + assert.equal((await openLocalAdminBoxAsync(box, 'offline-passphrase-0310')).ok, true) + assert.equal((await openLocalAdminBoxAsync(box, 'wrong-passphrase-0310')).ok, false) +}) + +test('QR pending survives; public scan coordinates cannot poll or consume on another browser', () => { + const store = new QrChallengeStore(), c = store.create({ loginSystemCode: 'sys', originSystemCode: '' }) + assert.equal(c.qrCodeStr.includes(c.browserBinding), false) + assert.equal(store.peek(c.qrCodeKey, c.qrCodeValue, '').reason, 'qr_binding_required') + assert.equal(store.peek(c.qrCodeKey, c.qrCodeValue, 'f'.repeat(64)).ok, false) + assert.equal(store.peek(c.qrCodeKey, c.qrCodeValue, c.browserBinding).ok, true) + assert.equal(store.peek(c.qrCodeKey, c.qrCodeValue, c.browserBinding).ok, true) + const results = Array.from({ length: 20 }, () => store.consume(c.qrCodeKey, c.qrCodeValue, c.browserBinding)) + assert.equal(results.filter(r => r.ok).length, 1) + assert.equal(results.filter(r => r.reason === 'qr_replay').length, 19) +}) + +test('QR expiry and missing-credential success fail closed', () => { + const store = new QrChallengeStore(), c = store.create({ loginSystemCode: 'sys', originSystemCode: '' }) + store.rows.get(c.qrCodeKey).expiresAt = Date.now() - 1 + assert.equal(store.consume(c.qrCodeKey, c.qrCodeValue, c.browserBinding).reason, 'qr_expired') + assert.equal(qrLoginCredentials({ code: { code: '0000' }, bo: { code: '0000' } }), null) + assert.equal(qrLoginCredentials({ code: '0000', bo: { code: '4002' }, other: { account: 'u', token: 't' } }), null) + assert.deepEqual(qrLoginCredentials({ code: '0000', bo: { code: '0000' }, other: { account: 'u', token: 't' } }), { empNo: 'u', token: 't' }) +}) + +test('shell/cron environment derives the owner and revokes on tool result/abort/disposal', () => { + const roles = new RolesStore({}), caps = new TaskCapabilityStore() + const owners = new Map([['s1', 'u']]) + const env = createTaskEnvironment({ getOwner: sid => owners.get(sid), rolesStore: roles, + capabilities: caps, getWebServer: () => ({ port: 43210 }) }) + const controller = new AbortController() + const execution = { token: {}, agent: { session: { header: { id: 's1' } } }, signal: controller.signal, + arguments: { empNo: 'other', env: { UDS_TASK_CAPABILITY: 'forged' } } } + let values = env.contributor.resolve(execution) + let cap = caps.verify(values.DSH_UDS_TASK_CAPABILITY, { dshSessionId: 's1' }) + assert.equal(cap.empNo, 'u'); assert.deepEqual(cap.scopes, ['outbound']) + assert.equal(values.DSH_UDS_AUTH_BASE, 'http://127.0.0.1:43210/uds-auth') + assert.equal(caps.verify(values.DSH_UDS_TASK_CAPABILITY, { dshSessionId: 's2' }), null) + env.release({ token: execution.token }); assert.equal(caps.verify(values.DSH_UDS_TASK_CAPABILITY), null) + values = env.contributor.resolve(execution); controller.abort() + assert.equal(caps.verify(values.DSH_UDS_TASK_CAPABILITY), null) + const next = { token: {}, agent: execution.agent, signal: new AbortController().signal } + values = env.contributor.resolve(next); env.dispose() + assert.equal(caps.verify(values.DSH_UDS_TASK_CAPABILITY), null) + assert.equal(env.contributor.resolve({ token: {}, agent: { session: { header: { id: 'missing' } } } }).DSH_UDS_TASK_CAPABILITY, '') +}) + +test('WS tickets authorize only GET with a real upgrade on the Gateway mux entry', () => { + const bridge = new SessionBridgeStore({}), cap = bridge.mintWsTicket({ empNo: 'u', bearer: 'b'.repeat(32) }) + const headers = { upgrade: 'websocket', connection: 'Upgrade' } + const good = { method: 'GET', url: '/api/remote.mux?udsWsTicket=' + cap.ticket, headers } + // Wrong path / method must not consume. Missing Connection is allowed (0.3.9 Desktop). + for (const bad of [ + { ...good, url: '/foreign?udsWsTicket=' + cap.ticket }, + { ...good, method: 'POST' }, + { ...good, headers: { connection: 'Upgrade' } }, + ]) { + assert.equal(extractSessionBearer(bad, { sessionBridge: bridge }), null) + } + assert.equal( + extractSessionBearer( + { ...good, headers: { upgrade: 'websocket' } }, + { sessionBridge: bridge }, + )?.via, + 'ws_ticket', + ) + const cap2 = bridge.mintWsTicket({ empNo: 'u', bearer: 'b'.repeat(32) }) + const good2 = { method: 'GET', url: '/api/remote.mux?udsWsTicket=' + cap2.ticket, headers } + assert.equal(extractSessionBearer(good2, { sessionBridge: bridge })?.via, 'ws_ticket') + assert.equal(extractSessionBearer(good2, { sessionBridge: bridge }), null) +}) + +test('Host/CSRF/upgrade matrix rejects hostile authority, Origin and browser cross-site signals', () => { + const req = { method: 'POST', headers: { host: 'localhost:3000', origin: 'http://localhost:3000' }, socket: { remoteAddress: '127.0.0.1' } } + assert.equal(checkRequestOrigin(req).ok, true) + for (const headers of [{ ...req.headers, host: 'evil.invalid:3000' }, { ...req.headers, origin: 'http://evil.invalid:3000' }, + { ...req.headers, origin: 'file://localhost:3000' }, { ...req.headers, origin: 'http://localhost:3000/path' }, + { ...req.headers, host: 'localhost:3000/evil' }, { ...req.headers, 'sec-fetch-site': 'cross-site' }]) { + assert.equal(checkRequestOrigin({ ...req, method: 'GET', headers }, { upgrade: true }).ok, false) + } + assert.equal(checkRequestOrigin({ ...req, headers: { host: '[::1]:3000', origin: 'null' }, socket: { remoteAddress: '::1' } }).ok, true) + assert.equal(checkRequestOrigin({ ...req, headers: { host: 'localhost:3000', origin: 'dsh-app://app' } }).ok, true) + assert.equal(checkRequestOrigin({ ...req, headers: { host: 'localhost:3000', origin: 'dsh-app://foreign' } }).ok, false) +}) + +test('legacy migration rolls back partial writes, resumes identical files and never overwrites conflicts', async t => { + const root = await temporary(t), src = join(root, 'src'), dst = join(root, 'dst') + await mkdir(src); await mkdir(dst) + await writeFile(join(src, 'roles.json'), '{"roles":{"boss":"super_admin"}}') + await writeFile(join(src, 'session-owners.json'), '{"owners":{"s":"boss"}}') + let calls = 0 + const { copyFile } = await import('node:fs/promises') + await assert.rejects(migrateState(dst, src, { error() {} }, { copyFile: async (...args) => { + if (++calls === 2) throw Object.assign(new Error('disk error'), { code: 'EIO' }) + return copyFile(...args) + } }), /disk error/) + assert.deepEqual(await readdir(dst), []) + await copyFile(join(src, 'roles.json'), join(dst, 'roles.json')) + assert.equal((await migrateState(dst, src, { info() {} })).migrated, true) + assert.equal((await migrateState(dst, src)).reason, 'already') + const conflict = join(root, 'conflict'); await mkdir(conflict) + await writeFile(join(conflict, 'roles.json'), '{"roles":{}}') + await assert.rejects(migrateState(conflict, src), { code: 'legacy_migrate_conflict' }) + assert.deepEqual(await readdir(conflict), ['roles.json']) +}) + +test('failed atomic rename cleans temporary secret buffers from disk', async t => { + const dir = await temporary(t), dest = join(dir, 'directory'); await mkdir(dest) + await assert.rejects(atomicWriteFile(dest, 'secret')) + assert.deepEqual(await readdir(dir), ['directory']) +}) + +test('profile applies and verifies actual platform permissions on a disposable directory', async t => { + const dir = await temporary(t); await writeFile(join(dir, 'roles.json'), '{}') + const result = await protectProfileDirectory(dir) + if (process.platform === 'win32') { + assert.equal(result.protected, true); assert.deepEqual(result.principals, ['current-user', 'SYSTEM']) + } else { + assert.equal((await lstat(dir)).mode & 0o777, 0o700) + assert.equal((await lstat(join(dir, 'roles.json'))).mode & 0o777, 0o600) + } +}) + +test('actual Desktop fetch/WS wrapper preserves headers and only attaches credentials to the owned authority', async () => { + const src = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8') + const calls = [], sockets = [], bridge = { empNo: 'administrator', token: 'b'.repeat(32), kind: 'sealed_box' } + const window = { fetch: async (input, opts) => { calls.push({ input, opts }); return {} }, + WebSocket: function(url) { sockets.push(url) }, localStorage: { getItem: () => JSON.stringify(bridge) }, + __ModuleLoader__: { load({ factory }) { factory(() => ({ createElement() {} })) } } } + const location = { protocol: 'dsh-app:', hostname: 'app', port: '', href: 'dsh-app://app/' } + const ticketOpens = [] + const context = { window, location, URL, Headers, console, XMLHttpRequest: function() { + this.open = (method, url) => { ticketOpens.push(String(url)) } + this.setRequestHeader = () => {}; this.send = () => {} + this.status = 200; this.responseText = '{"ticket":"once"}' + } } + runInNewContext(src, context) + // 0.3.9: without streamBaseUrl, only loopback is trusted (not arbitrary hosts). + await window.fetch('http://example.com/api/remote') + assert.equal(calls.at(-1).opts, undefined, 'boot not ready must not trust non-loopback') + await window.fetch('http://127.0.0.1:1111/api/remote') + assert.equal(calls.at(-1).opts.headers['X-UDS-Bridge-Token'], bridge.token, 'loopback Gateway trusted before transport ready') + context.__DSH_TRANSPORT__ = { streamBaseUrl: 'http://127.0.0.1:43210/' } + await window.fetch('dsh-app://foreign/api/remote'); assert.equal(calls.at(-1).opts, undefined) + await window.fetch('http://127.0.0.1:43211/api/remote'); assert.equal(calls.at(-1).opts, undefined) + await window.fetch({ url: 'http://127.0.0.1:43210/api/remote', headers: new Headers({ 'X-Original': 'keep' }) }) + assert.equal(calls.at(-1).opts.headers['X-UDS-Bridge-Token'], bridge.token) + assert.equal(calls.at(-1).opts.headers['x-original'], 'keep') + assert.equal(calls.at(-1).opts.redirect, 'error') + new window.WebSocket('ws://127.0.0.1:43210/api/remote.mux') + assert.equal(new URL(sockets.at(-1)).searchParams.get('udsWsTicket'), 'once') + assert.equal(sockets.at(-1).includes(bridge.token), false) + assert.ok( + ticketOpens[0] === '/uds-auth/api/ws-ticket' + || ticketOpens.some((u) => u.startsWith('http://127.0.0.1:43210/uds-auth/api/ws-ticket')), + 'Desktop ticket mint must hit relative /uds-auth or Host streamBaseUrl', + ) + new window.WebSocket('ws://127.0.0.1:43210/foreign') + assert.equal(new URL(sockets.at(-1)).search, '') +}) diff --git a/uds-auth/test/sealed-workspace-follow.test.js b/uds-auth/test/sealed-workspace-follow.test.js new file mode 100644 index 00000000..6f396d06 --- /dev/null +++ b/uds-auth/test/sealed-workspace-follow.test.js @@ -0,0 +1,130 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { RolesStore, ROLES } from '../lib/roles.js' +import { + classifyAuthKind, + identityFromSessionRecord, + createSessionAccess, +} from '../lib/dsh-acl.js' +import { + buildLocalAdminUserContext, + sealLocalAdminBox, + isLocalAdminBoxConfigured, +} from '../lib/local-admin.js' +import { MemoryStore } from '../lib/session/memory-store.js' +import { UserWorkspaceStore } from '../lib/workspace-provision.js' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') + +test('A08: sealed_box identity survives when emergency password is disabled', async () => { + process.env.UDS_AUTH_LOCAL_ADMIN_BOX = sealLocalAdminBox('test-passphrase-ok') + assert.equal(isLocalAdminBoxConfigured(), true) + + const roles = new RolesStore({}) + roles._fallbackPasswordHash = null + roles._prefs.set('administrator', { viewAllSessions: true }) + assert.equal(roles.isFallbackEnabled(), false) + + const store = new MemoryStore() + const { bearer } = await store.create(buildLocalAdminUserContext(), 600) + const session = store.getByBearerSync(bearer) + assert.equal(session.kind, 'sealed_box') + + const id = identityFromSessionRecord(session, roles) + assert.ok(id) + assert.equal(id.kind, 'sealed_box') + assert.equal(id.permissions.canCreateWorkspace, true) + assert.equal(id.permissions.canViewAllSessions, true) + + // Incomplete ALS identity must still classify via live session row fields. + assert.equal(classifyAuthKind(session, 'administrator'), 'sealed_box') + assert.equal( + classifyAuthKind({ empNo: 'administrator' }, 'administrator'), + 'fallback', + ) +}) + +test('sealed_box admin still sees shared workspaces when view-all is on', async () => { + process.env.UDS_AUTH_LOCAL_ADMIN_BOX = sealLocalAdminBox('test-passphrase-ok') + const roles = new RolesStore({}) + roles._fallbackPasswordHash = null + roles._prefs.set('administrator', { viewAllSessions: true }) + + const store = new MemoryStore() + const { bearer } = await store.create(buildLocalAdminUserContext(), 600) + const identity = identityFromSessionRecord(store.getByBearerSync(bearer), roles) + + const uw = new UserWorkspaceStore({}) + uw.set('administrator', { + path: 'C:\\Users\\zhout\\.dsh\\user-workspaces\\administrator', + workspaceId: 'ws-admin', + }) + const access = createSessionAccess({ + sessionAcl: { getOwner: () => null }, + userWorkspaces: uw, + getWorkspaceRoot: () => 'C:\\Users\\zhout\\.dsh\\user-workspaces', + rolesStore: roles, + getWorkspaceRegistry: () => ({ list: () => [] }), + }) + assert.equal(access.canSeeAll(identity), true) + assert.equal( + access.isVisibleWorkspace(identity, { id: 'ws-harness', path: 'D:\\project\\harness' }), + true, + ) +}) + +test('assertPrincipalLive prefers session.kind sealed_box over bare administrator identity', () => { + const src = readFileSync(join(root, 'lib/dsh-acl.js'), 'utf8') + assert.match(src, /classifyAuthKind\(sessionRow \|\| identity/) + assert.match(src, /incomplete ALS identities/) + assert.doesNotMatch( + src, + /emp === 'administrator' && kind !== 'uds'/, + ) +}) + +test('Desktop WS ticket mint prefetches via fetch then sync cache', () => { + const src = readFileSync(join(root, 'lib/client.js'), 'utf8') + assert.match(src, /prefetchWsTickets/) + assert.match(src, /reconnectWithWsTicket/) + assert.match(src, /_wsTicketCache/) + assert.match(src, /udsBridgeToken/) + assert.match(src, /data-uds-session-only/) + assert.match(src, /getDesktopTransportBase\(\)/) + assert.match(src, /new URL\('\/uds-auth\/api\/ws-ticket', transport\)/) + assert.match(src, /WS ticket mint failed/) + assert.match(src, /__udsAuthWsAuthedOnce/) + // Reconnect storm must be coalesced — second ticketed reconnect kills the stream. + assert.match(src, /_softReconnectQuietUntil/) + assert.match(src, /WorkspaceStateStream/) + assert.match(src, /!window\.__udsAuthWsAuthedOnce/) +}) + +test('workspace follow yields empty baseline for anonymous (Desktop must not die)', () => { + const src = readFileSync(join(root, 'lib/dsh-acl.js'), 'utf8') + assert.match(src, /emptyWorkspaceBaseline/) + assert.match(src, /holdUntilAbort/) + assert.match(src, /ended before its opening snapshot/) + assert.match(src, /未分组/) + // Must not early-return with zero frames for anonymous browsers. + assert.doesNotMatch(src, /if \(!empOf\(identity\)\) return\n\s*\/\/ R04/) +}) + +test('classifyAuthKind keeps local-admin-unlock as sealed_box', () => { + assert.equal( + classifyAuthKind({ authMode: 'local-admin-unlock', empNo: 'administrator' }), + 'sealed_box', + ) + assert.equal( + classifyAuthKind({ kind: 'sealed_box', empNo: 'administrator' }), + 'sealed_box', + ) + assert.equal( + classifyAuthKind({ kind: 'fallback', empNo: 'administrator' }), + 'fallback', + ) + assert.equal(ROLES.FALLBACK_ADMIN, 'fallback_admin') +}) diff --git a/uds-auth/test/session-persistence.test.js b/uds-auth/test/session-persistence.test.js new file mode 100644 index 00000000..a1a1ab33 --- /dev/null +++ b/uds-auth/test/session-persistence.test.js @@ -0,0 +1,129 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { MemoryStore } from '../lib/session/memory-store.js' +import { createSessionStore } from '../lib/session/factory.js' + +const quiet = { info() {}, warn() {} } + +async function freshDir() { + return mkdtemp(join(tmpdir(), 'uds-sessions-')) +} + +async function openStore(dir) { + const store = new MemoryStore({ file: join(dir, 'sessions.json'), dataDir: dir, logger: quiet }) + await store.init() + return store +} + +test('sessions survive a Host restart (emergency login stays logged in)', async () => { + const dir = await freshDir() + try { + const a = await openStore(dir) + const { bearer, sessionId } = await a.create({ + empNo: 'administrator', + authMode: 'local-admin-unlock', + displayName: 'Local Admin', + }, 1800) + await a.flush() + await a.clear() + + const b = await openStore(dir) + const row = b.getByBearerSync(bearer) + assert.equal(row?.sessionId, sessionId) + assert.equal(row?.empNo, 'administrator') + assert.equal(row?.kind, 'sealed_box') + assert.equal(row?.userData.displayName, 'Local Admin') + assert.equal((await b.getProfile('administrator'))?.displayName, 'Local Admin') + assert.equal(b.getByBearerSync('f'.repeat(64)), null) + await b.clear() + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('disk holds only the bearer hash; userData (UAC token) is encrypted', async () => { + const dir = await freshDir() + try { + const a = await openStore(dir) + const { bearer } = await a.create({ empNo: '1001', token: 'UAC-SECRET-TOKEN', email: 'x@example.test' }, 1800) + await a.flush() + const disk = await readFile(join(dir, 'sessions.json'), 'utf-8') + assert.equal(disk.includes(bearer), false, 'raw bearer never written') + assert.equal(disk.includes('UAC-SECRET-TOKEN'), false, 'token not in clear') + assert.equal(disk.includes('x@example.test'), false, 'profile fields not in clear') + await a.clear() + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('expired and tampered rows are dropped on load', async () => { + const dir = await freshDir() + try { + const a = await openStore(dir) + const live = await a.create({ empNo: 'u1' }, 1800) + const dead = await a.create({ empNo: 'u2' }, 1800) + const forged = await a.create({ empNo: 'u3' }, 1800) + await a.flush() + await a.clear() + + const file = join(dir, 'sessions.json') + const data = JSON.parse(await readFile(file, 'utf-8')) + for (const row of data.sessions) { + if (row.sessionId === dead.sessionId) row.expiresAt = Date.now() - 1 + if (row.sessionId === forged.sessionId) row.userData = row.userData.slice(0, -4) + 'AAAA' + } + await writeFile(file, JSON.stringify(data)) + + const b = await openStore(dir) + assert.ok(b.getByBearerSync(live.bearer)) + assert.equal(b.getByBearerSync(dead.bearer), null) + assert.equal(b.getByBearerSync(forged.bearer), null) + await b.clear() + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('revocation (logout) is persisted', async () => { + const dir = await freshDir() + try { + const a = await openStore(dir) + const { bearer } = await a.create({ empNo: 'administrator', authMode: 'fallback-password' }, 1800) + await a.revokeBearer(bearer) + await a.flush() + await a.clear() + const b = await openStore(dir) + assert.equal(b.getByBearerSync(bearer), null) + await b.clear() + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('factory wires persistence; no file configured keeps pure in-memory behavior', async () => { + const dir = await freshDir() + try { + const persisted = await createSessionStore({ storeType: 'memory' }, { + file: join(dir, 'sessions.json'), dataDir: dir, logger: quiet, + }) + const { bearer } = await persisted.create({ empNo: 'u1' }, 1800) + await persisted.flush() + await persisted.clear() + const again = await createSessionStore({ storeType: 'memory' }, { + file: join(dir, 'sessions.json'), dataDir: dir, logger: quiet, + }) + assert.ok(again.getByBearerSync(bearer)) + await again.clear() + + const plain = await createSessionStore({ storeType: 'memory' }) + await plain.create({ empNo: 'u1' }, 1800) + await plain.flush() + await plain.clear() + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) diff --git a/uds-auth/test/startup-and-session-lifetime.test.js b/uds-auth/test/startup-and-session-lifetime.test.js new file mode 100644 index 00000000..e8ad82e1 --- /dev/null +++ b/uds-auth/test/startup-and-session-lifetime.test.js @@ -0,0 +1,69 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { MemoryStore } from '../lib/session/memory-store.js' +import { SESSION_ABSOLUTE_MAX_SECONDS } from '../lib/session/store.js' +import { patchWebServerWithIdentity } from '../lib/dsh-acl.js' + +test('session absolute lifetime is 7 days, independent of the 30-min idle TTL', async () => { + assert.equal(SESSION_ABSOLUTE_MAX_SECONDS, 7 * 24 * 60 * 60) + const store = new MemoryStore() + const before = Date.now() + const { sessionId, record } = await store.create({ empNo: 'administrator', authMode: 'local-admin-unlock' }, 1800) + assert.ok(record.absoluteExpiresAt >= before + SESSION_ABSOLUTE_MAX_SECONDS * 1000) + assert.ok(record.expiresAt <= Date.now() + 1800 * 1000) + // Simulate 25 minutes passing, then activity: idle expiry must slide past the old 30-min mark. + const row = store._sessions.get(sessionId) + row.createdAt -= 25 * 60_000 + row.expiresAt -= 25 * 60_000 + row.absoluteExpiresAt -= 25 * 60_000 + const touched = await store.touch(sessionId, 1800) + // Old min(ttl, 7d) cap left only ~5 minutes here; now the full idle window is restored. + assert.ok(touched.expiresAt > Date.now() + 29 * 60_000, 'idle TTL slides past the old 30-min hard stop') +}) + +function fakeServer() { + return { + exact: new Map(), + prefixes: new Map(), + upgrades: new Map(), + register(route) { this.prefixes.set(route.path, route) }, + } +} + +function fakeRes() { + return { + status: null, + body: null, + writeHead(code) { this.status = code }, + end(body) { this.body = body }, + } +} + +test('/api/* requests wait for the gateway ACL during startup instead of 403', async () => { + const server = fakeServer() + let ready = false + let handled = 0 + server.register({ kind: 'prefix', path: '/api', handler: async () => { handled++ } }) + patchWebServerWithIdentity(server, async () => null, () => null, () => ( + ready ? { ok: true } : { ok: false, reason: 'gateway_acl_not_ready' } + )) + setTimeout(() => { ready = true }, 150) + const res = fakeRes() + const req = { url: '/api/settings/describe', method: 'POST', headers: { host: '127.0.0.1:1' } } + await server.prefixes.get('/api').handler(req, res) + assert.equal(handled, 1, 'request proceeded once the ACL was installed') + assert.equal(res.status, null) +}) + +test('other origin/host rejections still fail immediately', async () => { + const server = fakeServer() + let handled = 0 + server.register({ kind: 'prefix', path: '/api', handler: async () => { handled++ } }) + patchWebServerWithIdentity(server, async () => null, () => null, () => ({ ok: false, reason: 'host_not_trusted' })) + const res = fakeRes() + const started = Date.now() + await server.prefixes.get('/api').handler({ url: '/api/x', method: 'POST', headers: {} }, res) + assert.equal(res.status, 403) + assert.equal(handled, 0) + assert.ok(Date.now() - started < 1000) +}) diff --git a/uds-auth/test/ws-ticket-userinfo.test.js b/uds-auth/test/ws-ticket-userinfo.test.js new file mode 100644 index 00000000..bb3f8f47 --- /dev/null +++ b/uds-auth/test/ws-ticket-userinfo.test.js @@ -0,0 +1,86 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { SessionBridgeStore } from '../lib/session-bridge.js' +import { extractSessionBearer } from '../lib/session/request-auth.js' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') + +test('T01: WS ticket is single-use and extractSessionBearer consumes it', () => { + const bridge = new SessionBridgeStore({}) + const minted = bridge.mintWsTicket({ empNo: 'A001', bearer: 'b'.repeat(32), ttlMs: 60_000 }) + assert.ok(minted.ticket) + const req = { + method: 'GET', url: '/api/remote.mux?udsWsTicket=' + minted.ticket, + headers: { upgrade: 'websocket', connection: 'keep-alive, Upgrade' }, + } + const first = extractSessionBearer(req, { sessionBridge: bridge }) + assert.equal(first?.via, 'ws_ticket') + assert.equal(first?.bearer, 'b'.repeat(32)) + const replay = extractSessionBearer(req, { sessionBridge: bridge }) + assert.equal(replay, null) + assert.equal(bridge.consumeWsTicket(minted.ticket), null) +}) + +test('T01/0.3.9: Upgrade: websocket alone is enough (Connection may be absent on Desktop)', () => { + const bridge = new SessionBridgeStore({}) + const minted = bridge.mintWsTicket({ empNo: 'A001', bearer: 'b'.repeat(32), ttlMs: 60_000 }) + const req = { + method: 'GET', url: '/api/remote.mux?udsWsTicket=' + minted.ticket, + headers: { upgrade: 'websocket' }, + } + const first = extractSessionBearer(req, { sessionBridge: bridge }) + assert.equal(first?.via, 'ws_ticket') + assert.equal(first?.bearer, 'b'.repeat(32)) +}) + +test('D07: WS ticket is ignored on normal HTTP (no upgrade)', () => { + const bridge = new SessionBridgeStore({}) + const minted = bridge.mintWsTicket({ empNo: 'A001', bearer: 'b'.repeat(32), ttlMs: 60_000 }) + const req = { url: '/uds-auth/api/config?udsWsTicket=' + minted.ticket, headers: {} } + assert.equal(extractSessionBearer(req, { sessionBridge: bridge }), null) + assert.ok(bridge.consumeWsTicket(minted.ticket)) +}) + +test('T01: client prefers udsWsTicket; loopback bridge query is Desktop fallback', () => { + const src = readFileSync(join(root, 'lib/client.js'), 'utf8') + assert.match(src, /udsWsTicket/) + assert.match(src, /mintWsTicketSync/) + assert.match(src, /udsBridgeToken/) + assert.match(src, /0\.3\.9 Desktop path/) + const auth = readFileSync(join(root, 'lib/session/request-auth.js'), 'utf8') + assert.match(auth, /bridge_query/) + assert.match(auth, /requestIsLoopback/) +}) + +test('Desktop loopback WS upgrade accepts udsBridge query when ticket missing', () => { + const bridge = new SessionBridgeStore({}) + const token = 'c'.repeat(32) + bridge.mint({ empNo: 'administrator', kind: 'sealed_box', token }) + const req = { + method: 'GET', + url: '/api/remote.mux?udsBridgeEmpNo=administrator&udsBridgeToken=' + token, + headers: { upgrade: 'websocket' }, + socket: { remoteAddress: '127.0.0.1' }, + } + const got = extractSessionBearer(req, { sessionBridge: bridge }) + assert.equal(got?.via, 'bridge_query') + assert.equal(got?.bearer, token) + // Off-loopback must not accept bridge query (even with upgrade). + const remote = { + ...req, + socket: { remoteAddress: '8.8.8.8' }, + } + assert.equal(extractSessionBearer(remote, { sessionBridge: bridge }), null) +}) + +test('T03: user-info rejects query token; QR client adopts a local session without SSO cookies', () => { + const index = readFileSync(join(root, 'lib/index.js'), 'utf8') + const client = readFileSync(join(root, 'lib/client.js'), 'utf8') + assert.match(index, /token_in_query_forbidden/) + assert.ok(client.includes('adoptAuthSession(auth)')) + assert.ok(!client.includes("setCookie('PORTALSSOCookie', token")) + assert.doesNotMatch(client, /user-info\?empNo=/) +})