因此,您一直在开发这个新奇的 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
属性,该属性可以是 ADMIN
、EDITOR
或 GUEST
。 由于同一个用户在不同文件中可能具有不同的权限,因此我们始终需要提供该参数。
至于第二个参数,我们传递一个操作,例如删除文件。 然后,该函数应返回一个布尔值 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
中。 我个人将它们放在一个文件中,但,嘿,我不会告诉你如何生活。 :-)
让我们分析一下这里发生了什么
- 我们使用我们之前确定的相同 API 签名定义了一个新函数
hasPermission
。 它接受文件(来自后端)以及我们要执行的操作。 - 为了安全起见,如果由于某种原因,该文件为
null
或不包含accessLevel
属性,我们将返回false
。 最好格外小心,不要因代码中的故障或错误而将“机密”信息泄露给用户。 - 进入核心,我们检查
mappings
是否包含我们正在查找的操作。如果是,我们可以安全地获取其值(记住,它是一个角色数组),并检查我们当前登录的用户是否具有该操作所需的权限。这将返回true
或false
。 - 最后,如果
mappings
不包含我们正在查找的操作(可能是代码中的错误或另一个故障),为了更加安全,我们将返回false
。 - 在最后两行,我们不仅导出了
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 或其他测试运行器中轻松实现,并确保没有任何东西意外地通过代码审查。但是,如果权限经常更改,更新这些快照可能会很繁琐。
- 为
hasLicense
或hasPermission
添加单元测试,并断言该函数按预期工作,方法是硬编码一些现实世界的测试用例。对函数进行单元测试通常是一个好主意(如果不是总是的话),因为您想确保返回正确的值。 - 除了确保内部逻辑工作正常之外,您还可以将额外的快照测试与您的常量结合使用,以覆盖每个场景。我的团队使用类似于此的方法。
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 对此想法进行了演示。它包括不同的权限检查,甚至还有一些测试覆盖率。
您怎么看?您是否有类似的方法来处理这些问题,您认为它值得付出努力吗?我总是对其他人的想法感兴趣,欢迎您在评论区发表任何反馈。保重!
不错的实现。
由于它与之相关,我一直思考如何设计/实现一个后端系统,例如在 Nodejs 中,其中像超级管理员这样的用户可以根据角色更改所有这些权限和操作,就像 AWS 使用的那样来处理权限。
谢谢!本文中的实现也可以用于此,至少在理论上是这样。权限的问题在于每个应用程序的处理方式都不同。有些使用 Express,有些使用 GraphQL,您明白我的意思。对这些模式做出一般性假设总是有点困难。我倾向于查看类似规模的其他应用程序已经做过的事情,但同样,特定的平台,特定的解决方案。
非常优雅的代码。祝贺您。
但是,由于 JS 中的一切都可以被任何人读取,您建议如何隐藏这种逻辑?谢谢。
谢谢!您是指隐藏应用程序用户可以看到的这种逻辑,例如出于安全原因吗?如果它在前端,您能做的不多。理想情况下,这些代码会被压缩和打包,这会使“解码”变得更加困难,但并非不可能。但它必须被发送到浏览器,用户可以在那里对其进行分析。
也就是说,您的后端应该始终独立地检查这些权限,您永远不应该只依靠前端,原因正是如此——用户可以发现它。即使恶意用户从前端解码了逻辑,他们也无法更改任何数据,因为后端会进行干预。
如果您想阻止用户猜测有哪些角色、权限或许可证,一种方法是在构建时对值进行编码,例如使用 Babel 插件。这可以将字符串从例如
OWNER
更改为随机的字符串,例如l334erdca2
。您需要提到安全问题,这对于安全来说是不可用的。它只用于便利性和 UI。
任何 API 调用都必须 100% 检查用户的访问权限,您需要将其添加进去,否则人们将会复制/粘贴这个[redacted],而没有对安全模型/威胁的任何理解。
感谢您的反馈。您能详细说明一下为什么您认为它在安全方面不可用吗?
我完全同意您的观点,前端永远不应该成为检查这些权限的唯一实例,在后端也要覆盖这些问题至关重要。我在文章开头提到了这一点,不过它应该更加清晰,以强调安全性。
但是,在我看来(以及我的经验),此解决方案绝对可以在生产应用程序中使用。再说一次,如果您发现任何我没有考虑到的重要安全问题,我很想听听您的意见:)
非常感谢您撰写这篇文章。它回顾了许多概念,我学到了很多东西!
非常棒的文章!我只想指出一点——您在这里编写的测试https://css-tricks.org.cn/handling-user-permissions-in-javascript/#testing在我看来很难读懂。如果您使用的是
jest
,那么您可以使用describe.each
和jest.each
功能。感谢您的反馈。我同意,这些测试可以以更好的方式构建。由于到目前为止,我还没有使用参数化测试和
.each
在 Jest 中,为了避免传播任何不良做法,我没有在本文中包含它,但我肯定会很快查看它:)天哪!除了用户便利性之外,绝不要这样做,即使那样,也要从后端获取单一事实来源。后端实现的任何内容都是唯一安全且准确的事实来源。创建常量文件来映射访问控制规则,保证你最终会与后端失去同步。除此之外,不错的实现……但老实说,这绝不应该是除了用户便利性之外的任何事情——例如,将用户没有权限的按钮变灰。
是的,你完全正确,在权限方面,后端应该扮演最重要的角色。纯粹依赖前端会导致很高的安全风险。
正如另一个回复中所指出的,我在本文的介绍中对此做了一些评论,但我本可以更清楚地说明这一点,所以那是我自己的问题。
最终,就像我们构建的每个反应式 SPA 一样,这确实是为了用户便利。在我工作的公司,我们使用这个概念来实时显示/隐藏 UI 元素,例如,当用户从项目中移除时。尽管如此,后端会验证所有传入的请求,如果有人设法绕过前端的权限检查,他们会在尝试获取或操作数据的端点处失败。我认为这应该是这样,因为我们大多数用户(没有恶意意图)都从这个 UI 中受益。