mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-17 13:13:18 +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
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue