news 2026/5/8 7:22:31

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
完整实战:用 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辅助下完成。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/8 7:22:30

OpenSoul开源框架:构建拥有持久记忆与人格的AI角色

1. 项目概述与核心价值最近在开源社区里&#xff0c;一个名为“samttoo22-MewCat/OpenSoul”的项目引起了我的注意。乍一看这个标题&#xff0c;可能会觉得有些神秘——“OpenSoul”&#xff0c;开放的灵魂&#xff1f;这听起来更像是一个哲学或艺术项目。但当你点进仓库&#…

作者头像 李华
网站建设 2026/5/8 7:22:05

Guru:Go语言开发的插件化命令行工具箱,提升开发效率

1. 项目概述&#xff1a;一个为开发者打造的“瑞士军刀”式工具箱最近在GitHub上闲逛&#xff0c;发现了一个挺有意思的项目&#xff0c;叫shafreeck/guru。乍一看这个名字&#xff0c;你可能会联想到“大师”、“导师”之类的意思。没错&#xff0c;这个项目的核心定位&#x…

作者头像 李华
网站建设 2026/5/8 7:10:43

可穿戴设备、物联网传感器、机器人导航:LSM6DSV16XTR的运动感知角色

LSM6DSV16XTR&#xff1a;集成AI与传感器融合的6轴惯性测量单元在运动感知领域&#xff0c;惯性测量单元&#xff08;IMU&#xff09;的技术演进正从“提供原始数据”向“输出处理结果”转变。意法半导体的LSM6DSV16XTR正是这一趋势的代表性产品——它不仅是加速度计与陀螺仪的…

作者头像 李华
网站建设 2026/5/8 7:10:41

32KB Flash+2KB RAM+16MHz STM8内核:STM8L052C6T6TR的参数解析

STM8L052C6T6TR&#xff1a;超低功耗8位MCU的实用之选在电池供电的嵌入式产品设计中&#xff0c;功耗往往是决定方案可行性的关键因素。意法半导体的STM8L052C6T6TR作为STM8L超低功耗系列的一员&#xff0c;用32KB Flash、2KB RAM和从350nA到5.1μA的多级低功耗模式&#xff0c…

作者头像 李华
网站建设 2026/5/8 7:05:30

《机乎的野心:AI社交如何重新定义知识问答?》

2011年知乎上线时&#xff0c;凭借严格的邀请注册制和高质量问答内容&#xff0c;迅速在中文互联网的知识阶层心中树立起“精英社区”的称号。即便在今日&#xff0c;知乎依然是一二线城市年轻人分享知识、经验与见解的首选阵地。然而&#xff0c;一个隐秘却早已存在的疑问正在…

作者头像 李华
网站建设 2026/5/8 7:04:41

MegSpot:专业图片视频对比工具的5个实用场景与解决方案

MegSpot&#xff1a;专业图片视频对比工具的5个实用场景与解决方案 【免费下载链接】MegSpot MegSpot是一款高效、专业、跨平台的图片&视频对比应用 项目地址: https://gitcode.com/gh_mirrors/me/MegSpot 你是否曾为两张相似图片的细微差别而苦恼&#xff1f;是否在…

作者头像 李华