ltaoo's web

前端怎么做好权限控制

现如今我们访问的每个网站,或多或少都会存在「权限控制」。

以一个博客系统为例,系统内共有三种角色

  • 1、游客
  • 2、会员
  • 3、管理员

不同角色支持不同的操作,比如

  • 1、游客可以访问博客列表页、博客详情页
  • 2、会员除游客权限外,还可以提交博客、提交评论、对博客进行编辑、删除操作等。
  • 3、管理员除上述权限外,还可访问用户管理页对用户进行管理。

上述说明「什么用户能够进行什么操作,不能进行什么操作」,可以理解为就是「权限」,根据这些权限,做出正确的处理,就是「权限控制」,而「角色」,是「权限」的集合,也方便我们对用户的定义。

一、为什么要做权限控制

最主要的原因是有了「用户」的概念,需要对不同的用户提供不同的服务。

如果一个博客系统没有权限控制,游客可以任意发布、编辑、删除博客,整个系统就乱了套,无法进行有效的管理。

不过,这些都是针对后端而言的,对前端来说,做权限控制,很大程度上是为了用户体验,下面会解释这个观点。

二、如何做权限控制

1、后端的权限控制

既然提到了「权限控制仅针对后端有用」,那么后端是如何做权限控制呢?

Django为例,初始化项目后,就会预置'django.contrib.auth'应用,该应用提供了现成的用户表、登录注册功能以及用户权限相关的功能,无需开发者自己实现。

它的权限很简单,除了是否为管理员,就是对指定「数据表」是否有addchangedelete的权限了。

当后端接收到请求,会做如下处理:

  • 1、获取当前请求属于哪个用户发出
  • 2、获取该用户拥有什么权限
  • 3、想要进行什么操作
  • 4、是否有该操作的权限
  • 5、成功操作或失败

通过条件判断,给出用户操作的结果,就完成了后端的权限控制。

2、前端的权限控制

前端的权限控制,往往通过隐藏DOM元素实现,比如博客页面,作者和管理员可以看到「删除博客」按钮,游客则看不到。

仅仅这样就够了吗?是的,这样就够了。

作为前端开发,我们都听过「前端不可信」,即后端无论如何都不能相信前端发送的请求数据。

无论Web端还是移动端,都是可以被「破解」的,通过抓包等手段,可以得到发送的实际请求,从而伪造请求并发送。

假设会员 A 写了一篇博客,博客序号为 20,通过控制台得到删除该博客的请求为:

1
DELETE /api/blog/20/

会员A 退出登录后,身份变为了游客,当他访问另一篇序号为 21 的博客时,恶意的使用控制台发送DELETE /api/blog/21/的请求。

如果后端完全相信前端而没有做权限判断,那博客 21 的作者就无故丢失了一篇博客。所以后端仍需要对发起请求的用户进行权限的判断,并给出正确的处理。

问:既然后端都做好了权限控制,前端再做一次是不是没有必要?

从「处理权限并返回处理结果」上来说,的确是没有必要。

如果前端没有对游客隐藏「删除博客」按钮,游客点击删除后,返回提示「没有权限操作」,游客将十分困惑,「为什么能让我点击却又告诉我没有权限?」。

从另一方面,尝试过一次「没有权限」的提示后,大部分都不会再去点了,但是可能会有一部分继续「疯狂操作」,就为了看看错误提示。。。

对应这种情况,前端隐藏删除按钮后,提高了「误操作」成本,可以减轻一部分服务器的压力,避免处理一些无意义的请求。

但是对于恶意用户,总是可以找出办法「疯狂操作」,所以说前端的权限控制,很大程度上是为了更好的用户体验。

三、前端权限控制具体方案

在之前后端渲染模板的做法,当用户请求页面时,就可以得到用户角色,并判断是否有权限,如果没有则重定向到登录页或者错误页。

模板渲染时,也可以在模板内根据用户角色决定是否渲染和权限有关的DOM

而现在流行的单页面应用,路由都交给了前端控制,模板内也无法直接获取到用户从而决定是否渲染,一切都交给了js处理。

