成为全栈·Node 后端篇·注册登录全流程实现
把认证"聊明白",和把它"写出来",中间隔着一条很宽的河。上一篇我们刚把方案定下来——JWT 访问令牌 + 有状态刷新令牌。可方案只是图纸,真动起手来,问题才一个个浮出水面:密码怎么存,才不至于数据库一泄露、用户就全被交代出去?登录失败,该不该告诉对方"这个账号不存在"?用户点了登出,旧的令牌到底还算不算数?
这一篇我不绕理论,把注册、登录、刷新、登出四条链路一行行走通——从 bcrypt 盐哈希讲到双令牌签发,从防账号枚举讲到即时登出。每一行代码,都告诉你为什么非得这么写。
走到底,还会撞上后端新手几乎必卡的一道坎:注册接口永远只能造出member,提权却必须由admin来操作——那系统里的第一个管理员,到底从哪来?这个死锁和我们的破局方式,留到后文专门拆解。
一、注册:密码绝不能明文存
注册的本质就三步:校验入参(M1-10 已在最外层做完)→ 查重 → 落库。其中最重要的一条铁律是:密码永远不能明文存储。一旦数据库泄露,明文密码等于把用户全交代了。
我们用bcrypt做"盐哈希"。看src/shared/password.ts:
import{compare,hash}from'bcryptjs';constBCRYPT_ROUNDS=12;exportconsthashPassword=(plain:string):Promise<string>=>hash(plain,BCRYPT_ROUNDS);exportconstverifyPassword=(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:
exportconstregisterUser=async(input:RegisterUserInput):Promise<User>=>{constdb=getDb();const{username,email,password,nickname}=input;constdup=awaitdb.select({id:users.id}).from(users).where(or(eq(users.username,username),eq(users.email,email))).all();if(dup.length>0)thrownewAppError(ErrCode.CONFLICT,409);// 3002 常见路径try{constinserted=awaitdb.insert(users).values({username,email,passwordHash:awaithashPassword(password),displayName:nickname??username,role:'member',// ← 强制 member,不可自定角色status:'active',level:1,createdAt:newDate(),updatedAt:newDate(),}).returning().all();// ...}catch(err){if(isUniqueConstraintError(err))thrownewAppError(ErrCode.CONFLICT,409);// 3002 并发兜底throwerr;}};两个细节值得记住:
第一,role被硬编码为'member'。客户端传什么role我们都不认(呼应 M1-10 的"信任边界"),新用户永远是普通会员。提权只能走后台审核接口,且操作者自身得是 admin——这是防越权的一道硬墙。
第二,查重 + 并发兜底双保险。先查重(常见路径),但并发下两个相同请求可能都"查到不冲突",于是插入时撞唯一约束——这时靠catch里的isUniqueConstraintError收口回409(这就是 M1-09 讲的 P-19 模式)。先查后插防正常情况,唯一约束防并发竞态,两层缺一不可。
二、登录:不暴露"账号是否存在"
登录校验在authenticateUser,它有个精妙的安全设计:
exportconstauthenticateUser=async(username,password):Promise<User>=>{constuser=(awaitgetDb().select().from(users).where(eq(users.username,username)).all())[0];if(!user)thrownewAppError(ErrCode.USERNAME_OR_PASSWORD_ERROR,401);// 1001if(user.status==='disabled')thrownewAppError(ErrCode.ACCOUNT_DISABLED,401);// 1005if(!(awaitverifyPassword(password,user.passwordHash))){thrownewAppError(ErrCode.USERNAME_OR_PASSWORD_ERROR,401);// 1001}returnuser;};注意:无论"用户不存在"还是"密码错",统一返回1001(用户名或密码错误)。攻击者无法靠返回差异判断"哪个用户名有人注册",从根上挡住了账号枚举攻击。账号被禁用(1005)单独区分,是因为前端要据此跳"账号异常"页,但 1005 本身也不泄露"账号存在"之外的信息——它只在"用户名确实对得上"时才可能出现。
举个账号枚举的具体画面帮你看清危害。如果登录时"用户不存在"返回1003、“密码错"返回1001,攻击者写个脚本,用一堆常见用户名(admin、test、fungleo…)挨个试登录,看哪个返回的是"密码错"而不是"用户不存在”——凡是返回"密码错"的,就说明这个账号真实存在,下一步只需暴力破解它的密码。我们把两者合并成同一个1001,攻击者的脚本就失去了"哪个账号存在"这个关键信号,枚举无从下手。这层"不暴露账号存在性"的讲究,是后端安全里性价比极高的一笔投入。
三、签发双令牌:无状态 + 有状态各管一摊
登录验证通过后,由buildAuthResult签发两个令牌:
exportconstbuildAuthResult=async(user,refreshRaw?):Promise<AuthResult>=>{constaccessToken=awaitsignAccessToken({sub:String(user.id),role:user.roleasRole},getActiveEnv().JWT_SECRET,ACCESS_TTL_SEC,// 3600 秒 = 1 小时);constrefreshToken=refreshRaw??(awaitissueRefreshToken(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:
// scripts/seed-users.ts(节选)constexisting=(awaitdb.select().from(users).where(eq(users.username,username)).limit(1).all())[0];if(!existing){constinserted=awaitdb.insert(users).values({username,email,passwordHash:awaithashPassword(password),// 复用同一 hashPassword,与登录 verifyPassword 兼容displayName:nickname,role:'admin',status:'active',level:1,createdAt:newDate(),updatedAt:newDate(),}).returning().all();// ...}elseif(existing.role!=='admin'||forceReset){// 已存在但非 admin(残留账号)→ 确保提升为 admin;--reset 强制重置密码awaitdb.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