淺談前端權限設計方案

前言

前端權限架構的設計一直都是備受關注的技術點. 通過給項目引入了權限控制方案, 可以滿足我們靈活的調整用戶訪問相關頁面的許可.

比如哪些頁面向遊客開放, 哪些頁面必須要登錄後才能訪問, 哪些頁面只能被某些角色訪問 (比如超級管理員). 有些頁面即使用戶登錄了但受到角色的限制, 他也只被允許看到頁面的部分內容.

出於實際工作的需要, 很多項目 (尤其類後臺管理系統) 需要引入權限控制. 倘若權限整體的架構設計的不好或者沒有設計, 會導致項目中各種權限代碼混入業務代碼造成結構混亂, 其次想給新模塊引入權限控制或者功能擴展都十分棘手.

雖然前端在權限層面能做一些事情, 但很遺憾真正對權限進行把關的是後端. 例如一個軟件系統, 前端在不寫一行權限代碼的情況下, 當用戶進入某個他無權訪問的頁面時, 後端是可以判斷他越權訪問並拒絕返回數據的. 由此可見前端即使不做什麼整個系統也是可以正常運行的, 但這樣應用的體驗很不好. 另外一個很重要的原因就是前端做的權限校驗都是可以被本地數據造假越權通過.

前端如果能判斷某用戶越權訪問頁面時, 就不要讓他進入那張頁面後再彈出無權訪問的信息提示, 因爲這樣體驗很差. 最優方案是直接關閉那些頁面的入口, 只讓他看到他能訪問的頁面. 即使他通過輸入路徑惡意訪問, 導航最後只會將它帶到默認頁面或404頁面.

前端做的權限控制大抵是先接受後臺發送的權限數據, 然後將數據注入到應用中. 整個應用於是開始對頁面的展現內容以及導航邏輯進行控制, 從而達到權限控制的目的. 前端做的權限控制雖然能提供一層防護, 但根本目的還是爲了優化體驗.

本文接下來將從下面三個層面, 從易到難步步推進, 講述目前前端主流的權限控制方案的實現.(下面代碼將會以vue3vue-router 4演示)

登錄權限控制

登錄權限控制要做的事情, 是實現哪些頁面能被遊客訪問, 哪些頁面只有登錄後才能被訪問. 在一些沒有引入角色的軟件系統中, 通過是否登錄來評定頁面能否被訪問在實際工作中非常常見.

實現這個功能也非常簡單, 首先按照慣例定義一份路由.

    export const routes = [
      {
         path: '/login', //登錄頁面
         name: 'Login',
         component: Login,
      },
      {
         path:"/list", // 列表頁
         name:"List",
         component: List, 
      },
      {
         path:"/myCenter", // 個人中心
         name:"MyCenter",
         component: MyCenter, 
         meta:{
            need_login:true //需要登錄
         }
      }
    ]

假定存在三個頁面: 登錄頁、列表頁和個人中心頁. 登錄頁和列表頁所有人都可以訪問, 但個人中心頁面需要登錄後才能看到, 給該路由添加一個meta對象, 並將need_login置爲true;

另外對於那些需要登錄後才能看到的頁面, 用戶如果沒有登錄就訪問, 就將頁面跳轉到登錄頁. 等到他填寫完用戶名和密碼點擊登錄後直接跳轉到原來他想訪問的頁面.

在代碼層面, 通過router.beforeEach可以輕鬆實現上述目標, 每次頁面跳轉時都會調用router.beforeEach包裹的函數, 代碼如下.

to是要即將訪問的路由信息, 從其中拿到need_login的值可以判斷是否需要登錄. 再從vuex中拿到用戶的登錄信息.

如果用戶沒有登錄並且要訪問的頁面又需要登錄時就使用next跳轉到登錄頁面, 並將需要訪問的頁面路由名稱通過redirect_page傳遞過去, 在登錄頁面就可以拿到redirect_page等登錄成功後直接跳轉.

//vue-router4 創建路由實例
const router = createRouter({  
  history: createWebHashHistory(),
  routes,
});

router.beforeEach((to, from, next) ={
  const { need_login = false } = to.meta;
  const { user_info } = store.state; //從vuex中獲取用戶的登錄信息
  if (need_login && !user_info) {
    // 如果頁面需要登錄但用戶沒有登錄跳到登錄頁面
    const next_page = to.name; // 配置路由時,每一條路由都要給name賦值
    next({
      name: 'Login',
      params: {
        redirect_page: next_page,
        ...from.params, //如果跳轉需要攜帶參數就把參數也傳遞過去
      },
    });
  } else {
    //不需要登錄直接放行
    next();
  }
});

