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.json 的 rootDir/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 生成随机盐并编码进结果。两个相同密码得到不同摘要是正常且必要的。
密码规则应与登录和修改密码功能共享,例如:
- 明确最小、最大长度;
- 最大长度能限制恶意大输入带来的 Hash 成本;
- 不使用
trim()默默修改用户密码; - username 可以 trim/规范化,但密码应按原始字符处理。
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
预期:
- 退出码为 0;
- 数据库新增一个 active 管理员;
password_hash是$argon2id$...;- 终端没有打印密码或摘要;
- 数据库连接正常关闭。
重复执行
再次执行相同命令,预期返回明确失败并且:
- 不创建第二个初始管理员;
- 不覆盖原密码;
- 退出码非 0;
- 没有未处理 Promise rejection。
密码验证小实验
在一次性测试代码中调用:
await argon2.verify(storedHash, originalPassword);
正确密码应为 true,错误密码应为 false。测试输出只打印 Boolean,不打印密码与摘要。
12. 新人常见错误
- 提供公开管理员注册接口;
- 保存明文、MD5 或 SHA-1 密码;
- 记录完整输入对象;
- 把密码先 trim 再 Hash;
- 在事务开启后执行耗时 Hash;
- 认为
findOne()查重可以替代唯一约束; - 忘记关闭 Sequelize,导致 CLI 不退出;
- 失败时仍返回退出码 0,使部署脚本误判成功;
- 每次部署都自动执行初始化脚本;
- 使用
sync({ force: true })为了得到管理员表。
13. 本章练习
- 完成环境变量读取和输入校验,不输出秘密。
- 实现
hashPassword(),说明为什么相同密码的两次结果不同。 - 实现
createInitialAdmin()的 TODO,并捕获唯一约束错误。 - 为成功、重复执行和配置缺失分别设计进程退出码。
- 为 Service 写测试,验证返回 DTO 不包含
passwordHash。 - 思考表为空检查的并发窗口,并写出可接受的第一阶段策略。
- 编译脚本后确认实际输出路径,再用 Node.js 直接运行。
- 创建成功后删除临时环境变量,并说明为什么不能把初始密码留在 shell history。