完整实战用 bcryptjs jose 搭建 Node.js 登录系统前三篇分别讲了全局视角、密码哈希、JWT 签发与验签。这一篇把它们拼起来写一个完整可跑的项目。不再分散讲概念直接给一个能注册、登录、带 token 访问受保护接口的最小系统。目标是跑完这篇之后你手上有一套结构清晰的代码后面再加数据库、refresh token、角色权限都有地方接。1. 项目结构src/ index.ts # 入口启动服务 config.ts # 配置密钥、常量 store.ts # 用户存储内存模拟 auth-service.ts # 注册、登录、token 签发 auth-middleware.ts # JWT 鉴权中间件 routes/ auth-routes.ts # 注册、登录路由 user-routes.ts # 受保护的用户接口不用 ORM不用数据库用内存数组模拟存储。这样你只需要关注认证逻辑本身。2. 依赖安装npminit-ynpminstallexpress bcryptjs josenpminstall-Dtypescript types/express types/bcryptjs tsxtsconfig.json最小配置{compilerOptions:{target:ES2022,module:ESNext,moduleResolution:bundler,strict:true,esModuleInterop:true,outDir:dist,rootDir:src},include:[src]}package.json里加上启动脚本和模块类型{type:module,scripts:{dev:tsx watch src/index.ts}}3. config.ts集中管理密钥和常量constjwtSecretprocess.env.JWT_SECRET;if(!jwtSecret){thrownewError(环境变量 JWT_SECRET 未设置);}exportconstconfig{port:Number(process.env.PORT)||3000,jwtSecret:newTextEncoder().encode(jwtSecret),jwtIssuer:https://api.example.com,jwtAudience:my-app,jwtExpiresIn:15m,bcryptRounds:10,}asconst;密钥从环境变量读不写死在代码里。启动时如果缺少JWT_SECRET直接报错退出比运行到一半才发现好得多。4. store.ts内存用户存储exporttypeUserRecord{id:string;email:string;passwordHash:string;role:string;createdAt:Date;};constusers:UserRecord[][];letnextId1;exportfunctionfindUserByEmail(email:string){returnusers.find((u)u.emailemail)??null;}exportfunctionfindUserById(id:string){returnusers.find((u)u.idid)??null;}exportfunctioncreateUser(email:string,passwordHash:string,role:stringuser):UserRecord{constuser:UserRecord{id:String(nextId),email,passwordHash,role,createdAt:newDate(),};users.push(user);returnuser;}后面换成数据库时只需要替换这个文件里的实现其他层不用动。5. auth-service.ts核心业务逻辑这里集中了注册、登录、token 签发三件事。importbcryptfrombcryptjs;import{SignJWT}fromjose;import{config}from./config.js;import{findUserByEmail,createUser}from./store.js;exportasyncfunctionregister(email:string,password:string){constexistingfindUserByEmail(email);if(existing){thrownewError(EMAIL_ALREADY_EXISTS);}constpasswordHashawaitbcrypt.hash(password,config.bcryptRounds);constusercreateUser(email,passwordHash);return{id:user.id,email:user.email,role:user.role};}exportasyncfunctionlogin(email:string,password:string){constuserfindUserByEmail(email);if(!user){thrownewError(INVALID_CREDENTIALS);}constmatchedawaitbcrypt.compare(password,user.passwordHash);if(!matched){thrownewError(INVALID_CREDENTIALS);}constaccessTokenawaitsignAccessToken(user.id,user.role);return{accessToken,user:{id:user.id,email:user.email,role:user.role},};}asyncfunctionsignAccessToken(userId:string,role:string){returnawaitnewSignJWT({role}).setProtectedHeader({alg:HS256,typ:JWT}).setSubject(userId).setIssuer(config.jwtIssuer).setAudience(config.jwtAudience).setIssuedAt().setExpirationTime(config.jwtExpiresIn).setJti(crypto.randomUUID()).sign(config.jwtSecret);}几个关键点register()里用bcrypt.hash()存哈希绝不存明文login()里用bcrypt.compare()校验不是重新 hash 再比字符串登录失败统一抛INVALID_CREDENTIALS不区分用户不存在和密码错误signAccessToken()是私有函数只在 login 成功后调用6. auth-middleware.tsJWT 鉴权中间件importtype{Request,Response,NextFunction}fromexpress;import{jwtVerify}fromjose;import{config}from./config.js;exporttypeAuthenticatedUser{id:string;role:string;};declareglobal{namespaceExpress{interfaceRequest{user?:AuthenticatedUser;}}}exportasyncfunctionauthMiddleware(req:Request,res:Response,next:NextFunction){try{constheaderreq.headers.authorization;if(!header||!header.startsWith(Bearer )){returnres.status(401).json({message:未提供认证信息});}consttokenheader.slice(Bearer .length).trim();const{payload}awaitjwtVerify(token,config.jwtSecret,{issuer:config.jwtIssuer,audience:config.jwtAudience,});req.user{id:String(payload.sub),role:typeofpayload.rolestring?payload.role:user,};next();}catch{returnres.status(401).json({message:token 无效或已过期});}}这个中间件做了三层检查请求头格式是否正确token 签名、过期、issuer、audience 是否都通过通过后才把用户信息挂到req.user7. 加一个角色守卫鉴权中间件解决的是你是谁。角色守卫解决的是你能不能进。importtype{Request,Response,NextFunction}fromexpress;exportfunctionrequireRole(...allowedRoles:string[]){return(req:Request,res:Response,next:NextFunction){if(!req.user){returnres.status(401).json({message:未登录});}if(!allowedRoles.includes(req.user.role)){returnres.status(403).json({message:权限不足});}next();};}用法app.get(/admin/stats,authMiddleware,requireRole(admin),handler);先过认证再过授权顺序很重要。8. routes/auth-routes.ts注册和登录路由import{Router}fromexpress;import{register,login}from../auth-service.js;exportconstauthRouterRouter();authRouter.post(/register,async(req,res){try{const{email,password}req.bodyas{email?:string;password?:string;};if(!email||!password){returnres.status(400).json({message:邮箱和密码不能为空});}if(password.length8){returnres.status(400).json({message:密码长度不能少于 8 位});}constuserawaitregister(email,password);returnres.status(201).json({message:注册成功,user});}catch(error){if(errorinstanceofErrorerror.messageEMAIL_ALREADY_EXISTS){returnres.status(409).json({message:该邮箱已注册});}console.error(register error:,error);returnres.status(500).json({message:服务器内部错误});}});authRouter.post(/login,async(req,res){try{const{email,password}req.bodyas{email?:string;password?:string;};if(!email||!password){returnres.status(400).json({message:邮箱和密码不能为空});}constresultawaitlogin(email,password);returnres.status(200).json(result);}catch(error){if(errorinstanceofErrorerror.messageINVALID_CREDENTIALS){returnres.status(401).json({message:邮箱或密码错误});}console.error(login error:,error);returnres.status(500).json({message:服务器内部错误});}});路由层只负责参数校验、调用 service、处理错误、返回响应。不做任何密码操作或 token 操作。9. routes/user-routes.ts受保护接口import{Router}fromexpress;import{authMiddleware}from../auth-middleware.js;import{requireRole}from../auth-middleware.js;// 如果放在同一文件import{findUserById}from../store.js;exportconstuserRouterRouter();// 所有 user 路由都需要登录userRouter.use(authMiddleware);// 获取当前用户信息userRouter.get(/me,(req,res){constuserfindUserById(req.user!.id);if(!user){returnres.status(404).json({message:用户不存在});}returnres.json({id:user.id,email:user.email,role:user.role,createdAt:user.createdAt,});});// 仅管理员可访问userRouter.get(/admin/dashboard,requireRole(admin),(_req,res){returnres.json({message:欢迎进入管理后台});});/me是登录后的典型接口拿到 token 里的用户 id再去查完整信息。/admin/dashboard展示了角色守卫的用法。10. index.ts把一切拼起来importexpressfromexpress;import{config}from./config.js;import{authRouter}from./routes/auth-routes.js;import{userRouter}from./routes/user-routes.js;constappexpress();app.use(express.json());app.use(/auth,authRouter);app.use(/user,userRouter);app.listen(config.port,(){console.log(server running at http://localhost:${config.port});});最终的接口列表方法路径说明是否需要 tokenPOST/auth/register注册否POST/auth/login登录否GET/user/me获取当前用户是GET/user/admin/dashboard管理后台是 admin 角色11. 启动和测试启动JWT_SECRETa-very-long-random-string-at-least-32-charsnpmrun dev注册curl-XPOST http://localhost:3000/auth/register\-HContent-Type: application/json\-d{email:aliceexample.com,password:MyPassword123!}登录curl-XPOST http://localhost:3000/auth/login\-HContent-Type: application/json\-d{email:aliceexample.com,password:MyPassword123!}登录成功后会返回accessToken。用它访问受保护接口curlhttp://localhost:3000/user/me\-HAuthorization: Bearer 这里替换成拿到的 token不带 token 或 token 过期会得到 401。普通用户访问/user/admin/dashboard会得到 403。12. 整个请求链路回顾把一次完整的注册 - 登录 - 访问接口串起来注册客户端提交邮箱和密码路由层做参数校验service 层用bcrypt.hash()生成密码哈希哈希值存入用户表返回注册成功登录客户端提交邮箱和密码service 层查用户用bcrypt.compare()校验密码校验通过后用jose的SignJWT签发 access token返回 token 和用户基本信息访问受保护接口客户端在请求头带上Authorization: Bearer token鉴权中间件用jwtVerify()验签、验过期、验 issuer 和 audience通过后把用户信息挂到req.user路由处理函数正常执行如果有角色守卫再检查req.user.role每一步的职责都很清楚没有交叉。13. 这套结构为什么值得保持这个项目虽然小但分层已经比较健康config管配置和密钥集中且显式store管数据存取后面可以直接换成数据库service管业务逻辑密码和 token 操作都在这里middleware管请求级别的认证和授权routes只做参数校验和响应格式化这种结构的好处是后面不管加什么功能——接数据库、加 refresh token、加日志、加限流——都能找到明确的位置放进去不会把认证逻辑散得到处都是。14. 这一篇之后还缺什么到这里最小闭环已经跑通了。但离生产级还有几件事没做Refresh Tokenaccess token 过期后怎么续期不能让用户反复登录注销和 token 撤销JWT 是无状态的主动失效需要额外机制密码重置忘记密码的完整流程限流和防暴力破解密码哈希挡不住接口被持续撞HTTPS 和 token 传输安全token 在网络层的保护密钥轮换密钥不能永远不变下一篇可以专门讲 refresh token 和 token 生命周期管理把短 access token 长 refresh token这套常见模式讲清楚。后记2026年5月7日于上海在claude opus 4.6辅助下完成。