前端单页系统的权限控制

Posted by Melissa Zhou on 2018-11-28

前端单页系统的权限控制

写在前面:说到权限的控制,前端的权限是不安全的。所有的权限应该都由后端来做才能保证安全。那么我们为什么要在前端做权限控制呢?答案很简单,为了用户体验。一个普通用户明明看到了一个路由,点击以后系统报错说没有权限的体验,远远不如用户一开始并没有看到这个路由。这就是前端做权限控制的意义。也就是说,前端只负责把有权限的页面或者按钮展示给用户,其他的隐藏起来。前端单页的应用大大提升了前端权限控制的需求。很多后台管理系统都是单页应用,也都需要权限控制,这样不同的角色可以进行不同的操作。我所做的项目也是如此。这里将react和react-router等相关的类容一起总结一下。

前端的权限的维度

经验统计,前端权限的体现主要有以下3种类型:

  • 路由

    最常见的就是菜单栏或者导航栏的控制,不同的用户看到的菜单栏不一样,比如登录某云的后台管理系统,发现导航栏有2个下拉的选项,而管理员登录却能看到十几二十个下拉选项。这样的权限,通常是针对整个页面的,也就是设计者希望没有权限的人整个页面都看不到。就算使用者知道页面的路由,进去以后依然什么也看不见。

  • 按钮

    同样的页面,可以操作的按钮不一样,比如登录某云的后台管理系统,一个工单,普通用户只看到“查看”按钮,但是管理员就能看到“查看”,“审批”两个按钮。这样的权限,通常是希望使用者能看到页面,但是页面上的某些操作不能操作。

  • 请求

    请求,是前端权限的最后一次拦截。就是即将要发出请求的时候对权限进行检验,如果没有权限则不发送这个请求。

以上3种形式组成了前端权限的控制。

实现思路

上面说到权限的3中体现,这里说明下针对这3种情况的实现思路

路由

初始化路由之前就拿到权限的列表,再进行初始化,直接根据权限,初始化相应的路由。

页面和按钮

后端对于实现权限、日志等功能都是借用AOP面向切面的思想来实现。面向切面的思想把一些与核心业务无关,但任何模块都可能使用的功能抽离出来,然后动态给业务模块添加上需要的功能。然鹅,前端并没有实现动态添加业务模块的机制,不过仍然可以参考这个思想。我们把与权限有关的抽离成一个公用的组件,在需要使用权限的地方初始化这个组件,这个组件的插入尽量做到不影响页面的原有逻辑。

请求

可以写一个公用的方法,这个方法可以判断,当前的用户是否有这个接口的访问权限,如果有就发送请求,如果没有就不发送请求。

实战

上面说了实现的思路,下面就具体说下怎么做。以react + react router 为例。

react路由权限控制

想要路由渲染的时候根据权限来进行渲染,那在初始化路由之前就应该拿到权限的列表。因此所有内容都应该在权限加载完以后加载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import ReactDOM, { render } from 'react-dom'
import React, { Component } from 'react'
import App from 'es/components/app'
import Permission from 'es/components/permission'
import Root from './root'
import { needPermissions as headPermissions } from '../../../permissions/es-header'

class MyAPP extends Component {
render () {
return (
<App>
<Permission
queryPermissions={headPermissions.concat(['ADMINGROUPS_GET_LIST'])}
>
<Root />
</Permission>
</App>
);
}
}

render((<MyAPP />), document.getElementById('application'))

上面代码是页面的入口文件。可以看到,这个入口文件最外层组件是App 组件,其次是Permission 组件。 Root 组件就是页面真正的内容。为什么我们需要再最外层套两层呢?因为之前说的,我们在初始化路由的时候需要拿到用户的权限以及用户的信息,于是我们在App 组件获取到用户的信息,在Permission 组件中获取权限。这里可以根据各自的业务逻辑灵活使用,如果不需要用户的信息,可以直接只套一层。

在上面两个组件中获取到了用户和权限信息,我们怎么传递给其他组价使用呢?react中,传递变量通常是用props一层层往下传,但是像用户信息和权限这类信息,在全局任何地方都有可能用到,如果使用props从顶层组价一层层往下传会显得非常繁琐,debug问题的时候也很难快速定位。有没有什么方法可以让这类信息像全局变量一样,任何一个自组件都能轻松的访问到呢?有一个非官方的方法context

Context——定义游离于组件之外的全局变量

context的使用非常方便,只要在父组件或者说外层组件定义一个getChildContext方法和childContextTypes 变量,在里层定义contextTypes就可以直接在里层用this.context访问。举个栗子:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import React, { Children, Component, ReactElement } from 'react';
import PropTypes from 'prop-types';

import request, { getApis } from 'es/utils/api-request';
import apis from 'es/constants/apis';

export default class Permission extends Component {
_queryPermissions: Array<string>

static childContextTypes = {
permissions: PropTypes.object,
hasPermissions: PropTypes.func,
hasSomePermissions: PropTypes.func
}

getChildContext () {
return {
permissions: this.state.permissions,
hasPermissions: this.hasPermissions.bind(this),
hasSomePermissions: this.hasSomePermissions.bind(this)
};
}

state = {
permissions: undefined
}

constructor (props, context) {
super(props, context);

const {needPermissions = [], needSomePermissions = [], queryPermissions = []
} = this.props;

this._queryPermissions = Array.from(
new Set([...queryPermissions, ...needPermissions, ...needSomePermissions]));
}

componentDidMount () {
/* 从后端获取权限并保存在state中*/
this.getPermissions();
}
/* 判断当前用户是否具有传入的权限*/
hasPermissions (checkPermissions) {
let { permissions = {} } = this.state;
return !checkPermissions || checkPermissions.every((p) => permissions[p]);
}
/* 判断当前用户是否具有传入的权限组*/
hasSomePermissions (checkPermissions) {
let { permissions = {} } = this.state;
return !checkPermissions || checkPermissions.some((p) => permissions[p]);
}

render () {
let { props } = this;
const {needPermissions, needSomePermissions, noPermissionChild } = props;

var child;

if (this.state.permissions) {
if (
(needPermissions && !this.hasPermissions(needPermissions)) ||
(needSomePermissions && !this.hasSomePermissions(needSomePermissions))
) {
child = noPermissionChild;
} else {
child = props.children;
}
}

return child ? Children.only(child) : null;
}
}

