成为全栈·Node 后端篇·注册登录全流程实现

成为全栈·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.tsregisterUser

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 不冲突 ------也就是说两个 emailNULL 的用户不会撞索引。我们的处理是:注册时 email 必填,所以"可空唯一索引"在实践中等价于"非空唯一",既享受了允许缺席的灵活性,又没牺牲唯一性。这套"nullable 唯一 = 部分唯一"的小聪明,在多个表(slug、email)上都有体现,是 SQLite 路线下要心里有数的特性。

七、小结与前瞻

注册登录全流程,是把前面所有零件拧在一起:

  1. 密码哈希:bcryptjs 12 轮、纯 JS 零编译依赖,契合双端;明文绝不入库存。
  2. 注册role 强制 member、查重 + isUniqueConstraintError 并发兜底双保险。
  3. 登录1001 不区分"用户不存在/密码错",防账号枚举;1005 单独标识禁用。
  4. 双令牌:access(JWT 1h 无状态)+ refresh(有状态、可旋转/吊销/登出作废)。
  5. 登出revokeUserTokens 删 refresh 记录,即时终结会话------有状态 refresh 的硬价值。
  6. P-25 :无首 admin 死锁靠 pnpm seed 走正规应用层破局(复用 hashPassword、幂等、过门禁),绝不停留在"自己直插 SQL 绕过"
  7. 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

相关推荐
AI大模型-小华4 小时前
Codex CLI第一次怎么用?从安装到读取本地项目完整教程
git·node.js·ai编程·开发工具·代码分析·codex·codex cli
晴天164 小时前
打造自己的 npm 包实战指南
前端·npm·node.js
西西小飞龙5 小时前
npm vs pnpm
前端·npm·node.js
FungLeo6 小时前
成为全栈·Node 后端篇·权限模型:从认证到 RBAC
node.js·rbac·用户认证·权限模型·成为全栈
码力斜杠哥7 小时前
CodeBuddy + Node.js 后端API完整开发指南
node.js
八角丶1 天前
Node 网络编程 —— TLS 模块
javascript·后端·node.js
嘿嘿-661 天前
Cherry Studio 接入ChatGpt:GPT-5.6 Sol 文本测试与 GPT-Image-2 出图教程
人工智能·gpt·ai·chatgpt·node.js·ai编程
大牧师1 天前
TypeORM 学习教程
数据库·sql·学习·node.js·orm·nest.js·typeorm
逻辑01 天前
安装 Claude Code 完整指南(用户级 Node.js 环境)
node.js·claude code