首页 / 文章

Express 中基于角色的访问控制:JWT与两个中间件的应用

了解如何通过将 JWT 认证中间件与可重用的 authorize() 保护机制结合,在 Express API 中实现基于角色的访问控制,以及何时返回 401 错误码而非 403 错误码。

发布者:REACTAPP.TOP阅读约 4 分钟1024 词

来源: What is RBAC in Node.js?

在大多数后端系统中,不同用户被允许执行不同的操作:管理员可以删除账户,经理可以编辑账户,而普通用户只能读取。如果在控制器中散布大量 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权限由调用者被分配的角色决定。
  • 在 Express 中是如何实现的? 一个中间件用于验证 JWT,另一个中间件则根据路由允许的列表检查用户角色。
  • 应在何处实施限制? 在后端。仅在用户界面中隐藏按钮只是表面处理,因为任何人都可以直接调用 API。
  • 为何要使用中间件? 它能让授权逻辑被重复利用且不混入业务逻辑中,从而让一个防护机制覆盖多个路由。
  • RBAC 无法处理什么? 如“用户只能编辑自己的个人资料”这样的所有权规则,需要针对资源本身进行额外检查。
  • 总结

    整个流程非常简单:

    Client
      ↓
    JWT
      ↓
    Authentication
      ↓
    Authorization / RBAC
      ↓
    Controller
      ↓
    Database
    

    首先验证调用者的身份,读取其角色信息,并将其与路由允许的角色进行比较,之后决定是允许访问还是拒绝。随着应用程序的扩展,建议将角色映射为更细粒度的权限,如user:create、user:update和user:delete,并在路由层面直接检查这些权限而非仅依赖角色名称。这样一来,新增角色时只需修改相应的映射关系,而无需调整所有路由。