总体来说,分为三步,对,就和「如何将大象装进冰箱」这个问题的答案一样。

  • 1、获取用户角色
  • 2、保存用户角色
  • 3、判断用户角色并作出不同处理

1、获取用户角色

要根据用户角色做不同的处理,首先要获取到用户角色。可以在登录时一并返回,也可以提供一个独立的接口用以请求。

2、保存用户角色

角色往往和其他用户信息一样,保存在localStorage,也是因为除了这里,没有其他合适的地方了。

也许会说保存在localStorage会不会不安全,很容易被篡改。的确可能,所以前面也提到,前端的权限,只是为了更好的用户体验,真正的权限控制必须要由后端来做。

3、根据角色进行处理

当然,最难的就是这一步了,核心原理,就是if条件判断(废话),假设我们有这些角色:

1
2
3
4
const ROLE_MAP = {
1: '会员',
2: '管理员'
};

这是已登录用户,如未登录,则用户角色为「游客」。登录后会返回如下信息:

1
2
3
4
{
"username": "wuya"
"role": 2
}

用户角色为 2,表示是「管理员」,所以能够看到「删除博客」按钮。

vue举例,简单在指定组件上使用v-if指令即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<template>
<div>
<button v-if="hasPermission">删除博客</button>
</div>
</template>

<script>
export default {
name: 'permission_example',
data() {
const user = localStorage.getItem('user');
return {
user,
};
},
computed: {
hasPermission() {
return this.user.role === 2;
}
},
}
</script>

如果是react则稍微麻烦一丢丢:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class App extends React.Component {
state = {
role: 2,
}
render() {
const {
role,
} = this.state;

const deleteBtn = null;
if (role === 2) {
deleteBtn = <button>删除博客</button>
}
return (
<div>
{deleteBtn}
</div>
);
}
}

完美解决~下班回家

产品:等下,我觉得吧,管理员还是不能删博客,另外再加一个「专门删博客管理员」角色,只有他能删吧。

…好的没问题,简单。增加角色,并将2改成了3(专门删博客管理员)。

产品:为什么博客列表页管理员还是有删除按钮啊?

啊,博客列表页还有删除按钮啊,我再补一下。。。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class BlogList extends React.Component {
state = {
role: 1,
blogs: [],
}
render() {
const {
role,
blogs,
} = this.state;

const deleteBtn = null;
if (role === 3) {
deleteBtn = <button>删除博客</button>
}
return (
<div>
{blogs.map(blog => (
<div>
<p>{blog.title}</p>
{deleteBtn}
</div>
))}
</div>
);
}
}

产品:我又有个新想法。。。

四、权限组件

为了避免这种枯燥且易出错的的方式,我们可以参考后端,将用以与用户交互的组件定义为「资源」,同时维护一组「角色」拥有的「资源」对照表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import React from "react";

// 角色对应有什么资源的权限
const PERMISSION_MAP = {
// 声明会员有什么权限
1: [],
// 管理员拥有的权限
2: ['deleteBlog']
};

export default class PermissionWrapper extends React.Component {
state = {
role: 1
};
render() {
const { role } = this.state;
const { source } = this.props;
// 如果当前用户有该资源权限,就展示该组件
if (PERMISSION_MAP[role].includes(source)) {
return this.props.children;
}
return null;
}
}

然后就可以在页面上使用该组件了:

1
2
3
4
5
6
7
8
9
10
11
class App extends React.Component {
render() {
return (
<div>
<PermissionWrapper source="deleteBlog">
<button>删除博客</button>
</PermissionWrapper>
</div>
);
}
}

如果出现需求反复变更的情况,只需要编辑PERMISSION_MAP这一个对象即可,即使删除博客按钮会出现在多个地方。

这种方式可以减轻一部分维护成本。

1、核心逻辑

核心逻辑还是之前提到的if判断,只是多了一个组件,将if条件判断移到了该组件内。

所以任何框架都适用,再以vue为例,要实现上述功能,该怎么做。

2、vue 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<template>
<div>
<div v-if="hasPermission">
<slot name="content">
</slot>
</div>
</div>
</template>