頁面權限控制

頁面權限控制要探討的問題是如何給不同角色賦予不同的頁面訪問權限, 接下來先了解一下角色的概念.

在一些權限設置比較簡單的系統裏, 使用上面第一種方法就足夠了, 但如果系統引入了角色, 那麼就要在上面基礎上, 再進一步改造增強權限控制的能力.

角色的出現是爲了更加個性化配置權限列表. 比如當前系統設置三個角色: 普通會員, 管理員以及超級管理員. 普通會員能夠瀏覽軟件系統的所有內容, 但是它不能編輯和刪除內容. 管理員擁有普通會員的所有能力, 另外它還能刪除和編輯內容. 超級管理員擁有軟件系統所有權限, 他單獨擁有賦予某個賬號爲管理員或移除其身份的能力.

一旦軟件系統引入了角色的概念, 那麼每個賬戶在註冊之後就會被賦予相應的角色, 從而擁有相應的權限. 我們前端要做的事情就是依據不同角色給與它相應頁面訪問和操作的權限. 這裏要注意, 前端依據的客體是角色, 不是某個賬戶, 因爲賬戶是依託於角色的.

普通會員, 管理員以及超級管理員這樣角色的安排還是一種非常簡單的劃分方式, 在實際項目中, 角色的劃份要更加細緻的多. 比如一些常見的後臺業務系統, 軟件系統會按照公司的各個部門來建立角色, 諸如市場部, 銷售部, 研發部之類. 公司的每個成員就會被劃分到相應角色中, 從而只具備該角色所擁有的權限.

公司另外一些高層領導他們的賬戶則會被劃分到普通管理員或高級管理員中, 那麼他們相較於其他角色也會擁有更多的權限.

上面介紹那麼多角色的概念其實是爲了從全棧的維度去理解權限的設計, 但真正落地到前端項目中是不需要去處理角色邏輯的, 那部分功能主要由後端完成.

現在假定後端不處理角色完全交給前端來做會出現什麼問題. 首先前端新建一個配置文件, 假定當前系統設定三種角色: 普通會員, 管理員以及超級管理員以及每個角色能訪問的頁面列表 (僞代碼如下).

 export const permission_list = {
   member:["List","Detail"], //普通會員
   admin:["List","Detail","Manage"],  // 管理員
   super_admin:["List","Detail","Manage","Admin"]  // 超級管理員
 }

數組裏每個值對應着前端路由配置的name值. 普通會員能訪問列表頁詳情頁, 管理員能額外訪問內容管理頁面, 超級管理員能額外訪問人員管理頁面.

整個運作流程簡述如下. 當用戶登錄成功之後, 通過接口返回值得知用戶數據和所屬角色. 拿到角色值後就去配置文件裏取出該角色能訪問的頁面列表數組, 隨後將這部分權限數據加載到應用中從而達到權限控制的目的.

從上面流程看, 角色放在前端配置也是可以的. 但假如項目已經上線, 產品經理要求項目急需增加一個新角色合作伙伴, 並把原來已經存在的用戶張三移動到合作伙伴角色下面. 那這樣的變動會導致前端需要修改代碼文件, 在原來的配置文件上再新建角色來滿足這一需求.

由此可見由前端來配置角色列表是非常不靈活且容易出錯的, 那麼最優方案是交給後端去配置. 用戶一旦登錄後, 後端接口直接返回該賬號擁有的權限列表就行了, 至於該賬戶屬於什麼角色以及角色擁有的頁面權限全部丟給後端去處理.

用戶登錄成功後, 後端接口數據返回如下.

{
  user_id:1,
  user_name:"張三",
  permission_list:["List","Detail","Manage"]
}

前端現在不需要理會張三屬於什麼角色, 只需要按照張三的權限列表給他相應的訪問權限就行了, 其他都交給後端處理.

