从零构建带JWT认证与RBAC权限的CRUD API全流程
2026/9/11 2:19:49 网站建设 项目流程

我做了几个月的项目 API,踩得最多的坑反而不是业务逻辑,而是“接口写好了没人敢用”——因为没有做权限保护,任何一个知道路由的人都能把数据删光。后来我彻底梳理了一套 CRUD API 的添加与保护流程,才发现这件事其实可以体系化:先用标准化的资源端点把增删改查做扎实,再通过 JWT 认证、角色授权、输入校验把每个端点保护起来。这套方法我后来用到好几个项目上都成立,今天就把完整的思路和可复现的步骤写出来,给正在搭 Web API 后端、或者想把自己接口补上安全保护的朋友一份参考。

这次会从一个实际可运行的 Node.js + Express + JWT 项目出发,包含数据模型设计、CRUD 端点实现、用户注册登录、中间件保护、RBAC 角色控制,以及我实测中遇到的高频问题和排查思路。虽然代码示例用的是 JavaScript 技术栈,但设计思想和安全问题在 ASP.NET Core、Spring 等框架下同样适用。

1. 需求拆解与整体设计思路

1.1 这个项目到底要做成什么样

标题里的 “Add and Protect CRUD Web API Endpoints” 拆开看其实包含两个核心诉求:Add 指的是把最基本的增删改查接口按规范补全,Protect 则是指在接口前面加一道安全门,让请求必须通过身份验证才能访问。两者结合,才是生产可用的 API。

为了让例子不空洞,我选了一个最常见的业务场景:做一个“文章管理 API”。这个 API 提供以下端点:

  • POST /api/articles:创建一篇新文章
  • GET /api/articles:获取文章列表
  • GET /api/articles/:id:获取单篇文章详情
  • PUT /api/articles/:id:更新指定文章
  • DELETE /api/articles/:id:删除指定文章

保护策略分三个层次:匿名用户只能读取文章列表和详情;登录用户(认证通过)可以创建文章;管理员(具备特定角色)才能更新和删除文章。这个权限模型简单但覆盖面广,涵盖了“公开读、登录写、管理员管”的典型场景。

为什么要选这个模型?因为在真实项目中你会发现,不加区分的“必须登录才能访问所有接口”往往太粗暴,而“全部公开”又太危险。合理的做法是:区分资源的读操作和写操作,再根据业务需要叠加角色限制。文章 API 这种模型可以直接平移到商品、订单、用户管理等各种资源上。

1.2 为什么选择 JWT 而不是 Session 或 Cookie

做接口保护时,最容易犹豫的点是“用 Session 还是 JWT”。我的建议是:如果你的 API 主要给移动端、单页应用或第三方系统调用,优先考虑 JWT(JSON Web Token);如果 API 和传统服务端渲染页面绑定,Session 也可以,但前后端分离项目里 JWT 的体验和扩展性明显更好。

核心区别在于状态存储。Session 方案要求服务端保存会话记录,客户端拿着 Session ID 去匹配;JWT 则是把用户标识、过期时间、角色等信息加密签名后发给客户端,服务端不再保存会话状态。每次请求时,服务端只要验证 Token 的签名是否合法、是否过期,就能确认用户身份。

这带来几个现实好处:第一,服务端可以做到无状态,横向扩容时不需要同步 Session;第二,移动端、小程序、跨域前端都能轻松携带 Token;第三,把角色信息塞进 Token 后,授权判断不需要查数据库。当然 JWT 也有短板,比如难以主动踢人、Token 泄漏后不好撤销,这一点我会在第 4 部分专门讲怎么缓解。

1.3 项目结构规划

我习惯在动手前先把目录结构理顺,避免所有代码堆在一个文件里。这个例子采用按功能分层的结构:

project/ ├── package.json ├── .env ├── src/ │ ├── server.js │ ├── app.js │ ├── config/ │ │ └── env.js │ ├── models/ │ │ ├── user.model.js │ │ └── article.model.js │ ├── middlewares/ │ │ ├── auth.middleware.js │ │ ├── rbac.middleware.js │ │ └── errorHandler.middleware.js │ ├── controllers/ │ │ ├── auth.controller.js │ │ └── article.controller.js │ ├── routes/ │ │ ├── auth.routes.js │ │ └── article.routes.js │ └── utils/ │ └── response.js

控制器负责处理业务逻辑,路由负责 URL 与控制器方法的映射,中间件负责认证和授权,模型负责数据存取。这样拆的好处是:以后加一个“评论 API”,只需要照着 article 的套路复制一份,独立改动不互相影响。

2. 环境准备与基础代码搭建

2.1 初始化项目与依赖安装

先把空项目初始化出来。我用 npm 做包管理,前提是你本机已经装好了 Node.js(建议 v16 以上)。在终端执行:

mkdir crud-api-demo cd crud-api-demo npm init -y

然后安装核心依赖:

npm install express jsonwebtoken bcryptjs dotenv npm install nodemon --save-dev

依赖的用途说明一下:

  • express:Web 框架,负责路由和中间件
  • jsonwebtoken:签发和校验 JWT
  • bcryptjs:对用户密码做哈希,避免明文存储
  • dotenv:加载 .env 配置,保存密钥、端口等信息
  • nodemon:开发时会监听文件变动自动重启

安装完成后,在 package.json 的 scripts 里加上"dev": "nodemon src/server.js",后续直接用 npm run dev 启动开发服务。

2.2 建立数据模型

因为重点在 API 设计和保护,数据持久化我用一个轻量方案:直接把数据存放在内存数组中,每次重启会重置。这样所有注意力都集中在接口逻辑上,不会被数据库细节干扰。如果你要接入 MongoDB 或 MySQL,只需要把模型层的实现替换掉,路由和控制器层的代码可以完全复用。

用户模型如下:

// src/models/user.model.js const users = []; let nextId = 1; function createUser({ username, password, role }) { const user = { id: nextId++, username, password, role: role || 'user', createdAt: new Date() }; users.push(user); return user; } function findUserByUsername(username) { return users.find((u) => u.username === username); } function findUserById(id) { return users.find((u) => u.id === Number(id)); } module.exports = { createUser, findUserByUsername, findUserById };

文章模型类似,包括 id、title、content、authorId、createdAt、updatedAt 这几个字段:

// src/models/article.model.js const articles = []; let nextId = 1; function createArticle({ title, content, authorId }) { const article = { id: nextId++, title, content, authorId, createdAt: new Date(), updatedAt: new Date() }; articles.push(article); return article; } function getArticles() { return articles; } function getArticleById(id) { return articles.find((a) => a.id === Number(id)); } function updateArticle(id, data) { const article = getArticleById(id); if (!article) return null; article.title = data.title || article.title; article.content = data.content || article.content; article.updatedAt = new Date(); return article; } function deleteArticle(id) { const index = articles.findIndex((a) => a.id === Number(id)); if (index === -1) return false; articles.splice(index, 1); return true; } module.exports = { createArticle, getArticles, getArticleById, updateArticle, deleteArticle };

用内存数组看起来很简单,但对本项目的核心目标完全够用。我在项目初期也经常先用内存数据把接口调通,再接数据库,这样定位问题会快很多。

2.3 统一响应结构与错误处理

接口返回给前端的结构如果不统一,前端解析起来会非常痛苦。我所有接口的返回格式都遵循一个约定:

{ "success": true, "data": {}, "message": "ok" }

成功时 success 为 true,data 里放业务数据;失败时 success 为 false,data 为 null,message 里放错误信息。

封装一个响应工具类:

// src/utils/response.js function success(res, data = null, message = 'ok', status = 200) { return res.status(status).json({ success: true, data, message }); } function failure(res, message = '服务器内部错误', status = 500) { return res.status(status).json({ success: false, data: null, message }); } module.exports = { success, failure };

更重要的是错误处理中间件,Express 的异步错误必须被捕获并交给统一处理器,否则会直接挂掉进程。我通常这样写:

// src/middlewares/errorHandler.middleware.js function notFound(req, res) { return res.status(404).json({ success: false, data: null, message: `接口不存在: ${req.method} ${req.originalUrl}` }); } function errorHandler(err, req, res, next) { console.error(err.stack); if (err.name === 'ValidationError') { return res.status(400).json({ success: false, data: null, message: err.message }); } if (err.name === 'JsonWebTokenError') { return res.status(401).json({ success: false, data: null, message: 'Token 无效,请重新登录' }); } if (err.name === 'TokenExpiredError') { return res.status(401).json({ success: false, data: null, message: 'Token 已过期,请重新登录' }); } return res.status(500).json({ success: false, data: null, message: '服务器内部错误' }); } module.exports = { notFound, errorHandler };

顺手说明一下为什么要把错误类型区分得这么细致。前端拿到 401 就跳登录页,拿到 400 就在表单上提示字段错误,拿到 403 就提示无权限,拿到 404 就提示资源不存在。如果所有错误都返回 500,前端根本没法做精细化交互。

3. CRUD 端点的完整实现

3.1 创建端点:POST /api/articles

创建文章接口要求请求体包含 title 和 content,同时要从当前登录用户中拿到 authorId。在控制器里做输入校验是必不可少的一步,绝不能信任前端传过来的任何数据。

// src/controllers/article.controller.js const articleModel = require('../models/article.model'); const { success, failure } = require('../utils/response'); async function createArticle(req, res, next) { try { const { title, content } = req.body || {}; if (!title || !content) { const err = new Error('title 和 content 不能为空'); err.name = 'ValidationError'; throw err; } if (typeof title !== 'string' || title.trim().length < 2) { const err = new Error('title 长度不能少于 2 个字符'); err.name = 'ValidationError'; throw err; } const article = articleModel.createArticle({ title: title.trim(), content, authorId: req.user.id }); return success(res, article, '创建成功', 201); } catch (err) { next(err); } }

我在这里特意做了两层校验:第一层是空值校验,第二层是类型和长度校验。实际开发中还会加上 title 最大长度、content 是否为字符串等,校验逻辑越靠前,后面发生脏数据的概率就越低。

创建成功后返回 201 状态码,这是 RESTful 语义的一部分。很多人容易忽略状态码的语义,习惯一律返回 200,但这对调用方不友好——正确的语义应该是:创建类操作返回 201,删除类操作返回 204,查询类操作返回 200。

3.2 读取端点:GET /api/articles 和 GET /api/articles/:id

列表查询我通常会加一些可选参数:page 表示页码,pageSize 表示每页条数,这样数据量大的时候客户端可以分页加载。为了控制篇幅,这里保留最简单的版本,但哪怕是最简版本,也要注意返回值结构的一致性:无论查到多少条,都返回数组。

async function getArticles(req, res, next) { try { const data = articleModel.getArticles(); return success(res, data, 'ok'); } catch (err) { next(err); } } async function getArticleById(req, res, next) { try { const article = articleModel.getArticleById(req.params.id); if (!article) { const err = new Error('文章不存在'); err.name = 'NotFoundError'; throw err; } return success(res, article, 'ok'); } catch (err) { next(err); } }

单条查询注意点在于:ID 拿不到数据时返回 404,而不是 200 + null。因为前端拿到 200 会认为“接口正常调用,只是数据为空”,但实际上客户端传的 ID 可能根本不存在,这会掩盖 bug。配合上一节的错误处理中间件,需要在 errorHandler 里补上 NotFoundError 的分支,返回 404。

3.3 更新与删除端点:PUT 和 DELETE

