在 JavaScript 中处理用户权限

Avatar of Andreas Remdt
Andreas Remdt

DigitalOcean 为您旅程的每个阶段提供云产品。 立即开始使用 $200 免费积分!

因此,您一直在开发这个新奇的 Web 应用程序。 无论是食谱应用程序、文档管理器还是您自己的私有云,您现在都已进入处理用户和权限的阶段。 以文档管理器为例:您不只是想要管理员;也许您想邀请具有只读访问权限的访客,或者可以编辑但不能删除您文件的人员。 您如何在前端处理该逻辑,而不会用太多复杂的条件和检查来使您的代码混乱?

在本文中,我们将回顾一个示例实现,说明您如何以优雅简洁的方式处理这类情况。 请谨慎使用——您的需求可能有所不同,但我希望您能从中获得一些想法。

假设您已经构建了后端,在数据库中添加了所有用户的表,并且可能为角色提供了专门的列或属性。 实现细节完全取决于您(取决于您的技术栈和偏好)。 为了演示,让我们使用以下角色

  • 管理员:可以执行任何操作,例如创建、删除和编辑自己的或他人的文档。
  • 编辑:可以创建、查看和编辑文件,但不能删除文件。
  • 访客:可以查看文件,就这么简单。

与大多数现代 Web 应用程序一样,您的应用程序可能会使用 RESTful API 与后端进行通信,因此让我们在演示中使用此场景。 即使您选择其他方法,例如 GraphQL 或服务器端渲染,您仍然可以应用我们将要查看的相同模式。

关键是在获取一些数据时返回当前登录用户的角色(或权限,如果您更喜欢该名称)。

{
  id: 1,
  title: "My First Document",
  authorId: 742,
  accessLevel: "ADMIN",
  content: {...}
}

在这里,我们获取一个包含一些属性的文档,包括一个名为 accessLevel 的属性,用于用户的角色。 这就是我们知道当前登录用户被允许或不允许执行的操作的方式。 我们的下一步是添加一些前端逻辑,以确保访客看不到他们不应该看到的东西,反之亦然。

理想情况下,您不仅依赖前端来检查权限。 熟悉 Web 技术的人仍然可以发送没有 UI 的请求到服务器,意图操纵数据,因此您的后端也应该进行检查。

顺便说一下,这种模式与框架无关;无论您使用 React、Vue 还是一些非凡的 Vanilla JavaScript 都没关系。

定义常量

第一步(可选,但强烈推荐)是创建一些常量。 这些将是简单的对象,包含应用程序可能包含的所有操作、角色和其他重要部分。 我喜欢将它们放在一个专用文件中,也许称为 constants.js

const actions = {
  MODIFY_FILE: "MODIFY_FILE",
  VIEW_FILE: "VIEW_FILE",
  DELETE_FILE: "DELETE_FILE",
  CREATE_FILE: "CREATE_FILE"
};

const roles = {
  ADMIN: "ADMIN",
  EDITOR: "EDITOR",
  GUEST: "GUEST"
};

export { actions, roles };

如果您有幸使用 TypeScript,可以使用 枚举 来获得更简洁的语法。

为您的操作和角色创建常量集合有一些优势

  • 唯一的真理来源。 您不必浏览整个代码库,只需打开 constants.js 即可查看应用程序中可能存在的内容。 这种方法也具有很强的可扩展性,例如当您添加或删除操作时。
  • 没有打字错误。 您不必每次都手动输入角色或操作,这容易出错,并且调试起来很麻烦,您可以导入该对象,并且由于您最喜欢的编辑器的魔力,您可以免费获得建议和自动完成。 即使您仍然输入错误的名称,ESLint 或其他一些工具很可能会一直提醒您,直到您修复它为止。
  • 文档。 您是在团队中工作吗? 新团队成员将很高兴,因为他们不必浏览大量文件才能了解有哪些权限或操作。 它也可以使用 JSDoc 很容易地记录。

使用这些常量非常简单;导入并像这样使用它们

import { actions } from "./constants.js";

console.log(actions.CREATE_FILE);

定义权限

进入激动人心的部分:对数据结构进行建模,以将我们的操作映射到角色。 解决此问题有很多方法,但我最喜欢以下方法。 让我们创建一个新文件,将其称为 permissions.js,并在其中添加一些代码

import { actions, roles } from "./constants.js";

const mappings = new Map();