通過接口的返回值permission_list可知, 張三能訪問列表頁詳情頁以及內容管理頁. 我們先回到路由配置頁面, 看看如何配置.

    //靜態路由
    export const routes = [
      {
         path: '/login', //登錄頁面
         name: 'Login',
         component: Login,
      },
      {
         path:"/myCenter", // 個人中心
         name:"MyCenter",
         component: MyCenter, 
         meta:{
            need_login:true //需要登錄
         }
      },
      {
         path:"/", // 首頁
         name:"Home",
         component: Home, 
      }
    ]
    
    //動態路由
    export const dynamic_routes = [
       {
           path:"/list", // 列表頁
           name:"List",
           component: List
       },
       {
           path:"/detail", // 詳情頁
           name:"Detail",
           component: Detail
       },
       {
           path:"/manage", // 內容管理頁
           name:"Manage",
           component: Manage
       },
       {
           path:"/admin", // 人員管理頁
           name:"Admin",
           component: Admin
       }
    ]

現在將所有路由分成兩部分, 靜態路由routes和動態路由dynamic_routes. 靜態路由裏面的頁面是所有角色都能訪問的, 它裏面主要區分登錄訪問和非登錄訪問, 處理的邏輯與上面介紹的登錄權限控制一致.

動態路由dynamic_routes裏面存放的是與角色定製化相關的的頁面. 現在繼續看下面張三的接口數據, 該如何給他設置權限.

{
  user_id:1,
  user_name:"張三",
  permission_list:["List","Detail","Manage"]
}

用戶登錄成功後, 一般會將上述接口信息存到vuexlocalStorage裏面. 假如此時刷新瀏覽器, 我們就要動態添加路由信息.

import store from "@/store";

export const routes = [...]; //靜態路由

export const dynamic_routes = [...]; //動態路由

const router = createRouter({ //創建路由對象
  history: createWebHashHistory(),
  routes,
});

//動態添加路由
if(store.state.user != null){ //從vuex中拿到用戶信息
    //用戶已經登錄
    const { permission_list } = store.state.user; // 從用戶信息中獲取權限列表
    const allow_routes = dynamic_routes.filter((route)=>{ //過濾允許訪問的路由
      return permission_list.includes(route.name); 
    })
    allow_routes.forEach((route)=>{ // 將允許訪問的路由動態添加到路由棧中
      router.addRoute(route);
    })
}

export default router;

核心代碼在動態添加路由裏面, 主要利用了vue-router 4提供的APIrouter.addRoute, 它能夠給已經創建的路由實例繼續添加路由信息.

我們先從vuex裏面拿到當前用戶的權限列表, 然後遍歷動態路由數組dynamic_routes, 從裏面過濾出允許訪問的路由, 最後將這些路由動態添加到路由實例裏.

這樣就實現了用戶只能按照他對應的權限列表裏的規則訪問到相應的頁面, 至於那些他無權訪問的頁面, 路由實例根本沒有添加相應的路由信息, 因此即使用戶在瀏覽器強行輸入路徑越權訪問也是訪問不到的.

由於vue-router 4廢除了之前的router.addRoutes, 換成了router.addRoute. 每一次只能一個個添加路由信息, 所以要將allow_routes遍歷循環添加.

動態添加路由這部分代碼最好單獨封裝起來, 因爲用戶第一次使用還沒登錄時,store.state.user是爲空的, 上面動態添加路由的邏輯會被跳過. 那麼在用戶登錄成功獲取到權限列表的信息後, 需要再把上面動態添加路由的邏輯執行一遍.

添加嵌套子路由

假如靜態路由的形式如下, 現在想把列表頁添加到Tabs嵌套路由的children裏面.

const routes = [
  {
    path: '/',  //標籤容器
    name: 'Tabs',
    component: Tabs,
    children: [{
       path: '', //首頁
       name: 'Home',
       component: Home,
    }]
  }
]

export const dynamic_routes = [
  {
      path:"/list", // 列表頁
      name:"List",
      component: List
  }
]

官方router.addRoute給出了相應的配置去滿足這樣的需求 (代碼如下).router.addRoute接受兩個參數, 第一個參數對應父路由的name屬性, 第二個參數是要添加的子路由信息.

 router.addRoute("Tabs"{
        path: "/list",
        name: "List",
        component: List,
 });

切換用戶

切換用戶信息是非常常見的功能, 但是應用在切換成不同賬號後可能會引發一些問題. 例如用戶先使用超級管理員登錄, 由於超級管理員能訪問所有頁面, 因此所有頁面路由信息都會被添加到路由實例裏.

此時該用戶退出賬號, 使用一個普通會員的賬號登錄. 在不刷新瀏覽器的情況下, 路由實例裏面仍然存放了所有頁面的路由信息, 即使當前賬號只是一個普通會員, 如果他越權訪問相關頁面, 路由還是會跳轉的, 這樣的結果並不是我們想要的.

