成为全栈·Node 后端篇·注册登录全流程实现
把认证"聊明白",和把它"写出来",中间隔着一条很宽的河。上一篇我们刚把方案定下来------JWT 访问令牌 + 有状态刷新令牌。可方案只是图纸,真动起手来,问题才一个个浮出水面:密码怎么存,才不至于数据库一泄露、用户就全被交代出去?登录失败,该不该告诉对方"这个账号不存在"?用户点了登出,旧的令牌到底还算不算数?
这一篇我不绕理论,把 注册、登录、刷新、登出 四条链路一行行走通------从 bcrypt 盐哈希讲到双令牌签发,从防账号枚举讲到即时登出。每一行代码,都告诉你为什么非得这么写。

走到底,还会撞上后端新手几乎必卡的一道坎:注册接口永远只能造出 member,提权却必须由 admin 来操作------那系统里的第一个管理员,到底从哪来? 这个死锁和我们的破局方式,留到后文专门拆解。
一、注册:密码绝不能明文存
注册的本质就三步:校验入参(M1-10 已在最外层做完)→ 查重 → 落库。其中最重要的一条铁律是:密码永远不能明文存储。一旦数据库泄露,明文密码等于把用户全交代了。
我们用 bcrypt 做"盐哈希"。看 src/shared/password.ts:
ts
import { compare, hash } from 'bcryptjs';
const BCRYPT_ROUNDS = 12;
export const hashPassword = (plain: string): Promise<string> => hash(plain, BCRYPT_ROUNDS);
export const verifyPassword = (plain: string, storedHash: string): Promise<boolean> =>
compare(plain, storedHash);
选型 bcryptjs(纯 JS 实现)而非原生 bcrypt,是因为它零原生编译依赖,在我们的"本地 + Cloudflare 双端"诉求下不会卡编译。成本因子 12 轮,对注册/登录这种低频操作,安全性优先于那点性能开销(生产负载高时可降到 10--11)。
为什么非得用 bcrypt 这种"慢哈希",而不能用 MD5 / SHA 这类常见哈希?因为 MD5 / SHA 的设计目标是"快",而"快"对密码存储恰恰是缺点------攻击者拿到泄露的哈希库,可以用彩虹表或暴力穷举在几秒内反查出常见密码。bcrypt 故意把计算做得又慢又带盐(每个密码随机加盐,相同密码的哈希结果也不同),让暴力破解的成本高到不划算。一句话:存密码用慢哈希,这是安全底线中的底线,没有商量余地。
注册落库的逻辑在 src/services/user.ts 的 registerUser:
ts
export const registerUser = async (input: RegisterUserInput): Promise<User> => {
const db = getDb();
const { username, email, password, nickname } = input;
const dup = await db.select({ id: users.id }).from(users)
.where(or(eq(users.username, username), eq(users.email, email))).all();
if (dup.length > 0) throw new AppError(ErrCode.CONFLICT, 409); // 3002 常见路径
try {
const inserted = await db.insert(users).values({
username, email,
passwordHash: await hashPassword(password),
displayName: nickname ?? username,
role: 'member', // ← 强制 member,不可自定角色
status: 'active', level: 1,
createdAt: new Date(), updatedAt: new Date(),
}).returning().all();
// ...
} catch (err) {
if (isUniqueConstraintError(err)) throw new AppError(ErrCode.CONFLICT, 409); // 3002 并发兜底
throw err;
}
};
两个细节值得记住:
第一,role 被硬编码为 'member'。 客户端传什么 role 我们都不认(呼应 M1-10 的"信任边界"),新用户永远是普通会员。提权只能走后台审核接口,且操作者自身得是 admin------这是防越权的一道硬墙。
第二,查重 + 并发兜底双保险。 先查重(常见路径),但并发下两个相同请求可能都"查到不冲突",于是插入时撞唯一约束------这时靠 catch 里的 isUniqueConstraintError 收口回 409(这就是 M1-09 讲的 P-19 模式)。先查后插防正常情况,唯一约束防并发竞态,两层缺一不可。
二、登录:不暴露"账号是否存在"
登录校验在 authenticateUser,它有个精妙的安全设计:
ts
export const authenticateUser = async (username, password): Promise<User> => {
const user = (await getDb().select().from(users).where(eq(users.username, username)).all())[0];
if (!user) throw new AppError(ErrCode.USERNAME_OR_PASSWORD_ERROR, 401); // 1001
if (user.status === 'disabled') throw new AppError(ErrCode.ACCOUNT_DISABLED, 401); // 1005
if (!(await verifyPassword(password, user.passwordHash))) {
throw new AppError(ErrCode.USERNAME_OR_PASSWORD_ERROR, 401); // 1001
}
return user;
};
注意:无论"用户不存在"还是"密码错",统一返回 1001(用户名或密码错误)。攻击者无法靠返回差异判断"哪个用户名有人注册",从根上挡住了账号枚举攻击。账号被禁用(1005)单独区分,是因为前端要据此跳"账号异常"页,但 1005 本身也不泄露"账号存在"之外的信息------它只在"用户名确实对得上"时才可能出现。
举个账号枚举的具体画面帮你看清危害。如果登录时"用户不存在"返回 1003、"密码错"返回 1001,攻击者写个脚本,用一堆常见用户名(admin、test、fungleo...)挨个试登录,看哪个返回的是"密码错"而不是"用户不存在"------凡是返回"密码错"的,就说明这个账号真实存在,下一步只需暴力破解它的密码。我们把两者合并成同一个 1001,攻击者的脚本就失去了"哪个账号存在"这个关键信号,枚举无从下手。这层"不暴露账号存在性"的讲究,是后端安全里性价比极高的一笔投入。
三、签发双令牌:无状态 + 有状态各管一摊
登录验证通过后,由 buildAuthResult 签发两个令牌:
ts
export const buildAuthResult = async (user, refreshRaw?): Promise<AuthResult> => {
const accessToken = await signAccessToken(
{ sub: String(user.id), role: user.role as Role },
getActiveEnv().JWT_SECRET,
ACCESS_TTL_SEC, // 3600 秒 = 1 小时
);
const refreshToken = refreshRaw ?? (await issueRefreshToken(user.id)).raw;
return { accessToken, expiresIn: ACCESS_TTL_SEC, refreshToken, user: toPublicUser(user) };
};
- access token:JWT,1 小时有效,无状态------请求进来验章即用,不查库(呼应 M1-12 的 P-23 角色快照)。
- refresh token :有状态,明文只在这一刻返回一次,库里只存哈希(在
refresh_tokens表)。它负责"access 过期时换新的",且可被随时吊销。
路由层(我们之前读过的 routes/auth.ts)把这两个令牌一个塞进响应体、一个种进 HttpOnly; Secure; SameSite=None 的 Cookie------刷新令牌放 Cookie 而非 JSON,能借浏览器的同源/CSRF 防护少一层风险。