mappings.set(actions.MODIFY_FILE, [roles.ADMIN, roles.EDITOR]);
mappings.set(actions.VIEW_FILE, [roles.ADMIN, roles.EDITOR, roles.GUEST]);
mappings.set(actions.DELETE_FILE, [roles.ADMIN]);
mappings.set(actions.CREATE_FILE, [roles.ADMIN, roles.EDITOR]);

让我们逐步进行

  • 首先,我们需要导入我们的常量。
  • 然后,我们创建一个新的 JavaScript Map,称为 mappings。 我们本可以使用任何其他数据结构,例如对象、数组,等等。 我喜欢使用 Map,因为它们提供了一些方便的方法,例如 .has().get() 等。
  • 接下来,我们为应用程序的每个操作添加(或更确切地说,设置)一个新条目。 操作充当键,通过该键,我们随后获取执行该操作所需的 角色。 至于值,我们定义了一个必需角色数组。

这种方法可能一开始看起来很奇怪(对我来说的确如此),但我逐渐欣赏它。 它的优势显而易见,尤其是在具有大量操作和角色的较大应用程序中

  • 再次强调,唯一的真理来源。 您需要知道哪些角色需要编辑文件吗? 没问题,转到 permissions.js 并查找该条目。
  • 修改业务逻辑出奇地简单。 假设您的产品经理决定从明天开始,编辑可以删除文件;只需将他们的角色添加到 DELETE_FILE 条目中,然后就完成了。 添加新角色也是如此:将更多条目添加到 mappings 变量中,您就完成了。
  • 可测试。 您可以使用快照测试来确保这些映射中没有任何意外变化。 在代码审查期间,它也更清晰。

上面的示例相当简单,可以扩展到涵盖更复杂的情况。 例如,如果您有不同文件类型,它们具有不同的角色访问权限。 在本文末尾会详细介绍这一点。

在 UI 中检查权限

我们定义了所有操作和角色,并且创建了一个地图来解释谁被允许执行什么操作。 现在是实现一个函数的时候了,以便我们在 UI 中使用它来检查这些角色。

在创建这种新行为时,我总是喜欢从 API 应该如何工作开始。 之后,我将实现该 API 背后的实际逻辑。

假设我们有一个 React 组件,它渲染一个下拉菜单


function Dropdown() {
  return (
    <ul>
      <li><button type="button">Refresh</button><li>
      <li><button type="button">Rename</button><li>
      <li><button type="button">Duplicate</button><li>
      <li><button type="button">Delete</button><li>
    </ul>
  );
}

显然,我们不希望访客看到或单击“删除”或“重命名”选项,但我们希望他们看到“刷新”。 另一方面,编辑者应该看到除“删除”之外的所有选项。 我想象一下这样的 API

hasPermission(file, actions.DELETE_FILE);

第一个参数是文件本身,由我们的 REST API 获取。 它应该包含我们之前提到的 accessLevel 属性,该属性可以是 ADMINEDITORGUEST。 由于同一个用户在不同文件中可能具有不同的权限,因此我们始终需要提供该参数。

至于第二个参数,我们传递一个操作,例如删除文件。 然后,该函数应返回一个布尔值 true(如果当前登录用户对该操作具有权限),或 false(如果用户没有权限)。

import hasPermission from "./permissions.js";
import { actions } from "./constants.js";

function Dropdown() {
  return (
    <ul>
      {hasPermission(file, actions.VIEW_FILE) && (
        <li><button type="button">Refresh</button></li>
      )}
      {hasPermission(file, actions.MODIFY_FILE) && (
        <li><button type="button">Rename</button></li>
      )}
      {hasPermission(file, actions.CREATE_FILE) && (
        <li><button type="button">Duplicate</button></li>
      )}
      {hasPermission(file, actions.DELETE_FILE) && (
        <li><button type="button">Delete</button></li>
      )}
    </ul>
  );
}

您可能想要找到一个不太冗长的函数名称,或者甚至可能想出另一种实现整个逻辑的方法(柯里化就足够了),但对我来说,这种方法已经做得相当不错了,即使是在具有超级复杂权限的应用程序中也是如此。 当然,JSX 看起来更混乱,但这只是一个小的代价。 在整个应用程序中始终如一地使用这种模式使权限更加干净和直观。

如果您仍然不相信,让我们看看如果没有 hasPermission 助手会是什么样子

