'use strict'; /** * 管理员账号管理(R178 · C 层) * ============================================================================ * * 职责:多管理员账号的增 / 改权限 / 重置口令 / 禁用启用 / 删除,以及高危操作审计。 * * 三条不可协商的边界: * * 1. **最高管理员不可变动**(用户裁定):`is_super=1` 的账号不允许改权限、重置口令、 * 禁用、删除。判断集中在 `lib/adminPermissions.isManageable()`,⛔ 不得在各方法里 * 各写一遍 `id === 1` —— 那样改一处漏一处,且将来账号 id 变化就失效。 * * 2. **口令策略单源**:一律走 `lib/passwordPolicy.checkPassword()`, * ⛔ 不在这里写第二套长度/黑名单规则(第 25 步门禁会比对两套规则是否漂移)。 * 生产环境另外拒绝「已知默认口令」,与 adminLogin 的守卫同源。 * * 3. **令牌失效靠自增**:改密 / 禁用时 `token_version += 1`。 * middleware/auth.js 每请求比对,旧令牌立即 401。本项目没有会话表, * 这是唯一的「强制下线」手段。 */ const Service = require('egg').Service; const passwordPolicy = require('../lib/passwordPolicy'); const adminPermissions = require('../lib/adminPermissions'); const { recordAudit, ACTIONS } = require('../lib/auditLog'); /** 用户名规则:字母数字与 _ . - ,3~50 位。⛔ 不允许中文与空格,避免与登录校验规则不一致。 */ const USERNAME_RE = /^[A-Za-z0-9_.-]{3,50}$/; const REMARK_MAX = 200; /** 业务错误:由 controller 翻译成 { code, message },不直接改 HTTP 状态。 */ function bizError(code, message) { const err = new Error(message); err.bizCode = code; err.bizMessage = message; return err; } class AdminUserService extends Service { /** * 管理员列表(脱敏:绝不返回 password) * 排序:最高管理员置顶,其余按 id 升序 —— 与「主账号永远第一行」的视觉预期一致。 */ async list() { const { ctx } = this; const rows = await ctx.model.Admin.findAll({ attributes: [ 'id', 'username', 'is_super', 'permissions', 'status', 'last_login_at', 'last_login_ip', 'remark', 'created_at', 'updated_at', ], order: [['is_super', 'DESC'], ['id', 'ASC']], raw: true, }); return rows.map(r => ({ id: r.id, username: r.username, isSuper: Number(r.is_super) === 1, permissions: adminPermissions.parsePermissions(r.permissions), status: Number(r.status) === 1 ? 1 : 0, lastLoginAt: r.last_login_at || null, lastLoginIp: r.last_login_ip || null, remark: r.remark || '', createdAt: r.created_at || null, // 前端据此把「改权限/重置口令/禁用/删除」四个按钮整体置灰 manageable: adminPermissions.isManageable(r), })); } /** * 取一个**可被管理**的目标账号。 * @throws bizCode 400 不存在 / 403 最高管理员不可变动 */ async _loadManageable(id) { const { ctx } = this; const numericId = Number(id); if (!Number.isInteger(numericId) || numericId <= 0) { throw bizError(400, '管理员 ID 不合法'); } const admin = await ctx.model.Admin.findOne({ where: { id: numericId }, attributes: ['id', 'username', 'is_super', 'permissions', 'status', 'token_version'], raw: true, }); if (!admin) throw bizError(400, '该管理员账号不存在'); if (!adminPermissions.isManageable(admin)) { throw bizError(403, '最高管理员账号不可修改(权限 / 口令 / 状态 / 删除均被禁止)'); } return admin; } /** * 新增管理员 * * @param {{username:string, password:string, permissions:string[], remark?:string}} data */ async create(data) { const { ctx } = this; const username = String((data && data.username) || '').trim(); const password = String((data && data.password) || ''); if (!USERNAME_RE.test(username)) { throw bizError(400, '用户名需为 3-50 位字母、数字、下划线、点或短横线'); } const pwdCheck = passwordPolicy.checkPassword(password, { username }); if (!pwdCheck.ok) throw bizError(400, pwdCheck.reason); // 与 adminLogin 的守卫同源:生产环境不允许把「已知默认口令」分配给新账号 if (this.app.config.isProduction && passwordPolicy.DEFAULT_ADMIN_PASSWORDS.includes(password)) { throw bizError(400, '生产环境禁止使用默认口令,请设置强密码'); } const exists = await ctx.model.Admin.findOne({ where: { username }, attributes: ['id'] }); if (exists) throw bizError(400, '该用户名已存在'); const perms = adminPermissions.sanitizePermissions(data && data.permissions); const remark = data && data.remark ? String(data.remark).slice(0, REMARK_MAX) : null; let created; try { created = await ctx.model.Admin.create({ username, password: await ctx.helper.hashPassword(password), is_super: 0, // 新增账号永远不是最高管理员(最高管理员只能由初始化脚本产生) permissions: JSON.stringify(perms), status: 1, token_version: 0, remark, created_at: new Date(), updated_at: new Date(), }); } catch (e) { // 并发下 findOne 与 create 之间仍可能撞唯一索引 if (/ER_DUP_ENTRY/.test(String(e && e.message))) { throw bizError(400, '该用户名已存在'); } throw e; } await recordAudit(ctx, ACTIONS.ADMIN_CREATE, `admin:${username}`, { id: created.id, permissions: perms, }); return { id: created.id, username, permissions: perms, remark: remark || '' }; } /** * 编辑权限(含备注) * * ⚠️ 不递增 token_version:权限是每请求查库判定,改完立即生效; * 递增反而会把正在操作的人无谓踢下线。 */ async updatePermissions(id, data) { const { ctx } = this; const target = await this._loadManageable(id); const perms = adminPermissions.sanitizePermissions(data && data.permissions); const patch = { permissions: JSON.stringify(perms), updated_at: new Date(), }; if (data && data.remark !== undefined) { patch.remark = data.remark ? String(data.remark).slice(0, REMARK_MAX) : null; } await ctx.model.Admin.update(patch, { where: { id: target.id } }); const before = adminPermissions.parsePermissions(target.permissions); await recordAudit(ctx, ACTIONS.ADMIN_PERMISSIONS, `admin:${target.username}`, { before, after: perms, added: perms.filter(p => !before.includes(p)), removed: before.filter(p => !perms.includes(p)), }); return { id: target.id, permissions: perms }; } /** * 重置口令(管理员代改,不要求旧口令) * 递增 token_version ⇒ 目标账号所有在线会话立即失效。 */ async resetPassword(id, data) { const { ctx } = this; const target = await this._loadManageable(id); const password = String((data && data.password) || ''); const pwdCheck = passwordPolicy.checkPassword(password, { username: target.username }); if (!pwdCheck.ok) throw bizError(400, pwdCheck.reason); if (this.app.config.isProduction && passwordPolicy.DEFAULT_ADMIN_PASSWORDS.includes(password)) { throw bizError(400, '生产环境禁止使用默认口令,请设置强密码'); } const nextVersion = Number(target.token_version || 0) + 1; await ctx.model.Admin.update({ password: await ctx.helper.hashPassword(password), token_version: nextVersion, updated_at: new Date(), }, { where: { id: target.id } }); // ⛔ 审计 detail 不含口令本身,只记事实 await recordAudit(ctx, ACTIONS.ADMIN_PASSWORD, `admin:${target.username}`, { tokenVersion: nextVersion, }); return { id: target.id, tokenVersion: nextVersion }; } /** * 禁用 / 启用 * 禁用时递增 token_version ⇒ 该账号已签发的令牌下次请求即 401。 * 启用不递增:重新启用后旧令牌早已因禁用期间的版本差异失效,无需再踢。 */ async setStatus(id, data) { const { ctx } = this; const target = await this._loadManageable(id); const status = Number(data && data.status); if (status !== 0 && status !== 1) throw bizError(400, '状态值只能是 0(禁用)或 1(启用)'); // 不允许把自己禁用 —— 否则可能一次性把所有可用账号锁死 const me = ctx.state.currentAdmin; if (me && Number(me.id) === Number(target.id) && status === 0) { throw bizError(400, '不能禁用自己的账号'); } const patch = { status, updated_at: new Date() }; if (status === 0) patch.token_version = Number(target.token_version || 0) + 1; await ctx.model.Admin.update(patch, { where: { id: target.id } }); await recordAudit(ctx, ACTIONS.ADMIN_STATUS, `admin:${target.username}`, { status, tokenVersion: patch.token_version || target.token_version, }); return { id: target.id, status }; } /** * 删除账号 * 不可删最高管理员(_loadManageable 已挡)、不可删自己。 */ async remove(id) { const { ctx } = this; const target = await this._loadManageable(id); const me = ctx.state.currentAdmin; if (me && Number(me.id) === Number(target.id)) { throw bizError(400, '不能删除自己的账号'); } const affected = await ctx.model.Admin.destroy({ where: { id: target.id } }); if (affected !== 1) throw bizError(500, '删除失败:目标账号已不存在'); await recordAudit(ctx, ACTIONS.ADMIN_DELETE, `admin:${target.username}`, { id: target.id, lastPermissions: adminPermissions.parsePermissions(target.permissions), }); return { id: target.id, deleted: true }; } } module.exports = AdminUserService;