04 创建第一个管理员

系统刚部署时没有任何账号,因此无法通过“已登录管理员创建账号”的接口获得第一个管理员。本章使用独立 CLI 脚本解决启动引导问题,不开放注册接口。

1. 为什么没有公开注册接口

不要实现:

POST /api/auth/register

这是内部管理后台。如果公开接口允许任何访问者创建管理员,就等于绕过了整个认证系统。

正确边界:

第一个管理员:服务器上运行一次性 CLI 脚本
后续管理员:由已登录管理员调用受保护接口创建

2. 脚本位置与运行方式

建议:

scripts/create-initial-admin.ts

开发/学习环境:

ts-node scripts/create-initial-admin.ts

生产构建后:

node dist/scripts/create-initial-admin.js

确认 tsconfig.jsonrootDir/include/outDir 会把脚本编译到预期位置。PM2 不需要运行该脚本,它不是长期服务。

3. 输入来源选择

新人第一版可从专用环境变量读取:

INITIAL_ADMIN_USERNAME=admin
INITIAL_ADMIN_PASSWORD=只在创建时临时设置
INITIAL_ADMIN_DISPLAY_NAME=系统管理员
INITIAL_ADMIN_EMAIL=admin@example.com

执行完成后删除 INITIAL_ADMIN_PASSWORD。不要把它写入仓库的 .env.example 示例值,也不要输出到日志。

更好的进阶方式是在 TTY 中隐藏输入密码,但它需要额外处理交互、非交互环境和确认输入,本练习先不强制。

4. 输入类型与 Service 契约

export interface CreateInitialAdminInput {
  username: string;
  password: string;
  displayName: string;
  email?: string;
}

export async function createInitialAdmin(
  input: CreateInitialAdminInput,
): Promise<AdminUserDto> {
  // TODO
  throw new Error("TODO");
}

虽然它是 CLI,仍应把业务函数和 process.env/process.exitCode 分开,以便测试。

5. 主程序骨架

import "dotenv/config";

async function main(): Promise<void> {
  const username = process.env.INITIAL_ADMIN_USERNAME;
  const password = process.env.INITIAL_ADMIN_PASSWORD;
  const displayName = process.env.INITIAL_ADMIN_DISPLAY_NAME;
  const email = process.env.INITIAL_ADMIN_EMAIL;

  if (!username || !password || !displayName) {
    throw new Error("缺少创建初始管理员所需的环境变量");
  }

  // TODO:使用和 HTTP 服务相同的数据库初始化入口
  // TODO:调用 createInitialAdmin()
  // 只打印管理员 ID/username;绝不打印 password 或 passwordHash
}

main()
  .catch((error: unknown) => {
    // TODO:记录经过脱敏的错误
    process.exitCode = 1;
  })
  .finally(async () => {
    // TODO:关闭 Sequelize 连接;注意初始化失败时的情况
  });

不要在函数中调用 process.exit(1) 强行退出,它可能让日志和数据库资源来不及完成清理。设置 process.exitCode 后让事件循环自然结束通常更稳妥。

6. Service 实现步骤

保留为 TODO 的核心流程:

export async function createInitialAdmin(
  input: CreateInitialAdminInput,
): Promise<AdminUserDto> {
  // TODO 1:校验并规范化 username、displayName、email
  // TODO 2:应用密码长度与强度规则
  // TODO 3:检查系统是否允许继续创建“初始管理员”
  // TODO 4:在数据库事务外执行 Argon2id hash
  // TODO 5:创建 AdminUser
  // TODO 6:捕获数据库唯一约束竞争
  // TODO 7:转换为 AdminUserDto
  throw new Error("TODO");
}

为什么 Hash 通常放在事务外:Argon2 是故意耗时且耗内存的计算。如果先开启数据库事务再 Hash,会无意义地延长事务和连接占用时间。

7. Argon2id 骨架

import argon2 from "argon2";

export async function hashPassword(password: string): Promise<string> {
  return argon2.hash(password, {
    type: argon2.argon2id,
    // TODO:学习默认参数,并根据部署服务器资源决定是否显式配置
  });
}

不要自己生成“固定盐”。Argon2 库会为每次 Hash 生成随机盐并编码进结果。两个相同密码得到不同摘要是正常且必要的。

密码规则应与登录和修改密码功能共享,例如:

8. “第一个”到底如何判断

有两种练习策略。

策略 A:只限制用户名唯一

脚本可重复用于创建不同管理员。简单,但脚本权限很大,服务器上任何能执行它的人都可创建管理员。

策略 B:只有管理员表为空时允许创建

SELECT COUNT(*)
  -> count === 0 才允许

它更符合“初始管理员”语义,但“先 count 再 insert”存在并发窗口。两个脚本可能同时观察到 0。

本练习建议先实现策略 B,并思考:

不要用删除所有管理员再重跑脚本的方式恢复生产账号。

9. 唯一约束仍要捕获

即便先执行:

await AdminUser.findOne({ where: { username } });

在查询和插入之间,另一个进程仍可能创建同名账号。因此需要识别 Sequelize:

UniqueConstraintError

并映射为明确错误,而不是把 SQL 和数据库字段暴露给终端用户或 HTTP 客户端。

10. 日志与敏感数据

允许记录:

{
  "event": "initial_admin_created",
  "adminId": "1",
  "username": "admin"
}

禁止记录:

password
passwordHash
JWT Secret
完整 process.env
包含敏感 replacement 的 SQL

特别警惕:

logger.info({ input });
logger.info({ admin: admin.toJSON() });

它们可能把密码输入或密码摘要写入长期日志。

11. 验收步骤

首次执行

ts-node scripts/create-initial-admin.ts

预期:

重复执行

再次执行相同命令,预期返回明确失败并且:

密码验证小实验

在一次性测试代码中调用:

await argon2.verify(storedHash, originalPassword);

正确密码应为 true,错误密码应为 false。测试输出只打印 Boolean,不打印密码与摘要。

12. 新人常见错误

13. 本章练习

  1. 完成环境变量读取和输入校验,不输出秘密。
  2. 实现 hashPassword(),说明为什么相同密码的两次结果不同。
  3. 实现 createInitialAdmin() 的 TODO,并捕获唯一约束错误。
  4. 为成功、重复执行和配置缺失分别设计进程退出码。
  5. 为 Service 写测试,验证返回 DTO 不包含 passwordHash
  6. 思考表为空检查的并发窗口,并写出可接受的第一阶段策略。
  7. 编译脚本后确认实际输出路径,再用 Node.js 直接运行。
  8. 创建成功后删除临时环境变量,并说明为什么不能把初始密码留在 shell history。