<script>
const PERMISSION_MAP = {
1: [],
2: ["deleteBlog"]
};
export default {
name: "Permission",
props: ["source"],
data() {
return {
user: {
role: 1
}
};
},
computed: {
hasPermission() {
const { role } = this.user;
const { source } = this;
return PERMISSION_MAP[role].includes(source);
},
}
};
</script>

使用:

1
2
3
4
5
 <Permission source="deleteBlog">
<div slot="content">
<button>删除博客</button>
</div>
</Permission>

3、jQuery 实现

不同于使用框架可以决定是否渲染指定组件,采用jQuery只能隐藏或者移除指定的DOM,而且也不够优雅。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<body>
<button class="resource" data-type="deleteBlog">删除博客</button>
</body>
<script>
const PERMISSION_MAP = {
1: [],
2: ['deleteBlog'],
};
const user = {
role: 1,
};
$(document).ready(function () {
const { role } = user;
resourceDOM = $('.resource');
for (let i = 0, l = resourceDOM.length; i < l; i += 1) {
const resource = resourceDOM[i];
const type = resource.dataset.type;
if (!PERMISSION_MAP[role].includes(type)) {
resource.remove();
}
}
});
</script>

五、页面访问权限

上面实现的权限组件,似乎只能处理页面内的组件,能处理页面吗?

现在博客系统新增需求,游客无法访问所有页面,只能注册成为会员,登录后进行访问。

使用vue的情况下,官方推荐在路由钩子内,进入页面前对当前用户权限进行判断并处理,能够进入,或者重定向到登录页。

同样的,我们可以将「页面」视为资源,判断指定角色是否拥有该资源即可。

使用react则麻烦一些,由于react-router没有提供路由钩子,无法像vue一样统一处理,

但仍然可以和处理页面组件权限一样,有两种方案:

  • 1、修改页面组件
  • 2、修改Route组件

1、修改页面组件

下面是博客列表页的代码:

1
2
3
4
5
6
7
class Blogs extends React.Component {
render() {
<Permission source="blog_page">
<div>...原先的页面代码</div>
</Permission>
}
}

虽然能够在没有权限的情况下无法看到页面了,但显示一片空白也不好,我们可以在Permission内不返回null,而根据条件比如指定当前是处理页面,就返回一个NotFound页面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export default class PermissionWrapper extends React.Component {
state = {
user: {
role: 1
}
};
render() {
const { user } = this.state;
// 声明资源类型,增加 page 参数
const { source, page } = this.props;
// 如果当前用户有该资源权限,就展示该组件
if (PERMISSION_MAP[user.role].includes(source)) {
return this.props.children;
}
if (page) {
return <NotFound />
}
return null;
}
}

2、修改 Route 组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
export default class PermissionRoute extends React.Component {
constructor(props) {
super(props);

const { resourceName, user = {} } = props;
const { staffType } = user;
this.state = {
hasPermission: this.computedHasPermission(staffType, resourceName),
};
}
componentWillReceiveProps(nextProps) {
const { resourceName, user = {} } = nextProps;
const { staffType } = user;
const hasPermission = this.computedHasPermission(staffType, resourceName);
this.setState({
hasPermission,
});
}
computedHasPermission = (staffType, resourceName) => {
return (permission[staffType] || []).includes(resourceName);
}
render() {
const { hasPermission } = this.state;
return (
hasPermission ? <Route {...this.props} /> : <NotFound />
);
}
}

核心逻辑还是一致的,判断用户权限,如果有权限,则返回正常的<Route>,就可以渲染出预期的页面。否则显示NotFound页面。

六、缺点

虽然解决了一部分问题,但也引入了「资源」的维护成本,对资源的描述需要足够准确,才能直观的从权限对照表中看出资源对应的组件到底是什么。

同时对于一些复杂的权限处理无法胜任,还是以「删除博客」按钮为例,博客的作者对博客有删除权限。

那么,博客删除按钮只应该出现在作者属于「当前登录用户」的博客页面,如果访问其他作者的博客详情页,只是简单判断当前用户「是否有权限」,还是能够看到「删除博客」按钮。

即不仅是「有没有权限」,还涉及到「是不是」的问题。