change log in

This commit is contained in:
oliver 2026-09-08 00:28:58 +08:00
parent 9cf026f605
commit 8ca42dd973
211 changed files with 48417 additions and 109 deletions

258
uds-auth/lib/roles.js Normal file
View file

@ -0,0 +1,258 @@
/**
* uds-auth 角色存储 + 权限管理
*
* 角色:
* super_admin 所有权限 + 用户管理
* admin 设置权限 + 仅看自己会话
* user 仅看自己会话,无设置
*
* 持久化: roles.json (单实例文件) + MemoryStore 同步
*/
import { createHash, randomBytes } from 'node:crypto'
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
export const ROLES = {
SUPER_ADMIN: 'super_admin',
ADMIN: 'admin',
USER: 'user',
FALLBACK_ADMIN: 'fallback_admin', // 特殊,UAC 挂了时用,等同 super_admin 权限
}
export const ROLE_LABELS = {
super_admin: '超级管理员',
admin: '管理员',
user: '普通用户',
fallback_admin: '兜底管理员',
}
/** 计算角色权限 (纯函数) */
export function computePermissions(role) {
switch (role) {
case ROLES.SUPER_ADMIN:
return { canManageUsers: true, canAccessSettings: true, canViewAllSessions: true }
case ROLES.FALLBACK_ADMIN:
return { canManageUsers: true, canAccessSettings: true, canViewAllSessions: true }
case ROLES.ADMIN:
return { canManageUsers: false, canAccessSettings: true, canViewAllSessions: false }
default: // user / undefined
return { canManageUsers: false, canAccessSettings: false, canViewAllSessions: false }
}
}
function hashPassword(password) {
return createHash('sha256').update(password).digest('hex')
}
/**
* RolesStore — 角色存储
* 内存 Map + 可选 JSON 文件持久化
*
* fallback admin: 默认不启用,需在 DSH 设置里配置密码哈希
* 登录路径: POST /uds-auth/admin-login { username, password }
*/
export class RolesStore {
constructor(options = {}) {
this._roles = new Map() // empNo → role
this._firstBootLock = Promise.resolve()
this._rolesFile = options.rolesFile ? resolve(options.rolesFile) : null
this._fallbackPasswordHash = null // SHA-256 hex,null = 未启用
this._fallbackRateLimit = new Map() // ip → { count, resetAt }
this._dirty = false
this._saveTimer = null
}
// === 持久化 ===
async init() {
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)
}
if (data.fallbackPasswordHash) {
this._fallbackPasswordHash = data.fallbackPasswordHash
}
} catch (err) {
if (err.code === 'ENOENT') {
// 首次启动,文件不存在,正常
} else {
console.warn('[uds-auth:RolesStore] Failed to load roles file:', err.message)
}
}
}
}
_markDirty() {
this._dirty = true
if (this._saveTimer) return
this._saveTimer = setTimeout(() => this._save(), 2000)
}
async _save() {
this._saveTimer = null
if (!this._dirty || !this._rolesFile) return
this._dirty = false
try {
const data = {
roles: Object.fromEntries(this._roles),
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')
} catch (err) {
console.warn('[uds-auth:RolesStore] Failed to save roles file:', err.message)
}
}
// === 首次部署 bootstrap ===
/**
* 原子 check-and-set: 第一个登录的用户 = super_admin
* 返回 { role, bootstrapped } bootstrapped=true 表示本次是 bootstrap
*/
async bootstrapFirstUser(empNo) {
// 用锁保证原子性
this._firstBootLock = this._firstBootLock.then(async () => {
if (this._roles.size === 0) {
this._roles.set(empNo, ROLES.SUPER_ADMIN)
this._markDirty()
return { role: ROLES.SUPER_ADMIN, bootstrapped: true }
}
if (!this._roles.has(empNo)) {
this._roles.set(empNo, ROLES.USER)
this._markDirty()
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
}
hasRole(empNo) {
return this._roles.has(empNo)
}
getAll() {
return Array.from(this._roles.entries()).map(([empNo, role]) => ({ empNo, role }))
}
async countByRole(role) {
let n = 0
for (const r of this._roles.values()) if (r === role) n++
return n
}
// === 角色管理 (super_admin only) ===
/**
* 设置用户角色
* 保护性 invariant: 至少保留 1 个 super_admin
*/
async setRole(empNo, newRole, currentAdminRole) {
if (currentAdminRole !== ROLES.SUPER_ADMIN) {
throw new Error('只有超级管理员可以修改角色')
}
// 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 new Error('系统至少需要 1 个超级管理员,不能降级最后一个')
}
}
this._roles.set(empNo, newRole)
this._markDirty()
return true
}
/** 删除用户 */
async removeUser(empNo, currentAdminRole) {
if (currentAdminRole !== ROLES.SUPER_ADMIN) {
throw new Error('只有超级管理员可以删除用户')
}
const currentRole = this._roles.get(empNo)
if (currentRole === ROLES.SUPER_ADMIN) {
const superAdmins = await this.countByRole(ROLES.SUPER_ADMIN)
if (superAdmins <= 1) {
throw new Error('系统至少需要 1 个超级管理员,不能删除最后一个')
}
}
this._roles.delete(empNo)
this._markDirty()
return true
}
/** 确保用户存在 (如果不存在设为 user) */
ensureUser(empNo, currentAdminRole) {
if (currentAdminRole !== ROLES.SUPER_ADMIN) {
throw new Error('只有超级管理员可以添加用户')
}
if (!this._roles.has(empNo)) {
this._roles.set(empNo, ROLES.USER)
this._markDirty()
}
return true
}
// === Fallback Administrator ===
setFallbackPassword(password, currentAdminRole) {
if (currentAdminRole !== ROLES.SUPER_ADMIN) {
throw new Error('只有超级管理员可以设置兜底管理员密码')
}
if (!password || password.length < 6) {
throw new Error('密码至少 6 位')
}
this._fallbackPasswordHash = hashPassword(password)
this._markDirty()
return true
}
clearFallbackPassword(currentAdminRole) {
if (currentAdminRole !== ROLES.SUPER_ADMIN) {
throw new Error('只有超级管理员可以清除兜底管理员密码')
}
this._fallbackPasswordHash = null
this._markDirty()
return true
}
isFallbackEnabled() {
return this._fallbackPasswordHash !== null
}
/**
* 验证 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
}
return hashPassword(password) === this._fallbackPasswordHash
}
}