mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 00:40:45 +08:00
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 <cursoragent@cursor.com>
This commit is contained in:
parent
922abaa89e
commit
c26dc68f77
10 changed files with 583 additions and 44 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 时)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
251
uds-auth/lib/local-admin.js
Normal file
251
uds-auth/lib/local-admin.js
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
/**
|
||||
* Local admin via sealed box (decrypt-to-unlock).
|
||||
*
|
||||
* Env holds only ciphertext:
|
||||
* UDS_AUTH_LOCAL_ADMIN_BOX=<base64url sealed blob>
|
||||
*
|
||||
* 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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
25
uds-auth/scripts/seal-local-admin.mjs
Normal file
25
uds-auth/scripts/seal-local-admin.mjs
Normal file
|
|
@ -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 "<passphrase>"')
|
||||
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)
|
||||
}
|
||||
53
uds-auth/test/local-admin.test.js
Normal file
53
uds-auth/test/local-admin.test.js
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue