ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

成为全栈·Node 后端篇·注册登录全流程实现 成为全栈·Node 后端篇·注册登录全流程实现把认证聊明白和把它写出来中间隔着一条很宽的河。上一篇我们刚把方案定下来——JWT 访问令牌 有状态刷新令牌。可方案只是图纸真动起手来问题才一个个浮出水面密码怎么存才不至于数据库一泄露、用户就全被交代出去登录失败该不该告诉对方这个账号不存在用户点了登出旧的令牌到底还算不算数这一篇我不绕理论把注册、登录、刷新、登出四条链路一行行走通——从 bcrypt 盐哈希讲到双令牌签发从防账号枚举讲到即时登出。每一行代码都告诉你为什么非得这么写。走到底还会撞上后端新手几乎必卡的一道坎注册接口永远只能造出member提权却必须由admin来操作——那系统里的第一个管理员到底从哪来这个死锁和我们的破局方式留到后文专门拆解。一、注册密码绝不能明文存注册的本质就三步校验入参M1-10 已在最外层做完→ 查重 → 落库。其中最重要的一条铁律是密码永远不能明文存储。一旦数据库泄露明文密码等于把用户全交代了。我们用bcrypt做盐哈希。看src/shared/password.tsimport{compare,hash}frombcryptjs;constBCRYPT_ROUNDS12;exportconsthashPassword(plain:string):Promisestringhash(plain,BCRYPT_ROUNDS);exportconstverifyPassword(plain:string,storedHash:string):Promisebooleancompare(plain,storedHash);选型bcryptjs纯 JS 实现而非原生bcrypt是因为它零原生编译依赖在我们的本地 Cloudflare 双端诉求下不会卡编译。成本因子 12 轮对注册/登录这种低频操作安全性优先于那点性能开销生产负载高时可降到 10–11。为什么非得用 bcrypt 这种慢哈希而不能用 MD5 / SHA 这类常见哈希因为 MD5 / SHA 的设计目标是快而快对密码存储恰恰是缺点——攻击者拿到泄露的哈希库可以用彩虹表或暴力穷举在几秒内反查出常见密码。bcrypt 故意把计算做得又慢又带盐每个密码随机加盐相同密码的哈希结果也不同让暴力破解的成本高到不划算。一句话存密码用慢哈希这是安全底线中的底线没有商量余地。注册落库的逻辑在src/services/user.ts的registerUserexportconstregisterUserasync(input:RegisterUserInput):PromiseUser{constdbgetDb();const{username,email,password,nickname}input;constdupawaitdb.select({id:users.id}).from(users).where(or(eq(users.username,username),eq(users.email,email))).all();if(dup.length0)thrownewAppError(ErrCode.CONFLICT,409);// 3002 常见路径try{constinsertedawaitdb.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它有个精妙的安全设计exportconstauthenticateUserasync(username,password):PromiseUser{constuser(awaitgetDb().select().from(users).where(eq(users.username,username)).all())[0];if(!user)thrownewAppError(ErrCode.USERNAME_OR_PASSWORD_ERROR,401);// 1001if(user.statusdisabled)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签发两个令牌exportconstbuildAuthResultasync(user,refreshRaw?):PromiseAuthResult{constaccessTokenawaitsignAccessToken({sub:String(user.id),role:user.roleasRole},getActiveEnv().JWT_SECRET,ACCESS_TTL_SEC,// 3600 秒 1 小时);constrefreshTokenrefreshRaw??(awaitissueRefreshToken(user.id)).raw;return{accessToken,expiresIn:ACCESS_TTL_SEC,refreshToken,user:toPublicUser(user)};};access tokenJWT1 小时有效无状态——请求进来验章即用不查库呼应 M1-12 的 P-23 角色快照。refresh token有状态明文只在这一刻返回一次库里只存哈希在refresh_tokens表。它负责access 过期时换新的且可被随时吊销。路由层我们之前读过的routes/auth.ts把这两个令牌一个塞进响应体、一个种进HttpOnly; Secure; SameSiteNone的 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){constinsertedawaitdb.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.exampleM1-07 讲过的SEED_ADMIN_*默认值可用生产覆盖强口令即可。种子脚本本身也要过门禁P-55biome格式/规范检查 实跑验证不能因为它是脚本就免检。六、P-31 轻提email 的假唯一顺带回应一个数据库细节P-31。users表的email上有唯一索引但 SQLite 的唯一索引对NULL不冲突——也就是说两个email为NULL的用户不会撞索引。我们的处理是注册时email必填所以可空唯一索引在实践中等价于非空唯一既享受了允许缺席的灵活性又没牺牲唯一性。这套nullable 唯一 部分唯一的小聪明在多个表slug、email上都有体现是 SQLite 路线下要心里有数的特性。七、小结与前瞻注册登录全流程是把前面所有零件拧在一起密码哈希bcryptjs 12 轮、纯 JS 零编译依赖契合双端明文绝不入库存。注册role强制member、查重 isUniqueConstraintError并发兜底双保险。登录1001不区分用户不存在/密码错防账号枚举1005单独标识禁用。双令牌accessJWT 1h 无状态 refresh有状态、可旋转/吊销/登出作废。登出revokeUserTokens删 refresh 记录即时终结会话——有状态 refresh 的硬价值。P-25无首 admin 死锁靠pnpm seed走正规应用层破局复用 hashPassword、幂等、过门禁绝不停留在自己直插 SQL 绕过。P-31nullable 唯一索引 部分唯一注册 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
返回列表