mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-17 00:30:45 +08:00
fix(uds-auth): emergency login workspaces, Desktop key prompts, persistent sessions
Emergency (fallback / sealed-box) admins lost the workspace sidebar and saw "sign-in failed / add API key" on DSH Desktop because the remote.mux opens anonymously at boot and every early call was rejected terminally. - gateway: anonymous workspace/follow reaches dsh-acl's empty baseline; anonymous streams are parked until the carrier aborts instead of failing; anonymous workspace/initializeDefault answers "nothing created" - request-auth: a dead UDS_SESSION cookie no longer shadows a valid WS ticket or bridge (sync + async variants used by all callers) - dsh-acl: /api/* waits (bounded 15s) for the gateway ACL during Host startup instead of 403, so the Desktop welcome read no longer fails - desktop-bootstrap: key/account projection is best-effort per reference - client: emit connection/reset after login so boot-time caches (settings describe mirror -> Settings > Models) re-read under the real principal - sessions: absolute lifetime fixed at 7 days (was min(ttl, 7d) = 30 min); bridge expiry slides with its session; MemoryStore persisted to sessions.json (bearer hash only, userData AES-256-GCM sealed) - README: session lifetime/persistence and DSH compatibility notes Also includes previously uncommitted uds-auth 0.3.x work in this tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fa4d5d6d97
commit
df97c5e042
72 changed files with 8436 additions and 1192 deletions
|
|
@ -1,21 +1,22 @@
|
|||
/**
|
||||
* uds-auth 角色存储 + 权限管理
|
||||
*
|
||||
* 角色(身份标签;admin 级权限相同):
|
||||
* super_admin 身份:首位扫码 bootstrap / 显式提权;权限 = admin 级
|
||||
* fallback_admin 身份:应急账号 administrator;权限 = admin 级
|
||||
* 角色(身份标签;admin 级权限相同 — SEC-26 产品决策:当前同权):
|
||||
* super_admin 身份:显式初始管理员 / 管理 API 提权;权限 = admin 级
|
||||
* fallback_admin 身份:仅应急账号 administrator(不可经管理 API 分配);权限 = admin 级
|
||||
* admin 身份:用户管理中提权;权限 = admin 级
|
||||
* user 仅看自己会话,无设置 / 不可建工作区
|
||||
*
|
||||
* admin 级(isAdminClass)权限相同:用户管理、设置、建工作区、默认可看全部会话。
|
||||
* 超管只是身份;默认可持有该身份的包括 admin,以及首位扫码加入者(bootstrap)。
|
||||
* prefs.viewAllSessions === false 时关闭全览。
|
||||
* 「超管」是身份标签,不是更强权限隔离;viewAllSessions 是显示偏好,非安全边界。
|
||||
* 持久化: roles.json (roles + prefs + fallbackPasswordHash)
|
||||
*/
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { createHash, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import { ROLE_LABELS_ZH } from './i18n.js'
|
||||
import { atomicWriteJson } from './utils/atomic-write.js'
|
||||
import { hashPasswordAsync, verifyScryptAsync } from './utils/password-kdf.js'
|
||||
|
||||
function codedError(code) {
|
||||
const err = new Error(code)
|
||||
|
|
@ -23,6 +24,73 @@ function codedError(code) {
|
|||
return err
|
||||
}
|
||||
|
||||
const ACCOUNT_STATUS = {
|
||||
ACTIVE: 'active',
|
||||
DISABLED: 'disabled',
|
||||
}
|
||||
|
||||
/** Known shared default from pre-0.3 installs — must never remain usable (R10). */
|
||||
const LEGACY_DEFAULT_SHA256 = createHash('sha256').update('Admin@123').digest('hex')
|
||||
|
||||
/**
|
||||
* R10: versioned scrypt password hash.
|
||||
* Format: scrypt$N$r$p$saltB64$urlHashB64
|
||||
* Legacy bare SHA-256 hex still verifies once, then must be rotated (except known default).
|
||||
*/
|
||||
function hashPasswordScrypt(password, saltBuf = randomBytes(16)) {
|
||||
const N = 16384
|
||||
const r = 8
|
||||
const p = 1
|
||||
const derived = scryptSync(String(password), saltBuf, 32, { N, r, p })
|
||||
return [
|
||||
'scrypt',
|
||||
String(N),
|
||||
String(r),
|
||||
String(p),
|
||||
saltBuf.toString('base64url'),
|
||||
derived.toString('base64url'),
|
||||
].join('$')
|
||||
}
|
||||
|
||||
function verifyPasswordHash(password, stored) {
|
||||
const s = String(stored || '')
|
||||
if (!s) return false
|
||||
if (s.startsWith('scrypt$')) {
|
||||
const parts = s.split('$')
|
||||
if (parts.length !== 6) return false
|
||||
const N = Number(parts[1])
|
||||
const r = Number(parts[2])
|
||||
const p = Number(parts[3])
|
||||
const salt = Buffer.from(parts[4], 'base64url')
|
||||
const expect = Buffer.from(parts[5], 'base64url')
|
||||
if (N !== 16384 || r !== 8 || p !== 1 || salt.length !== 16 || expect.length !== 32) return false
|
||||
let derived
|
||||
try {
|
||||
derived = scryptSync(String(password), salt, expect.length, { N, r, p })
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (derived.length !== expect.length) return false
|
||||
return timingSafeEqual(derived, expect)
|
||||
}
|
||||
// Legacy unsalted SHA-256 — reject known shared default outright.
|
||||
if (/^[0-9a-f]{64}$/i.test(s)) {
|
||||
if (s.toLowerCase() === LEGACY_DEFAULT_SHA256) return false
|
||||
const got = createHash('sha256').update(String(password)).digest('hex')
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(got, 'hex'), Buffer.from(s, 'hex'))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** @deprecated keep name for call sites that still import hashPassword */
|
||||
function hashPassword(password) {
|
||||
return hashPasswordScrypt(password)
|
||||
}
|
||||
|
||||
export const ROLES = {
|
||||
SUPER_ADMIN: 'super_admin',
|
||||
ADMIN: 'admin',
|
||||
|
|
@ -73,11 +141,7 @@ export function canToggleViewAllSessions(role) {
|
|||
return isAdminClass(role)
|
||||
}
|
||||
|
||||
function hashPassword(password) {
|
||||
return createHash('sha256').update(password).digest('hex')
|
||||
}
|
||||
|
||||
/** 初始部署默认兜底密码(扫码不可用时用);可在设置里改密或关闭。 */
|
||||
/** Historical default — never enable automatically (R10). */
|
||||
export const DEFAULT_FALLBACK_PASSWORD = 'Admin@123'
|
||||
export const DEFAULT_FALLBACK_USERNAME = 'administrator'
|
||||
|
||||
|
|
@ -85,35 +149,39 @@ export const DEFAULT_FALLBACK_USERNAME = 'administrator'
|
|||
* RolesStore — 角色存储
|
||||
* 内存 Map + 可选 JSON 文件持久化
|
||||
*
|
||||
* fallback admin: 默认启用,密码 Admin@123;roles.json 显式 null 表示已关闭。
|
||||
* fallback admin: 默认关闭;须在设置中显式设置 scrypt 口令(≥10,禁 Admin@123)。
|
||||
* 登录路径: POST /uds-auth/api/fallback/login { username, password }
|
||||
*/
|
||||
export class RolesStore {
|
||||
constructor(options = {}) {
|
||||
this._roles = new Map() // empNo → role
|
||||
this._prefs = new Map() // empNo → { viewAllSessions?: boolean }
|
||||
this._status = new Map() // empNo → active|disabled (R12)
|
||||
this._firstBootLock = Promise.resolve()
|
||||
this._mutateChain = Promise.resolve() // R09: serialize role mutations
|
||||
this._saveChain = Promise.resolve()
|
||||
this._rolesFile = options.rolesFile ? resolve(options.rolesFile) : null
|
||||
this._fallbackPasswordHash = null // SHA-256 hex,null = 未启用
|
||||
this._fallbackPasswordHash = null // scrypt$… or legacy hex;null = 未启用
|
||||
this._fallbackNeedsRehash = false
|
||||
this._fallbackCredVersion = 0 // F02: bumps on set/clear password
|
||||
this._committedFallback = { hash: null, needs: false, ver: 0 }
|
||||
this._fallbackRateLimit = new Map() // ip → { count, resetAt }
|
||||
this._dirty = false
|
||||
this._saveTimer = null
|
||||
}
|
||||
|
||||
/**
|
||||
* New installs do NOT enable a shared default password (SEC-08).
|
||||
* Admins must set a password explicitly via settings / setFallbackPassword.
|
||||
*/
|
||||
_ensureDefaultFallback() {
|
||||
if (this._fallbackPasswordHash) return
|
||||
this._fallbackPasswordHash = hashPassword(DEFAULT_FALLBACK_PASSWORD)
|
||||
this._markDirty()
|
||||
console.info(
|
||||
'[uds-auth] fallback_admin enabled by default'
|
||||
+ ` (user=${DEFAULT_FALLBACK_USERNAME}, change password in settings)`,
|
||||
)
|
||||
// no-op — kept for call-site compatibility
|
||||
}
|
||||
|
||||
// === 持久化 ===
|
||||
|
||||
async init() {
|
||||
let loadedHash = undefined // undefined = missing / new file; null = explicitly cleared
|
||||
let loadedOk = false
|
||||
if (this._rolesFile) {
|
||||
try {
|
||||
const raw = await readFile(this._rolesFile, 'utf-8')
|
||||
|
|
@ -127,27 +195,60 @@ export class RolesStore {
|
|||
}
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(data, 'fallbackPasswordHash')) {
|
||||
loadedHash = data.fallbackPasswordHash || null
|
||||
if (loadedHash) this._fallbackPasswordHash = loadedHash
|
||||
this._fallbackPasswordHash = data.fallbackPasswordHash || null
|
||||
}
|
||||
this._fallbackCredVersion = Number.isSafeInteger(data.fallbackCredVersion)
|
||||
&& data.fallbackCredVersion >= 0 ? data.fallbackCredVersion : 0
|
||||
for (const [empNo, st] of Object.entries(data.status || {})) {
|
||||
if (st === ACCOUNT_STATUS.DISABLED || st === ACCOUNT_STATUS.ACTIVE) {
|
||||
this._status.set(String(empNo), st)
|
||||
}
|
||||
}
|
||||
loadedOk = true
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
// 首次启动,文件不存在 — 走默认兜底
|
||||
// First boot — empty roles, fallback disabled until explicitly configured.
|
||||
console.info(
|
||||
'[uds-auth] roles file missing; fallback password disabled until configured'
|
||||
+ ` (set initialAdmin via UDS_AUTH_INITIAL_ADMIN)`,
|
||||
)
|
||||
} else {
|
||||
console.warn('[uds-auth:RolesStore] Failed to load roles file:', err.message)
|
||||
// Corrupt roles must NOT restore a default password (SEC-08/21).
|
||||
console.error('[uds-auth:RolesStore] Failed to load roles file — refusing default password:', err.message)
|
||||
this._loadFailed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// 无持久化哈希(新部署或旧文件未写该字段)→ 默认开启;显式 null 表示超管已关闭
|
||||
if (loadedHash === undefined && !this._fallbackPasswordHash) {
|
||||
this._ensureDefaultFallback()
|
||||
// R10: known shared default hash cannot remain enabled after upgrade.
|
||||
if (loadedOk && this._fallbackPasswordHash
|
||||
&& String(this._fallbackPasswordHash).toLowerCase() === LEGACY_DEFAULT_SHA256) {
|
||||
console.warn('[uds-auth] refusing legacy shared default fallback password — disabled until reset')
|
||||
this._fallbackPasswordHash = null
|
||||
this._dirty = true
|
||||
await this._save()
|
||||
} else if (loadedOk && this._fallbackPasswordHash
|
||||
&& /^[0-9a-f]{64}$/i.test(String(this._fallbackPasswordHash))) {
|
||||
this._fallbackNeedsRehash = true
|
||||
console.warn('[uds-auth] legacy SHA-256 fallback hash loaded — will upgrade on next successful login')
|
||||
}
|
||||
if (loadedOk && this._fallbackPasswordHash) {
|
||||
console.info('[uds-auth] fallback_admin password loaded from roles file')
|
||||
}
|
||||
this._captureCommittedFallback()
|
||||
}
|
||||
|
||||
get loadFailed() {
|
||||
return !!this._loadFailed
|
||||
}
|
||||
|
||||
_markDirty() {
|
||||
this._dirty = true
|
||||
if (this._saveTimer) return
|
||||
this._saveTimer = setTimeout(() => { void this._save() }, 2000)
|
||||
this._saveTimer = setTimeout(() => {
|
||||
this._saveTimer = null
|
||||
void this._withMutationLock(() => this.flush()).catch(() => {})
|
||||
}, 2000)
|
||||
this._saveTimer.unref?.()
|
||||
}
|
||||
|
||||
/** Flush pending roles/prefs to disk immediately (e.g. view-all toggle). */
|
||||
|
|
@ -160,26 +261,106 @@ export class RolesStore {
|
|||
}
|
||||
|
||||
async _save() {
|
||||
const run = this._saveChain.then(() => this._saveNow())
|
||||
this._saveChain = run.catch(() => {})
|
||||
return run
|
||||
}
|
||||
|
||||
async _saveNow() {
|
||||
this._saveTimer = null
|
||||
if (!this._dirty || !this._rolesFile) return
|
||||
const snapshot = {
|
||||
roles: Object.fromEntries(this._roles),
|
||||
prefs: Object.fromEntries(this._prefs),
|
||||
status: Object.fromEntries(this._status),
|
||||
fallbackPasswordHash: this._fallbackPasswordHash,
|
||||
fallbackCredVersion: this._fallbackCredVersion,
|
||||
savedAt: new Date().toISOString(),
|
||||
}
|
||||
this._dirty = false
|
||||
try {
|
||||
const data = {
|
||||
roles: Object.fromEntries(this._roles),
|
||||
prefs: Object.fromEntries(this._prefs),
|
||||
fallbackPasswordHash: this._fallbackPasswordHash,
|
||||
savedAt: new Date().toISOString(),
|
||||
}
|
||||
await mkdir(dirname(this._rolesFile), { recursive: true })
|
||||
await writeFile(this._rolesFile, JSON.stringify(data, null, 2), 'utf-8')
|
||||
await atomicWriteJson(this._rolesFile, snapshot, { mode: 0o600 })
|
||||
} catch (err) {
|
||||
this._dirty = true
|
||||
console.warn('[uds-auth:RolesStore] Failed to save roles file:', err.message)
|
||||
console.error('[uds-auth:RolesStore] Failed to save roles file:', err.message)
|
||||
const e = new Error('roles_persist_failed')
|
||||
e.code = 'roles_persist_failed'
|
||||
e.cause = err
|
||||
throw e
|
||||
}
|
||||
// Changes during await writeFile — schedule another save.
|
||||
if (this._dirty) this._markDirty()
|
||||
}
|
||||
|
||||
/** R09: serialize mutations that check last-admin invariants. */
|
||||
_withMutationLock(fn) {
|
||||
const transaction = async () => {
|
||||
const previous = {
|
||||
roles: new Map(this._roles), prefs: new Map(this._prefs), status: new Map(this._status),
|
||||
hash: this._fallbackPasswordHash, needs: this._fallbackNeedsRehash,
|
||||
ver: this._fallbackCredVersion,
|
||||
}
|
||||
this._transactionSnapshot = previous
|
||||
try {
|
||||
return await fn()
|
||||
} catch (err) {
|
||||
this._roles = previous.roles
|
||||
this._prefs = previous.prefs
|
||||
this._status = previous.status
|
||||
this._fallbackPasswordHash = previous.hash
|
||||
this._fallbackNeedsRehash = previous.needs
|
||||
this._fallbackCredVersion = previous.ver
|
||||
this._dirty = false
|
||||
if (this._saveTimer) clearTimeout(this._saveTimer)
|
||||
this._saveTimer = null
|
||||
throw err
|
||||
} finally {
|
||||
this._transactionSnapshot = null
|
||||
}
|
||||
}
|
||||
const run = this._mutateChain.then(transaction, transaction)
|
||||
this._mutateChain = run.then(() => {}, () => {})
|
||||
return run
|
||||
}
|
||||
|
||||
getAccountStatus(empNo) {
|
||||
const e = String(empNo || '').trim()
|
||||
if (!e) return ACCOUNT_STATUS.ACTIVE
|
||||
return (this._transactionSnapshot?.status || this._status).get(e) || ACCOUNT_STATUS.ACTIVE
|
||||
}
|
||||
|
||||
isDisabled(empNo) {
|
||||
return this.getAccountStatus(empNo) === ACCOUNT_STATUS.DISABLED
|
||||
}
|
||||
|
||||
async setAccountStatus(empNo, status, currentAdminRole) {
|
||||
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) throw codedError('forbidden_set_role')
|
||||
const e = String(empNo || '').trim()
|
||||
if (!e) throw codedError('invalid_role_params')
|
||||
if (status !== ACCOUNT_STATUS.ACTIVE && status !== ACCOUNT_STATUS.DISABLED) {
|
||||
throw codedError('invalid_role_params')
|
||||
}
|
||||
return this._withMutationLock(async () => {
|
||||
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) throw codedError('forbidden_set_role')
|
||||
if (status === ACCOUNT_STATUS.DISABLED && this._roles.get(e) === ROLES.SUPER_ADMIN
|
||||
&& !this.isDisabled(e)) {
|
||||
const n = await this.countActiveByRole(ROLES.SUPER_ADMIN)
|
||||
if (n <= 1) throw codedError('last_super_admin_demote')
|
||||
}
|
||||
const prevStatus = this._status.get(e)
|
||||
this._status.set(e, status)
|
||||
this._dirty = true
|
||||
try {
|
||||
await this.flush()
|
||||
} catch (err) {
|
||||
if (prevStatus === undefined) this._status.delete(e)
|
||||
else this._status.set(e, prevStatus)
|
||||
throw err
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// === 首次部署 bootstrap ===
|
||||
|
||||
/**
|
||||
|
|
@ -187,28 +368,28 @@ export class RolesStore {
|
|||
* 返回 { role, bootstrapped } bootstrapped=true 表示本次是 bootstrap
|
||||
*/
|
||||
async bootstrapFirstUser(empNo) {
|
||||
// 用锁保证原子性
|
||||
this._firstBootLock = this._firstBootLock.then(async () => {
|
||||
return this._withMutationLock(async () => {
|
||||
if (this._roles.size === 0) {
|
||||
this._roles.set(empNo, ROLES.SUPER_ADMIN)
|
||||
this._markDirty()
|
||||
this._dirty = true
|
||||
await this.flush()
|
||||
return { role: ROLES.SUPER_ADMIN, bootstrapped: true }
|
||||
}
|
||||
if (!this._roles.has(empNo)) {
|
||||
this._roles.set(empNo, ROLES.USER)
|
||||
this._markDirty()
|
||||
this._dirty = true
|
||||
await this.flush()
|
||||
return { role: ROLES.USER, bootstrapped: false }
|
||||
}
|
||||
return { role: this._roles.get(empNo), bootstrapped: false }
|
||||
})
|
||||
return this._firstBootLock
|
||||
}
|
||||
|
||||
// === 角色查询 ===
|
||||
|
||||
getRole(empNo) {
|
||||
if (empNo === 'administrator') return ROLES.FALLBACK_ADMIN
|
||||
return this._roles.get(empNo) || ROLES.USER
|
||||
return (this._transactionSnapshot?.roles || this._roles).get(empNo) || ROLES.USER
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -217,7 +398,7 @@ export class RolesStore {
|
|||
*/
|
||||
isViewAllSessionsEnabled(empNo) {
|
||||
if (!empNo) return false
|
||||
const prefs = this._prefs.get(String(empNo))
|
||||
const prefs = (this._transactionSnapshot?.prefs || this._prefs).get(String(empNo))
|
||||
if (prefs && Object.prototype.hasOwnProperty.call(prefs, 'viewAllSessions')) {
|
||||
return !!prefs.viewAllSessions
|
||||
}
|
||||
|
|
@ -244,6 +425,14 @@ export class RolesStore {
|
|||
return true
|
||||
}
|
||||
|
||||
async setViewAllSessionsDurable(empNo, enabled) {
|
||||
return this._withMutationLock(async () => {
|
||||
this.setViewAllSessions(empNo, enabled)
|
||||
await this.flush()
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/** 角色 + 个人偏好 → 有效权限 */
|
||||
resolvePermissions(empNo, role) {
|
||||
const id = empNo != null ? String(empNo) : ''
|
||||
|
|
@ -255,16 +444,16 @@ export class RolesStore {
|
|||
}
|
||||
|
||||
hasRole(empNo) {
|
||||
return this._roles.has(empNo)
|
||||
return (this._transactionSnapshot?.roles || this._roles).has(empNo)
|
||||
}
|
||||
|
||||
getAll() {
|
||||
return Array.from(this._roles.entries()).map(([empNo, role]) => ({ empNo, role }))
|
||||
return Array.from((this._transactionSnapshot?.roles || this._roles).entries()).map(([empNo, role]) => ({ empNo, role }))
|
||||
}
|
||||
|
||||
/** 角色表是否为空(用于 bootstrap) */
|
||||
isEmpty() {
|
||||
return this._roles.size === 0
|
||||
return (this._transactionSnapshot?.roles || this._roles).size === 0
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -276,7 +465,7 @@ export class RolesStore {
|
|||
const pageSize = Math.min(200, Math.max(1, Number(opts.pageSize) || 50))
|
||||
const q = String(opts.q || '').trim().toLowerCase()
|
||||
|
||||
let rows = Array.from(this._roles.entries()).map(([empNo, role]) => ({ empNo, role }))
|
||||
let rows = Array.from((this._transactionSnapshot?.roles || this._roles).entries()).map(([empNo, role]) => ({ empNo, role }))
|
||||
if (q) {
|
||||
rows = rows.filter((r) => String(r.empNo).toLowerCase().includes(q)
|
||||
|| String(ROLE_LABELS[r.role] || r.role).toLowerCase().includes(q))
|
||||
|
|
@ -304,86 +493,258 @@ export class RolesStore {
|
|||
return n
|
||||
}
|
||||
|
||||
/** A07: count only active (non-disabled) accounts with the given role. */
|
||||
async countActiveByRole(role) {
|
||||
let n = 0
|
||||
for (const [empNo, r] of this._roles) {
|
||||
if (r !== role) continue
|
||||
if (this.isDisabled(empNo)) continue
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// === 角色管理 (admin 级) ===
|
||||
|
||||
/**
|
||||
* 设置用户角色
|
||||
* 保护性 invariant: 至少保留 1 个 super_admin 身份(若表中曾有)
|
||||
* 保护性 invariant: 至少保留 1 个 active super_admin(若表中曾有)
|
||||
*/
|
||||
async setRole(empNo, newRole, currentAdminRole) {
|
||||
if (!isAdminClass(currentAdminRole)) {
|
||||
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) {
|
||||
throw codedError('forbidden_set_role')
|
||||
}
|
||||
|
||||
// invariant: 不能让系统变成 0 个 super_admin(身份仍保留)
|
||||
const currentRole = this._roles.get(empNo)
|
||||
if (currentRole === ROLES.SUPER_ADMIN && newRole !== ROLES.SUPER_ADMIN) {
|
||||
const superAdmins = await this.countByRole(ROLES.SUPER_ADMIN)
|
||||
if (superAdmins <= 1) {
|
||||
throw codedError('last_super_admin_demote')
|
||||
}
|
||||
// SEC-26: management API may only assign assignable roles — never fallback_admin.
|
||||
const assignable = new Set([ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.USER])
|
||||
if (!assignable.has(newRole)) {
|
||||
throw codedError('invalid_role_params')
|
||||
}
|
||||
|
||||
this._roles.set(empNo, newRole)
|
||||
this._markDirty()
|
||||
return true
|
||||
return this._withMutationLock(async () => {
|
||||
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) throw codedError('forbidden_set_role')
|
||||
const currentRole = this._roles.get(empNo)
|
||||
if (currentRole === ROLES.SUPER_ADMIN && newRole !== ROLES.SUPER_ADMIN
|
||||
&& !this.isDisabled(empNo)) {
|
||||
const superAdmins = await this.countActiveByRole(ROLES.SUPER_ADMIN)
|
||||
if (superAdmins <= 1) {
|
||||
throw codedError('last_super_admin_demote')
|
||||
}
|
||||
}
|
||||
const prev = this._roles.get(empNo)
|
||||
const prevStatus = this._status.get(empNo)
|
||||
this._roles.set(empNo, newRole)
|
||||
// Re-activating via setRole clears disabled when admin assigns a role.
|
||||
if (this._status.get(empNo) === ACCOUNT_STATUS.DISABLED) {
|
||||
this._status.set(empNo, ACCOUNT_STATUS.ACTIVE)
|
||||
}
|
||||
this._dirty = true
|
||||
try {
|
||||
await this.flush()
|
||||
} catch (err) {
|
||||
if (prev === undefined) this._roles.delete(empNo)
|
||||
else this._roles.set(empNo, prev)
|
||||
// A07: roll back status as well as role on persist failure.
|
||||
if (prevStatus === undefined) this._status.delete(empNo)
|
||||
else this._status.set(empNo, prevStatus)
|
||||
throw err
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除用户 */
|
||||
async removeUser(empNo, currentAdminRole) {
|
||||
if (!isAdminClass(currentAdminRole)) {
|
||||
/**
|
||||
* R12: disable account (preferred) — keeps record so open registration cannot re-add.
|
||||
* Pass { hard: true } to delete role entry entirely.
|
||||
*/
|
||||
async removeUser(empNo, currentAdminRole, opts = {}) {
|
||||
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) {
|
||||
throw codedError('forbidden_remove_user')
|
||||
}
|
||||
const currentRole = this._roles.get(empNo)
|
||||
if (currentRole === ROLES.SUPER_ADMIN) {
|
||||
const superAdmins = await this.countByRole(ROLES.SUPER_ADMIN)
|
||||
if (superAdmins <= 1) {
|
||||
throw codedError('last_super_admin_delete')
|
||||
return this._withMutationLock(async () => {
|
||||
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) throw codedError('forbidden_remove_user')
|
||||
const currentRole = this._roles.get(empNo)
|
||||
if (currentRole === ROLES.SUPER_ADMIN && !this.isDisabled(empNo)) {
|
||||
const superAdmins = await this.countActiveByRole(ROLES.SUPER_ADMIN)
|
||||
if (superAdmins <= 1) {
|
||||
throw codedError('last_super_admin_delete')
|
||||
}
|
||||
}
|
||||
}
|
||||
this._roles.delete(empNo)
|
||||
this._prefs.delete(empNo)
|
||||
this._markDirty()
|
||||
return true
|
||||
if (opts.hard === true) {
|
||||
const prevRole = this._roles.get(empNo)
|
||||
const prevPrefs = this._prefs.get(empNo)
|
||||
const prevStatus = this._status.get(empNo)
|
||||
this._roles.delete(empNo)
|
||||
this._prefs.delete(empNo)
|
||||
this._status.delete(empNo)
|
||||
this._dirty = true
|
||||
try {
|
||||
await this.flush()
|
||||
} catch (err) {
|
||||
if (prevRole !== undefined) this._roles.set(empNo, prevRole)
|
||||
if (prevPrefs !== undefined) this._prefs.set(empNo, prevPrefs)
|
||||
if (prevStatus !== undefined) this._status.set(empNo, prevStatus)
|
||||
throw err
|
||||
}
|
||||
} else {
|
||||
const key = String(empNo)
|
||||
const prevStatus = this._status.get(key)
|
||||
this._status.set(key, ACCOUNT_STATUS.DISABLED)
|
||||
this._dirty = true
|
||||
try {
|
||||
await this.flush()
|
||||
} catch (err) {
|
||||
if (prevStatus === undefined) this._status.delete(key)
|
||||
else this._status.set(key, prevStatus)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/** 确保用户存在 (如果不存在设为 user) */
|
||||
ensureUser(empNo, currentAdminRole) {
|
||||
if (!isAdminClass(currentAdminRole)) {
|
||||
/**
|
||||
* 确保用户存在 (如果不存在设为 user);disabled 账号不会被自动复活。
|
||||
* F03: await flush before success — no silent delayed-only persist.
|
||||
*/
|
||||
async ensureUser(empNo, currentAdminRole) {
|
||||
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) {
|
||||
throw codedError('forbidden_add_user')
|
||||
}
|
||||
if (!this._roles.has(empNo)) {
|
||||
this._roles.set(empNo, ROLES.USER)
|
||||
this._markDirty()
|
||||
if (this.isDisabled(empNo)) {
|
||||
throw codedError('account_disabled')
|
||||
}
|
||||
return true
|
||||
return this._withMutationLock(async () => {
|
||||
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) throw codedError('forbidden_add_user')
|
||||
if (this.isDisabled(empNo)) throw codedError('account_disabled')
|
||||
if (!this._roles.has(empNo)) {
|
||||
this._roles.set(empNo, ROLES.USER)
|
||||
this._status.set(String(empNo), ACCOUNT_STATUS.ACTIVE)
|
||||
this._dirty = true
|
||||
try {
|
||||
await this.flush()
|
||||
} catch (err) {
|
||||
this._roles.delete(empNo)
|
||||
this._status.delete(String(empNo))
|
||||
throw err
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// === Fallback Administrator ===
|
||||
|
||||
setFallbackPassword(password, currentAdminRole) {
|
||||
if (!isAdminClass(currentAdminRole)) {
|
||||
throw codedError('forbidden_set_fallback')
|
||||
_ensureCommittedFallback() {
|
||||
if (!this._committedFallback) {
|
||||
this._committedFallback = {
|
||||
hash: this._fallbackPasswordHash,
|
||||
needs: this._fallbackNeedsRehash,
|
||||
ver: this._fallbackCredVersion || 0,
|
||||
}
|
||||
}
|
||||
if (!password || password.length < 6) {
|
||||
throw codedError('password_too_short')
|
||||
}
|
||||
this._fallbackPasswordHash = hashPassword(password)
|
||||
this._markDirty()
|
||||
return true
|
||||
}
|
||||
|
||||
clearFallbackPassword(currentAdminRole) {
|
||||
if (!isAdminClass(currentAdminRole)) {
|
||||
_restoreCommittedFallback() {
|
||||
this._ensureCommittedFallback()
|
||||
this._fallbackPasswordHash = this._committedFallback.hash
|
||||
this._fallbackNeedsRehash = this._committedFallback.needs
|
||||
this._fallbackCredVersion = this._committedFallback.ver
|
||||
}
|
||||
|
||||
_captureCommittedFallback() {
|
||||
this._committedFallback = {
|
||||
hash: this._fallbackPasswordHash,
|
||||
needs: this._fallbackNeedsRehash,
|
||||
ver: this._fallbackCredVersion || 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* D05/D06/F03: persist against last-committed snapshot.
|
||||
* Failed writes restore the last successful commit (not a concurrent peer's
|
||||
* uncommitted value), including credVersion.
|
||||
*/
|
||||
async setFallbackPassword(password, currentAdminRole) {
|
||||
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) {
|
||||
throw codedError('forbidden_set_fallback')
|
||||
}
|
||||
if (String(password) === 'Admin@123') {
|
||||
throw codedError('password_too_common')
|
||||
}
|
||||
if (typeof password !== 'string' || password.length < 10) {
|
||||
throw codedError('password_too_short')
|
||||
}
|
||||
if (Buffer.byteLength(password) > 1024) throw codedError('password_invalid')
|
||||
return this._withMutationLock(async () => {
|
||||
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) throw codedError('forbidden_set_fallback')
|
||||
const newHash = await hashPasswordAsync(password)
|
||||
this._fallbackPasswordHash = newHash
|
||||
this._fallbackNeedsRehash = false
|
||||
this._fallbackCredVersion++
|
||||
this._dirty = true
|
||||
await this.flush()
|
||||
this._captureCommittedFallback()
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
async clearFallbackPassword(currentAdminRole) {
|
||||
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) {
|
||||
throw codedError('forbidden_clear_fallback')
|
||||
}
|
||||
this._fallbackPasswordHash = null
|
||||
this._markDirty()
|
||||
return true
|
||||
return this._withMutationLock(async () => {
|
||||
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) throw codedError('forbidden_clear_fallback')
|
||||
this._fallbackPasswordHash = null
|
||||
this._fallbackNeedsRehash = false
|
||||
this._fallbackCredVersion++
|
||||
this._dirty = true
|
||||
await this.flush()
|
||||
this._captureCommittedFallback()
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/** Auth material version for password-fallback sessions (F02). */
|
||||
getFallbackCredVersion() {
|
||||
return this._transactionSnapshot?.ver ?? this._fallbackCredVersion ?? 0
|
||||
}
|
||||
|
||||
isFallbackEnabled() {
|
||||
return this._fallbackPasswordHash !== null
|
||||
return (this._transactionSnapshot ? this._transactionSnapshot.hash : this._fallbackPasswordHash) !== null
|
||||
}
|
||||
|
||||
/** Runtime path: asynchronous bounded KDF and recheck after a concurrent rotation. */
|
||||
async verifyFallbackAsync(password, ip) {
|
||||
if (typeof password !== 'string' || Buffer.byteLength(password) > 1024) return false
|
||||
const hash = this._transactionSnapshot ? this._transactionSnapshot.hash : this._fallbackPasswordHash
|
||||
const version = this.getFallbackCredVersion()
|
||||
if (!hash) return false
|
||||
const now = Date.now()
|
||||
for (const [key, value] of this._fallbackRateLimit) {
|
||||
if (value.resetAt <= now) this._fallbackRateLimit.delete(key)
|
||||
}
|
||||
let bucket = this._fallbackRateLimit.get(ip)
|
||||
if (!bucket) {
|
||||
if (this._fallbackRateLimit.size >= 5000) return false
|
||||
this._fallbackRateLimit.set(ip, bucket = { count: 0, resetAt: now + 60_000 })
|
||||
}
|
||||
if (++bucket.count > 5) return false
|
||||
const ok = String(hash).startsWith('scrypt$')
|
||||
? await verifyScryptAsync(password, hash) : verifyPasswordHash(password, hash)
|
||||
if (!ok) return false
|
||||
// Serialize the final check with all durable credential changes.
|
||||
return this._withMutationLock(async () => {
|
||||
if (this._fallbackPasswordHash !== hash || this._fallbackCredVersion !== version) return false
|
||||
if (!String(hash).startsWith('scrypt$')) {
|
||||
this._fallbackPasswordHash = await hashPasswordAsync(password)
|
||||
this._fallbackNeedsRehash = false
|
||||
this._dirty = true
|
||||
await this.flush()
|
||||
this._captureCommittedFallback()
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -405,6 +766,16 @@ export class RolesStore {
|
|||
return false // rate limited
|
||||
}
|
||||
|
||||
return hashPassword(password) === this._fallbackPasswordHash
|
||||
const ok = verifyPasswordHash(password, this._fallbackPasswordHash)
|
||||
if (ok && (this._fallbackNeedsRehash || !String(this._fallbackPasswordHash).startsWith('scrypt$'))) {
|
||||
try {
|
||||
this._fallbackPasswordHash = hashPasswordScrypt(password)
|
||||
this._fallbackNeedsRehash = false
|
||||
this._markDirty()
|
||||
} catch { /* keep legacy until next save */ }
|
||||
}
|
||||
return ok
|
||||
}
|
||||
}
|
||||
|
||||
export { ACCOUNT_STATUS, hashPasswordScrypt, verifyPasswordHash, LEGACY_DEFAULT_SHA256 }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue