271 lines
9.8 KiB
JavaScript
271 lines
9.8 KiB
JavaScript
|
|
'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;
|