From c26dc68f7788b784b1e4e73729119c35b11cc81e Mon Sep 17 00:00:00 2001 From: oliver Date: Sat, 12 Sep 2026 22:16:21 +0800 Subject: [PATCH] Add decrypt-to-unlock local admin via sealed env box. Replace auto-grant-on-env with AES-GCM box + passphrase unlock, rebuild fallback sessions after restart, and document seal script usage. Co-authored-by: Cursor --- uds-auth/README.md | 8 + uds-auth/README.zh.md | 5 + uds-auth/lib/api.js | 3 +- uds-auth/lib/client.js | 85 ++++++- uds-auth/lib/i18n.js | 18 ++ uds-auth/lib/index.js | 125 ++++++++-- uds-auth/lib/local-admin.js | 251 +++++++++++++++++++++ uds-auth/lib/middleware/auth-middleware.js | 54 +++-- uds-auth/scripts/seal-local-admin.mjs | 25 ++ uds-auth/test/local-admin.test.js | 53 +++++ 10 files changed, 583 insertions(+), 44 deletions(-) create mode 100644 uds-auth/lib/local-admin.js create mode 100644 uds-auth/scripts/seal-local-admin.mjs create mode 100644 uds-auth/test/local-admin.test.js diff --git a/uds-auth/README.md b/uds-auth/README.md index 4c269ed8..807b52d2 100644 --- a/uds-auth/README.md +++ b/uds-auth/README.md @@ -28,6 +28,14 @@ Bundled skill: [skills/uds-skill-auth](skills/uds-skill-auth). Handoff notes: [d Loopback agent APIs: `GET|POST /uds-auth/agent-credentials`, `POST /uds-auth/outbound`. +### Local admin unlock (optional, decrypt-to-login) + +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 + +Env alone does **not** grant admin. Legacy `UDS_AUTH_LOCAL_ADMIN_KEY` is ignored. + ## Layout | Piece | Path | Role | diff --git a/uds-auth/README.zh.md b/uds-auth/README.zh.md index 98a64148..465df69a 100644 --- a/uds-auth/README.zh.md +++ b/uds-auth/README.zh.md @@ -41,6 +41,11 @@ originSystemCode: '' - `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**:`super_admin` / 兜底 `administrator` 可见全部会话(含 `@` 提及);`admin` / `user` 仅可见 **自己拥有的** 或 **自己工作区路径下的** 会话。侧栏、`session/search` 与 `@` 候选共用同一规则 ## Skill 认证(给他人改造 skill 时) diff --git a/uds-auth/lib/api.js b/uds-auth/lib/api.js index 758fe9f5..d7284a07 100644 --- a/uds-auth/lib/api.js +++ b/uds-auth/lib/api.js @@ -54,12 +54,13 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {}) for (const name of [ 'UDS_FALLBACK_USER', 'UDS_FALLBACK_UI', + 'UDS_LOCAL_ADMIN', 'PORTALSSOUser', 'PORTALSSOCookie', 'ZTEDPGSSOUser', 'ZTEDPGSSOCookie', ]) { - const httpOnly = name === 'UDS_FALLBACK_USER' + const httpOnly = name === 'UDS_FALLBACK_USER' || name === 'UDS_LOCAL_ADMIN' const base = httpOnly ? (name + '=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax') : (name + '=; Max-Age=0; Path=/; SameSite=Lax') diff --git a/uds-auth/lib/client.js b/uds-auth/lib/client.js index 7fae6aa6..e7f08856 100644 --- a/uds-auth/lib/client.js +++ b/uds-auth/lib/client.js @@ -72,6 +72,10 @@ window.__ModuleLoader__.load({ "ui.fallbackLink": "UAC 不可用?应急账号登录", "ui.fallbackLogin": "应急登录", "ui.fallbackDetail": "UAC / 扫码不可用时使用", + "ui.localKeyLink": "本机密钥解锁", + "ui.localKeyLogin": "本机密钥解锁", + "ui.localKeyDetail": "用密封盒口令解密后登录(仅设环境变量不会自动登录)", + "ui.localKey": "解密密钥", "ui.username": "用户名", "ui.password": "密码", "ui.login": "登录", @@ -109,6 +113,10 @@ window.__ModuleLoader__.load({ "err.last_super_admin_demote": "系统至少需要 1 个超级管理员,不能降级最后一个", "err.last_super_admin_delete": "系统至少需要 1 个超级管理员,不能删除最后一个", "err.password_too_short": "密码至少 6 位", + "err.local_admin_not_configured": "未配置本机管理员密封盒", + "err.key_required": "请输入解密密钥", + "err.decrypt_failed": "密钥无法解密,登录失败", + "err.rate_limited": "尝试过多,请稍后再试", "err.config_not_ready": "配置未初始化", "err.request_failed": "请求失败", "err.method_not_allowed": "方法不允许", @@ -142,7 +150,8 @@ window.__ModuleLoader__.load({ "ok.user_removed": "{empNo} 已删除", "ok.fallback_password_set": "应急管理员密码已设置", "ok.fallback_password_cleared": "应急管理员密码已清除", - "ok.fallback_login": "应急管理员登录成功" + "ok.fallback_login": "应急管理员登录成功", + "ok.local_admin_unlock": "本机密钥解锁成功" }, "en": { "role.super_admin": "Super admin", @@ -195,6 +204,10 @@ window.__ModuleLoader__.load({ "ui.fallbackLink": "UAC down? Emergency account", "ui.fallbackLogin": "Emergency login", "ui.fallbackDetail": "Use when UAC / QR is unavailable", + "ui.localKeyLink": "Unlock with local key", + "ui.localKeyLogin": "Local key unlock", + "ui.localKeyDetail": "Decrypt the sealed box with your passphrase (env alone does nothing)", + "ui.localKey": "Decryption key", "ui.username": "Username", "ui.password": "Password", "ui.login": "Sign in", @@ -232,6 +245,10 @@ window.__ModuleLoader__.load({ "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.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", + "err.rate_limited": "Too many attempts, try later", "err.config_not_ready": "Config not initialized", "err.request_failed": "Request failed", "err.method_not_allowed": "Method not allowed", @@ -265,7 +282,8 @@ window.__ModuleLoader__.load({ "ok.user_removed": "{empNo} removed", "ok.fallback_password_set": "Emergency admin password set", "ok.fallback_password_cleared": "Emergency admin password cleared", - "ok.fallback_login": "Emergency admin signed in" + "ok.fallback_login": "Emergency admin signed in", + "ok.local_admin_unlock": "Local admin unlocked" } } const UDS_HOST_ARIA = { @@ -462,7 +480,7 @@ window.__ModuleLoader__.load({ } function clearAuthCookies() { - const names = ['PORTALSSOUser', 'PORTALSSOCookie', 'ZTEDPGSSOUser', 'ZTEDPGSSOCookie', 'UDS_FALLBACK_USER', 'UDS_FALLBACK_UI'] + const names = ['PORTALSSOUser', 'PORTALSSOCookie', 'ZTEDPGSSOUser', 'ZTEDPGSSOCookie', 'UDS_FALLBACK_USER', 'UDS_FALLBACK_UI', 'UDS_LOCAL_ADMIN'] for (const key of names) { // Match both Secure and non-Secure variants; HttpOnly ones need server clear. document.cookie = encodeURIComponent(key) + '=; Max-Age=0; Path=/; SameSite=Lax' @@ -992,8 +1010,10 @@ function reloadAfterLogin() { // Default true (server enables fallback by default). If status fetch fails // while QR is also down, keep the emergency link visible so admins can still sign in. const [fallbackEnabled, setFallbackEnabled] = useState(true) + const [localKeyEnabled, setLocalKeyEnabled] = useState(false) const [fbUser, setFbUser] = useState('administrator') const [fbPass, setFbPass] = useState('') + const [localKey, setLocalKey] = useState('') const [fbBusy, setFbBusy] = useState(false) const [fbErr, setFbErr] = useState('') const qrRef = useRef({ key: null, value: null, timer: null, timeout: null, deadline: 0 }) @@ -1213,6 +1233,26 @@ function reloadAfterLogin() { } }, [fbUser, fbPass, refreshUser]) + const submitLocalKey = useCallback(async () => { + setFbBusy(true) + setFbErr('') + try { + await fetchJson('/uds-auth/api/local-admin/unlock', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: localKey }), + }) + setLocalKey('') + await refreshUser() + setOpen(false) + reconnectAfterLogin() + } catch (err) { + setFbErr(apiMessage(err) || t('err.decrypt_failed')) + } finally { + setFbBusy(false) + } + }, [localKey, refreshUser]) + useEffect(() => { // Restore session after login reload / refresh — cookie alone does not set the badge. // (Previously me ran only inside startQr, so a click on「未登录」was required.) @@ -1223,6 +1263,9 @@ function reloadAfterLogin() { fetchJson('/uds-auth/api/fallback/status') .then((st) => setFallbackEnabled(!!st.enabled)) .catch(() => { /* keep default true — do not hide emergency login */ }) + fetchJson('/uds-auth/api/local-admin/status') + .then((st) => setLocalKeyEnabled(!!st.enabled)) + .catch(() => { setLocalKeyEnabled(false) }) return () => { stopQr() } }, [refreshUser, stopQr]) @@ -1344,6 +1387,42 @@ function reloadAfterLogin() { className: 'uds-auth-btn-link', onClick: () => { stopQr(); setLoginMode('fallback'); setFbErr('') }, }, t('ui.fallbackLink')), + localKeyEnabled && h('button', { + type: 'button', + className: 'uds-auth-btn-link', + onClick: () => { stopQr(); setLoginMode('localKey'); setFbErr(''); setLocalKey('') }, + }, t('ui.localKeyLink')), + ) + : loginMode === 'localKey' + ? h(React.Fragment, null, + h('div', { className: 'uds-auth-info' }, + h('div', { className: 'uds-auth-info-name' }, t('ui.localKeyLogin')), + h('div', { className: 'uds-auth-info-detail' }, t('ui.localKeyDetail')), + ), + h('div', { className: 'uds-auth-fallback' }, + h('label', { htmlFor: 'uds-local-key' }, t('ui.localKey')), + h('input', { + id: 'uds-local-key', + type: 'password', + value: localKey, + onChange: (e) => setLocalKey(e.target.value), + autoComplete: 'current-password', + onKeyDown: (e) => { if (e.key === 'Enter') submitLocalKey() }, + }), + 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 || !localKey, + onClick: submitLocalKey, + }, fbBusy ? t('ui.loggingIn') : t('ui.login')), + ), + h('button', { + type: 'button', + className: 'uds-auth-btn-link', + onClick: () => setLoginMode('qr'), + }, t('ui.backToQr')), ) : h(React.Fragment, null, h('div', { className: 'uds-auth-info' }, diff --git a/uds-auth/lib/i18n.js b/uds-auth/lib/i18n.js index 2f8cadcc..cc0a6767 100644 --- a/uds-auth/lib/i18n.js +++ b/uds-auth/lib/i18n.js @@ -67,6 +67,10 @@ export const MESSAGES = { 'ui.fallbackLink': 'UAC 不可用?应急账号登录', 'ui.fallbackLogin': '应急登录', 'ui.fallbackDetail': 'UAC / 扫码不可用时使用', + 'ui.localKeyLink': '本机密钥解锁', + 'ui.localKeyLogin': '本机密钥解锁', + 'ui.localKeyDetail': '用密封盒口令解密后登录(仅设环境变量不会自动登录)', + 'ui.localKey': '解密密钥', 'ui.username': '用户名', 'ui.password': '密码', 'ui.login': '登录', @@ -136,6 +140,10 @@ export const MESSAGES = { 'err.upstream_failed': '上游请求失败', 'err.skill_credentials_not_ready': 'skill 凭证未就绪', 'err.outbound_not_ready': 'outbound 未就绪', + 'err.local_admin_not_configured': '未配置本机管理员密封盒', + 'err.key_required': '请输入解密密钥', + 'err.decrypt_failed': '密钥无法解密,登录失败', + 'err.rate_limited': '尝试过多,请稍后再试', // API success 'ok.logged_out': '已退出登录', @@ -146,6 +154,7 @@ export const MESSAGES = { 'ok.fallback_password_set': '应急管理员密码已设置', 'ok.fallback_password_cleared': '应急管理员密码已清除', 'ok.fallback_login': '应急管理员登录成功', + 'ok.local_admin_unlock': '本机密钥解锁成功', }, en: { 'role.super_admin': 'Super admin', @@ -200,6 +209,10 @@ export const MESSAGES = { 'ui.fallbackLink': 'UAC down? Emergency account', 'ui.fallbackLogin': 'Emergency login', 'ui.fallbackDetail': 'Use when UAC / QR is unavailable', + 'ui.localKeyLink': 'Unlock with local key', + 'ui.localKeyLogin': 'Local key unlock', + 'ui.localKeyDetail': 'Decrypt the sealed box with your passphrase (env alone does nothing)', + 'ui.localKey': 'Decryption key', 'ui.username': 'Username', 'ui.password': 'Password', 'ui.login': 'Sign in', @@ -266,6 +279,10 @@ export const MESSAGES = { 'err.upstream_failed': 'upstream failed', 'err.skill_credentials_not_ready': 'skill credentials not ready', 'err.outbound_not_ready': 'outbound not ready', + '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', + 'err.rate_limited': 'Too many attempts, try later', 'ok.logged_out': 'Signed out', 'ok.config_saved': 'Config saved', @@ -275,6 +292,7 @@ export const MESSAGES = { 'ok.fallback_password_set': 'Emergency admin password set', 'ok.fallback_password_cleared': 'Emergency admin password cleared', 'ok.fallback_login': 'Emergency admin signed in', + 'ok.local_admin_unlock': 'Local admin unlocked', }, } diff --git a/uds-auth/lib/index.js b/uds-auth/lib/index.js index 9d24f9a2..96eabe75 100644 --- a/uds-auth/lib/index.js +++ b/uds-auth/lib/index.js @@ -10,6 +10,16 @@ import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' import { createRequire } from 'node:module' import { createHash, randomBytes } from 'node:crypto' +import { + logLocalAdminStatus, + appendLocalAdminCookie, + isLocalAdminBoxConfigured, + readLocalAdminBox, + openLocalAdminBox, + allowUnlockAttempt, + buildLocalAdminUserContext, + LOCAL_ADMIN_BOX_ENV, +} from './local-admin.js' const __dirname = dirname(fileURLToPath(import.meta.url)) const require = createRequire(import.meta.url) @@ -454,6 +464,30 @@ function isHttpsRequest(req) { return xf === 'https' } +function setFallbackAdminCookies(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] + res.setHeader('Set-Cookie', list) +} + async function handleFallbackLogin(req, res) { let body = '' for await (const chunk of req) body += chunk @@ -488,29 +522,7 @@ async function handleFallbackLogin(req, res) { await _sessionStore.setex(empNo, Math.floor(INTERNAL.session.cookieMaxAge / 1000), userContext) await ensureUserWorkspace(empNo) - // 给浏览器设 cookie,让后续请求 auth-middleware 能识别 - const fbMaxAge = Math.floor(INTERNAL.session.cookieMaxAge / 1000) - res.setHeader('Set-Cookie', (() => { - 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') - } - return [partsUser.join('; '), partsUi.join('; ')] - })()) + setFallbackAdminCookies(req, res) sendOkMsg(res, req, 'fallback_login', null, userContext, { success: true, @@ -519,6 +531,62 @@ async function handleFallbackLogin(req, res) { }) } +/** + * Decrypt UDS_AUTH_LOCAL_ADMIN_BOX with operator passphrase → admin session. + * Env ciphertext alone never grants login. + */ +async function handleLocalAdminUnlock(req, res) { + if (!isLocalAdminBoxConfigured()) { + return sendErr(res, req, 404, 'local_admin_not_configured') + } + const ip = req.socket?.remoteAddress || 'unknown' + if (!allowUnlockAttempt(ip)) { + return sendErr(res, req, 429, 'rate_limited') + } + + let body = '' + for await (const chunk of req) body += chunk + let parsed + try { parsed = JSON.parse(body) } catch { parsed = {} } + const key = String(parsed.key || parsed.passphrase || parsed.password || '').trim() + if (!key) { + return sendErr(res, req, 400, 'key_required') + } + + const opened = openLocalAdminBox(readLocalAdminBox(), key) + if (!opened.ok) { + 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) + await ensureUserWorkspace(empNo) + + // Build local-admin session cookie into the same Set-Cookie batch. + const fakeRes = { + headersSent: false, + _cookies: [], + getHeader(name) { + if (String(name).toLowerCase() === 'set-cookie') return this._cookies + return undefined + }, + setHeader(name, value) { + if (String(name).toLowerCase() === 'set-cookie') { + this._cookies = Array.isArray(value) ? value : [value] + } + }, + } + appendLocalAdminCookie(fakeRes, req) + setFallbackAdminCookies(req, res, fakeRes._cookies) + + sendOkMsg(res, req, 'local_admin_unlock', null, userContext, { + success: true, + empNo, + role: 'fallback_admin', + }) +} + /** 运行时配置文件路径 — 持久化 _currentConfig 让重启后不丢 */ const RUNTIME_CONFIG_FILE = resolve(__dirname, '..', 'config.runtime.json') @@ -663,6 +731,16 @@ function handleRequest(req, res) { if (url === '/api/fallback/status' && method === 'GET') { return sendJSON(res, 200, { enabled: _rolesStore.isFallbackEnabled() }) } + if (url === '/api/local-admin/status' && method === 'GET') { + return sendJSON(res, 200, { + enabled: isLocalAdminBoxConfigured(), + env: LOCAL_ADMIN_BOX_ENV, + }) + } + if (url === '/api/local-admin/unlock' && method === 'POST') { + await handleLocalAdminUnlock(req, res) + return + } // 以下都需要登录态 if (!ctx2.empNo) { @@ -931,6 +1009,7 @@ async function initServices(ctx, config) { const rolesFile = resolve(__dirname, '..', 'roles.json') _rolesStore = new RolesStore({ rolesFile }) await _rolesStore.init() + logLocalAdminStatus(ctx.logger || console) _sessionAcl = new SessionAclStore({ ownersFile: resolve(__dirname, '..', 'session-owners.json') }) await _sessionAcl.init() diff --git a/uds-auth/lib/local-admin.js b/uds-auth/lib/local-admin.js new file mode 100644 index 00000000..7618cb74 --- /dev/null +++ b/uds-auth/lib/local-admin.js @@ -0,0 +1,251 @@ +/** + * Local admin via sealed box (decrypt-to-unlock). + * + * Env holds only ciphertext: + * 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" + */ +import { + createCipheriv, + createDecipheriv, + randomBytes, + scryptSync, + timingSafeEqual, + createHash, +} from 'node:crypto' +import { ROLES, computePermissions, DEFAULT_FALLBACK_USERNAME } from './roles.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_COOKIE = 'UDS_LOCAL_ADMIN' +export const LOCAL_ADMIN_COOKIE_MAX_AGE = 7 * 24 * 60 * 60 + +const MAGIC = 'uds-local-admin-v1' +const SCRYPT_N = 16384 +const SCRYPT_R = 8 +const SCRYPT_P = 1 +const KEY_LEN = 32 +const SALT_LEN = 16 +const IV_LEN = 12 + +const MIN_PASSPHRASE_LEN = 12 + +function b64urlEncode(buf) { + return Buffer.from(buf).toString('base64url') +} + +function b64urlDecode(str) { + return Buffer.from(String(str), 'base64url') +} + +function deriveKey(passphrase, salt) { + return scryptSync(passphrase, salt, KEY_LEN, { + N: SCRYPT_N, + r: SCRYPT_R, + p: SCRYPT_P, + maxmem: 64 * 1024 * 1024, + }) +} + +/** + * Seal plaintext capability with passphrase → env-safe box string. + * @param {string} passphrase + * @returns {string} + */ +export function sealLocalAdminBox(passphrase) { + const pw = String(passphrase || '') + if (pw.length < MIN_PASSPHRASE_LEN) { + throw new Error(`passphrase must be at least ${MIN_PASSPHRASE_LEN} characters`) + } + const salt = randomBytes(SALT_LEN) + const iv = randomBytes(IV_LEN) + const key = deriveKey(pw, salt) + const cipher = createCipheriv('aes-256-gcm', key, iv) + const plaintext = Buffer.from(`${MAGIC}|${DEFAULT_FALLBACK_USERNAME}`, 'utf8') + const enc = Buffer.concat([cipher.update(plaintext), cipher.final()]) + const tag = cipher.getAuthTag() + // version(1) | salt | iv | tag | ciphertext + const out = Buffer.concat([Buffer.from([1]), salt, iv, tag, enc]) + return b64urlEncode(out) +} + +/** + * @param {string} box + * @param {string} passphrase + * @returns {{ ok: true, empNo: string } | { ok: false, reason: string }} + */ +export function openLocalAdminBox(box, passphrase) { + const pw = String(passphrase || '') + if (!box || !pw) return { ok: false, reason: 'missing' } + let raw + try { + raw = b64urlDecode(box) + } catch { + return { ok: false, reason: 'bad_box' } + } + if (raw.length < 1 + SALT_LEN + IV_LEN + 16 + 1) { + return { ok: false, reason: 'bad_box' } + } + const version = raw[0] + if (version !== 1) return { ok: false, reason: 'bad_version' } + let o = 1 + const salt = raw.subarray(o, o + SALT_LEN); o += SALT_LEN + const iv = raw.subarray(o, o + IV_LEN); o += IV_LEN + const tag = raw.subarray(o, o + 16); o += 16 + const enc = raw.subarray(o) + try { + const key = deriveKey(pw, salt) + const decipher = createDecipheriv('aes-256-gcm', key, iv) + decipher.setAuthTag(tag) + const plain = Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8') + const [magic, empNo] = plain.split('|') + if (magic !== MAGIC || empNo !== DEFAULT_FALLBACK_USERNAME) { + return { ok: false, reason: 'bad_payload' } + } + return { ok: true, empNo: DEFAULT_FALLBACK_USERNAME } + } catch { + return { ok: false, reason: 'decrypt_failed' } + } +} + +export function readLocalAdminBox() { + return String(process.env[LOCAL_ADMIN_BOX_ENV] || '').trim() || null +} + +export function isLocalAdminBoxConfigured() { + const box = readLocalAdminBox() + if (!box) return false + try { + const raw = b64urlDecode(box) + return raw.length > 40 && raw[0] === 1 + } catch { + return false + } +} + +/** Session cookie token after successful unlock (HMAC of box+empNo, not the passphrase). */ +export function localAdminSessionToken(box = readLocalAdminBox()) { + if (!box) return null + return createHash('sha256').update(`sess:${box}`).digest('hex') +} + +function safeEqualStr(a, b) { + if (a == null || b == null) return false + const ha = createHash('sha256').update(String(a)).digest() + const hb = createHash('sha256').update(String(b)).digest() + return timingSafeEqual(ha, hb) +} + +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 +} + +/** + * After unlock, browser holds UDS_LOCAL_ADMIN session token (not the passphrase). + * This only proves a prior successful decrypt on this browser — does not skip decrypt on first login. + */ +export function requestHasLocalAdminSession(req) { + const expect = localAdminSessionToken() + if (!expect) return false + const got = parseCookie(req?.headers?.cookie || '', LOCAL_ADMIN_COOKIE) + return !!(got && safeEqualStr(got, expect)) +} + +export function buildLocalAdminUserContext() { + const now = new Date().toISOString() + return { + empNo: DEFAULT_FALLBACK_USERNAME, + userId: DEFAULT_FALLBACK_USERNAME, + username: 'Local Admin', + displayName: 'Local Admin', + isAuthenticated: true, + role: ROLES.FALLBACK_ADMIN, + authMode: 'local-admin-unlock', + authenticatedAt: now, + lastActiveAt: now, + sessionCreatedAt: now, + } +} + +export function buildLocalAdminIdentity(rolesStore) { + const empNo = DEFAULT_FALLBACK_USERNAME + const role = rolesStore?.getRole?.(empNo) || ROLES.FALLBACK_ADMIN + return { + empNo, + role, + permissions: computePermissions(role), + userContext: buildLocalAdminUserContext(), + kind: 'fallback', + } +} + +export function appendLocalAdminCookie(res, req) { + const token = localAdminSessionToken() + if (!token || !res || res.headersSent) return + const secure = !!(req?.socket?.encrypted) + || String(req?.headers?.['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase() === 'https' + const parts = [ + `${LOCAL_ADMIN_COOKIE}=${token}`, + `Max-Age=${LOCAL_ADMIN_COOKIE_MAX_AGE}`, + 'Path=/', + 'HttpOnly', + 'SameSite=Lax', + ] + if (secure) parts.push('Secure') + const prev = res.getHeader('Set-Cookie') + const next = parts.join('; ') + if (!prev) res.setHeader('Set-Cookie', next) + else if (Array.isArray(prev)) res.setHeader('Set-Cookie', [...prev, next]) + else res.setHeader('Set-Cookie', [String(prev), next]) +} + +export function logLocalAdminStatus(logger = console) { + if (!isLocalAdminBoxConfigured()) { + if (process.env[LOCAL_ADMIN_ENV]) { + const log = logger?.warn?.bind(logger) || console.warn + log( + `[uds-auth] ${LOCAL_ADMIN_ENV} is ignored.` + + ` Use ${LOCAL_ADMIN_BOX_ENV} (sealed ciphertext) + unlock with passphrase.`, + ) + } + return + } + const log = logger?.info?.bind(logger) || console.info + log( + `[uds-auth] local admin box configured (${LOCAL_ADMIN_BOX_ENV}).` + + ' Unlock requires decrypting with your passphrase — env alone does not grant login.', + ) +} + +/** Simple in-memory rate limit for unlock attempts. */ +const unlockBuckets = new Map() + +export function allowUnlockAttempt(ip) { + const now = Date.now() + let b = unlockBuckets.get(ip) + if (!b || now > b.resetAt) { + b = { count: 0, resetAt: now + 15 * 60 * 1000 } + unlockBuckets.set(ip, b) + } + b.count += 1 + return b.count <= 10 +} diff --git a/uds-auth/lib/middleware/auth-middleware.js b/uds-auth/lib/middleware/auth-middleware.js index e38cc33d..75976798 100644 --- a/uds-auth/lib/middleware/auth-middleware.js +++ b/uds-auth/lib/middleware/auth-middleware.js @@ -10,7 +10,8 @@ import { searchUserByEmpNoToken } from '../uds/user-search.js' * (带 X-Emp-No / X-Auth-Value)直连内网拉用户详情;成功才建会话。 * 出站请求绕过 HTTP(S)_PROXY。 * - * 兜底登录:仅认可已由 /api/fallback/login 写好的 administrator 会话。 + * 兜底登录:仅认可已由 /api/fallback/login 或 /api/local-admin/unlock 写好的 + * administrator 会话;重启后若仅有 UDS_FALLBACK_USER cookie,会重建内存会话。 * * 可选 onSkillCredentials(empNo, token):UI 会话写入成功后并行写入 skill 凭证缓存。 */ @@ -91,6 +92,25 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = { return false } + async function attachUser(ctx, empNo, userContext, kind, { persist = false } = {}) { + if (persist || slidingExpiration) { + userContext.lastActiveAt = new Date().toISOString() + await sessionStore.setex( + empNo, + Math.floor(cookieMaxAge / 1000), + userContext, + ) + } + 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 = computePermissions(role) + } + async function authMiddleware(ctx, next) { const { req } = ctx const cookieHeader = req.headers.cookie || '' @@ -125,26 +145,26 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = { } if (userContext) { - if (slidingExpiration) { - userContext.lastActiveAt = new Date().toISOString() - await sessionStore.setex( - extracted.empNo, - Math.floor(cookieMaxAge / 1000), - 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 - ctx.role = role - ctx.permissions = computePermissions(role) + await attachUser(ctx, extracted.empNo, userContext, extracted.kind) return next() } + // Fallback cookie 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: 'fallback-cookie', + authenticatedAt: new Date().toISOString(), + lastActiveAt: new Date().toISOString(), + sessionCreatedAt: new Date().toISOString(), + } + await attachUser(ctx, empNo, userContext, 'fallback', { persist: true }) return next() } diff --git a/uds-auth/scripts/seal-local-admin.mjs b/uds-auth/scripts/seal-local-admin.mjs new file mode 100644 index 00000000..fd54338d --- /dev/null +++ b/uds-auth/scripts/seal-local-admin.mjs @@ -0,0 +1,25 @@ +#!/usr/bin/env node +/** + * Generate UDS_AUTH_LOCAL_ADMIN_BOX for local decrypt-to-unlock admin. + * + * Usage: + * node scripts/seal-local-admin.mjs "your-passphrase-here" + * + * Then set the printed env on the Harness process (keep the passphrase offline). + */ +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 ""') + process.exit(1) +} + +try { + const box = sealLocalAdminBox(passphrase) + console.log(`# Keep the passphrase secret. Only the box goes into the process env.`) + console.log(`${LOCAL_ADMIN_BOX_ENV}=${box}`) +} catch (err) { + console.error(err.message || err) + process.exit(1) +} diff --git a/uds-auth/test/local-admin.test.js b/uds-auth/test/local-admin.test.js new file mode 100644 index 00000000..03ae1d08 --- /dev/null +++ b/uds-auth/test/local-admin.test.js @@ -0,0 +1,53 @@ +import { describe, it, after } from 'node:test' +import assert from 'node:assert/strict' +import { + LOCAL_ADMIN_BOX_ENV, + LOCAL_ADMIN_ENV, + sealLocalAdminBox, + openLocalAdminBox, + isLocalAdminBoxConfigured, + readLocalAdminBox, +} from '../lib/local-admin.js' + +describe('local-admin sealed box', () => { + const prevBox = process.env[LOCAL_ADMIN_BOX_ENV] + const prevKey = process.env[LOCAL_ADMIN_ENV] + + after(() => { + if (prevBox === undefined) delete process.env[LOCAL_ADMIN_BOX_ENV] + else process.env[LOCAL_ADMIN_BOX_ENV] = prevBox + if (prevKey === undefined) delete process.env[LOCAL_ADMIN_ENV] + else process.env[LOCAL_ADMIN_ENV] = prevKey + }) + + it('seal + open with correct passphrase', () => { + const passphrase = 'my-local-secret-key' + const box = sealLocalAdminBox(passphrase) + assert.ok(box.length > 40) + const ok = openLocalAdminBox(box, passphrase) + assert.equal(ok.ok, true) + assert.equal(ok.empNo, 'administrator') + }) + + it('wrong passphrase fails decrypt', () => { + const box = sealLocalAdminBox('correct-passphrase-xx') + const bad = openLocalAdminBox(box, 'wrong-passphrase-yyy') + assert.equal(bad.ok, false) + assert.equal(bad.reason, 'decrypt_failed') + }) + + it('env box alone does not imply auto-login; only configures unlock', () => { + delete process.env[LOCAL_ADMIN_BOX_ENV] + assert.equal(isLocalAdminBoxConfigured(), false) + + const box = sealLocalAdminBox('another-strong-key') + process.env[LOCAL_ADMIN_BOX_ENV] = box + assert.equal(isLocalAdminBoxConfigured(), true) + assert.equal(readLocalAdminBox(), box) + + // Old KEY env must not unlock without decrypt + process.env[LOCAL_ADMIN_ENV] = 'aaaaaaaaaaaaaaaa' + const stillNeedDecrypt = openLocalAdminBox(box, process.env[LOCAL_ADMIN_ENV]) + assert.equal(stillNeedDecrypt.ok, false) + }) +})