From 64761c830077da93a013864578c1ec0d3d010ae3 Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 8 Sep 2026 23:01:11 +0800 Subject: [PATCH] Add parallel skill credential path to uds-auth for agent/cron use. UI session behavior stays unchanged; login writes a separate skill cache, with optional retain-on-logout and loopback agent-credentials/outbound APIs plus helpers/docs. Co-authored-by: Cursor --- uds-auth/.gitignore | 1 + uds-auth/README.md | 7 + uds-auth/README.zh.md | 10 +- uds-auth/config.default.yaml | 9 + uds-auth/docs/skill-auth-standard.zh.md | 106 ++++++++++ uds-auth/lib/agent-auth.js | 189 ++++++++++++++++++ uds-auth/lib/api.js | 16 +- uds-auth/lib/config.js | 3 + uds-auth/lib/index.js | 137 ++++++++++++- uds-auth/lib/middleware/auth-middleware.js | 12 +- uds-auth/lib/skill-credentials.js | 132 ++++++++++++ uds-auth/skill-helpers/README.md | 4 + .../skill-helpers/python/uds_skill_auth.py | 182 +++++++++++++++++ 13 files changed, 798 insertions(+), 10 deletions(-) create mode 100644 uds-auth/docs/skill-auth-standard.zh.md create mode 100644 uds-auth/lib/agent-auth.js create mode 100644 uds-auth/lib/skill-credentials.js create mode 100644 uds-auth/skill-helpers/README.md create mode 100644 uds-auth/skill-helpers/python/uds_skill_auth.py diff --git a/uds-auth/.gitignore b/uds-auth/.gitignore index dec3e520..7a6d7df8 100644 --- a/uds-auth/.gitignore +++ b/uds-auth/.gitignore @@ -8,3 +8,4 @@ config.runtime.json Thumbs.db session-owners.json user-workspaces.json +skill-credentials.json diff --git a/uds-auth/README.md b/uds-auth/README.md index 6326e6dc..0b6cb7c8 100644 --- a/uds-auth/README.md +++ b/uds-auth/README.md @@ -17,8 +17,15 @@ uacBaseUrl: https://uac.zte.com.cn userSearchUrl: https://icenterapi.zte.com.cn/zte-km-icenter-addresearch/user/plain/docs/search loginSystemCode: '100000455558' originSystemCode: '' +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). Sample skill: `../skills/uds-icenter`. + +Loopback agent APIs: `GET|POST /uds-auth/agent-credentials`, `POST /uds-auth/outbound`. + ## Layout | Piece | Path | Role | diff --git a/uds-auth/README.zh.md b/uds-auth/README.zh.md index f8ee0acc..bd2fb90f 100644 --- a/uds-auth/README.zh.md +++ b/uds-auth/README.zh.md @@ -36,11 +36,19 @@ originSystemCode: '' - `GET /uds-auth/qr?data=` — 二维码 SVG - `POST /uds-auth/qr-proxy` — 代理 UAC 扫码校验 - `GET /uds-auth/api/me` — 当前用户 -- `POST /uds-auth/api/logout` — 登出 +- `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`(首次启动自动启用;可在设置中改密或关闭) - **ACL**:租户边界以工作区为准(可见工作区下的会话可访问);`session-owners.json` 仅记录工号供导出/分析,不是主鉴权键 +## Skill 认证 + +见 [docs/skill-auth-standard.zh.md](docs/skill-auth-standard.zh.md)。官方样板 skill:`../skills/uds-icenter`。 + +可配置:`retainSkillCredentialsOnLogout`(默认 true)、`skillCredentialTtlSeconds`、`outboundAllowedHosts`。 + ## License MIT diff --git a/uds-auth/config.default.yaml b/uds-auth/config.default.yaml index 601f8692..1e5442f1 100644 --- a/uds-auth/config.default.yaml +++ b/uds-auth/config.default.yaml @@ -16,4 +16,13 @@ originSystemCode: '' # Per-user workspaces under workspaceRoot/; empty => $DSH_HOME/user-workspaces workspaceRoot: "" +# Skill 凭证:UI 退出时是否保留(默认 true,供 cron/skill 继续用) +retainSkillCredentialsOnLogout: true + +# Skill 凭证 TTL(秒),默认 7 天 +skillCredentialTtlSeconds: 604800 + +# outbound 白名单 hosts(逗号分隔) +outboundAllowedHosts: icenterapi.zte.com.cn,icentermsg.dt.zte.com.cn + # 兜底管理员默认在首次启动启用:administrator / Admin@123(roles.json 可改密或关闭) diff --git a/uds-auth/docs/skill-auth-standard.zh.md b/uds-auth/docs/skill-auth-standard.zh.md new file mode 100644 index 00000000..b5f020a2 --- /dev/null +++ b/uds-auth/docs/skill-auth-standard.zh.md @@ -0,0 +1,106 @@ +# Skill 认证标准(uds-auth) + +面向 DeepSeekHarness:现场安装 **uds-auth** 后,第三方 / 自研 skill 按本标准取凭证或出站调用。官方样板:[skills/uds-icenter](../../skills/uds-icenter)。 + +## 现场形态 + +```text +安装 uds-auth 插件 → 用户扫码登录 → 加载 skill → 可用 +``` + +无需再配全局 `coclaw_token` / `AUTH_VALUE`。 + +## 双轨模型 + +| 轨道 | 用途 | 行为 | +|------|------|------| +| UI | Cookie + `sessionStore` + `/api/me` | **保持原样**;logout 仍清 cookie 并 `sessionStore.delete` | +| Skill | `skillCredentialCache`(`skill-credentials.json`) | 登录成功时写入/刷新;供 agent / cron | + +配置项: + +- `retainSkillCredentialsOnLogout`(默认 `true`):UI 退出时是否保留 skill token +- `skillCredentialTtlSeconds`(默认 `604800` = 7 天) +- `outboundAllowedHosts`:出站白名单(逗号分隔) + +管理员删除用户会同时清掉该用户的 skill 凭证。 + +## Agent 环境 + +Shell 已注入(非密钥): + +- `DSH_SESSION_ID` — 当前 agent 会话 +- `DSH_WEB_URL` — 可选,用于拼 Host 地址 + +Host 在 `session/created` 时会 `stampSessionOwner(sessionId, empNo)`。Cron 可按 `ownerEmpNo` 解析。 + +## 两种合法模式 + +### Mode Outbound(推荐新 skill) + +```http +POST /uds-auth/outbound +X-DSH-Session-Id: +{ "url": "https://icenterapi.zte.com.cn/...", "method": "POST", "headers": {...}, "body": {...} } +``` + +- 仅 **loopback** +- Host 注入 `X-Emp-No` / `X-Auth-Value` +- 剥离客户端自带鉴权头 +- host 必须在白名单内 + +Python: + +```python +from uds_skill_auth import request +out = request("POST", url, headers={...}, body={...}) +# out["statusCode"], out["json"], out["body"] +``` + +### Mode Creds(必须自管 HTTP 时) + +```http +POST /uds-auth/agent-credentials +X-DSH-Session-Id: +``` + +返回 `{ empNo, token }`。可设进程内 `EMP_NO` / `AUTH_VALUE` 别名,**禁止**写进 SKILL.md 教模型 `printenv`。 + +```python +from uds_skill_auth import resolve +creds = resolve() # empNo + token;默认写入 EMP_NO/AUTH_VALUE +``` + +Helpers 路径:`uds-auth/skill-helpers/python/`(或环境变量 `UDS_AUTH_HELPERS`)。 + +## 自研 Skill 清单 + +1. 建目录:`SKILL.md` + `scripts/` +2. 引用 helpers(见样板 `lib_uds.py`) +3. 默认走 `request()` outbound;发信等桌面端口可用 `resolve()` 贴头 +4. SKILL 只写「需已 UDS 登录」,不写 token 变量名 +5. JSON stdout;日志脱敏 + +## 禁止项 + +- 全局共享 token / 把 token 注入 `shellEnv` +- SKILL 教打印环境变量中的密钥 +- 无白名单的开放代理 +- 用 UI `sessionStore` 的 30min TTL 冒充 skill 长凭证 + +## 验收 + +- UI 登录/退出与改前一致 +- 默认配置下 UI 退出后 skill/cron 仍可用 +- `retainSkillCredentialsOnLogout=false` 时退出后取证失败 +- 两会话不串号;未登录错误可理解且无 token 泄露 + +## 样板走读 + +| 文件 | 作用 | +|------|------| +| `skills/uds-icenter/SKILL.md` | 给模型的用法 | +| `scripts/lib_uds.py` | 定位 helpers | +| `scripts/contacts.py` | 搜人/搜群/群成员(outbound) | +| `scripts/messaging.py` | 本机发信 + resolve 贴头 | +| `scripts/cli.py` | 统一入口 | diff --git a/uds-auth/lib/agent-auth.js b/uds-auth/lib/agent-auth.js new file mode 100644 index 00000000..2cba6054 --- /dev/null +++ b/uds-auth/lib/agent-auth.js @@ -0,0 +1,189 @@ +/** + * Loopback agent APIs: credentials + outbound proxy. + */ +import { directRequest } from './uds/user-search.js' +import { requestIsLoopback } from './skill-credentials.js' + +const DEFAULT_OUTBOUND_HOSTS = [ + 'icenterapi.zte.com.cn', + 'icentermsg.dt.zte.com.cn', +] + +function sendJSON(res, code, data) { + res.statusCode = code >= 200 && code < 300 ? 200 : code + res.setHeader('Content-Type', 'application/json; charset=utf-8') + res.setHeader('Cache-Control', 'no-store') + res.end(JSON.stringify(data)) +} + +function readSessionId(req, body) { + const h = req.headers || {} + return ( + h['x-dsh-session-id'] + || h['X-DSH-Session-Id'] + || body?.sessionId + || body?.dshSessionId + || null + ) +} + +async function readJsonBody(req) { + let raw = '' + for await (const chunk of req) raw += chunk + if (!raw) return {} + try { return JSON.parse(raw) } catch { return {} } +} + +/** + * @param {{ + * resolveCredentialsForSession: (sessionId: string) => Promise<{empNo:string,token:string}|null>, + * resolveCredentialsForEmpNo: (empNo: string) => Promise<{empNo:string,token:string}|null>, + * empNoHeader?: string, + * authValueHeader?: string, + * outboundHosts?: string[], + * }} deps + */ +export function createAgentAuthHandlers(deps) { + const empNoHeader = deps.empNoHeader || 'X-Emp-No' + const authValueHeader = deps.authValueHeader || 'X-Auth-Value' + + 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() + } + + function hostAllowed(hostname) { + const host = String(hostname || '').toLowerCase() + return allowedHosts().some((h) => host === h.toLowerCase() || host.endsWith('.' + h.toLowerCase())) + } + + async function resolveFromRequest(req, body = {}) { + const sessionId = readSessionId(req, body) + if (sessionId) { + const creds = await deps.resolveCredentialsForSession(String(sessionId)) + if (creds) return { creds, sessionId: String(sessionId) } + } + 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 } + } + return { creds: null, sessionId: sessionId ? String(sessionId) : null } + } + + async function handleAgentCredentials(req, res) { + if (!requestIsLoopback(req)) { + return sendJSON(res, 403, { error: 'agent-credentials is loopback-only' }) + } + const method = (req.method || 'GET').toUpperCase() + if (method !== 'GET' && method !== 'POST') { + return sendJSON(res, 405, { error: 'Method not allowed' }) + } + const body = method === 'POST' ? await readJsonBody(req) : {} + const { creds, sessionId } = await resolveFromRequest(req, body) + if (!creds) { + return sendJSON(res, 401, { + error: 'no_skill_credentials', + message: '请先完成 UDS 扫码登录', + sessionId: sessionId || null, + }) + } + return sendJSON(res, 200, { + empNo: creds.empNo, + token: creds.token, + updatedAt: creds.updatedAt || null, + }) + } + + async function handleOutbound(req, res) { + if (!requestIsLoopback(req)) { + return sendJSON(res, 403, { error: 'outbound is loopback-only' }) + } + const method = (req.method || 'POST').toUpperCase() + if (method !== 'POST') { + return sendJSON(res, 405, { error: 'Method not allowed' }) + } + const body = await readJsonBody(req) + const { creds } = await resolveFromRequest(req, body) + if (!creds) { + return sendJSON(res, 401, { + error: 'no_skill_credentials', + message: '请先完成 UDS 扫码登录', + }) + } + + const targetUrl = body.url + if (!targetUrl || typeof targetUrl !== 'string') { + return sendJSON(res, 400, { error: 'url required' }) + } + let parsed + try { + parsed = new URL(targetUrl) + } catch { + return sendJSON(res, 400, { error: 'invalid url' }) + } + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + return sendJSON(res, 400, { error: 'unsupported protocol' }) + } + if (!hostAllowed(parsed.hostname)) { + return sendJSON(res, 403, { error: 'host_not_allowed', 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] + } + } + headers[empNoHeader] = creds.empNo + headers[authValueHeader] = creds.token + if (!headers['Content-Type'] && !headers['content-type'] && body.body != null) { + headers['Content-Type'] = 'application/json;charset=UTF-8' + } + + let upstreamBody = body.body + if (upstreamBody != null && typeof upstreamBody !== 'string' && !Buffer.isBuffer(upstreamBody)) { + upstreamBody = JSON.stringify(upstreamBody) + } + + try { + const out = await directRequest(parsed, { + method: upstreamMethod, + headers, + body: upstreamBody, + timeoutMs: Number(body.timeoutMs) || 30000, + }) + 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), + body: out.body, + json: out.json, + })) + } catch (err) { + return sendJSON(res, 502, { error: 'upstream_failed', message: err.message || String(err) }) + } + } + + return { handleAgentCredentials, handleOutbound, DEFAULT_OUTBOUND_HOSTS } +} + +function sanitizeUpstreamHeaders(headers) { + if (!headers || typeof headers !== 'object') return {} + const out = {} + for (const [k, v] of Object.entries(headers)) { + const lower = k.toLowerCase() + if (lower === 'x-auth-value' || lower.includes('token') || lower.includes('cookie')) continue + out[k] = v + } + return out +} diff --git a/uds-auth/lib/api.js b/uds-auth/lib/api.js index 0437bb35..b18877ed 100644 --- a/uds-auth/lib/api.js +++ b/uds-auth/lib/api.js @@ -14,7 +14,13 @@ import { requirePermission } from './middleware/auth-middleware.js' * POST /fallback/password → super_admin only(设置兜底密码) * POST /fallback/clear → super_admin only(清除兜底密码) */ -export function createApiHandlers(config, sessionStore, rolesStore) { +/** + * @param {object} config + * @param {import('./session/store.js').SessionStore} sessionStore + * @param {*} rolesStore + * @param {{ skillCredentials?: { delete: (empNo: string) => void }, retainSkillCredentialsOnLogout?: () => boolean }} [extra] + */ +export function createApiHandlers(config, sessionStore, rolesStore, extra = {}) { async function sendRes(res, code, data) { res.statusCode = code >= 200 && code < 300 ? 200 : code res.setHeader('Content-Type', 'application/json; charset=utf-8') @@ -24,6 +30,13 @@ export function createApiHandlers(config, sessionStore, rolesStore) { async function logout(ctx) { const empNo = ctx.empNo if (empNo) await sessionStore.delete(empNo) + // Skill 凭证轨:默认保留;retainSkillCredentialsOnLogout=false 时清除 + const retain = extra.retainSkillCredentialsOnLogout + ? extra.retainSkillCredentialsOnLogout() !== false + : true + if (empNo && !retain) { + try { extra.skillCredentials?.delete(empNo) } catch { /* ignore */ } + } // Cookie clear attrs must match login (Secure + HttpOnly), or browsers keep the old cookie. const clear = [] for (const name of [ @@ -122,6 +135,7 @@ export function createApiHandlers(config, sessionStore, rolesStore) { try { await rolesStore.removeUser(empNo, ctx.role) await sessionStore.delete(empNo) + try { extra.skillCredentials?.delete(empNo) } catch { /* ignore */ } await sendRes(ctx.res, 200, { message: `${empNo} 已删除` }) } catch (err) { await sendRes(ctx.res, 400, { error: err.message }) diff --git a/uds-auth/lib/config.js b/uds-auth/lib/config.js index fa92bd6e..1550c114 100644 --- a/uds-auth/lib/config.js +++ b/uds-auth/lib/config.js @@ -21,6 +21,9 @@ const DEFAULT_CONFIG = { userSearchUrl: 'https://icenterapi.zte.com.cn/zte-km-icenter-addresearch/user/plain/docs/search', loginSystemCode: '100000455558', originSystemCode: '', + retainSkillCredentialsOnLogout: true, + skillCredentialTtlSeconds: 7 * 24 * 60 * 60, + outboundAllowedHosts: 'icenterapi.zte.com.cn,icentermsg.dt.zte.com.cn', } // 内部常量(不暴露) diff --git a/uds-auth/lib/index.js b/uds-auth/lib/index.js index 624a975e..572860ee 100644 --- a/uds-auth/lib/index.js +++ b/uds-auth/lib/index.js @@ -62,6 +62,12 @@ const CONFIG_DEFAULTS = { loginSystemCode: '100000455558', originSystemCode: '', workspaceRoot: '', + /** 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', } // 内部常量(不暴露给用户,UDS 固定协议) @@ -97,6 +103,9 @@ function loadConfigSchemaAndSettings(ctx) { loginSystemCode: z.string().default(CONFIG_DEFAULTS.loginSystemCode), originSystemCode: z.string().default(CONFIG_DEFAULTS.originSystemCode), workspaceRoot: z.string().default(CONFIG_DEFAULTS.workspaceRoot), + retainSkillCredentialsOnLogout: z.boolean().default(CONFIG_DEFAULTS.retainSkillCredentialsOnLogout), + skillCredentialTtlSeconds: z.number().default(CONFIG_DEFAULTS.skillCredentialTtlSeconds), + outboundAllowedHosts: z.string().default(CONFIG_DEFAULTS.outboundAllowedHosts), }) _DshSettings = require('@deepseek-ai/dsh-settings') ctx?.logger?.info?.('[uds-auth] schemastery + dsh-settings loaded') @@ -122,6 +131,8 @@ let _sessionAcl = null let _userWorkspaces = null let _pluginCtx = null let _logger = console +let _skillCredentials = null +let _agentAuthHandlers = null function buildVerifyUrl(uacBaseUrl, uacQrVerifyPath) { if (/^https?:\/\//.test(uacQrVerifyPath)) return uacQrVerifyPath @@ -312,8 +323,11 @@ async function handleConfigGet(req, res) { userSearchUrl: c.userSearchUrl, loginSystemCode: c.loginSystemCode, originSystemCode: c.originSystemCode, - workspaceRoot: c.workspaceRoot, - }, + workspaceRoot: c.workspaceRoot, + retainSkillCredentialsOnLogout: c.retainSkillCredentialsOnLogout !== false, + skillCredentialTtlSeconds: c.skillCredentialTtlSeconds, + outboundAllowedHosts: c.outboundAllowedHosts, + }, })) } @@ -494,10 +508,25 @@ const RUNTIME_CONFIG_FILE = resolve(__dirname, '..', 'config.runtime.json') async function saveRuntimeConfig(partial) { if (!_currentConfig) throw new Error('配置未初始化') // 只允许修改 4 个可配置字段 - const allowed = ['uacBaseUrl', 'userSearchUrl', 'loginSystemCode', 'originSystemCode', 'workspaceRoot'] + const allowed = [ + 'uacBaseUrl', + 'userSearchUrl', + 'loginSystemCode', + 'originSystemCode', + 'workspaceRoot', + 'retainSkillCredentialsOnLogout', + 'skillCredentialTtlSeconds', + 'outboundAllowedHosts', + ] for (const k of allowed) { if (partial[k] !== undefined) { - _currentConfig[k] = String(partial[k]) + if (k === 'retainSkillCredentialsOnLogout') { + _currentConfig[k] = partial[k] === true || partial[k] === 'true' + } else if (k === 'skillCredentialTtlSeconds') { + _currentConfig[k] = Number(partial[k]) || CONFIG_DEFAULTS.skillCredentialTtlSeconds + } else { + _currentConfig[k] = typeof partial[k] === 'string' ? partial[k] : String(partial[k]) + } } } // 写运行时配置文件(不覆盖原始 config.default.yaml) @@ -550,6 +579,26 @@ async function handleAllRoutes(req, res) { return await handleUserInfo(req, res) } + // Skill / agent:本机凭证与出站代理(loopback-only) + if (pathname === '/uds-auth/agent-credentials' && (method === 'GET' || method === 'POST')) { + if (!_agentAuthHandlers) { + res.statusCode = 503 + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ error: 'skill credentials not ready' })) + return + } + return await _agentAuthHandlers.handleAgentCredentials(req, res) + } + if (pathname === '/uds-auth/outbound' && method === 'POST') { + if (!_agentAuthHandlers) { + res.statusCode = 503 + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ error: 'outbound not ready' })) + return + } + return await _agentAuthHandlers.handleOutbound(req, res) + } + // 客户端配置(RPC config.get 在 web 端走 HTTP) if (pathname === '/uds-auth/config.get' && (method === 'GET' || method === 'POST')) { return await handleConfigGet(req, res) @@ -805,6 +854,8 @@ async function initServices(ctx, config) { const { SessionAclStore } = await import('./session-acl.js') const { UserWorkspaceStore } = await import('./workspace-provision.js') const { patchWebServerWithIdentity, resolveIdentityFromRequest, resolveIdentityFromRequestSync, installDshAcl } = await import('./dsh-acl.js') + const { SkillCredentialCache } = await import('./skill-credentials.js') + const { createAgentAuthHandlers } = await import('./agent-auth.js') _pluginCtx = ctx _currentConfig = config @@ -819,8 +870,31 @@ async function initServices(ctx, config) { } } catch { /* runtime config optional */ } + // Normalize skill-related config after merge + if (_currentConfig.retainSkillCredentialsOnLogout === undefined) { + _currentConfig.retainSkillCredentialsOnLogout = CONFIG_DEFAULTS.retainSkillCredentialsOnLogout + } else { + _currentConfig.retainSkillCredentialsOnLogout = + _currentConfig.retainSkillCredentialsOnLogout === true + || _currentConfig.retainSkillCredentialsOnLogout === 'true' + } + if (!_currentConfig.skillCredentialTtlSeconds) { + _currentConfig.skillCredentialTtlSeconds = CONFIG_DEFAULTS.skillCredentialTtlSeconds + } + if (!_currentConfig.outboundAllowedHosts) { + _currentConfig.outboundAllowedHosts = CONFIG_DEFAULTS.outboundAllowedHosts + } + _sessionStore = await createSessionStore(INTERNAL.session) + const ttlMs = Math.max(60, Number(_currentConfig.skillCredentialTtlSeconds) || CONFIG_DEFAULTS.skillCredentialTtlSeconds) * 1000 + _skillCredentials = new SkillCredentialCache({ + file: resolve(__dirname, '..', 'skill-credentials.json'), + ttlMs, + logger: ctx.logger || console, + }) + await _skillCredentials.init() + const rolesFile = resolve(__dirname, '..', 'roles.json') _rolesStore = new RolesStore({ rolesFile }) await _rolesStore.init() @@ -831,6 +905,10 @@ async function initServices(ctx, config) { _userWorkspaces = new UserWorkspaceStore({ mapFile: resolve(__dirname, '..', 'user-workspaces.json') }) await _userWorkspaces.init() + const rememberSkillCreds = (empNo, token) => { + try { _skillCredentials?.set(empNo, token) } catch { /* ignore */ } + } + _authMiddleware = createAuthMiddleware({ userSearchUrl: _currentConfig.userSearchUrl, udsAuth: { @@ -841,8 +919,48 @@ async function initServices(ctx, config) { authValueHeader: INTERNAL.authValueHeader, }, session: INTERNAL.session, - }, _sessionStore, _rolesStore) - _apiHandlers = createApiHandlers({ session: INTERNAL.session }, _sessionStore, _rolesStore) + }, _sessionStore, _rolesStore, { onSkillCredentials: rememberSkillCreds }) + + _apiHandlers = createApiHandlers( + { session: INTERNAL.session }, + _sessionStore, + _rolesStore, + { + skillCredentials: _skillCredentials, + retainSkillCredentialsOnLogout: () => _currentConfig?.retainSkillCredentialsOnLogout !== false, + }, + ) + + 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) + } + + async function resolveCredentialsForEmpNo(empNo) { + if (!empNo) return null + const row = _skillCredentials?.get(empNo) + if (!row?.token) return null + return { empNo: row.empNo, token: row.token, updatedAt: row.updatedAt } + } + + async function resolveCredentialsForSession(sessionId) { + if (!sessionId) return null + const empNo = _sessionAcl?.getOwner(sessionId) || null + if (!empNo) return null + return resolveCredentialsForEmpNo(empNo) + } + + _agentAuthHandlers = createAgentAuthHandlers({ + resolveCredentialsForSession, + resolveCredentialsForEmpNo, + empNoHeader: INTERNAL.empNoHeader, + authValueHeader: INTERNAL.authValueHeader, + outboundHosts: () => parseOutboundHosts(), + }) const identityDeps = { sessionStore: _sessionStore, @@ -938,10 +1056,15 @@ async function initServices(ctx, config) { getSessionOwner(sessionId) { return _sessionAcl?.getOwner(sessionId) || null }, + resolveCredentialsForSession, + resolveCredentialsForEmpNo, + rememberSkillCredentials(empNo, token) { + rememberSkillCreds(empNo, token) + }, } ctx.provide('udsAuth', udsAuth) - ctx.logger?.info?.('[uds-auth] Initialized (ACL + workspaces + udsAuth service)') + ctx.logger?.info?.('[uds-auth] Initialized (ACL + workspaces + skill credentials + udsAuth service)') } catch (err) { ctx.logger?.error?.('[uds-auth] Init failed: ' + (err.message || err)) } diff --git a/uds-auth/lib/middleware/auth-middleware.js b/uds-auth/lib/middleware/auth-middleware.js index 90aa85b0..e38cc33d 100644 --- a/uds-auth/lib/middleware/auth-middleware.js +++ b/uds-auth/lib/middleware/auth-middleware.js @@ -11,8 +11,13 @@ import { searchUserByEmpNoToken } from '../uds/user-search.js' * 出站请求绕过 HTTP(S)_PROXY。 * * 兜底登录:仅认可已由 /api/fallback/login 写好的 administrator 会话。 + * + * 可选 onSkillCredentials(empNo, token):UI 会话写入成功后并行写入 skill 凭证缓存。 */ -export function createAuthMiddleware(config, sessionStore, rolesStore) { +export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {}) { + const onSkillCredentials = typeof hooks.onSkillCredentials === 'function' + ? hooks.onSkillCredentials + : null const udsClient = new UdsClient(config.udsAuth) const validatorConfig = { ...config.udsAuth, @@ -107,6 +112,7 @@ export function createAuthMiddleware(config, sessionStore, rolesStore) { Math.floor(cookieMaxAge / 1000), userContext, ) + try { onSkillCredentials?.(profile.empNo, credentials.token) } catch { /* ignore */ } } else { // 查不到资料则作废 trust 会话,避免“假登录” await sessionStore.delete(extracted.empNo) @@ -127,6 +133,9 @@ export function createAuthMiddleware(config, sessionStore, rolesStore) { userContext, ) } + if (userContext.token) { + try { onSkillCredentials?.(extracted.empNo, userContext.token) } catch { /* ignore */ } + } const role = await resolveRole(extracted.empNo, extracted.kind) ctx.userContext = userContext ctx.empNo = extracted.empNo @@ -172,6 +181,7 @@ export function createAuthMiddleware(config, sessionStore, rolesStore) { Math.floor(cookieMaxAge / 1000), userContext, ) + try { onSkillCredentials?.(profile.empNo, credentials.token) } catch { /* ignore */ } const role = await resolveRole(profile.empNo, 'uds') ctx.userContext = userContext diff --git a/uds-auth/lib/skill-credentials.js b/uds-auth/lib/skill-credentials.js new file mode 100644 index 00000000..e4f7be5b --- /dev/null +++ b/uds-auth/lib/skill-credentials.js @@ -0,0 +1,132 @@ +/** + * Skill credential cache — separate from UI sessionStore. + * empNo → { token, updatedAt }; long TTL for cron/skills after UI logout. + */ +import { readFile, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { mkdir } from 'node:fs/promises' + +const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000 + +export class SkillCredentialCache { + /** + * @param {{ file?: string, ttlMs?: number, logger?: Console }} opts + */ + constructor(opts = {}) { + this._file = 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 + } + + async init() { + if (!this._file) return + try { + const raw = await readFile(this._file, 'utf-8') + const data = JSON.parse(raw) + const entries = data?.entries && typeof data.entries === 'object' ? data.entries : data + if (!entries || typeof entries !== 'object') return + const now = Date.now() + for (const [empNo, row] of Object.entries(entries)) { + if (!row?.token) continue + const expiresAt = Number(row.expiresAt) || (now + this._ttlMs) + if (expiresAt <= now) continue + this._map.set(String(empNo), { + token: String(row.token), + updatedAt: row.updatedAt || new Date().toISOString(), + expiresAt, + }) + } + } catch { + /* optional file */ + } + } + + /** + * @param {string} empNo + * @param {string} token + */ + set(empNo, token) { + if (!empNo || !token) return + if (String(empNo).startsWith('__')) return + if (empNo === 'administrator') return + const now = Date.now() + this._map.set(String(empNo), { + token: String(token), + updatedAt: new Date().toISOString(), + expiresAt: now + this._ttlMs, + }) + this._scheduleFlush() + } + + /** + * @param {string} empNo + * @returns {{ empNo: string, token: string, updatedAt: string } | null} + */ + get(empNo) { + if (!empNo) return null + const key = String(empNo) + const row = this._map.get(key) + if (!row) return null + if (row.expiresAt && Date.now() > row.expiresAt) { + this._map.delete(key) + this._scheduleFlush() + return null + } + return { empNo: key, token: row.token, updatedAt: row.updatedAt } + } + + /** + * @param {string} empNo + */ + delete(empNo) { + if (!empNo) return + if (this._map.delete(String(empNo))) this._scheduleFlush() + } + + _scheduleFlush() { + if (!this._file) return + this._dirty = true + if (this._flushTimer) return + this._flushTimer = setTimeout(() => { + this._flushTimer = null + void this._flush() + }, 250) + } + + async _flush() { + if (!this._file || !this._dirty) return + this._dirty = false + const entries = {} + for (const [empNo, row] of this._map.entries()) { + entries[empNo] = { + token: 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') + } catch (err) { + this._logger?.warn?.('[uds-auth] skillCredentialCache flush failed:', err.message) + } + } +} + +export function isLoopbackAddress(addr) { + if (!addr) return false + const a = String(addr).replace(/^::ffff:/i, '') + return a === '127.0.0.1' || a === '::1' || a === 'localhost' +} + +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 +} diff --git a/uds-auth/skill-helpers/README.md b/uds-auth/skill-helpers/README.md new file mode 100644 index 00000000..89c084c4 --- /dev/null +++ b/uds-auth/skill-helpers/README.md @@ -0,0 +1,4 @@ +# uds-auth skill helpers +# +# Python: add this directory to PYTHONPATH or sys.path, then: +# from uds_skill_auth import resolve, request, UdsAuthError diff --git a/uds-auth/skill-helpers/python/uds_skill_auth.py b/uds-auth/skill-helpers/python/uds_skill_auth.py new file mode 100644 index 00000000..d65b81f8 --- /dev/null +++ b/uds-auth/skill-helpers/python/uds_skill_auth.py @@ -0,0 +1,182 @@ +# -*- coding: utf-8 -*- +""" +Official uds-auth skill helpers (Python). + +Resolve current-user SSO credentials via loopback Host APIs, or call intranet +APIs through the outbound proxy (token never needed in skill code for outbound). + +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 +""" +from __future__ import annotations + +import json +import os +import ssl +import urllib.error +import urllib.request +from typing import Any, Dict, Optional, Tuple +from urllib.parse import urlparse + + +class UdsAuthError(RuntimeError): + def __init__(self, message: str, *, code: str = "uds_auth_error", status: int = 0): + super().__init__(message) + self.code = code + self.status = status + + +def _base_url() -> str: + explicit = (os.environ.get("UDS_AUTH_BASE") or "").strip().rstrip("/") + if explicit: + return explicit + web = (os.environ.get("DSH_WEB_URL") or "").strip().rstrip("/") + if web: + return web + "/uds-auth" + # Common local defaults — try DSH_WEB_URL first in production. + return "http://127.0.0.1:8787/uds-auth" + + +def _session_id() -> str: + return (os.environ.get("DSH_SESSION_ID") or "").strip() + + +def _opener_no_proxy(): + return urllib.request.build_opener(urllib.request.ProxyHandler({})) + + +def _http_json( + method: str, + url: str, + *, + headers: Optional[Dict[str, str]] = None, + body: Any = None, + timeout: float = 30.0, +) -> Tuple[int, Any, str]: + data = None + hdrs = dict(headers or {}) + if body is not None: + if isinstance(body, (dict, list)): + data = json.dumps(body, ensure_ascii=False).encode("utf-8") + hdrs.setdefault("Content-Type", "application/json;charset=UTF-8") + elif isinstance(body, str): + data = body.encode("utf-8") + elif isinstance(body, bytes): + data = body + else: + 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() + try: + # Prefer no-proxy opener for loopback Host + if "127.0.0.1" in url or "localhost" in url.lower(): + opener = _opener_no_proxy() + with opener.open(req, timeout=timeout) as resp: + raw = resp.read().decode("utf-8", errors="replace") + status = getattr(resp, "status", 200) or 200 + else: + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + raw = resp.read().decode("utf-8", errors="replace") + status = getattr(resp, "status", 200) or 200 + except urllib.error.HTTPError as e: + raw = (e.fp.read().decode("utf-8", errors="replace") if e.fp else "") + status = e.code + except urllib.error.URLError as e: + raise UdsAuthError(f"无法连接 uds-auth: {e.reason}", code="uds_unreachable") from e + + parsed: Any = None + if raw.strip(): + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + parsed = None + return status, parsed, raw + + +def resolve(*, apply_env_aliases: bool = True) -> Dict[str, str]: + """ + Fetch {empNo, token} for the current DSH session from Host. + Optionally set process-local EMP_NO / AUTH_VALUE (and coclaw_* aliases). + """ + sid = _session_id() + if not sid: + raise UdsAuthError("缺少 DSH_SESSION_ID,请在 Agent shell 中运行", code="no_session") + + url = _base_url() + "/agent-credentials" + status, parsed, raw = _http_json( + "POST", + url, + headers={"X-DSH-Session-Id": sid, "Accept": "application/json"}, + body={"sessionId": sid}, + 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 != 200 or not isinstance(parsed, dict): + raise UdsAuthError( + f"获取凭证失败 (HTTP {status})", + code="credentials_http", + status=status, + ) + emp_no = str(parsed.get("empNo") or "").strip() + token = str(parsed.get("token") or "").strip() + if not emp_no or not token: + raise UdsAuthError("凭证响应不完整", code="bad_credentials") + + if apply_env_aliases: + os.environ["EMP_NO"] = emp_no + os.environ["AUTH_VALUE"] = token + os.environ["coclaw_empno"] = emp_no + os.environ["coclaw_token"] = token + + return {"empNo": emp_no, "token": token, "updatedAt": str(parsed.get("updatedAt") or "")} + + +def request( + method: str, + url: str, + *, + headers: Optional[Dict[str, str]] = None, + body: Any = None, + timeout: float = 30.0, +) -> Dict[str, Any]: + """ + Call an intranet URL via Host outbound proxy (injects X-Emp-No / X-Auth-Value). + Returns {statusCode, headers, body, json}. + """ + sid = _session_id() + if not sid: + raise UdsAuthError("缺少 DSH_SESSION_ID,请在 Agent shell 中运行", code="no_session") + + host = urlparse(url).hostname or "" + payload = { + "sessionId": sid, + "method": method.upper(), + "url": url, + "headers": headers or {}, + "body": body, + "timeoutMs": int(timeout * 1000), + } + status, parsed, raw = _http_json( + "POST", + _base_url() + "/outbound", + headers={"X-DSH-Session-Id": sid, "Accept": "application/json"}, + body=payload, + timeout=timeout + 5.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") == "host_not_allowed": + raise UdsAuthError(f"主机不在 outbound 白名单: {host}", code="host_not_allowed", status=403) + if not isinstance(parsed, dict) or "statusCode" not in parsed: + raise UdsAuthError( + f"outbound 失败 (HTTP {status}): {raw[:200]}", + code="outbound_http", + status=status, + ) + return parsed