更新接口使用 PUT 语义,表示整体替换。严格的 RESTful 中 PUT 要求客户端提交完整的资源字段,如果只提交部分字段应该用 PATCH。但很多团队实践里会把 PUT 当“更新”用,允许部分字段更新。我在自己的项目里会明确选择一种并在文档里写清楚,避免团队认知不一致。

async function updateArticle(req, res, next) { try { const { title, content } = req.body || {}; if (!title && !content) { const err = new Error('没有需要更新的内容'); err.name = 'ValidationError'; throw err; } const article = articleModel.updateArticle(req.params.id, { title, content }); if (!article) { const err = new Error('文章不存在'); err.name = 'NotFoundError'; throw err; } return success(res, article, '更新成功'); } catch (err) { next(err); } } async function deleteArticle(req, res, next) { try { const result = articleModel.deleteArticle(req.params.id); if (!result) { const err = new Error('文章不存在'); err.name = 'NotFoundError'; throw err; } return res.status(204).json(); } catch (err) { next(err); } }

删除接口返回 204 无内容在语义上是最准确的,因为客户端通常不需要删除后的数据。但如果你观察到公司项目里统一返回 JSON 结构,也可以让前端好处理一点,返回success: true的 JSON,这个属于团队约定,不强求。

3.4 路由设计与 RESTful 语义

路由层把控制器和 HTTP 方法绑定:

// src/routes/article.routes.js const express = require('express'); const router = express.Router(); const articleController = require('../controllers/article.controller'); const { authenticate } = require('../middlewares/auth.middleware'); const { authorize } = require('../middlewares/rbac.middleware'); router.get('/', articleController.getArticles); router.get('/:id', articleController.getArticleById); router.post('/', authenticate, articleController.createArticle); router.put('/:id', authenticate, authorize('admin'), articleController.updateArticle); router.delete('/:id', authenticate, authorize('admin'), articleController.deleteArticle); module.exports = router;

注意顺序:所有写在/:id下面的静态路由要小心被动态路由吞掉。如果我有一个GET /articles/me表示“获取我的文章”,这段代码必须放在GET /articles/:id之前,否则me会被当成:id解析。

RESTful 语义这块,我的经验是:能遵守就尽量遵守,但不要为了“纯 REST”而牺牲业务表达。比如“发布文章”在 REST 视角可以抽象为 PATCH 状态字段,也可以保留POST /articles/:id/publish。只要团队理解一致,哪种都没问题。

4. 端点保护与权限校验实战

4.1 实现注册登录并签发 JWT

保护端点的大前提是有“身份”的概念。第一步是注册接口,用户提交 username 和 password,服务端对密码做哈希后保存;第二步是登录接口,比对密码通过后签发一个 JWT。

// src/controllers/auth.controller.js const jwt = require('jsonwebtoken'); const bcrypt = require('bcryptjs'); const userModel = require('../models/user.model'); const { success, failure } = require('../utils/response'); async function register(req, res, next) { try { const { username, password, role } = req.body || {}; if (!username || !password) { const err = new Error('用户名和密码不能为空'); err.name = 'ValidationError'; throw err; } if (userModel.findUserByUsername(username)) { const err = new Error('用户名已存在'); err.name = 'ValidationError'; throw err; } const hashedPassword = await bcrypt.hash(password, 10); const user = userModel.createUser({ username, password: hashedPassword, role }); return success(res, { id: user.id, username: user.username, role: user.role }, '注册成功', 201); } catch (err) { next(err); } } async function login(req, res, next) { try { const { username, password } = req.body || {}; const user = userModel.findUserByUsername(username); if (!user) { return failure(res, '用户名或密码错误', 401); } const isPasswordValid = await bcrypt.compare(password, user.password); if (!isPasswordValid) { return failure(res, '用户名或密码错误', 401); } const token = jwt.sign( { id: user.id, username: user.username, role: user.role }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRES_IN || '2h' } ); return success(res, { token, user: { id: user.id, username: user.username, role: user.role } }, '登录成功'); } catch (err) { next(err); } } module.exports = { register, login };