return (
  <ul>
    {['ADMIN', 'EDITOR', 'GUEST'].includes(file.accessLevel) && (
      <li><button type="button">Refresh</button></li>
    )}
    {['ADMIN', 'EDITOR'].includes(file.accessLevel) && (
      <li><button type="button">Rename</button></li>
    )}
    {['ADMIN', 'EDITOR'].includes(file.accessLevel) && (
      <li><button type="button">Duplicate</button></li>
    )}
    {file.accessLevel == "ADMIN" && (
      <li><button type="button">Delete</button></li>
    )}
  </ul>
);

您可能会说这看起来并不糟糕,但想想如果添加了更多逻辑,例如许可证检查或更细粒度的权限会发生什么。 在我们的专业领域中,事情往往会迅速失控。

您想知道为什么当每个人都可以看到“刷新”按钮时我们还需要第一个权限检查吗? 我喜欢把它放在那里,因为你永远不知道将来可能会发生什么变化。 可能会引入一个新角色,这个角色甚至可能看不到这个按钮。 在这种情况下,您只需更新您的 permissions.js 并让组件保持原样,从而获得更干净的 Git 提交,并减少出错的机会。

实现权限检查器

最后,是时候实现将所有内容粘合在一起的函数了:操作、角色和 UI。 实现起来相当简单

import mappings from "./permissions.js";

function hasPermission(file, action) {
  if (!file?.accessLevel) {
    return false;
  }

  if (mappings.has(action)) {
    return mappings.get(action).includes(file.accessLevel);
  }

  return false;
}

export default hasPermission;
export { actions, roles };

您可以将上面的代码放在一个单独的文件中,甚至放在 permissions.js 中。 我个人将它们放在一个文件中,但,嘿,我不会告诉你如何生活。 :-)

让我们分析一下这里发生了什么

  1. 我们使用我们之前确定的相同 API 签名定义了一个新函数 hasPermission。 它接受文件(来自后端)以及我们要执行的操作。
  2. 为了安全起见,如果由于某种原因,该文件为 null 或不包含 accessLevel 属性,我们将返回 false。 最好格外小心,不要因代码中的故障或错误而将“机密”信息泄露给用户。
  3. 进入核心,我们检查mappings是否包含我们正在查找的操作。如果是,我们可以安全地获取其值(记住,它是一个角色数组),并检查我们当前登录的用户是否具有该操作所需的权限。这将返回truefalse
  4. 最后,如果mappings不包含我们正在查找的操作(可能是代码中的错误或另一个故障),为了更加安全,我们将返回false
  5. 在最后两行,我们不仅导出了hasPermission函数,还为了开发者方便而重新导出了我们的常量。这样,我们就可以在一行代码中导入所有实用程序。
import hasPermission, { actions } from "./permissions.js";

更多用例

展示的代码为了演示目的非常简单。但是,您可以将其作为应用程序的基础并根据需要进行调整。我认为这是任何 JavaScript 驱动的应用程序实现用户角色和权限的良好起点。

通过一些重构,您甚至可以重用此模式来检查其他内容,例如许可证。

import { actions, licenses } from "./constants.js";

const mappings = new Map();

mappings.set(actions.MODIFY_FILE, [licenses.PAID]);
mappings.set(actions.VIEW_FILE, [licenses.FREE, licenses.PAID]);
mappings.set(actions.DELETE_FILE, [licenses.FREE, licenses.PAID]);
mappings.set(actions.CREATE_FILE, [licenses.PAID]);

function hasLicense(user, action) {
  if (mappings.has(action)) {
    return mappings.get(action).includes(user.license);
  }

  return false;
}

我们不是断言用户的角色,而是断言他们的license属性:相同的输入,相同的输出,完全不同的上下文。

在我的团队中,我们需要检查用户角色和许可证,无论是一起还是单独。当我们选择此模式时,我们为不同的检查创建了不同的函数,并将它们组合在一个包装器中。我们最终使用的是一个hasAccess实用程序。

function hasAccess(file, user, action) {
  return hasPermission(file, action) && hasLicense(user, action);
}

每次调用hasAccess时传递三个参数并不理想,您可能在您的应用程序中找到解决方法(例如柯里化或全局状态)。在我们的应用程序中,我们使用包含用户信息的全局存储,因此我们可以简单地删除第二个参数,并从存储中获取该信息。

您也可以在权限结构方面更深入。您是否拥有不同类型的文件(或实体,更一般地说)?您是否想根据用户的许可证启用某些文件类型?让我们以上面的例子为例,并使其更强大一些。

