前言
在日常开发中,我们经常使用脚手架工具来快速搭建React项目。这些工具让我们能够迅速启动并运行应用,专注于业务逻辑的实现。然而,随着应用的发展和用户群体的增长,如何有效地管理和控制不同用户对应用各部分的访问权限逐渐成为一个不可忽视的问题。合理的权限管理不仅能提升用户体验,还能增强系统的安全性。
1. 接口权限控制
在Web应用开发中,接口权限控制是确保系统安全性的重要环节。它不仅包括用户的身份验证(Authentication),还包括授权(Authorization)。通过合理的权限管理,我们可以保证只有经过验证且被授权的用户才能访问特定资源或执行特定操作。
用户身份认证
基于Cookie的身份验证
在传统的Web应用程序中,基于Cookie的会话管理是最常用的方式之一。当用户登录成功后,服务器生成一个唯一的会话标识符(Session ID),并通过HTTP响应头Set-Cookie发送给客户端。浏览器会自动将这个Cookie附加到后续对同一域名的所有请求中,从而让服务器能够识别出每个请求对应的用户会话。Cookie的会话管理模式其实基本上是后端同学处理,因此前端同学无需手动处理token
基于Token的身份验证
另一种常见的方法是使用JSON Web Token (JWT) 或其他类型的令牌。在这种模式下,用户登录后,服务器返回一个加密签名的令牌给客户端,客户端则负责在每次请求时通过HTTP头部发送此令牌。这种方式的优点包括无状态性和跨域支持。
对于基于Token的身份验证,我们可以在每次请求前检查本地存储中的Token是否存在,并将其添加到请求头部:
ini
instance.interceptors.request.use(
config => {
const token = localStorage.getItem('authToken'); // 如果使用基于Token的方式
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => Promise.reject(error)
);
并在后续请求中通过HTTP头部携带这个token以证明身份。
适用于Cookie的会话管理
-
场景
- 页面应用,这类应用的所有页面通常位于同一个域名下,浏览器自动处理Cookies,使得用户状态可以在多个页面之间无缝传递,非常适合传统的多页面Web应用。
- 单页应用(SPA)和移动应用,这类应用通常通过API与后端交互,基于Token的身份验证提供了更灵活的方式来处理跨域请求和多设备同步,同时支持无状态的服务器端架构,有助于水平扩展。
-
特点
- 简单易用,浏览器自动管理和发送Cookies。
- 对于SPA和跨域请求的支持较弱。
-
安全建议
- 使用
HttpOnly
(当一个Cookie被标记为HttpOnly
时,它将无法通过客户端的JavaScript代码访问)和Secure
(只有在建立了一个加密的SSL/TLS连接之后,才会附带这个Cookie)标志来增强安全性,防止XSS攻击。
- 使用
适用于Token的会话管理
-
场景
- 当前端应用和后端API位于不同的域名时,API网关可以配置适当的CORS策略来允许合法的跨域请求。
- 在一个使用微服务架构的应用程序中,API网关充当所有外部请求的统一入口点。对于客户端来说,API网关提供了一个统一的接口。无论内部有多少个微服务,客户端只需要与一个URL进行通信。这大大简化了客户端代码,避免了直接管理多个微服务端点的复杂性。
-
特点
- 支持跨域请求,不受同源策略限制
- Token是自包含所有内容,意味着所有必要的认证信息都编码在Token内部,服务器不需要查询数据库或存储会话信息来验证用户身份。这使得每个请求都可以独立处理,减少了服务器端的状态管理负担。
-
安全建议
- 避免直接在客户端存储未加密的Token,考虑使用加密或签名技术保护敏感信息。
统一处理响应
为了实现细粒度的API权限控制,我们需要确保在每个受保护的API端点调用之前进行相应的权限验证。下面是如何在axios拦截器中处理不同HTTP状态码和业务逻辑错误的例子
我们还可以结合业务实施细粒度的API权限控制,为了让每个API端点都能正确地检查权限,我们可以约定一套清晰的角色和权限结构。例如,可以在API响应中包含一个特定的code字段来表示不同的业务逻辑错误。这样,前端就可以根据这个code做出相应的处理。
go
instance.interceptors.response.use(
response => {
if (response.status === 403 || (response.data && response.data.code === 403)) {
// 用户没有访问该资源的权限
router.push('/exception/403');
return Promise.reject(new Error('Permission denied'));
}
return response;
},
error => {
if (error.response) {
switch (error.response.status) {
case 401:
// 认证失败,可能是token过期或无效
router.push('/login');
break;
case 404:
router.push('/exception/404');
break;
case 500:
Modal.error({ title: '服务器内部错误' });
break;
case B502:
errorlog()
break;
default:
Modal.error({ title: '发生未知错误' });
}
}
return Promise.reject(error);
}
);
接口权限控制不仅仅是在用户登录时验证其身份,更重要的是在整个应用程序生命周期内持续地管理和保护用户的数据及操作权限。通过结合适当的技术和策略,我们可以构建起坚固的安全屏障,为用户提供一个既安全又高效的用户体验。无论是基于Cookie的会话管理还是基于Token的身份验证,都可以通过良好的设计和实施来提升系统的整体安全性。
2. 页面权限控制
在很多人的理解中,前端页面权限就是菜单的可见与否,其实这是不对的。举个例子如果有用户知道无权限菜单的完整路由,用户直接访问路径就可以访问了,这明显是不可以的。这里就涉及到了路由权限控制了
路由权限控制
前端路由表存储一般有两种,一种是前端固定路由表和权限配置,由后端提供用户权限标识,还有一种是后端提供权限和路由信息结构接口,动态生成权限和菜单。
以前端存储路由表为例。前端定义菜单路由属性然后在注册路由时和现有的权限(后端提供用户权限标识)对比有权限就正常进入,无权限就进入无权限操作
下面就是前端路由控制举例
yaml
{
name: "首页",
path: '/home',
component: StoreCommodity,
authority: ["admin","user"]
},
},
{
name: "商品推荐",
path: '/supply-chain-goods/complete-waybill',
component: CompleteWaybill,
authority: ["admin"],
},
},
{
name: "商品管理",
path: '/select-category',
component: SelectCategory,
authority: []
},
{
name: '403',
path: '/exception/403',
component: Exception,
authority: []
},
这里每个菜单都添加 authority 来表示能访问的角色, 下面是简单的大致实现方法
javascript
// 原src/router.js
import dynamic from "dva/dynamic"
import { Redirect, Route, routerRedux, Switch } from "dva/router"
import { routes } from "./utils"
import PropTypes from "prop-types"
import React from "react"
import NoMatch from "./components/no-match"
import App from "./routes/app"
import Exception from "./Exception"
const { ConnectedRouter } = routerRedux
const RouterConfig = ({ history, app }) => {
return (
<ConnectedRouter history={history}>
<App>
<Switch>
{routes.map(({ path, authority, component, ...dynamics }, key) => (
<Route
key={key}
path={path}
component={authority?.includes(currentAuthority) ? component : Exception}
/>
))}
<Route component={NoMatch} />
</Switch>
</App>
</ConnectedRouter>
)
}
RouterConfig.propTypes = {
history: PropTypes.object,
app: PropTypes.object
}
export default RouterConfig
上面只是最基础的路由注册权限控制,将没有权限的页面都重定向到无权限页面。但是在平常开发当中我们使用场景不仅仅只有这么多,比如可以添加一些类型无权限的时候是直接展示无权限,还是引导用户开权限页面等,没有登陆跳转到登陆页面等。这时候比较合适的方式就是包一个高阶组件 AuthRoute
首先,定义一个名为AuthRoute的高阶组件。它将接受一个受保护的组件作为参数,并返回一个新的组件,该组件会在渲染前检查用户的认证状态。
javascript
import React from 'react';
import { Navigate } from 'react-router-dom';
function useAuth() {
// 这里应该有一个更复杂的逻辑来检查用户的认证状态
return localStorage.getItem('user') ? true : false;
}
function AuthRoute(
component,
authority,
redirectPath,
{...rest}
) {
const isAuth = useAuth();
if (!isAuth) {
// 用户未认证时重定向到登录页面
return <Navigate to="/login" />;
}
if(authority?.length!==0 && !authority?.includes(currentAuthority)){
// 无权限定位到权限页面
return <Route key={key} path={path} component={Exception} />;
}
// 无权限引导页
...
// 用户已认证时允许继续渲染受保护的组件
return <Route {...rest} render={props => <Component {...props} />} />;
}
export default AuthRoute;
首先如果路由表中没有authority字段默认都可以访问。接着分别对authority做了简单的查找匹配,匹配到了就可以访问,匹配不到就返回Exception,也就是我们自定义的异常页面。这里还可以做很多业务上的统一操作,比如统一引导等
使用高阶组件
javascript
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import AuthRoute from './AuthRoute'; // 导入被包裹后的组件
import Home from "./Home"
function App() {
return (
<Switch>
{routerData.map(item => (
<AuthRoute {...item} key={item} redirectPath="/exception/403" />
))}
</Switch>
);
}
export default App;
菜单权限控制
菜单权限和路由权限差不多,他同样用到了上文中的对比方法来对当前菜单进行权限比对,如果没有权限就直接不展示当前菜单。
ini
<aside className="layout-user-aside">
<Menu onSelect={this.onRoute} selectedKeys={selectedKeys}>
{routes.map(item => (
getSubMenuOrItem(item)
))}
</Menu>
</aside>
javascript
getSubMenuOrItem = item => {
// 无权限不展示菜单
if (!authority?.includes(currentAuthority)) {
...
}
return <Menu.Item key={item.path}>{item.name}</Menu.Item>
}
在日常开发中还有些常见不同权限登陆看到的页面菜单样式不一样,比如运营和管理员看到的菜单就不一样这时候。这时候我们就要定义不同的路由 Layout 来实现这差异化的页面显示
javascript
/**
* 路由总入口文件
* 通过 getRouterData 向 layout 中注入路由配置
*/
import React from 'react';
import { routerRedux, Route, Switch } from 'dva/router';
import { getRouterData } from 'src/common/router';
import HermesLayout from 'src/layouts/HermesLayout';
import ZcyLayout from 'src/layouts/ZcyLayout';
const { ConnectedRouter } = routerRedux;
function RouterConfig({ history, app }) {
const routerData = getRouterData(app);
return (
<ConnectedRouter history={history}>
<Switch>
<Route
path="/sails"
render={props => <HermesLayout routerData={routerData} {...props} />}
redirectPath="/exception/403"
{...{} as any}
/>
<Route
path="/"
render={props => <ZcyLayout routerData={routerData} {...props} />}
/>
</Switch>
</ConnectedRouter>
);
}
export default RouterConfig;
通过不同的路径来实现不同场景渲染不同页面。
3. 页面元素权限控制
介绍完页面级的权限管理,接下来轮到页面元素级别的权限管理了。
普通处理权限
举个例子在平时开发中经常有这种场景某个按钮权限只有管理员才能看到。一般想到的做法是
scala
class Page extends Component{
render() {
let currentAuthority = tool.getAuth("currentAuthority");
return (
<div>
{currentAuthority ? <Button>创建</Button> : null}
</div>
);
}
}
但是有些场景 ,没有权限的话,按钮就置灰。于是代码改成了这样:
javascript
render() {
let currentAuthority = tool.getAuth("currentAuthority");
return (
<div>
<Button disabled={currentAuthority} >创建</Button>
</div>
);
}
还有些场景,没有权限的话,按钮也正常展示,只是点击后给个'申请权限'的文案提示。于是代码又改成了这样:
javascript
render() {
let currentAuthority = tool.getAuth("currentAuthority");
return (
<div>
<Button
onClick={()=>{
currentAuthority && message.info('权限不足,请找管理员申请');
}}
>
创建
</Button>
</div>
);
}
统一处理权限
如果是少量的这样权限控制还好。但是如果经常有这种需求,而且还时不时的改动需求的交互。这时候在这样实现起来就不是很方便了。这时候我们就很需要可以统一处理无权限的地方。还是可以封一个react高阶组件 ComponentAuth 统一处理权限
javascript
import React from 'react';
import PropTypes from 'prop-types';
// 假设 tool 和 getAuth 是你已经定义好的工具函数和权限检查方法
// import { getAuth } from './tool';
export const ComponentAuth = (ComposedComponent) => (props) => {
// hide隐藏 disable禁用 tips 提示
const { auth } = props
try {
// 判断是否有权限
var hasAuth = auth === null || auth === void 0 ? void 0 : auth[authKey];
if (hasAuth) {
return <ComposedComponent {...props} />
} else {
// 无权限时显示指定 UI
// 隐藏
if (auth === 'hide') {
return null
}
// 禁用
if (auth === 'disable') {
return <ComposedComponent disabled={true} {...props} />
}
// 提示
if (auth === 'tips') {
return <ComposedComponent
onClick={() => {
message.info('权限不足,请找管理员申请');
}}
{...props}
/>
}
}
} catch (error) {
console.log(error);
}
};
// propTypes 是 React 提供的一种类型检查工具,它允许你在开发环境中验证传递给组件的 props(属性)是否符合预期的数据类型。这有助于捕获潜在的错误和不一致,并确保组件按照设计的方式接收数据。当传递给组件的 prop 不符合 propTypes 中定义的类型时,React 会在浏览器的控制台中发出警告
ComponentAuth.propTypes = {
auth: PropTypes.string.isRequired, // 加上 .isRequired 表示这个 prop 是必须的
};
这个方法实际上是一个包装器,接受一个组件参数,根据权限,返回一个新的组件。然后页面按钮的权限控制实现改成:
scala
const AuthButton = ComponentAuth(Button);
class Page extends Component{
render() {
<div>
<AuthButton auth='hide'>创建</AuthButton>
</div>
);
}
}
这样当我们就可以跟进需求的变动,灵活的调整 ComponentAuth 里面的代码就行了,再多的场景也不怕了。
需要注意的是如果比较重要的权限还是尽量不要缓存在本地,本地存储的数据容易受到 XSS 攻击的影响,攻击者可能篡改权限信息。这时候就很容易绕开前端权限控制,如果后端没做权限控制的话就很容易导致安全漏洞。并且即使缓存也应尽量使用安全的方式,如加密存储。我们也可把数据缓存到redux公共状态管理中。
总结
前端权限控制一直是前端必须掌握的一个知识点,一般来说稍微正规一点的后台系统肯定有权限控制。当然还是那句老话,前端本来就是不安全的,真正的安全还是需要后端兄弟去把关,所以后端也必须也要做权限控制,我们前端的权限校验主要的目的是过滤不该有的请求和操作,减少服务端压力。