解決方案有兩個. 第一是用戶每次切換賬戶後刷新瀏覽器重新加載, 刷新後的路由實例是重新配置的所以可以避免這個問題, 但是刷新頁面會帶來不好的體驗.

第二個方案是當用戶選擇登出後, 清除掉路由實例裏面處存放的路由棧信息 (代碼如下).

  const router = useRouter(); // 獲取路由實例
  const logOut = () ={ //登出函數
      //將整個路由棧清空
      const old_routes = router.getRoutes();//獲取所有路由信息
      old_routes.forEach((item) ={
        const name = item.name;//獲取路由名詞
        router.removeRoute(name); //移除路由
      });
      //生成新的路由棧
      routes.forEach((route) ={
        router.addRoute(route);
      });
      router.push({ name: "Login" }); //跳轉到登錄頁面
    };

移除單個路由主要利用了官方提供的API, 即router.removeRoute.

路由棧清空後什麼頁面都不能訪問了, 甚至登錄頁面都訪問不了. 所以需要再把靜態的路由列表routes引入進來, 使用router.addRoute再添加進入. 這樣就能讓路由棧恢復到最初的狀態.

內容權限控制

頁面權限控制它能做到讓不同角色訪問不同的頁面, 但對於一些顆粒度更小的項目, 比如希望不同的角色都能進入頁面, 但要求看到的頁面內容不一樣, 這就需要對內容進行權限控制了.

假設某個後臺業務系統的界面如下圖所示. 表格裏面存放的是列表數據, 當點擊發布需求時跳轉到新增頁面. 當勾選列表中的某一條數據後, 點擊修改按鈕顯示修改該條數據的彈出框. 同理點擊刪除按鈕顯示刪除該條數據的彈出框

table.png

假設項目需求該系統存在三個角色: 職員、領導和高層領導. 職員不具備修改刪除以及發佈需求的功能, 他只能查看列表. 當職員進入該頁面時, 頁面上只顯示列表內容, 其他三個按鈕移除.

領導角色保留列表發佈需求按鈕. 高級領導角色保留頁面上所有內容.

我們拿到圖片後要先要對頁面內容整體分析一遍, 按照增刪查改四個維度對頁面內容進行歸類. 使用簡稱CURD來標識 (CURD分別代表創建(Create)、更新(Update)、讀取(Retrieve)和刪除(Delete)).

上圖中列表內容屬於查詢操作, 因此設定爲R. 凡是具備R權限的用戶就顯示該列表內容.

發佈需求屬於新增操作, 設定凡是具備C權限的用戶就顯示該按鈕.

同理修改按鈕對應着U權限,刪除按鈕對應着D權限.

由此可以推斷出職員角色在該頁面的權限編碼爲R, 它只能查看列表內容無法操作.

領導角色對應的權限編碼爲CR. 高級領導對應的權限編碼爲CURD.

現在用戶登錄完成後, 假設後端接口返回的數據如下 (將這條數據存到vuex):

{
  user_id:1,
  user_name:"張三",
  permission_list:{
    "List":"CR", //權限編碼
    "Detail":"CURD"  //權限編碼
  }
}

張三除了靜態路由設置的頁面外, 他只能額外訪問List列表頁以及Detail詳情頁. 其中列表頁他只具備創建和新增權限, 詳情頁他具備增刪查改所有權限. 那麼當張三訪問上圖中的頁面時, 頁面中應該只顯示列表發佈需求按鈕.

我們現在要做的就是設計一個方案儘可能讓頁面內容方便被權限編碼控制. 首先創建一個全局的自定義指令permission, 代碼如下:

import router from './router';
import store from './store';

const app = createApp(App); //創建vue的根實例

app.directive('permission'{
  mounted(el, binding, vnode) {
    const permission = binding.value; // 獲取權限值
    const page_name = router.currentRoute.value.name; // 獲取當前路由名稱
    const have_permissions = store.state.permission_list[page_name] || ''; // 當前用戶擁有的權限
    if (!have_permissions.includes(permission)) {
      el.parentElement.removeChild(el); //不擁有該權限移除dom元素
    }
  },
});

當元素掛載完畢後, 通過binding.value獲取該元素要求的權限編碼. 然後拿到當前路由名稱, 通過路由名稱可以在vuex中獲取到該用戶在該頁面所擁有的權限編碼. 如果該用戶不具備訪問該元素的權限, 就把元素dom移除.

