前端进行权限控制只是为了用户体验,对应的角色渲染对应的视图,真正的安全保障在后端。
前言
毕业之初,工作的主要内容便是开发一个后台管理系统,当时存在的一个现象是:
用户若记住了某个 url,直接浏览器输入,不论该用户是否拥有访问该页面的权限,均能进入页面。
若页面初始化时(componentDidMount
)进行接口请求,后端会返回 403 的 HTTP 状态码,同时前端封装的request.js
会对非业务异常进行相关处理,遇见 403,就重定向到无权限页面。
若是页面初始化时不存在前后端交互,那就要等用户触发某些操作(比如表单提交)后才会触发上述流程。
可以看到,安全保障是后端兜底的,那前端能做些什么呢?
- 明确告知用户没有权限,避免用户误以为自己拥有该权限而进行操作(即使无法操作成功),直接跳转至无权限页面;
- 拦截明确无权的请求,比如某些需要权限才能进行的操作入口(按钮 or 导航等)不对无权用户展示,其实本点包含上一点。
最近也在看Ant Design Pro
的权限相关处理,有必要进行一次总结。
需要注意的是,本文虽然基于Ant Design Pro
的权限设计思路,但并不是完全对其源码的解读(可能更偏向于 v1 的涉及思路,不涉及 umi)。
如果有错误以及理解偏差请轻捶并指正,谢谢。
模块级别的权限处理
假设存在以下关系:
角色 role | 权限枚举值 authority | 逻辑 |
---|---|---|
普通用户 | user | 不展示 |
管理员 | admin | 展示“进入管理后台”按钮 |
某页面上存在一个文案为“进入管理后台”的按钮,只对管理员展示,让我们实现一下。
简单实现
// currentAuthority 为当前用户权限枚举值
const AdminBtn = ({ currentAuthority }) => {
if ("admin" === currentAuthority) {
return <button>进入管理后台</button>;
}
return null;
};
好吧,简单至极。
权限控制就是if else
,实现功能并不复杂,大不了每个页面|模块|按钮涉及到的处理都写一遍判断就是了,总能实现需求的。
不过,现在只是一个页面中的一个按钮而已,我们还会碰到许多“某(几)个页面存在某个 xxx,只对 xxx(或/以及 xxx) 展示”的场景。
所以,还能做的更好一些。
下面来封装一个最基本的权限管理组件Authorized
。
组件封装-Authorized
期望调用形式如下:
<Authorized
currentAuthority={currentAuthority}
authority={"admin"}
noMatch={null}
>
<button>进入管理后台</button>
</Authorized>
api 如下:
参数 | 说明 | 类型 | 默认值 |
---|---|---|---|
children | 正常渲染的元素,权限判断通过时展示 | ReactNode | |
currentAuthority | 当前权限 | string | |
authority | 准入权限 | string/string[] | |
noMatch | 未通过权限判断时展示 | ReactNode |
currentAuthority
这个属性没有必要每次调用都手动传递一遍,此处假设用户信息是通过 redux
获取并存放在全局 store
中。
注意:我们当然也可以将用户信息挂在 window
下或者 localStorage
中,但很重要的一点是,绝大部分场景我们都是通过接口异步获取的数据,这点至关重要。如果是 html
托管在后端或是 ssr
的情况下,服务端直接注入了用户信息,那真是再好不过了。
新建src/components/Authorized/Authorized.jsx
实现如下:
import { connect } from "react-redux";
function Authorized(props) {
const { children, userInfo, authority, noMatch } = props;
const { currentAuthority } = userInfo || {};
if (!authority) return children;
const _authority = Array.isArray(authority) ? authority : [authority];
if (_authority.includes(currentAuthority)) return children;
return noMatch;
}
export default connect(store => ({ userInfo: store.common.userInfo }))(
Authorized
);
现在我们无需手动传递currentAuthority
:
<Authorized authority={"admin"} noMatch={null}>
<button>进入管理后台</button>
</Authorized>
✨ 很好,我们现在迈出了第一步。
在
Ant Design Pro
中,对于currentAuthority
(当前权限)与authority
(准入权限)的匹配功能,定义了一个checkPermissions
方法,提供了各种形式的匹配,本文只讨论authority
为数组(多个准入权限)或字符串(单个准入权限),currentAuthority
为字符串(当前角色只有一种权限)的情况。
页面级别的权限处理
页面就是放在Route
组件下的模块。
知道这一点后,我们很轻松的可以写出如下代码:
新建src/router/index.jsx
,当用户角色与路由不匹配时,渲染Redirect
组件用于重定向。
import React from "react";
import { BrowserRouter, Route, Switch } from "react-router-dom";
import NormalPage from "@/views/NormalPage"; /* 公开页面 */
import UserPage from "@/views/UserPage"; /* 普通用户和管理员均可访问的页面*/
import AdminPage from "@/views/AdminPage"; /* 管理员才可访问的页面*/
import Authorized from "@/components/Authorized";
// Layout就是一个布局组件,写一些公用头部底部啥的
function Router() {
<BrowserRouter>
<Layout>
<Switch>
<Route exact path="/" component={NormalPage} />
<Authorized
authority={["admin", "user"]}
noMatch={
<Route
path="/user-page"
render={() => <Redirect to={{ pathname: "/login" }} />}
/>
}
>
<Route path="/user-page" component={UserPage} />
</Authorized>
<Authorized
authority={"admin"}
noMatch={
<Route
path="/admin-page"
render={() => <Redirect to={{ pathname: "/403" }} />}
/>
}
>
<Route path="/admin-page" component={AdminPage} />
</Authorized>
</Switch>
</Layout>
</BrowserRouter>;
}
export default Router;
这段代码是不 work 的,因为当前权限信息是通过接口异步获取的,此时Authorized
组件获取不到当前权限(currentAuthority
),倘若直接通过 url 访问/user-page
或/admin-page
,不论用户身份是否符合,请求结果未回来,都会被重定向到/login
或/403
,这个问题后面再谈。
先优化一下我们的代码。
抽离路由配置
路由配置相关 jsx 内容太多了,页面数量过多就不好维护了,可读性也大大降低,我们可以将路由配置抽离出来。
新建src/router/router.config.js
,专门用于存放路由相关配置信息。
import NormalPage from "@/views/NormalPage";
import UserPage from "@/views/UserPage";
import AdminPage from "@/views/AdminPage";
export default [
{
exact: true,
path: "/",
component: NormalPage
},
{
path: "/user-page",
component: UserPage,
authority: ["user", "admin"],
redirectPath: "/login"
},
{
path: "/admin-page",
component: AdminPage,
authority: ["admin"],
redirectPath: "/403"
}
];
组件封装-AuthorizedRoute
接下来基于Authorized
组件对Route
组件进行二次封装。
新建src/components/Authorized/AuthorizedRoute.jsx
。
实现如下:
import React from "react";
import { Route } from "react-router-dom";
import Authorized from "./Authorized";
function AuthorizedRoute({
component: Component,
render,
authority,
redirectPath,
...rest
}) {
return (
<Authorized
authority={authority}
noMatch={
<Route
{...rest}
render={() => <Redirect to={{ pathname: redirectPath }} />}
/>
}
>
<Route
{...rest}
render={props => (Component ? <Component {...props} /> : render(props))}
/>
</Authorized>
);
}
export default AuthorizedRoute;
优化后
现在重写我们的 Router 组件。
import React from "react";
import { BrowserRouter, Route, Switch } from "react-router-dom";
import AuthorizedRoute from "@/components/AuthorizedRoute";
import routeConfig from "./router.config.js";
function Router() {
<BrowserRouter>
<Layout>
<Switch>
{routeConfig.map(rc => {
const { path, component, authority, redirectPath, ...rest } = rc;
return (
<AuthorizedRoute
key={path}
path={path}
component={component}
authority={authority}
redirectPath={redirectPath}
{...rest}
/>
);
})}
</Switch>
</Layout>
</BrowserRouter>;
}
export default Router;
心情舒畅了许多。
可是还留着一个问题呢——由于用户权限信息是异步获取的,在权限信息数据返回之前,AuthorizedRoute
组件就将用户推到了redirectPath
。
其实
Ant Design Pro
v4 版本就有存在这个问题,相较于 v2 的@/pages/Authorized
组件从localStorage
中获取权限信息,v4 改为从 redux 中获取(redux 中的数据则是通过接口获取),和本文比较类似。具体可见此次 PR。
异步获取权限
解决思路很简单:保证相关权限组件挂载时,redux 中已经存在用户权限信息。换句话说,接口数据返回后,再进行相关渲染。
我们可以在 Layout 中进行用户信息的获取,数据获取完毕后渲染children
。
结语
Ant Design Pro
从 v2 开始底层基于 umi
实现,通过路由配置的 Routes
属性,结合@/pages/Authorized
组件(该组件基于@/utils/Authorized
组件——@/components/Authorized
的二次封装,注入currentAuthority
(当前权限))实现主要流程。 同时,权限信息存放于localStorage
,通过@/utils/authority.js
提供的工具方法进行权限 get
以及 set
。
仔细看了下@/components/Authorized
文件下的内容,发现还提供了AuthorizedRoute
组件,但是并未在代码中使用(取而代之的是@/pages/Authorized
组件),翻了 issue 才了解到,v1 没有基于umi
的时候,是基于AuthorizedRoute
进行路由权限管理的,升级了之后,AuthorizedRoute
则并没有用于路由权限管理。
涉及到的相关文件比较多(components/pages/utils),v4 的文档又有些缺失,看源码的话,若没有理清版本之间差异,着实会有些费力。
本文在权限信息获取上,通过接口异步获取,存放至 redux(和 v4 版本有些类似,见@/pages/Authorized
以及@/layouts/SecurityLayout
)。