Files

271 lines
9.8 KiB
JavaScript
Raw Permalink Normal View History

'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;