基於 React 的權限系統設計

前端進行權限控制只是爲了用戶體驗,對應的角色渲染對應的視圖,真正的安全保障在後端。

前言

畢業之初,工作的主要內容便是開發一個後臺管理系統,當時存在的一個現象是:

用戶若記住了某個 url,直接瀏覽器輸入,不論該用戶是否擁有訪問該頁面的權限,均能進入頁面。

若頁面初始化時(componentDidMount)進行接口請求,後端會返回 403 的 HTTP 狀態碼,同時前端封裝的request.js會對非業務異常進行相關處理,遇見 403,就重定向到無權限頁面。

若是頁面初始化時不存在前後端交互,那就要等用戶觸發某些操作(比如表單提交)後纔會觸發上述流程。

可以看到,安全保障是後端兜底的,那前端能做些什麼呢?

  1. 明確告知用戶沒有權限,避免用戶誤以爲自己擁有該權限而進行操作(即使無法操作成功),直接跳轉至無權限頁面;
  2. 攔截明確無權的請求,比如某些需要權限才能進行的操作入口(按鈕 or 導航等)不對無權用戶展示,其實本點包含上一點。

最近也在看Ant Design Pro的權限相關處理,有必要進行一次總結。

需要注意的是,本文雖然基於Ant Design Pro的權限設計思路,但並不是完全對其源碼的解讀(可能更偏向於 v1 的涉及思路,不涉及 umi)。

如果有錯誤以及理解偏差請輕捶並指正,謝謝。

模塊級別的權限處理

假設存在以下關係:

mdT9i6

某頁面上存在一個文案爲 “進入管理後臺” 的按鈕,只對管理員展示,讓我們實現一下。

簡單實現

const AdminBtn = ({ currentAuthority }) => {
  if ('admin' === currentAuthority) {
    return <button>進入管理後臺</button>;
  }
  return null;
};

好吧,簡單至極。

權限控制就是if else,實現功能並不複雜,大不了每個頁面 | 模塊 | 按鈕涉及到的處理都寫一遍判斷就是了,總能實現需求的。

不過,現在只是一個頁面中的一個按鈕而已,我們還會碰到許多 “某(幾)個頁面存在某個 xxx,只對 xxx(或 / 以及 xxx) 展示” 的場景。

所以,還能做的更好一些。

下面來封裝一個最基本的權限管理組件Authorized

期望調用形式如下:

<Authorized currentAuthority={currentAuthority} authority={'admin'} noMatch={null}>
  <button>進入管理後臺</button>
</Authorized>

api 如下:

fYDUmk

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';



function Router() {
  return (
    <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() {
  return (
    <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)。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://worldzhao.github.io/post/authorization-with-react/