const mappings = new Map();

mappings.set(
  actions.EXPORT_FILE,
  new Map([
    [types.PDF, [licenses.FREE, licenses.PAID]],
    [types.DOCX, [licenses.PAID]],
    [types.XLSX, [licenses.PAID]],
    [types.PPTX, [licenses.PAID]]
  ])
);

这为我们的权限检查器添加了全新的级别。现在,我们可以为单个操作拥有不同类型的实体。假设您想为您的文件提供一个导出器,但您希望您的用户为构建的超级炫酷的 Microsoft Office 转换器付费(谁会反对呢?)。我们不是直接提供一个数组,而是在操作中嵌套了一个第二个 Map,并将我们想要覆盖的所有文件类型传递过去。为什么要使用 Map,您可能会问?与我之前提到的理由相同:它提供了一些友好的方法,例如.has()。不过,您可以随意使用其他方法。

随着最近的更改,我们的hasLicense函数不再适用,因此需要对其进行一些更新。

function hasLicense(user, file, action) {
  if (!user || !file) {
    return false;
  }

  if (mappings.has(action)) {
    const mapping = mappings.get(action);

    if (mapping.has(file.type)) {
      return mapping.get(file.type).includes(user.license);
    }
  }

  return false;
}

我不知道是不是只有我一个人,但即使复杂度增加了,这看起来仍然非常易读,不是吗?

测试

如果您想确保您的应用程序按预期工作,即使在代码重构或引入新功能后也是如此,您最好准备好一些测试覆盖率。关于测试用户权限,您可以使用不同的方法。

  • 为映射、操作、类型等创建快照测试。这可以在 Jest 或其他测试运行器中轻松实现,并确保没有任何东西意外地通过代码审查。但是,如果权限经常更改,更新这些快照可能会很繁琐。
  • hasLicensehasPermission添加单元测试,并断言该函数按预期工作,方法是硬编码一些现实世界的测试用例。对函数进行单元测试通常是一个好主意(如果不是总是的话),因为您想确保返回正确的值。
  • 除了确保内部逻辑工作正常之外,您还可以将额外的快照测试与您的常量结合使用,以覆盖每个场景。我的团队使用类似于此的方法。
Object.values(actions).forEach((action) => {
  describe(action.toLowerCase(), function() {
    Object.values(licenses).forEach((license) => {
      it(license.toLowerCase(), function() {
        expect(hasLicense({ type: 'PDF' }, { license }, action)).toMatchSnapshot();
        expect(hasLicense({ type: 'DOCX' }, { license }, action)).toMatchSnapshot();
        expect(hasLicense({ type: 'XLSX' }, { license }, action)).toMatchSnapshot();
        expect(hasLicense({ type: 'PPTX' }, { license }, action)).toMatchSnapshot();
      });
    });
  });
});

但同样,测试有很多不同的个人喜好和方法。

结论

就是这样!我希望您能够为您的下一个项目获得一些想法或灵感,并且此模式可能是您想用到的东西。回顾一下它的几个优点。

  • 不再需要在 UI(组件)中使用复杂的条件或逻辑。您可以依靠hasPermission函数的return值,并根据该值轻松显示和隐藏元素。能够将业务逻辑与 UI 分开有助于创建更干净、更易维护的代码库。
  • 权限的单一真相来源。您不需要遍历多个文件来弄清楚用户可以看到什么或不能看到什么,只需进入权限映射并在那里查看。这使得扩展和更改用户权限变得轻而易举,因为您甚至可能不需要接触任何标记。
  • 非常易于测试。无论您选择快照测试、与其他组件的集成测试还是其他测试,集中式权限都很容易编写测试。
  • 文档。您不需要用 TypeScript 编写应用程序来享受自动完成或代码验证的优势;使用预定义的常量来表示操作、角色、许可证等,可以简化您的工作并减少令人讨厌的拼写错误。此外,其他团队成员可以轻松地发现可用的操作、角色或其他任何内容以及它们的使用位置。

如果您想查看此模式的完整演示,请访问此 CodeSandbox,它使用 React 对此想法进行了演示。它包括不同的权限检查,甚至还有一些测试覆盖率。

您怎么看?您是否有类似的方法来处理这些问题,您认为它值得付出努力吗?我总是对其他人的想法感兴趣,欢迎您在评论区发表任何反馈。保重!