mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-17 03:13:33 +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`.
|
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
|
## Layout
|
||||||
|
|
||||||
| Piece | Path | Role |
|
| Piece | Path | Role |
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,11 @@ originSystemCode: ''
|
||||||
- `POST /uds-auth/outbound` — **loopback**:白名单出站并注入鉴权头
|
- `POST /uds-auth/outbound` — **loopback**:白名单出站并注入鉴权头
|
||||||
- 用户管理 / 兜底管理员:见 `/uds-auth/api/users*`、`/uds-auth/api/fallback/*`
|
- 用户管理 / 兜底管理员:见 `/uds-auth/api/users*`、`/uds-auth/api/fallback/*`
|
||||||
- **默认兜底账号**(扫码不可用时):用户名 `administrator`,密码 `Admin@123`(首次启动自动启用;可在设置中改密或关闭)
|
- **默认兜底账号**(扫码不可用时):用户名 `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` 与 `@` 候选共用同一规则
|
- **ACL**:`super_admin` / 兜底 `administrator` 可见全部会话(含 `@` 提及);`admin` / `user` 仅可见 **自己拥有的** 或 **自己工作区路径下的** 会话。侧栏、`session/search` 与 `@` 候选共用同一规则
|
||||||
|
|
||||||
## Skill 认证(给他人改造 skill 时)
|
## Skill 认证(给他人改造 skill 时)
|
||||||
|
|
|
||||||
|
|
@ -54,12 +54,13 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
|
||||||
for (const name of [
|
for (const name of [
|
||||||
'UDS_FALLBACK_USER',
|
'UDS_FALLBACK_USER',
|
||||||
'UDS_FALLBACK_UI',
|
'UDS_FALLBACK_UI',
|
||||||
|
'UDS_LOCAL_ADMIN',
|
||||||
'PORTALSSOUser',
|
'PORTALSSOUser',
|
||||||
'PORTALSSOCookie',
|
'PORTALSSOCookie',
|
||||||
'ZTEDPGSSOUser',
|
'ZTEDPGSSOUser',
|
||||||
'ZTEDPGSSOCookie',
|
'ZTEDPGSSOCookie',
|
||||||
]) {
|
]) {
|
||||||
const httpOnly = name === 'UDS_FALLBACK_USER'
|
const httpOnly = name === 'UDS_FALLBACK_USER' || name === 'UDS_LOCAL_ADMIN'
|
||||||
const base = httpOnly
|
const base = httpOnly
|
||||||
? (name + '=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax')
|
? (name + '=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax')
|
||||||
: (name + '=; Max-Age=0; Path=/; SameSite=Lax')
|
: (name + '=; Max-Age=0; Path=/; SameSite=Lax')
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,10 @@ window.__ModuleLoader__.load({
|
||||||
"ui.fallbackLink": "UAC 不可用?应急账号登录",
|
"ui.fallbackLink": "UAC 不可用?应急账号登录",
|
||||||
"ui.fallbackLogin": "应急登录",
|
"ui.fallbackLogin": "应急登录",
|
||||||
"ui.fallbackDetail": "UAC / 扫码不可用时使用",
|
"ui.fallbackDetail": "UAC / 扫码不可用时使用",
|
||||||
|
"ui.localKeyLink": "本机密钥解锁",
|
||||||
|
"ui.localKeyLogin": "本机密钥解锁",
|
||||||
|
"ui.localKeyDetail": "用密封盒口令解密后登录(仅设环境变量不会自动登录)",
|
||||||
|
"ui.localKey": "解密密钥",
|
||||||
"ui.username": "用户名",
|
"ui.username": "用户名",
|
||||||
"ui.password": "密码",
|
"ui.password": "密码",
|
||||||
"ui.login": "登录",
|
"ui.login": "登录",
|
||||||
|
|
@ -109,6 +113,10 @@ window.__ModuleLoader__.load({
|
||||||
"err.last_super_admin_demote": "系统至少需要 1 个超级管理员,不能降级最后一个",
|
"err.last_super_admin_demote": "系统至少需要 1 个超级管理员,不能降级最后一个",
|
||||||
"err.last_super_admin_delete": "系统至少需要 1 个超级管理员,不能删除最后一个",
|
"err.last_super_admin_delete": "系统至少需要 1 个超级管理员,不能删除最后一个",
|
||||||
"err.password_too_short": "密码至少 6 位",
|
"err.password_too_short": "密码至少 6 位",
|
||||||
|
"err.local_admin_not_configured": "未配置本机管理员密封盒",
|
||||||
|
"err.key_required": "请输入解密密钥",
|
||||||
|
"err.decrypt_failed": "密钥无法解密,登录失败",
|
||||||
|
"err.rate_limited": "尝试过多,请稍后再试",
|
||||||
"err.config_not_ready": "配置未初始化",
|
"err.config_not_ready": "配置未初始化",
|
||||||
"err.request_failed": "请求失败",
|
"err.request_failed": "请求失败",
|
||||||
"err.method_not_allowed": "方法不允许",
|
"err.method_not_allowed": "方法不允许",
|
||||||
|
|
@ -142,7 +150,8 @@ window.__ModuleLoader__.load({
|
||||||
"ok.user_removed": "{empNo} 已删除",
|
"ok.user_removed": "{empNo} 已删除",
|
||||||
"ok.fallback_password_set": "应急管理员密码已设置",
|
"ok.fallback_password_set": "应急管理员密码已设置",
|
||||||
"ok.fallback_password_cleared": "应急管理员密码已清除",
|
"ok.fallback_password_cleared": "应急管理员密码已清除",
|
||||||
"ok.fallback_login": "应急管理员登录成功"
|
"ok.fallback_login": "应急管理员登录成功",
|
||||||
|
"ok.local_admin_unlock": "本机密钥解锁成功"
|
||||||
},
|
},
|
||||||
"en": {
|
"en": {
|
||||||
"role.super_admin": "Super admin",
|
"role.super_admin": "Super admin",
|
||||||
|
|
@ -195,6 +204,10 @@ window.__ModuleLoader__.load({
|
||||||
"ui.fallbackLink": "UAC down? Emergency account",
|
"ui.fallbackLink": "UAC down? Emergency account",
|
||||||
"ui.fallbackLogin": "Emergency login",
|
"ui.fallbackLogin": "Emergency login",
|
||||||
"ui.fallbackDetail": "Use when UAC / QR is unavailable",
|
"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.username": "Username",
|
||||||
"ui.password": "Password",
|
"ui.password": "Password",
|
||||||
"ui.login": "Sign in",
|
"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_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.last_super_admin_delete": "At least one super admin is required; cannot delete the last one",
|
||||||
"err.password_too_short": "Password must be at least 6 characters",
|
"err.password_too_short": "Password must be at least 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.config_not_ready": "Config not initialized",
|
||||||
"err.request_failed": "Request failed",
|
"err.request_failed": "Request failed",
|
||||||
"err.method_not_allowed": "Method not allowed",
|
"err.method_not_allowed": "Method not allowed",
|
||||||
|
|
@ -265,7 +282,8 @@ window.__ModuleLoader__.load({
|
||||||
"ok.user_removed": "{empNo} removed",
|
"ok.user_removed": "{empNo} removed",
|
||||||
"ok.fallback_password_set": "Emergency admin password set",
|
"ok.fallback_password_set": "Emergency admin password set",
|
||||||
"ok.fallback_password_cleared": "Emergency admin password cleared",
|
"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 = {
|
const UDS_HOST_ARIA = {
|
||||||
|
|
@ -462,7 +480,7 @@ window.__ModuleLoader__.load({
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearAuthCookies() {
|
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) {
|
for (const key of names) {
|
||||||
// Match both Secure and non-Secure variants; HttpOnly ones need server clear.
|
// Match both Secure and non-Secure variants; HttpOnly ones need server clear.
|
||||||
document.cookie = encodeURIComponent(key) + '=; Max-Age=0; Path=/; SameSite=Lax'
|
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
|
// 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.
|
// while QR is also down, keep the emergency link visible so admins can still sign in.
|
||||||
const [fallbackEnabled, setFallbackEnabled] = useState(true)
|
const [fallbackEnabled, setFallbackEnabled] = useState(true)
|
||||||
|
const [localKeyEnabled, setLocalKeyEnabled] = useState(false)
|
||||||
const [fbUser, setFbUser] = useState('administrator')
|
const [fbUser, setFbUser] = useState('administrator')
|
||||||
const [fbPass, setFbPass] = useState('')
|
const [fbPass, setFbPass] = useState('')
|
||||||
|
const [localKey, setLocalKey] = useState('')
|
||||||
const [fbBusy, setFbBusy] = useState(false)
|
const [fbBusy, setFbBusy] = useState(false)
|
||||||
const [fbErr, setFbErr] = useState('')
|
const [fbErr, setFbErr] = useState('')
|
||||||
const qrRef = useRef({ key: null, value: null, timer: null, timeout: null, deadline: 0 })
|
const qrRef = useRef({ key: null, value: null, timer: null, timeout: null, deadline: 0 })
|
||||||
|
|
@ -1213,6 +1233,26 @@ function reloadAfterLogin() {
|
||||||
}
|
}
|
||||||
}, [fbUser, fbPass, refreshUser])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
// Restore session after login reload / refresh — cookie alone does not set the badge.
|
// 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.)
|
// (Previously me ran only inside startQr, so a click on「未登录」was required.)
|
||||||
|
|
@ -1223,6 +1263,9 @@ function reloadAfterLogin() {
|
||||||
fetchJson('/uds-auth/api/fallback/status')
|
fetchJson('/uds-auth/api/fallback/status')
|
||||||
.then((st) => setFallbackEnabled(!!st.enabled))
|
.then((st) => setFallbackEnabled(!!st.enabled))
|
||||||
.catch(() => { /* keep default true — do not hide emergency login */ })
|
.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() }
|
return () => { stopQr() }
|
||||||
}, [refreshUser, stopQr])
|
}, [refreshUser, stopQr])
|
||||||
|
|
||||||
|
|
@ -1344,6 +1387,42 @@ function reloadAfterLogin() {
|
||||||
className: 'uds-auth-btn-link',
|
className: 'uds-auth-btn-link',
|
||||||
onClick: () => { stopQr(); setLoginMode('fallback'); setFbErr('') },
|
onClick: () => { stopQr(); setLoginMode('fallback'); setFbErr('') },
|
||||||
}, t('ui.fallbackLink')),
|
}, 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(React.Fragment, null,
|
||||||
h('div', { className: 'uds-auth-info' },
|
h('div', { className: 'uds-auth-info' },
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,10 @@ export const MESSAGES = {
|
||||||
'ui.fallbackLink': 'UAC 不可用?应急账号登录',
|
'ui.fallbackLink': 'UAC 不可用?应急账号登录',
|
||||||
'ui.fallbackLogin': '应急登录',
|
'ui.fallbackLogin': '应急登录',
|
||||||
'ui.fallbackDetail': 'UAC / 扫码不可用时使用',
|
'ui.fallbackDetail': 'UAC / 扫码不可用时使用',
|
||||||
|
'ui.localKeyLink': '本机密钥解锁',
|
||||||
|
'ui.localKeyLogin': '本机密钥解锁',
|
||||||
|
'ui.localKeyDetail': '用密封盒口令解密后登录(仅设环境变量不会自动登录)',
|
||||||
|
'ui.localKey': '解密密钥',
|
||||||
'ui.username': '用户名',
|
'ui.username': '用户名',
|
||||||
'ui.password': '密码',
|
'ui.password': '密码',
|
||||||
'ui.login': '登录',
|
'ui.login': '登录',
|
||||||
|
|
@ -136,6 +140,10 @@ export const MESSAGES = {
|
||||||
'err.upstream_failed': '上游请求失败',
|
'err.upstream_failed': '上游请求失败',
|
||||||
'err.skill_credentials_not_ready': 'skill 凭证未就绪',
|
'err.skill_credentials_not_ready': 'skill 凭证未就绪',
|
||||||
'err.outbound_not_ready': 'outbound 未就绪',
|
'err.outbound_not_ready': 'outbound 未就绪',
|
||||||
|
'err.local_admin_not_configured': '未配置本机管理员密封盒',
|
||||||
|
'err.key_required': '请输入解密密钥',
|
||||||
|
'err.decrypt_failed': '密钥无法解密,登录失败',
|
||||||
|
'err.rate_limited': '尝试过多,请稍后再试',
|
||||||
|
|
||||||
// API success
|
// API success
|
||||||
'ok.logged_out': '已退出登录',
|
'ok.logged_out': '已退出登录',
|
||||||
|
|
@ -146,6 +154,7 @@ export const MESSAGES = {
|
||||||
'ok.fallback_password_set': '应急管理员密码已设置',
|
'ok.fallback_password_set': '应急管理员密码已设置',
|
||||||
'ok.fallback_password_cleared': '应急管理员密码已清除',
|
'ok.fallback_password_cleared': '应急管理员密码已清除',
|
||||||
'ok.fallback_login': '应急管理员登录成功',
|
'ok.fallback_login': '应急管理员登录成功',
|
||||||
|
'ok.local_admin_unlock': '本机密钥解锁成功',
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
'role.super_admin': 'Super admin',
|
'role.super_admin': 'Super admin',
|
||||||
|
|
@ -200,6 +209,10 @@ export const MESSAGES = {
|
||||||
'ui.fallbackLink': 'UAC down? Emergency account',
|
'ui.fallbackLink': 'UAC down? Emergency account',
|
||||||
'ui.fallbackLogin': 'Emergency login',
|
'ui.fallbackLogin': 'Emergency login',
|
||||||
'ui.fallbackDetail': 'Use when UAC / QR is unavailable',
|
'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.username': 'Username',
|
||||||
'ui.password': 'Password',
|
'ui.password': 'Password',
|
||||||
'ui.login': 'Sign in',
|
'ui.login': 'Sign in',
|
||||||
|
|
@ -266,6 +279,10 @@ export const MESSAGES = {
|
||||||
'err.upstream_failed': 'upstream failed',
|
'err.upstream_failed': 'upstream failed',
|
||||||
'err.skill_credentials_not_ready': 'skill credentials not ready',
|
'err.skill_credentials_not_ready': 'skill credentials not ready',
|
||||||
'err.outbound_not_ready': 'outbound 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.logged_out': 'Signed out',
|
||||||
'ok.config_saved': 'Config saved',
|
'ok.config_saved': 'Config saved',
|
||||||
|
|
@ -275,6 +292,7 @@ export const MESSAGES = {
|
||||||
'ok.fallback_password_set': 'Emergency admin password set',
|
'ok.fallback_password_set': 'Emergency admin password set',
|
||||||
'ok.fallback_password_cleared': 'Emergency admin password cleared',
|
'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',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,16 @@ import { resolve, dirname } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { createRequire } from 'node:module'
|
import { createRequire } from 'node:module'
|
||||||
import { createHash, randomBytes } from 'node:crypto'
|
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 __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
|
|
@ -454,6 +464,30 @@ function isHttpsRequest(req) {
|
||||||
return xf === 'https'
|
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) {
|
async function handleFallbackLogin(req, res) {
|
||||||
let body = ''
|
let body = ''
|
||||||
for await (const chunk of req) body += chunk
|
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 _sessionStore.setex(empNo, Math.floor(INTERNAL.session.cookieMaxAge / 1000), userContext)
|
||||||
await ensureUserWorkspace(empNo)
|
await ensureUserWorkspace(empNo)
|
||||||
|
|
||||||
// 给浏览器设 cookie,让后续请求 auth-middleware 能识别
|
setFallbackAdminCookies(req, res)
|
||||||
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('; ')]
|
|
||||||
})())
|
|
||||||
|
|
||||||
sendOkMsg(res, req, 'fallback_login', null, userContext, {
|
sendOkMsg(res, req, 'fallback_login', null, userContext, {
|
||||||
success: true,
|
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 让重启后不丢 */
|
/** 运行时配置文件路径 — 持久化 _currentConfig 让重启后不丢 */
|
||||||
const RUNTIME_CONFIG_FILE = resolve(__dirname, '..', 'config.runtime.json')
|
const RUNTIME_CONFIG_FILE = resolve(__dirname, '..', 'config.runtime.json')
|
||||||
|
|
||||||
|
|
@ -663,6 +731,16 @@ function handleRequest(req, res) {
|
||||||
if (url === '/api/fallback/status' && method === 'GET') {
|
if (url === '/api/fallback/status' && method === 'GET') {
|
||||||
return sendJSON(res, 200, { enabled: _rolesStore.isFallbackEnabled() })
|
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) {
|
if (!ctx2.empNo) {
|
||||||
|
|
@ -931,6 +1009,7 @@ async function initServices(ctx, config) {
|
||||||
const rolesFile = resolve(__dirname, '..', 'roles.json')
|
const rolesFile = resolve(__dirname, '..', 'roles.json')
|
||||||
_rolesStore = new RolesStore({ rolesFile })
|
_rolesStore = new RolesStore({ rolesFile })
|
||||||
await _rolesStore.init()
|
await _rolesStore.init()
|
||||||
|
logLocalAdminStatus(ctx.logger || console)
|
||||||
|
|
||||||
_sessionAcl = new SessionAclStore({ ownersFile: resolve(__dirname, '..', 'session-owners.json') })
|
_sessionAcl = new SessionAclStore({ ownersFile: resolve(__dirname, '..', 'session-owners.json') })
|
||||||
await _sessionAcl.init()
|
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)直连内网拉用户详情;成功才建会话。
|
* (带 X-Emp-No / X-Auth-Value)直连内网拉用户详情;成功才建会话。
|
||||||
* 出站请求绕过 HTTP(S)_PROXY。
|
* 出站请求绕过 HTTP(S)_PROXY。
|
||||||
*
|
*
|
||||||
* 兜底登录:仅认可已由 /api/fallback/login 写好的 administrator 会话。
|
* 兜底登录:仅认可已由 /api/fallback/login 或 /api/local-admin/unlock 写好的
|
||||||
|
* administrator 会话;重启后若仅有 UDS_FALLBACK_USER cookie,会重建内存会话。
|
||||||
*
|
*
|
||||||
* 可选 onSkillCredentials(empNo, token):UI 会话写入成功后并行写入 skill 凭证缓存。
|
* 可选 onSkillCredentials(empNo, token):UI 会话写入成功后并行写入 skill 凭证缓存。
|
||||||
*/
|
*/
|
||||||
|
|
@ -91,6 +92,25 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
|
||||||
return false
|
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) {
|
async function authMiddleware(ctx, next) {
|
||||||
const { req } = ctx
|
const { req } = ctx
|
||||||
const cookieHeader = req.headers.cookie || ''
|
const cookieHeader = req.headers.cookie || ''
|
||||||
|
|
@ -125,26 +145,26 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userContext) {
|
if (userContext) {
|
||||||
if (slidingExpiration) {
|
await attachUser(ctx, extracted.empNo, userContext, extracted.kind)
|
||||||
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)
|
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback cookie survives process restart; memory session does not — rebuild.
|
||||||
if (extracted.kind === 'fallback') {
|
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()
|
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