Permission 组件的主要功能就是,从后端获取所有的permissions,放在自己的state中,并通过context,将permissions以及几个方法存储在全局变量中,判断传入的权限名该用户是不是有,没有就显示没有权限,有就显示子组件。

上面的组件和一般的组件没有什么差别,就是多定义了getChildContext方法和childContextTypes 变量。

如何在子组件中拿到permissions呢?

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Menu } from 'antd';
import { functionalModules, commonModules } from '../../../permissions/es-header';

export default class AppHeader extends Component {
static contextTypes = {
user: PropTypes.object,
hasSomePermissions: PropTypes.func
}

getUserName () {
let { user } = this.context;

return (
<Menu.SubMenu
title={(<span>{user.name}</span>)}
>
</Menu.SubMenu>
);
}

getProducts (modules) {
return modules.map((m) => (
this.checkAuth(m.apis)
? (
<Menu.Item key={m.text}>
<a href={m.url}>{m.text}</a>
</Menu.Item>
)
: null
)).filter((item) => !!item);
}

checkAuth (apis) {
let { hasSomePermissions } = this.context;

if (apis) {
return hasSomePermissions(apis);
}
return hasSomePermissions(apis);
}

render () {
return (
<div>
<Menu>
<Menu.SubMenu title="功能1">
<Menu.ItemGroup title="具体模块">
{this.getProducts(functionalModules)}
</Menu.ItemGroup>
</Menu.SubMenu>
</Menu>
<Menu>
{this.getUserName()}
</Menu>
</div>
)
}
}

如上代码,只要在组件中定义contextTypes ,就能this.context 直接调用需要的变量或者方法。

事实上,上面的代码就是路由权限控制的关键。我们把所有完整的路由以json的形式静态保存起来,Menu.ItemGroup 组件,渲染出来的就是根据用户的权限从完整路由中过滤出来的目录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[
{
"apis": [
"GET_USER_LIST",
"ADD_USER_ITEM"
],
"text": "用户管理",
"url": "/user"
},
{
"apis": [
"ADD_FEE_ITEM",
"GET_FEE_LIST"
],
"text": "费用管理",
"url": "/fee"
}
]

这里实现的是一个很简单顶部。左侧导航栏也是同样的处理思路。

其实上还有一种思路是将路由的组件进行封装,这个组件在挂载前判断是不是有权限,就是利用路由的回调函数onEnteronLeave() 来做。这里没有那样做的理由是,如果按照这种思路来做,那每个路由的回调都会执行一次,有些浪费资源。

具体可以参看其他的文章:https://blog.csdn.net/qq_39985511/article/details/80885158

react页面和按钮权限控制

在路由的实现中其实已经实现了Permission 组件,无论是页面,还是按钮,只要需要权限控制的地方,就在组件外层添加这个组件,并传入需要的权限名字,如果有权限,就初始化子组件,没有就不处理或者显示没有权限。

一个页面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export default class MyPageContainer extends Component {
render () {
return (
<div>
<Breadcrumb routes={routes} params={allParams} />
<Permission
needPermissions={['GET_LIST']}
noPermissionChild={<span>无权限</span>}
>
<MyPage/>
</Permission>
</div>
)
}
}

一个按钮的例子:

1
2
3
4
5
6
7
8
9
10
11
render () {
return (
<div>
<Permission
needPermissions={['ADD_ITEM']}
>
<Button>新增</Button>
</Permission>
</div>
);
}

发送请求时的权限

这个基于前面的内容,在发送请求的时候再进行一次判断。Permission 组件返回了一些方法,可以通过context来获取到。同样这些方法可以判断当前用户是不是拥有该接口的权限。如果拥有就发送请求,否则就不发送。我们可以将发送请求的方法提取出来一并处理。但是我们必须在前端维护一个接口和对应权限名称的文件。这样才能知道哪个接口需要的是什么权限。类似于下面这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"URSUSER_GET_LIST": {
"url": "/users/info",
"method": "GET",
"action": "searchUserInfo",
"resource": "/users/info"
},
"URSUSER_MODIFY": {
"url": "/users/modify",
"method": "POST",
"action": "modifyUserInfo",
"resource": "/users/modify"
}
}

总结

有关权限的控制,都应该由后端来做。前端只是为了提高用户体验,让没有权限的资源不显示出来。前端的控制的主要思路就是有一个公用的方法,这个方法可以判断当前用户是不是拥有需要的权限,然后前端根据这个方法返回的结构生成路由,渲染页面等。

其他参考文章:

https://refined-x.com/2017/08/29/%E5%9F%BA%E4%BA%8EVue%E5%AE%9E%E7%8E%B0%E5%90%8E%E5%8F%B0%E7%B3%BB%E7%BB%9F%E6%9D%83%E9%99%90%E6%8E%A7%E5%88%B6/