Express 中基于角色的访问控制:JWT与两个中间件的应用
了解如何通过将 JWT 认证中间件与可重用的 authorize() 保护机制结合,在 Express API 中实现基于角色的访问控制,以及何时返回 401 错误码而非 403 错误码。
在大多数后端系统中,不同用户被允许执行不同的操作:管理员可以删除账户,经理可以编辑账户,而普通用户只能读取。如果在控制器中散布大量 if (user.role === ...) 判断,很快就会变得难以维护且容易出错。本指南将展示如何使用两个简单的中间件在 Express 中将这些规则转化为基于角色的访问控制(RBAC),以及如何在请求被拒绝时选择正确的状态码。
典型的权限矩阵如下所示:
Admin → Create, Read, Update, Delete
Manager → Read, Update
User → Read
RBAC 在请求处理流程中的位置
RBAC 是一种授权模型:根据调用者所拥有的角色来决定是否允许访问某个端点。在基于 JWT 的 Node.js API 中,各环节会按照固定的顺序依次处理:
Login
↓
JWT Token
↓
Authentication Middleware
↓
Check User Role
↓
Authorization Middleware
↓
Controller
在此过程中解答了两个独立的问题。认证用于确定调用者的身份,而授权则决定该调用者可以执行哪些操作。将它们分别放在不同的中间件中,可以使每个部分更加简单且易于测试。关于二者的区别,可在认证与授权在代码中的定位一文中进一步了解。
将角色映射到端点
以一组标准的用户管理路由为例:
GET /api/users
POST /api/users
PUT /api/users/:id
DELETE /api/users/:id
针对这些路由所需的策略为:
Admin → All APIs
Manager → GET, PUT
User → GET only
无需在每个控制器中重复检查角色,只需将策略以声明式方式附加到每个路由上即可。
将角色放入令牌中
每条用户记录都包含一个角色:
{
name: "Ash",
email: "ash@example.com",
role: "admin"
}
在登录时,角色信息会与用户ID一同被签入JWT中,这样后续请求无需查询数据库即可获得授权:
const token = jwt.sign(
{
userId: user._id,
role: user.role
},
process.env.JWT_SECRET,
{ expiresIn: "1h" }
);
其代价是:角色信息会在令牌中一直保留直至过期。如果管理员被降职,旧的令牌在长达一小时的时间里仍会显示为admin。较短的过期时间可以限制这一时段;对于敏感操作,从数据库重新读取角色信息更为安全。
请求的身份验证
第一个中间件会从Authorization: Bearer <token>请求头中获取令牌,验证其签名和过期时间,然后将解码后的有效载荷存储在req.user中。若令牌缺失或无效,请求将以401状态码终止:
const authenticate = (req, res, next) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) {
return res.status(401).json({
message: "Authentication required"
});
}
try {
const decoded = jwt.verify(
token,
process.env.JWT_SECRET
);
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({
message: "Invalid or expired token"
});
}
};
该代码运行后,所有下游处理程序都可以信任req.user。在验证时,最佳做法是通过jwt.verify的algorithms选项指定允许的算法,而非直接接受令牌头部声明的任何算法。
按角色授权
第二个中间件是一个工厂函数。它接收允许的角色列表,然后返回一个处理程序,对于角色不在列表中的用户会返回403错误并拒绝其请求:
const authorize = (...roles) => {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({
message: "Access denied"
});
}
next();
};
};
请注意,该处理程序会读取req.user.role,因此必须始终在authenticate之后执行;否则由于req.user未定义而会抛出错误。
现在,保护某个路由只需将这两个处理程序组合起来即可:
router.delete(
"/users/:id",
authenticate,
authorize("admin"),
deleteUser
);
只有经过身份验证的管理员才能调用deleteUser函数。允许多个角色只需传递更多参数即可:
router.put(
"/users/:id",
authenticate,
authorize("admin", "manager"),
updateUser
);
在这里,管理员和经理都可以更新用户信息。
401与403的区别
这两个代码经常被混淆,但它们表示不同的问题。
401:调用者未经过身份验证
服务器不知道是谁在发起请求,通常是由于以下原因之一:
No token
Invalid token
Expired token
客户端需要重新登录或刷新令牌。
403:调用者身份已知但未被授权
虽然已确认身份,但其角色并不具备相应的访问权限:
User role = user
Required role = admin
使用相同的凭据重新尝试不会解决问题。简单来说:401表示“我们不知道你是谁”;403则表示“我们知道你是谁,但拒绝访问”。
简要回顾
- 什么是RBAC?这是一种访问控制机制,API权限由调用者被分配的角色决定。
总结
整个流程非常简单:
Client
↓
JWT
↓
Authentication
↓
Authorization / RBAC
↓
Controller
↓
Database
首先验证调用者的身份,读取其角色信息,并将其与路由允许的角色进行比较,之后决定是允许访问还是拒绝。随着应用程序的扩展,建议将角色映射为更细粒度的权限,如user:create、user:update和user:delete,并在路由层面直接检查这些权限而非仅依赖角色名称。这样一来,新增角色时只需修改相应的映射关系,而无需调整所有路由。