四、刷新与登出:有状态 refresh 的真正价值
刷新接口 /refresh 调用 rotateRefreshToken:用旧 refresh token 换新 access token 时,同时把旧 refresh 标记为已旋转/作废,签发一个新的。这叫"旋转(rotation)"------即使旧令牌泄露,它也立刻失效,且无法被重放。
登出 /logout 则调用 revokeUserTokens(用户ID),直接删掉该用户所有 refresh 记录。这正是"有状态 refresh"对比纯 JWT 的最大优势:用户一点登出,他手里哪怕 access token 还没过期,等它一过期就再也换不到新的了------会话被干净终结。如果 refresh 也是无状态的 JWT,你根本做不到"即时登出",只能干等令牌自然过期。
对比一下就知价值:纯 JWT 无状态方案里,登出往往只是"前端把 token 扔掉",服务端那侧 token 依然有效,别人若在你登出前截获了它,还能继续用;而有状态 refresh 方案里,登出是"服务端把刷新记录删了",相当于把水龙头总闸关了,后续换发全断。对于"用户报告账号被盗、要立刻踢人下线"这种运营场景,有状态 refresh 是不可或缺的------这也是我们当初宁可多维护一张 refresh_tokens 表,也不走纯 JWT 的原因。
五、P-25:无首 admin 死锁,与 pnpm seed 破局
现在来到本文最核心的一个真实死锁(P-25)。回看上面:注册强制 新用户是 member;而把 member 提升成 editor/admin 的接口,又要求操作者本身是 admin。于是问题来了------
没有任何正常注册流程能产生第一个 admin。先有鸡还是先有蛋?
这是后端新手极容易卡住的地方,而且它属于"阻断性死锁",正确的处理态度是:遇到这类阻塞,先停下与 owner 对齐,不要自己造 workaround 绕过。我们曾在这个死锁上栽过跟头:一度图省事,临时造了个直插数据库的脚本来"造"首个 admin,被 owner 指出这违背了"遇到阻断性卡点必须停下对齐、不得自行绕过"的铁律------因为直插 SQL 绕过了领域逻辑(角色校验、密码哈希格式),看似解了眼前,实则埋下"生产环境和应用层逻辑不一致"的雷。正确流向是:发现死锁 → 立刻报告 owner → 等裁定 → 走正规的种子脚本。这个纪律值得每个做后端的记住:卡住时,停下来对齐比蒙头绕过安全。
我们最终商定的正解,是一个种子脚本 scripts/seed-users.ts,靠 pnpm seed 创建首个管理员。它走的是正规应用层,而不是绕过领域逻辑直插 SQL:
ts
// scripts/seed-users.ts(节选)
const existing = (await db.select().from(users)
.where(eq(users.username, username)).limit(1).all())[0];
if (!existing) {
const inserted = await db.insert(users).values({
username, email,
passwordHash: await hashPassword(password), // 复用同一 hashPassword,与登录 verifyPassword 兼容
displayName: nickname, role: 'admin', status: 'active', level: 1,
createdAt: new Date(), updatedAt: new Date(),
}).returning().all();
// ...
} else if (existing.role !== 'admin' || forceReset) {
// 已存在但非 admin(残留账号)→ 确保提升为 admin;--reset 强制重置密码
await db.update(users).set({ role: 'admin', status: 'active', /* ... */ })
.where(eq(users.id, existing.id)).run();
}
几个关键点:
- 复用
hashPassword:种子账号的密码哈希格式,和正常注册、登录校验完全兼容,不是两套算法。 - 走 Drizzle + 应用层唯一约束校验 :和
registerUser同语义,不绕过任何领域逻辑------这点和"冒烟测试用的直插 DB 脚手架"是刻意区分的,后者明确标注"生产需另行补种子机制",不能当真。 - 幂等 :用户名已存在就跳过;已存在但非 admin 则提升;
--reset才强制重置密码。反复跑不会出错。 - 种子变量进
.env.example(M1-07 讲过的SEED_ADMIN_*),默认值可用,生产覆盖强口令即可。
种子脚本本身也要过门禁(P-55):biome 格式/规范检查 + 实跑验证,不能因为它是"脚本"就免检。
六、P-31 轻提:email 的"假唯一"
顺带回应一个数据库细节(P-31)。users 表的 email 上有唯一索引,但 SQLite 的唯一索引对 NULL 不冲突 ------也就是说两个 email 为 NULL 的用户不会撞索引。我们的处理是:注册时 email 必填,所以"可空唯一索引"在实践中等价于"非空唯一",既享受了允许缺席的灵活性,又没牺牲唯一性。这套"nullable 唯一 = 部分唯一"的小聪明,在多个表(slug、email)上都有体现,是 SQLite 路线下要心里有数的特性。
七、小结与前瞻
注册登录全流程,是把前面所有零件拧在一起:
- 密码哈希:bcryptjs 12 轮、纯 JS 零编译依赖,契合双端;明文绝不入库存。
- 注册 :
role强制member、查重 +isUniqueConstraintError并发兜底双保险。 - 登录 :
1001不区分"用户不存在/密码错",防账号枚举;1005单独标识禁用。 - 双令牌:access(JWT 1h 无状态)+ refresh(有状态、可旋转/吊销/登出作废)。
- 登出 :
revokeUserTokens删 refresh 记录,即时终结会话------有状态 refresh 的硬价值。 - P-25 :无首 admin 死锁靠
pnpm seed走正规应用层破局(复用 hashPassword、幂等、过门禁),绝不停留在"自己直插 SQL 绕过"。 - P-31:nullable 唯一索引 = 部分唯一,注册 email 必填使其等价于非空唯一。
下一篇({{LINK:M1-14}})我们正式进权限模型(RBAC):认证和授权到底差在哪、三角色 member/editor/admin 怎么分层、以及 guard 这个"角色阶梯 OR 资源归属"的核心原语怎么写。
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer
