当前位置: 首页 > news >正文

完整实战:用 bcryptjs + jose 搭建 Node.js 登录系统

完整实战:用 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 tsx

tsconfig.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:集中管理密钥和常量

constjwtSecret=process.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[]=[];letnextId=1;exportfunctionfindUserByEmail(email:string){returnusers.find((u)=>u.email===email)??null;}exportfunctionfindUserById(id:string){returnusers.find((u)=>u.id===id)??null;}exportfunctioncreateUser(email:string,passwordHash:string,role:string="user"):UserRecord{constuser:UserRecord={id:String(nextId++),email,passwordHash,role,createdAt:newDate(),};users.push(user);returnuser;}

后面换成数据库时,只需要替换这个文件里的实现,其他层不用动。


5. auth-service.ts:核心业务逻辑

这里集中了注册、登录、token 签发三件事。

importbcryptfrom"bcryptjs";import{SignJWT}from"jose";import{config}from"./config.js";import{findUserByEmail,createUser}from"./store.js";exportasyncfunctionregister(email:string,password:string){constexisting=findUserByEmail(email);if(existing){thrownewError("EMAIL_ALREADY_EXISTS");}constpasswordHash=awaitbcrypt.hash(password,config.bcryptRounds);constuser=createUser(email,passwordHash);return{id:user.id,email:user.email,role:user.role};}exportasyncfunctionlogin(email:string,password:string){constuser=findUserByEmail(email);if(!user){thrownewError("INVALID_CREDENTIALS");}constmatched=awaitbcrypt.compare(password,user.passwordHash);if(!matched){thrownewError("INVALID_CREDENTIALS");}constaccessToken=awaitsignAccessToken(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.ts:JWT 鉴权中间件

importtype{Request,Response,NextFunction}from"express";import{jwtVerify}from"jose";import{config}from"./config.js";exporttypeAuthenticatedUser={id:string;role:string;};declareglobal{namespaceExpress{interfaceRequest{user?:AuthenticatedUser;}}}exportasyncfunctionauthMiddleware(req:Request,res:Response,next:NextFunction){try{constheader=req.headers.authorization;if(!header||!header.startsWith("Bearer ")){returnres.status(401).json({message:"未提供认证信息"});}consttoken=header.slice("Bearer ".length).trim();const{payload}=awaitjwtVerify(token,config.jwtSecret,{issuer:config.jwtIssuer,audience:config.jwtAudience,});req.user={id:String(payload.sub),role:typeofpayload.role==="string"?payload.role:"user",};next();}catch{returnres.status(401).json({message:"token 无效或已过期"});}}

这个中间件做了三层检查:

  1. 请求头格式是否正确
  2. token 签名、过期、issuer、audience 是否都通过
  3. 通过后才把用户信息挂到req.user

7. 加一个角色守卫

鉴权中间件解决的是"你是谁"。
角色守卫解决的是"你能不能进"。

importtype{Request,Response,NextFunction}from"express";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}from"express";import{register,login}from"../auth-service.js";exportconstauthRouter=Router();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.length<8){returnres.status(400).json({message:"密码长度不能少于 8 位"});}constuser=awaitregister(email,password);returnres.status(201).json({message:"注册成功",user});}catch(error){if(errorinstanceofError&&error.message==="EMAIL_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:"邮箱和密码不能为空"});}constresult=awaitlogin(email,password);returnres.status(200).json(result);}catch(error){if(errorinstanceofError&&error.message==="INVALID_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}from"express";import{authMiddleware}from"../auth-middleware.js";import{requireRole}from"../auth-middleware.js";// 如果放在同一文件import{findUserById}from"../store.js";exportconstuserRouter=Router();// 所有 user 路由都需要登录userRouter.use(authMiddleware);// 获取当前用户信息userRouter.get("/me",(req,res)=>{constuser=findUserById(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:把一切拼起来

importexpressfrom"express";import{config}from"./config.js";import{authRouter}from"./routes/auth-routes.js";import{userRouter}from"./routes/user-routes.js";constapp=express();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}`);});

最终的接口列表:

方法路径说明是否需要 token
POST/auth/register注册
POST/auth/login登录
GET/user/me获取当前用户
GET/user/admin/dashboard管理后台是 + admin 角色

11. 启动和测试

启动:

JWT_SECRET=a-very-long-random-string-at-least-32-charsnpmrun dev

注册:

curl-XPOST http://localhost:3000/auth/register\-H"Content-Type: application/json"\-d'{"email":"alice@example.com","password":"MyPassword123!"}'

登录:

curl-XPOST http://localhost:3000/auth/login\-H"Content-Type: application/json"\-d'{"email":"alice@example.com","password":"MyPassword123!"}'

登录成功后会返回accessToken。用它访问受保护接口:

curlhttp://localhost:3000/user/me\-H"Authorization: Bearer <这里替换成拿到的 token>"

不带 token 或 token 过期,会得到 401。
普通用户访问/user/admin/dashboard,会得到 403。


12. 整个请求链路回顾

把一次完整的"注册 -> 登录 -> 访问接口"串起来:

注册

  1. 客户端提交邮箱和密码
  2. 路由层做参数校验
  3. service 层用bcrypt.hash()生成密码哈希
  4. 哈希值存入用户表
  5. 返回注册成功

登录

  1. 客户端提交邮箱和密码
  2. service 层查用户,用bcrypt.compare()校验密码
  3. 校验通过后,用joseSignJWT签发 access token
  4. 返回 token 和用户基本信息

访问受保护接口

  1. 客户端在请求头带上Authorization: Bearer <token>
  2. 鉴权中间件用jwtVerify()验签、验过期、验 issuer 和 audience
  3. 通过后把用户信息挂到req.user
  4. 路由处理函数正常执行
  5. 如果有角色守卫,再检查req.user.role

每一步的职责都很清楚,没有交叉。


13. 这套结构为什么值得保持

这个项目虽然小,但分层已经比较健康:

  • config管配置和密钥,集中且显式
  • store管数据存取,后面可以直接换成数据库
  • service管业务逻辑,密码和 token 操作都在这里
  • middleware管请求级别的认证和授权
  • routes只做参数校验和响应格式化

这种结构的好处是,后面不管加什么功能——接数据库、加 refresh token、加日志、加限流——都能找到明确的位置放进去,不会把认证逻辑散得到处都是。


14. 这一篇之后还缺什么

到这里,最小闭环已经跑通了。但离生产级还有几件事没做:

  • Refresh Token:access token 过期后怎么续期,不能让用户反复登录
  • 注销和 token 撤销:JWT 是无状态的,主动失效需要额外机制
  • 密码重置:忘记密码的完整流程
  • 限流和防暴力破解:密码哈希挡不住接口被持续撞
  • HTTPS 和 token 传输安全:token 在网络层的保护
  • 密钥轮换:密钥不能永远不变

下一篇可以专门讲 refresh token 和 token 生命周期管理,把"短 access token + 长 refresh token"这套常见模式讲清楚。

后记

2026年5月7日于上海,在claude opus 4.6辅助下完成。

http://www.jsqmd.com/news/775157/

相关文章:

  • 基于 4sapi 搭建 AI 多模态内容生产矩阵:自媒体与企业内容营销的全流程自动化落地方案
  • 2026-05-08:反转字符串前缀。用go语言,给定字符串 s 和整数 k,把 s 的开头 k 个字符顺序完全倒过来(其余字符保持原样),输出新的字符串。 1 <= s.length <= 100。
  • AD16仿真模型引脚对不上?一个案例讲透原理图与SPICE模型的映射关系(以运放LF411CN为例)
  • 2026年4月行业内做得好的风水缸设计厂商推荐,风水缸/故宫铜缸/铜水缸/铜缸/铜大缸/门海铜缸,风水缸企业哪家好 - 品牌推荐师
  • 游戏服务器容器化部署:基于Docker的Archon镜像实战指南
  • DownKyi哔哩下载姬:专业级B站视频下载解决方案完全指南
  • 2026年靠谱的线束定制技术团队有哪些 - 工业品牌热点
  • 开发手记:关于JSON格式化工具的隐私安全问题
  • 从理论到实践:西瓜书机器学习代码实战深度指南
  • 豆包收费引发行业震荡:从技术视角看,为什么通用大模型搞不定严肃招投标?
  • Rime小狼毫的隐藏玩法:除了打字,还能用‘/’键快速输入符号、网址和颜文字
  • 2026年4月服务好的蓬莱酒店预定方式,蓬莱酒店/长岛民宿/特色民宿/高端民宿/军人优惠酒店/蓬莱民宿,蓬莱酒店首选 - 品牌推荐师
  • 2025最权威的十大AI辅助论文网站横评
  • 打造高效开发环境:VSCode配置黄金标准与团队实践指南
  • 2026年企业IT监控平台选型指南:四大主流方案深度对比
  • Go语言的接口介绍
  • OpenSoul开源框架:构建拥有持久记忆与人格的AI角色
  • #计算机毕设论文写到崩溃?AI自动生成万字初稿实测:从大纲到参考文献全流程解析(附查重降重技巧)
  • 使用 Python 通过 Taotoken 稳定调用多种大模型 API
  • Cursor AI液态玻璃主题:打造未来感代码编辑器的视觉美学与实战配置
  • SITS2026内部白皮书首度流出:AISMM驱动变革的12项组织适配指标(含权重算法与阈值红线)
  • 如何轻松解决3个《神界原罪》与《博德之门3》MOD制作核心难题:LSLib完全指南
  • 使用psql执行带变量的sql脚本
  • Product Hunt 2025-10-03 每日热榜背后:技术架构深度剖析
  • SkeyeVSS开发FAQ:国标视频流媒体转码与多码率自适应
  • 为什么现在的电商API,正在从“搬运工”变成“决策者”?
  • 保障高并发业务稳定,浅谈大模型API的容灾与路由策略
  • PaperClaw:基于Rust的高效Git仓库历史分析命令行工具
  • 【开源项目】EasyTier — 轻量级去中心化 SD-WAN 新范式
  • 关于tuna mini midi官方编辑器键盘无法连接问题的解决