密码哈希的成本因子给出的 10 是常用值。这个数字越高,破解耗时越长,但用户登录时服务端的计算压力也越大。在普通服务器上,10 大约需要几十毫秒,体验和安全的平衡点,大家可以根据机器性能调整。

JWT 签发时我塞入了 id、username、role 三个字段。千万不要把密码放进去,Token 虽然服务端不可篡改,但客户端可以解出来看到,这是很多人忽略的泄露点。

4.2 认证中间件:保护“需要登录”的端点

认证中间件的职责是:从请求头解析 Token,验证有效性,把解析出的用户信息挂到 req.user 上,然后放行。

// src/middlewares/auth.middleware.js const jwt = require('jsonwebtoken'); function authenticate(req, res, next) { const authHeader = req.headers.authorization || ''; const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null; if (!token) { return res.status(401).json({ success: false, data: null, message: '未提供认证 Token' }); } try { const payload = jwt.verify(token, process.env.JWT_SECRET); req.user = { id: payload.id, username: payload.username, role: payload.role }; next(); } catch (err) { next(err); } } module.exports = { authenticate };

这里有几个细节值得展开。第一,前端发送 Token 的标准姿势是Authorization: Bearer <token>,中间件要把Bearer前缀去掉再验证;第二,如果请求头为空或者格式不对,直接返回 401,不要继续执行后面的业务逻辑;第三,验证失败的异常要交给错误处理中间件,包括过期和签名不匹配两种情况。

可能你会有疑问:为什么认证中间件只是req.user = ...,不查数据库?因为在 JWT 里已经有用户 ID,而验证签名之后这个数据是可信任的。为了减少数据库压力,我大多数项目都是这样设计的。

4.3 RBAC 授权中间件:只让管理员执行敏感操作

认证解决“你是谁”,授权解决“你能做什么”。RBAC(基于角色的访问控制)是应用最广泛的授权模型,核心就是给用户分配角色,给角色分配权限,中间件判断当前用户是否具备目标权限。

// src/middlewares/rbac.middleware.js function authorize(...allowedRoles) { return (req, res, next) => { if (!req.user) { return res.status(401).json({ success: false, data: null, message: '未认证' }); } if (!allowedRoles.includes(req.user.role)) { return res.status(403).json({ success: false, data: null, message: '没有权限执行此操作' }); } next(); }; } module.exports = { authorize };

路由里用authorize('admin')就能限制只有 admin 角色能访问。如果某类操作同时允许 admin 和 editor,就调用authorize('admin', 'editor')

很多人搞不清楚 401 和 403 的具体区别。我把这条规则记得很死:401 表示“你是谁”没有验证成功,可能是没带 Token、Token 过期或者 Token 无效;403 表示“你是谁”已经确认了,但你的角色没有权限做这件事。接口返回这两个状态码时,前端的处理逻辑完全不同:401 跳登录,403 弹无权限提示。

4.4 安全保护中容易忽略的实战细节

认证和授权搭好之后,看似“端点已经被保护了”,但真实生产环境还有几个反直觉的坑:

第一,IDOR(越权访问)仍然存在。我们上面只判断了“登录用户能创建文章”,但没判断“用户只能更新自己的文章”。当前逻辑下,普通用户如果通过某种方式拿到管理员的 Token,自然可以删除文章;但如果业务要求普通用户只能修改自己的文章,那就必须在控制器里加一层归属校验:

async function updateArticle(req, res, next) { const article = articleModel.getArticleById(req.params.id); if (!article) { return next(...); } if (req.user.role !== 'admin' && article.authorId !== req.user.id) { return failure(res, '只能修改自己的文章', 403); } // ...更新 }

第二,JWT 过期策略不能设太长。我见过有人为了省事把过期时间设为 30 天,结果 Token 泄漏后等于把自己家的门敞开了 30 天。比较稳妥的方案是:access token 有效期 2 小时左右,另外提供 refresh token(有效期 7 天或更长)去刷新。这样即使 access token 泄漏,攻击者的可利用窗口也相对有限。

第三,敏感数据不要明文返回。即使用户密码在数据库中已哈希,也要在接口响应时把 password 字段剔除。最简单的方法是返回前手动删掉这个字段,或者用JSON.stringify的 replacer 做统一过滤。我建议在 user 模型的 toJSON 方法里直接删掉 password,这样任何接口返回 user 对象时都不会带出来。

5. 测试、问题排查与经验总结

5.1 用 curl 和 Postman 验证完整流程

写完代码后一定要完整过一遍流程,我习惯先用 curl 快速验证核心链路,再用 Postman 做更详细的测试。

第一步,启动服务:

npm run dev

第二步,注册一个管理员账号:

curl -X POST http://localhost:3000/api/auth/register \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"123456","role":"admin"}'

第三步,登录拿到 Token:

curl -X POST http://localhost:3000/api/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"123456"}'

登录返回的 JSON 里有 token 字段,把它复制出来,第四步创建文章:

curl -X POST http://localhost:3000/api/articles \ -H "Content-Type: application/json" \ -H "Authorization: Bearer <token>" \ -d '{"title":"第一篇博客","content":"内容内容"}'

第五步,尝试不带 Token 访问创建接口,应该返回 401;再尝试用一个普通用户的 Token 去删除文章,应该返回 403。如果这两个“失败用例”能按预期返回,说明保护逻辑基本正确。

5.2 常见问题速查表

我把实战中遇到的典型问题整理成一个速查表,方便你对照排查:

现象可能原因解决方案
调用接口返回 404路由没注册,或动态路由覆盖了静态路由查看 app.js 是否挂载了路由文件,静态路由放在/:id之前
返回 401 但 Token 确实存在Token 过期、签名不匹配、Bearer 前缀缺失检查 JWT_SECRET 是否一致,确认 Authorization 头格式为Bearer <token>
返回 403用户角色不在允许列表中检查登录签发的 role 字段,确认授权中间件参数
接口跨域调用被拦未配置 CORS安装 cors 中间件并app.use(cors())
密码字段出现在接口响应里返回 user 对象时未过滤在模型 toJSON 中剔除 password
修改别人文章也能成功控制器缺少归属校验加上article.authorId !== req.user.id判断
JWT Secret 硬编码在代码里泄露风险极大放入 .env,并通过 dotenv 加载

5.3 我从这几次实践里总结出的经验

整轮做完,我最大的体会有三个。

第一,接口设计要在写业务代码之前定好规范。哪些端点要登录、哪些角色能操作、错误码怎么定义,如果想清楚再动手,后面所有写代码的人都不会纠结。我看到很多项目接口越写越乱,根源往往不是技术能力,而是没有提前约定。

第二,认证和授权是两条线,不要混在一起。认证中间件只负责“确认你是谁”,授权中间件只负责“确认你能不能做”,职责单一才能在多个业务模块中复用。如果你想偷懒把权限判断直接写进认证中间件,后期每个接口的权限都不一样,很快就会变成一团乱麻。

第三,自动化测试一定要覆盖“未经授权访问”和“越权访问”这两个用例。很多人写单测时只测能通的路,却没测通不了的路。实际上,CRUD API 最容易出安全问题的恰恰是那些“不应该成功”的请求。我在项目里会专门写一组安全测试用例:无 Token 创建文章、普通用户删除文章、普通用户修改他人文章,确保这三个请求必须被拦截。

这个小项目做完之后,我把它稍微扩展成了带 MySQL 持久化和刷新 Token 的生产版本,接口层基本没动底层逻辑,只是把模型从内存数组换成了数据库查询和更新操作。核心启示其实就是:把 CRUD 端点和保护机制当成一套固定流程来沉淀,业务再复杂也走得稳。希望这篇文章能帮你少踩几个我之前踩过的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询