mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-13 05:50:45 +08:00
793 lines
28 KiB
JavaScript
793 lines
28 KiB
JavaScript
/**
|
||
* uds-auth 角色存储 + 权限管理
|
||
*
|
||
* 角色(身份标签;admin 级权限相同 — SEC-26 产品决策:当前同权):
|
||
* super_admin 身份:显式初始管理员 / 管理 API 提权;权限 = admin 级
|
||
* fallback_admin 身份:仅应急账号 administrator(不可经管理 API 分配);权限 = admin 级
|
||
* admin 身份:用户管理中提权;权限 = admin 级
|
||
* user 仅看自己会话,无设置 / 不可建工作区
|
||
*
|
||
* admin 级(isAdminClass)权限相同:用户管理、设置、建工作区、默认可看全部会话。
|
||
* 「超管」是身份标签,不是更强权限隔离;viewAllSessions 是显示偏好,非安全边界。
|
||
* 持久化: roles.json (roles + prefs + fallbackPasswordHash)
|
||
*/
|
||
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)
|
||
err.code = code
|
||
return err
|
||
}
|
||
|
||
const ACCOUNT_STATUS = {
|
||
ACTIVE: 'active',
|
||
DISABLED: 'disabled',
|
||
}
|
||
|
||
/** Shared emergency default requested for first boot and legacy migrations. */
|
||
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',
|
||
USER: 'user',
|
||
FALLBACK_ADMIN: 'fallback_admin', // 特殊,UAC 挂了时用,等同 super_admin 权限
|
||
}
|
||
|
||
/** zh labels for list/search; UI should translate via i18n role.* keys. */
|
||
export const ROLE_LABELS = ROLE_LABELS_ZH
|
||
|
||
/** admin 级身份:超管 / 应急 / 管理员(权限相同,仅身份标签不同) */
|
||
export function isAdminClass(role) {
|
||
return role === ROLES.SUPER_ADMIN
|
||
|| role === ROLES.FALLBACK_ADMIN
|
||
|| role === ROLES.ADMIN
|
||
}
|
||
|
||
/**
|
||
* 计算角色权限 (纯函数)
|
||
* @param {string} role
|
||
* @param {{ viewAllSessions?: boolean }} [opts] 个人偏好;admin 级默认可见全部
|
||
*/
|
||
export function computePermissions(role, opts = {}) {
|
||
const viewAll = !!opts.viewAllSessions
|
||
if (isAdminClass(role)) {
|
||
return {
|
||
canManageUsers: true,
|
||
canAccessSettings: true,
|
||
canToggleViewAllSessions: true,
|
||
canViewAllSessions: viewAll,
|
||
canViewSystemSessions: true,
|
||
canCreateWorkspace: true,
|
||
}
|
||
}
|
||
// user / undefined
|
||
return {
|
||
canManageUsers: false,
|
||
canAccessSettings: false,
|
||
canToggleViewAllSessions: false,
|
||
canViewAllSessions: false,
|
||
canViewSystemSessions: false,
|
||
canCreateWorkspace: false,
|
||
}
|
||
}
|
||
|
||
/** 角色是否允许开启「查看全部会话」(admin 级) */
|
||
export function canToggleViewAllSessions(role) {
|
||
return isAdminClass(role)
|
||
}
|
||
|
||
/** Default emergency credentials. Rotate the password after first sign-in. */
|
||
export const DEFAULT_FALLBACK_PASSWORD = 'Admin@123'
|
||
export const DEFAULT_FALLBACK_USERNAME = 'administrator'
|
||
|
||
/**
|
||
* RolesStore — 角色存储
|
||
* 内存 Map + 可选 JSON 文件持久化
|
||
*
|
||
* fallback admin: first boot uses the shared Admin@123 password; admins can rotate or disable it.
|
||
* 登录路径: 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 // 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
|
||
}
|
||
|
||
/** Seed the requested default emergency password on first initialization. */
|
||
_ensureDefaultFallback() {
|
||
if (this._fallbackPasswordHash !== null) return false
|
||
this._fallbackPasswordHash = hashPasswordScrypt(DEFAULT_FALLBACK_PASSWORD)
|
||
this._fallbackNeedsRehash = false
|
||
this._fallbackCredVersion = Math.max(1, this._fallbackCredVersion + 1)
|
||
this._dirty = true
|
||
return true
|
||
}
|
||
|
||
// === 持久化 ===
|
||
|
||
async init() {
|
||
let loadedOk = false
|
||
let seedDefaultFallback = !this._rolesFile
|
||
if (this._rolesFile) {
|
||
try {
|
||
const raw = await readFile(this._rolesFile, 'utf-8')
|
||
const data = JSON.parse(raw)
|
||
for (const [empNo, role] of Object.entries(data.roles || {})) {
|
||
this._roles.set(empNo, role)
|
||
}
|
||
for (const [empNo, prefs] of Object.entries(data.prefs || {})) {
|
||
if (prefs && typeof prefs === 'object') {
|
||
this._prefs.set(String(empNo), { ...prefs })
|
||
}
|
||
}
|
||
if (Object.prototype.hasOwnProperty.call(data, 'fallbackPasswordHash')) {
|
||
this._fallbackPasswordHash = data.fallbackPasswordHash || null
|
||
} else {
|
||
// Upgrade older roles files that predate explicit fallback state.
|
||
seedDefaultFallback = true
|
||
}
|
||
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 — seed and persist the requested default credentials.
|
||
seedDefaultFallback = true
|
||
console.info(
|
||
'[uds-auth] roles file missing; enabling default emergency account administrator'
|
||
+ ` (set initialAdmin via UDS_AUTH_INITIAL_ADMIN)`,
|
||
)
|
||
} else {
|
||
// 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
|
||
}
|
||
}
|
||
}
|
||
// Upgrade the historical SHA-256 default to a salted scrypt hash.
|
||
if (loadedOk && this._fallbackPasswordHash
|
||
&& String(this._fallbackPasswordHash).toLowerCase() === LEGACY_DEFAULT_SHA256) {
|
||
console.warn('[uds-auth] migrating legacy default fallback password to scrypt')
|
||
this._fallbackPasswordHash = hashPasswordScrypt(DEFAULT_FALLBACK_PASSWORD)
|
||
this._fallbackCredVersion = Math.max(1, this._fallbackCredVersion + 1)
|
||
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 (seedDefaultFallback && !this._loadFailed) {
|
||
this._ensureDefaultFallback()
|
||
await this.flush()
|
||
}
|
||
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(() => {
|
||
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). */
|
||
async flush() {
|
||
if (this._saveTimer) {
|
||
clearTimeout(this._saveTimer)
|
||
this._saveTimer = null
|
||
}
|
||
await this._save()
|
||
}
|
||
|
||
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 {
|
||
await atomicWriteJson(this._rolesFile, snapshot, { mode: 0o600 })
|
||
} catch (err) {
|
||
this._dirty = true
|
||
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 ===
|
||
|
||
/**
|
||
* 原子 check-and-set: 第一个登录的用户 = super_admin
|
||
* 返回 { role, bootstrapped } bootstrapped=true 表示本次是 bootstrap
|
||
*/
|
||
async bootstrapFirstUser(empNo) {
|
||
return this._withMutationLock(async () => {
|
||
if (this._roles.size === 0) {
|
||
this._roles.set(empNo, ROLES.SUPER_ADMIN)
|
||
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._dirty = true
|
||
await this.flush()
|
||
return { role: ROLES.USER, bootstrapped: false }
|
||
}
|
||
return { role: this._roles.get(empNo), bootstrapped: false }
|
||
})
|
||
}
|
||
|
||
// === 角色查询 ===
|
||
|
||
getRole(empNo) {
|
||
if (empNo === 'administrator') return ROLES.FALLBACK_ADMIN
|
||
return (this._transactionSnapshot?.roles || this._roles).get(empNo) || ROLES.USER
|
||
}
|
||
|
||
/**
|
||
* 个人偏好:admin 级默认开启查看全部;显式 false 才关闭。
|
||
* user 不会走到这里(resolvePermissions 里 allowToggle=false)。
|
||
*/
|
||
isViewAllSessionsEnabled(empNo) {
|
||
if (!empNo) return false
|
||
const prefs = (this._transactionSnapshot?.prefs || this._prefs).get(String(empNo))
|
||
if (prefs && Object.prototype.hasOwnProperty.call(prefs, 'viewAllSessions')) {
|
||
return !!prefs.viewAllSessions
|
||
}
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* 设置「查看全部会话」偏好(调用方需校验 canToggleViewAllSessions)
|
||
* @param {string} empNo
|
||
* @param {boolean} enabled
|
||
*/
|
||
setViewAllSessions(empNo, enabled) {
|
||
const key = String(empNo || '').trim()
|
||
if (!key) throw codedError('emp_no_required')
|
||
const role = this.getRole(key)
|
||
if (!canToggleViewAllSessions(role)) {
|
||
throw codedError('forbidden_view_all_sessions')
|
||
}
|
||
const cur = { ...(this._prefs.get(key) || {}) }
|
||
// Persist explicit true/false — deleting the key would fall back to default-on.
|
||
cur.viewAllSessions = !!enabled
|
||
this._prefs.set(key, cur)
|
||
this._markDirty()
|
||
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) : ''
|
||
const r = role || this.getRole(id)
|
||
const allowToggle = canToggleViewAllSessions(r)
|
||
return computePermissions(r, {
|
||
viewAllSessions: allowToggle && this.isViewAllSessionsEnabled(id),
|
||
})
|
||
}
|
||
|
||
hasRole(empNo) {
|
||
return (this._transactionSnapshot?.roles || this._roles).has(empNo)
|
||
}
|
||
|
||
getAll() {
|
||
return Array.from((this._transactionSnapshot?.roles || this._roles).entries()).map(([empNo, role]) => ({ empNo, role }))
|
||
}
|
||
|
||
/** 角色表是否为空(用于 bootstrap) */
|
||
isEmpty() {
|
||
return (this._transactionSnapshot?.roles || this._roles).size === 0
|
||
}
|
||
|
||
/**
|
||
* 分页 + 工号模糊搜索(上千用户场景)
|
||
* @param {{ page?: number, pageSize?: number, q?: string }} opts
|
||
*/
|
||
listPage(opts = {}) {
|
||
const page = Math.max(1, Number(opts.page) || 1)
|
||
const pageSize = Math.min(200, Math.max(1, Number(opts.pageSize) || 50))
|
||
const q = String(opts.q || '').trim().toLowerCase()
|
||
|
||
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))
|
||
}
|
||
rows.sort((a, b) => String(a.empNo).localeCompare(String(b.empNo), 'zh'))
|
||
const total = rows.length
|
||
const start = (page - 1) * pageSize
|
||
const users = rows.slice(start, start + pageSize).map(({ empNo, role }) => ({
|
||
empNo,
|
||
role,
|
||
roleLabel: ROLE_LABELS[role] || role,
|
||
}))
|
||
return {
|
||
users,
|
||
total,
|
||
page,
|
||
pageSize,
|
||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||
}
|
||
}
|
||
|
||
async countByRole(role) {
|
||
let n = 0
|
||
for (const r of this._roles.values()) if (r === role) n++
|
||
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 个 active super_admin(若表中曾有)
|
||
*/
|
||
async setRole(empNo, newRole, currentAdminRole) {
|
||
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) {
|
||
throw codedError('forbidden_set_role')
|
||
}
|
||
|
||
// 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')
|
||
}
|
||
|
||
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
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 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')
|
||
}
|
||
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')
|
||
}
|
||
}
|
||
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);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.isDisabled(empNo)) {
|
||
throw codedError('account_disabled')
|
||
}
|
||
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 ===
|
||
|
||
_ensureCommittedFallback() {
|
||
if (!this._committedFallback) {
|
||
this._committedFallback = {
|
||
hash: this._fallbackPasswordHash,
|
||
needs: this._fallbackNeedsRehash,
|
||
ver: this._fallbackCredVersion || 0,
|
||
}
|
||
}
|
||
}
|
||
|
||
_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')
|
||
}
|
||
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._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
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 验证 fallback 密码 + rate limit
|
||
* @returns {boolean}
|
||
*/
|
||
verifyFallback(password, ip) {
|
||
if (!this._fallbackPasswordHash) return false
|
||
const now = Date.now()
|
||
|
||
// rate limit: 5 tries per minute per IP
|
||
let bucket = this._fallbackRateLimit.get(ip)
|
||
if (!bucket || now > bucket.resetAt) {
|
||
bucket = { count: 0, resetAt: now + 60_000 }
|
||
this._fallbackRateLimit.set(ip, bucket)
|
||
}
|
||
bucket.count++
|
||
if (bucket.count > 5) {
|
||
return false // rate limited
|
||
}
|
||
|
||
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 }
|