對應到上面的案例, 在頁面裏按照如下方式使用v-permission指令.

<template>
    <div>
      <button v-permission="'U'">修改</button>  <button v-permission="'D'">刪除</button>
    </div>
    <p>
      <button v-permission="'C'">發佈需求</button>
    </p>

    <!--列表頁-->
    <div v-permission="'R'">
     ...
    </div>
</template>

將上面模板代碼和自定義指令結合理解一下就很容易明白整個內容權限控制的邏輯. 首先前端開發頁面時要將頁面分析一遍, 把每一塊內容按照權限編碼分類. 比如修改按鈕就屬於U, 刪除按鈕屬於D. 並用v-permission將分析結果填寫上去.

當頁面加載後, 頁面上定義的所有v-permission指令就會運行起來. 在自定義指令內部, 它會從vuex中取出該用戶所擁有的權限編碼, 再與該元素所設定的編碼結合起來判端是否擁有顯示權限, 權限不具備就移除元素.

雖然分析過程有點複雜, 但是以後每個新頁面想接入權限控制非常方便. 只需要將新頁面的各個dom元素添加一個v-permission和權限編碼就完成了, 剩下的工作都交給自定義指令內部去做.

延伸

如果項目中刪除操作並不是單獨放置在一個按鈕, 而是與列表捆綁在一起放在表格的最後一列, 如下圖所示.

table2.png

這樣的界面樣式在實際工作中非常常見, 但似乎上面的v-permission就並不能友好的支持這樣的樣式. 自定義指令在這種情況下雖然不能用, 但我們仍然可以採用相同的思路去優化我們現有的代碼結構.

例如模板代碼如下. 整個列表被封裝成了一個組件List, 那麼在List內部就可以寫很多的邏輯控制。

比如List組件內也可以通過vuex拿到該用戶在當前頁面的權限編碼, 如果發現具備D權限就顯示列表中最後刪除那一列, 否則就不顯示. 至於整個列表的顯示隱藏仍然可以使用v-permission來控制.

<template>
    <div>
      <button v-permission="'C'">添加資源</button>
    </div>
   
    <!--列表頁-->
    <List v-permission="'R'">
     ...
    </List>
</template>

動態導航

下圖中的動態導航也是實際工作中非常常見的需求, 比如銷售部所有成員只能看到銷售模塊下的兩個頁面, 同理採購部成員只能看到採購模塊下的頁面.

下面側邊欄導航組件需要根據不同權限顯示不同的頁面結構, 以滿足不同角色羣體的要求.

nav.png

我們要把這種需要個性化設置的組件與上面使用v-permission控制的模式區分開. 上面那些頁面之所以能使用v-permission來控制, 主要原因是因爲產品經理在設計整個軟件系統的頁面時是按照增刪查改的規則進行的. 因此我們就能抽象出其中存在的共性與規律, 再借助自定義指令來簡化權限系統的開發.

但是側邊欄組件一般全局只有一個, 沒有什麼特別的規律而言, 那就只需要在組件內部使用v-if依據權限值動態渲染就行了.

比如後臺接口如下:

 {
  user_id:1,
  user_name:"張三",
  permission_list:{
    "SALE":true, //顯示銷售大類 
    "S_NEED":"CR", //權限編碼
    "S_RESOURCE":"CURD", //權限編碼
  }
}

張三擁有訪問需求資源頁面, 但注意SALE並沒有與哪個頁面對應上, 它僅僅只是表示是否顯示銷售這個一級導航.

接下來在側面欄組件通過vuex拿到權限數據, 再動態渲染頁面就可以了.

<template>
  <div v-if="permission_list['HOME']">系統首頁</div>
  <div v-if="permission_list['SALE']">
     <p>銷售</p>
     <div v-if="permission_list['S_NEED']">需求</div>
     <div v-if="permission_list['S_RESOURCE']">資源</div>
  </div>
  <div v-if="permission_list['PURCHASE']">
     <p>採購</p>
     <div v-if="permission_list['P_NEED']">需求</div>
     <div v-if="permission_list['P_RESOURCE']">資源</div>
  </div>  
</template>

尾言

前端提供的權限控制爲應用加固了一層保險, 但同時也要警惕前端設定的校驗都是可以通過技術手段破解的. 權限問題關乎到軟件系統所有數據的安危, 重要性不言而喻.

爲了確保系統平穩運行, 前後端都應該做好自己的權限防護.

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/aOt_JtYycGRPy0DShQySjA