前端如何配合後端完成RBAC許可權控制

趙津發表於2018-12-23

補充一下模組原始碼


關聯上一篇Vue 前端應用實現RBAC許可權控制的一種方式,這篇寫的比較懶,哈哈,還是謝謝支援的朋友。

承蒙李楊的邀請,就寫了這篇文章,平時寫的不多,失誤之處,請大家多多包涵。

因為工作的原因要找一個管理端模板,用於開發一個網銀系統的前端介面骨架,我就找到了d2-admin,看著十分對胃口,接著我就想著先做一個前後端分離的demo,來看一下d2-admin模板是否滿足我們專案的需求,那麼一個許可權管理demo我覺得很適合拿來做實驗,就此我做了jiiiiiin-security許可權系統專案,以下是部分功能截圖:

前端如何配合後端完成RBAC許可權控制
前端如何配合後端完成RBAC許可權控制
前端如何配合後端完成RBAC許可權控制
前端如何配合後端完成RBAC許可權控制
前端如何配合後端完成RBAC許可權控制
前端如何配合後端完成RBAC許可權控制

為什麼我們需要前端實現RBAC

在說我們前端為什麼要實現許可權控制之前,大家勢必要了解一下我們要實現的東西的本質是什麼,下面簡單引用兩句介紹:

RBAC 以角色為基礎的訪問控制(英語:Role-based access controlRBAC),RBAC認為許可權授權實際上是Who、What、How的問題。在RBAC模型中,who、what、how構成了訪問許可權三元組,也就是“Who對What(Which)進行How的操作”。

RBAC是一種思想,任何程式語言都可以實現,其成熟簡單的控制思想 越來越受廣大開發人員喜歡。

更多內容,請大家不熟悉的勢必自行google;

我認為前後端是相輔相成的,所以要做好前端的許可權控制,如果能提前瞭解後端的許可權分配規則和資料結構是能夠更好的進行相互配合的,當然如果完全不理會後臺的許可權劃分,硬性來做上面的兩個需求也是能實現的,只是不掌握全域性,就很難理解這樣做的意義何在,所以建議大家在考慮這個問題的時候(這裡指前端同學),還是要大概去看看RBAC的概念,屬性經典的表結構,從而屬性後臺許可權分別的業務規則。

“許可權管理”一般大家的印象中都屬於後端的責任,但是這兩年隨著SPA應用的興起,很多應用都採用了前後端分離的方式進行開發,但是純前端的開發方式就導致很多以前由後端模板語言硬體解決的問題,現在勢必要重新造一次輪子,而這個時候前端我認為是配合後端對應語言的安全框架根據自身的業務需要來實現,在這裡就說說我們的需求:

  1. 完善我們自己的Vue外掛vue-viewplus的業務模組(這個外掛是我們經過一年的內部使用,用來將一些開發應用所需的公共需求,抽取為一個個模組,方便進行快速的應用開發所寫)
  2. 我們認為如果在前端根據後端配置的許可權規則就能攔截一些不必要的請求,就能減少後端不必要的資源損耗,也能更快的提示正常使用者
  3. 我們需要解決管理端介面選單和按鈕根據後端許可權配置隱藏顯示的需求
  4. 我們需要解決前端檢視可訪問性根據後端許可權配置動態調整的需求

以上2、3、4點在前後端不曾分離的時候,這些事情都是由後類html模板語言(如傳統的java中的jsp)所包辦的,類似這樣:

<html>
<sec:authorize access="hasRole('supervisor')">

This content will only be visible to users who have
the "supervisor" authority in their list of <tt>GrantedAuthority</tt>s.

</sec:authorize>
</html>
複製程式碼

docs.spring.io/spring-secu…

實現目標

  • 我們希望在進行頁面導航的時候能先根據登入使用者所具有的許可權判斷使用者是否能訪問該頁面
  • 實現可見頁面的區域性UI元件的可使用性或可見性控制,即基於自定義v-access指令,對比宣告的介面或資源別是否已經授權
  • 實現傳送請求前對待請求介面進行許可權檢查,如果使用者不具有訪問該後端介面的許可權,則不傳送請求,而是友好的提示使用者

實現方式

要實現【我們希望在進行頁面導航的時候能先根據登入使用者所具有的許可權判斷使用者是否能訪問該頁面】這個目標,我們的方案是:
  1. 獲得登入使用者的可訪問前端頁面的path列表
  2. 一個公共的path列表
  3. router進行導航的beforeEach前置鉤子中判斷當前使用者所請求的頁面是否在以上兩個集合之中,如果是則放行,如果不是,則通知外掛呼叫方,讓其自己處理失敗的情況

下面是程式碼實現:

/**
 * RBAC許可權控制模組
 */
import _ from 'lodash';
let _onPathCheckFail
let _publicPaths = []
let _authorizedPaths = []

/**
 * 是否是【超級管理員】
 * 如果登入使用者是這個`角色`,那麼就無需進行各種授權控制檢測
 * @type {boolean}
 * @private
 */
let _superAdminStatus = false

const _compare = function(rule, path) {
  let temp = false
  if (_.isRegExp(rule)) {
    temp = rule.test(path)
  } else {
    temp = _.isEqual(path, rule)
  }
  return temp
}

/**
 * 檢測登入使用者是否具有訪問對應頁面的許可權
 * 1.校驗是否登入
 * 2.校驗帶訪問的頁面是否在`loginStateCheck#authorizedPaths`授權`paths`集合中
 * @param to
 * @param from
 * @param next
 * @private
 */
const _rbacPathCheck = function(to, from, next) {
  if (_superAdminStatus) {
    next();
    return;
  }
  try {
    // 預設認為所有資源都需要進行許可權控制
    let isAllow = false
    const path = to.path;
    // 先檢測公共頁面集合
    const publicPathsLength = _publicPaths.length
    for (let i = publicPathsLength; i--;) {
      const rule = _publicPaths[i];
      isAllow = _compare(rule, path)
      if (isAllow) {
        break;
      }
    }
    // 非公共頁面 && 已經登入
    if (!isAllow && this.isLogin()) {
      // 檢測已授權頁面集合
      const authorizedPathsLength = _authorizedPaths.length;
      for (let i = authorizedPathsLength; i--;) {
        const rule = _authorizedPaths[i];
        isAllow = _compare(rule, path);
        if (isAllow) {
          break;
        }
      }
    }

    if (isAllow) {
      next();
    } else {
      if (_.isFunction(_onPathCheckFail)) {
        if (_debug) {
          console.error(`[v+] RBAC模組檢測:使用者無權訪問【${path}】,回撥onPathCheckFail鉤子`);
        }
        this::_onPathCheckFail(to, from, next);
      } else {
        next(new Error('check_authorize_paths_fail'));
      }
    }
  } catch (e) {
    if (_debug) {
      console.error(`[v+] RBAC模組檢測出錯: ${e.message}`);
    }
    if (_.isFunction(_errorHandler)) {
      this::_errorHandler(e)
    }
  }
};

const rbacModel = {
  /**
   * 【可選】有些系統存在一個超級使用者角色,其可以訪問任何資源、頁面,故如果設定,針對這個登入使用者將不會做任何許可權校驗,以便節省前端資源
   * @param status
   */
  rabcUpdateSuperAdminStatus(status) {
    _superAdminStatus = status;
    this.cacheSaveToSessionStore('AUTHORIZED_SUPER_ADMIN_STATUS', _superAdminStatus)
  },
  /**
   * 新增授權路徑集合
   * 如:登入完成之後,將使用者被授權可以訪問的頁面`paths`新增到`LoginStateCheck#authorizedPaths`中
   * @param paths
   */
  rabcAddAuthorizedPaths(paths) {
    this::rbacModel.rabcUpdateAuthorizedPaths(_.concat(_authorizedPaths, paths))
  },
  /**
   * 更新授權路徑集合
   * @param paths
   */
  rabcUpdateAuthorizedPaths(paths) {
    _authorizedPaths = [...new Set(paths)]
    this.cacheSaveToSessionStore('AUTHORIZED_PATHS', _authorizedPaths)
  },
  /**
   * 更新公共路徑集合
   * @param paths
   */
  rabcUpdatePublicPaths(paths) {
    _publicPaths = [...new Set(paths)];
    this.cacheSaveToSessionStore('PUBLIC_PATHS', _publicPaths)
  },
  /**
   * 新增公共路徑集合
   * @param paths
   */
  rabcAddPublicPaths(paths) {
    this::rbacModel.rabcUpdatePublicPaths(_.concat(_publicPaths, paths))
  },
  install(Vue, {
    /**
     * [*] 系統公共路由path路徑集合,即可以讓任何人訪問的頁面路徑
     * {Array<Object>}
     * <p>
     *   比如登入頁面的path,因為登入之前我們是無法判斷使用者是否可以訪問某個頁面的,故需要這個配置,當然如果需要這個配置也可以在初始化外掛之前從伺服器端獲取,這樣前後端動態性就更高,但是一般沒有這種需求:)
     * <p>
     * 陣列中的item,可以是一個**正規表示式字面量**,如`[/^((\/Interbus)(?!\/SubMenu)\/.+)$/]`,也可以是一個字串
     * <p>
     * 匹配規則:如果在`LoginStateCheck#publicPaths`**系統公共路由path路徑集合**中,那麼就直接跳過許可權校驗
     */
    publicPaths = [],
    /**
     * [*] 登入使用者擁有訪問許可權的路由path路徑集合
     * {Array<Object>}
     * <p>
     * 陣列中的item,可以是一個**正規表示式字面量**,如`[/^((\/Interbus)(?!\/SubMenu)\/.+)$/]`,也可以是一個字串
     * <p>
     * 匹配規則:如果在`LoginStateCheck#authorizedPaths`**需要身份認證規則集**中,那麼就需要檢視使用者是否登入,如果沒有登入就拒絕訪問
     */
    authorizedPaths = [],
    /**
     * [*] `$vp::onPathCheckFail(to, from, next)`
     * <p>
     * 訪問前端頁面時許可權檢查失敗時被回撥
     */
    onPathCheckFail = null,
  } = {}) {
    _onPathCheckFail = onPathCheckFail;
    router.beforeEach((to, from, next) => {
      this::_rbacPathCheck(to, from, next);
    });
  }
};

export default rbacModel;

複製程式碼

這裡解釋一下:

  1. 整個程式碼最終匯出了一個普通的json物件,作為vue-viewplus的一個自定義模組,將會被mixin到其外掛內部作為一個自定義模組:

    // 應用入口mian.js
    import Vue from 'vue'
    import router from './router'
    import ViewPlus from 'vue-viewplus'
    import viewPlusOptions from '@/plugin/vue-viewplus'
    import rbacModule from '@/plugin/vue-viewplus/rbac.js'
    
    Vue.use(ViewPlus, viewPlusOptions)
    
    ViewPlus.mixin(Vue, rbacModule, {
      moduleName: '自定義RBAC',
      router,
      publicPaths: ['/login'],
      onPathCheckFail(to, from, next) {
        NProgress.done()
        const title = to.meta.title
        this.dialog(`您無權訪問【${_.isNil(title) ? to.path : title}】頁面`)
          .then(() => {
            // 沒有登入的時候跳轉到登入介面
            // 攜帶上登陸成功之後需要跳轉的頁面完整路徑
            next(false)
          })
      }
    })
    複製程式碼

    大家如果沒有使用或者不想使用這個外掛(vue-viewplus也無所謂,這裡只要知道,匯出的這個物件的install會在應用入口被呼叫,並傳入幾個install方法幾個必須的引數:

    • 路由物件
    • 應用的公共頁面paths列表
    • 許可權校驗失敗之後的處理函式

    這樣我們就能在初始化函式中快取應用公共頁面paths列表,註冊路由鉤子,監聽路由變化。

    這裡我使用這個外掛為的還有第二個目的,利用其來管理使用者登入狀態,詳細看下面我為什麼要使用這個狀態

  2. 在監聽到某個公共頁面訪問的時候,_rbacPathCheck函式將會:

    • 首先判斷當前使用者是否是超級管理員,你可以理解為linux中的root使用者,如果是則直接放行,這樣做是為了減少判斷帶來的開銷,當然如果需要實現這個效果,需要在登入之後,根據後端返回的使用者資訊中檢視使用者的角色,是否是超級管理員,如果是,則呼叫檔案匯出的rabcUpdateSuperAdminStatus方法,在這裡是頁面例項的this.$vp.rabcUpdateSuperAdminStatus方法(vue-viewplus將每個模組匯出的api繫結到頁面例項即vm的$vp屬性之下):
    // 登入頁面提交按鈕繫結方法
    submit() {
          this.$refs.loginForm.validate(valid => {
            if (valid) {
              // 登入
              this.login({
                vm: this,
                username: this.formLogin.username,
                password: this.formLogin.password,
                imageCode: this.formLogin.code
              }).then((res) => {
                // 修改使用者登入狀態
                this.$vp.modifyLoginState(true);
                // 解析服務端返回的登入使用者資料,得到選單、許可權相關資料
                const isSuperAdminStatus = parseUserRoleIsSuperAdminStatus(res.principal.admin.roles);
                this.$vp.toast('登入成功', {
                  type: 'success'
                });
                // 重定向物件不存在則返回頂層路徑
                this.$router.replace(this.$route.query.redirect || '/')
              })
            } else {
              // 登入表單校驗失敗
              this.$message.error('表單校驗失敗')
            }
          })
        }
    複製程式碼
    • 如果不是則檢測待訪問的頁面的path是否在**應用的公共頁面paths列表_publicPaths**中,如果是則放行

      而做這個判斷的前提是應用登入成功之後需要將其獲得授權的前端paths設定this.$vp.rabcUpdateAuthorizedPaths給外掛:

      submit() {
            this.$refs.loginForm.validate(valid => {
              if (valid) {
                // 登入
                this.login({
                  vm: this,
                  username: this.formLogin.username,
                  password: this.formLogin.password,
                  imageCode: this.formLogin.code
                }).then((res) => {
                  this.$vp.rabcUpdateAuthorizedPaths(authorizeResources.paths);
                })
              } else {
                // 登入表單校驗失敗
                this.$message.error('表單校驗失敗')
              }
            })
          }
      複製程式碼

      資料的格式如下:

      ["/mngauth/admin", "/index", "/mngauth"]
      複製程式碼

      並且,陣列的值支援為正規表示式;

    • 如果不是則檢查待訪問頁面的path是否在**登入使用者擁有訪問許可權的路由path路徑集合_authorizedPaths**中,如果是則放行,如果不是則整個校驗結束,判斷使用者無權訪問該頁面,呼叫_onPathCheckFail回撥函式,通知應用,這裡應用則會列印dialog提示使用者

    因為我們的目的是抽象整個業務,所以這裡才以回撥的方式讓應用有實際去感知和處理這一情況;

    這樣我們就完成了第一個目標;

要實現【實現可見頁面的區域性UI元件的可使用性或可見性控制,即基於自定義v-access指令,對比宣告的介面或資源別是否已經授權】這個目標,我們的方案是:
  1. 獲得登入使用者的:

    • 被授權角色所擁有的資源列表,對應的資源別名

      資料格式類似:

      ["MNG_USERMNG", "MNG_ROLEMNG"]
      複製程式碼
    • 被授權角色所擁有的資源列表(或資源)所對應的後端介面集合

      資料格式類似:

      ["admin/dels/*", "admin/search/*/*/*", "admin/*/*/*", "role/list/*", "admin/*"]
      複製程式碼

      但是預設希望的是RESTful格式:

      [{url: "admin/dels/*", method: "DELETE"}, ....]
      複製程式碼

      當然同樣支援js正規表示式;

    通過以上兩組(二選一)授權資料,我們就可以對比使用者在指令中宣告的條件許可權進行對比。

  2. 定義一個Vue指令,這裡命名為access,其需要具備以下特點:

    • 可以讓使用者宣告不同的許可權表示式,如這個按鈕是需要一組介面,還是一個資源別名
    • 可以讓使用者控制,在不滿足許可權檢查之後,是讓UI元件不顯示還是讓其不可用

    當然要理解上面的資料結構後端是怎麼構建的,可以參考表結構和許可權說明

我們繼續往上面的程式碼中新增邏輯,下面是程式碼實現:

const rbacModel = {
  //....
  /**
   * 更新授權介面集合
   * @param interfaces
   */
  rabcUpdateAuthorizeInterfaces(interfaces) {
    _authorizeInterfaces = [...new Set(interfaces)]
    this.cacheSaveToSessionStore('AUTHORIZED_INTERFACES', _authorizeInterfaces)
  },
  /**
   * 新增授權介面集合
   * @param interfaces
   */
  rabcAddAuthorizeInterfaces(interfaces) {
    this::rbacModel.rabcUpdateAuthorizeInterfaces(_.concat(_authorizeInterfaces, interfaces))
  },
  /**
   * 更新資源別名集合
   * @param alias
   */
  rabcUpdateAuthorizeResourceAlias(alias) {
    _authorizeResourceAlias = [...new Set(alias)]
    this.cacheSaveToSessionStore('AUTHORIZED_RESOURCE_ALIAS', _authorizeResourceAlias)
  },
  /**
   * 新增資源別名集合
   * @param alias
   */
  rabcAddAuthorizeResourceAlias(alias) {
    this::rbacModel.rabcUpdateAuthorizeResourceAlias(_.concat(_authorizeResourceAlias, alias))
  },
  install(Vue, {
    //....
    /**
     * [可選] 登入使用者擁有訪問許可權的資源別名集合
     * {Array<Object>}
     * <p>
     * 陣列中的item,可以是一個**正規表示式字面量**,如`[/^((\/Interbus)(?!\/SubMenu)\/.+)$/]`,也可以是一個字串
     * <p>
     * 匹配規則:因為如果都用`LoginStateCheck#authorizeInterfaces`介面進行匹配,可能有一種情況,訪問一個資源,其需要n個介面,那麼我們在配置配置許可權指令:v-access="[n, n....]"的時候就需要宣告所有需要的介面,就會需要對比多次,
     * 當我們系統的介面集合很大的時候,勢必會成為一個瓶頸,故我們可以為資源宣告一個別名,這個別名則可以代表這n個介面,這樣的話就從n+減少到n次匹配;
     */
    authorizeResourceAlias = [],
    /**
     * [*] 登入使用者擁有訪問許可權的後臺介面集合
     * {Array<Object>}
     * <p>
     *   1.在`v-access`指令配置為url(預設)校驗格式時,將會使用該集合和指令宣告的待審查授權介面列表進行匹配,如果匹配成功,則指令校驗通過,否則校驗不通過,會將對應dom元素進行處理
     *   2.TODO 將會用於在傳送ajax請求之前,對待請求的介面和當前集合進行匹配,如果匹配失敗說明使用者就沒有請求許可權,則直接不傳送後臺請求,減少後端不必要的資源浪費
     * <p>
     * 陣列中的item,可以是一個**正規表示式字面量**,如`[/^((\/Interbus)(?!\/SubMenu)\/.+)$/]`,也可以是一個字串
     * <p>
     * 匹配規則:將會用於在傳送ajax請求之前,對待請求的介面和當前集合進行匹配,如果匹配失敗說明使用者就沒有請求許可權,則直接不傳送後臺請求,減少後端不必要的資源浪費
     * <p>
     *   注意需要根據`isRESTfulInterfaces`屬性的值,來判斷當前集合的資料型別:
     *
     * 如果`isRESTfulInterfaces`設定為`false`,則使用下面的格式:
     * ```json
     * ["admin/dels/*", ...]
     * ```
     * 如果`isRESTfulInterfaces`設定為`true`,**注意這是預設設定**,則使用下面的格式:
     * ```json
     * [[{url: "admin/dels/*", method: "DELETE"}, ...]]
     * ```
     */
    authorizeInterfaces = [],
    /**
     * [*] 宣告`authorizeInterfaces`集合儲存的是RESTful型別的介面還是常規介面
     * 1. 如果是(true),則`authorizeInterfaces`集合需要儲存的結構就是:
     * [{url: 'admin/dels/*', method: 'DELETE'}]
     * 即進行介面匹配的時候會校驗型別
     * 2. 如果不是(false),則`authorizeInterfaces`集合需要儲存的結構就是,即不區分介面型別:
     * ['admin/dels/*']
     */
    isRESTfulInterfaces = true
  } = {}) {
    //....
    this::_createRBACDirective(Vue)
  }
};

export default rbacModel;
複製程式碼

首先我們在外掛中新增幾個欄位和對應的設定介面:

  • isRESTfulInterfaces
  • authorizeInterfaces
  • authorizeResourceAlias

這樣我們就可以維護使用者擁有的授權資源別名列表、資源(對應介面)後端介面資料列表,並預設認為介面為RESTful資料結構;

接著我們就可以定義指令(在外掛初始化方法install中),並在指令的bind宣告週期,解析對應UI元件宣告的所需許可權資訊,並和持有的資源列表進行對比,如果對比失敗則對UI元件做相應的顯示或者disable操作:


/**
 * 推薦使用資源標識配置:`v-access:alias[.disable]="'LOGIN'"` 前提需要注入身份認證使用者所擁有的**授權資源標識集合**,因為這種方式可以較少比較的次數
 * 傳統使用介面配置:`v-access:[url][.disable]="'admin'"` 前提需要注入身份認證使用者所擁有的**授權介面集合**
 * 兩種都支援陣列配置
 * v-access:alias[.disable]="['LOGIN', 'WELCOME']"
 * v-access:[url][.disable]="['admin', 'admin/*']"
 * 針對於RESTful型別介面:
 * v-access="[{url: 'admin/search/*', method: 'POST'}]"
 * 預設使用url模式,因為這種方式比較通用
 * v-access="['admin', 'admin/*']"
 * <p>
 *   其中`[.disbale]`用來標明在檢測使用者不具有對當前宣告的許可權時,將會把當前宣告指令的`el`元素新增`el.disabled = true`,預設則是影藏元素:`el.style.display = 'none'`
 * <p>
 *   舉例:`<el-form v-access="['admin/search']" slot="search-inner-box" :inline="true" :model="searchForm" :rules="searchRules" ref="ruleSearchForm" class="demo-form-inline">...</el-form>`
 *   上面這個檢索表單需要登入使用者具有訪問`'admin/search'`介面的許可權,才會顯示
 * @param Vue
 * @private
 */
const _createRBACDirective = function(Vue) {
  Vue.directive('access', {
    bind: function(el, { value, arg, modifiers }) {
      if (_superAdminStatus) {
        return;
      }
      let isAllow = false
      const statementAuth = _parseAccessDirectiveValue2Arr(value)
      switch (arg) {
        case 'alias':
          isAllow = _checkPermission(statementAuth, _authorizeResourceAlias)
          break
        // 預設使用url模式
        case 'url':
        default:
          if (_isRESTfulInterfaces) {
            isAllow = _checkPermissionRESTful(statementAuth, _authorizeInterfaces)
          } else {
            isAllow = _checkPermission(statementAuth, _authorizeInterfaces)
          }
      }

      if (!isAllow) {
        if (_debug) {
          console.warn(`[v+] RBAC access許可權檢測不通過:使用者無權訪問【${_.isObject(value) ? JSON.stringify(value) : value}】`);
        }
        if (_.has(modifiers, 'disable')) {
          el.disabled = true;
          el.style.opacity = '0.5'
        } else {
          el.style.display = 'none';
        }
      }
    }
  })
}


/**
 * 校驗給定指令顯示宣告所需列表是否包含於身份認證使用者所具有的許可權集合中,如果是則返回`true`標識許可權校驗通過
 * @param statementAuth
 * @param authorizeCollection
 * @returns {boolean}
 * @private
 */
const _checkPermission = function(statementAuth, authorizeCollection) {
  let voter = []
  statementAuth.forEach(url => {
    voter.push(authorizeCollection.includes(url))
  })
  return !voter.includes(false)
}

/**
 * {@link _checkPermission} 附加了對介面型別的校驗
 * @param statementAuth
 * @param authorizeCollection
 * @returns {boolean}
 * @private
 */
const _checkPermissionRESTful = function(statementAuth, authorizeCollection) {
  let voter = []
  const expectedSize = statementAuth.length
  const size = authorizeCollection.length
  for (let i = 0; i < size; i++) {
    const itf = authorizeCollection[i]
    if (_.find(statementAuth, itf)) {
      voter.push(true)
      // 移除判斷成功的宣告許可權物件
      statementAuth.splice(i, 1)
    }
  }
  // 如果投票得到的true含量和需要判斷的宣告許可權長度一致,則標識校驗通過
  return voter.length === expectedSize
}

const _parseAccessDirectiveValue2Arr = function(value) {
  let params = []
  if (_.isString(value) || _.isPlainObject(value)) {
    params.push(value)
  } else if (_.isArray(value)) {
    params = value
  } else {
    throw new Error('access 配置的授權識別符號不正確,請檢查')
  }
  return params
}
複製程式碼

在使用指令之前,我們還需要解決外掛所需許可權列表的設定:

submit() {
      this.$refs.loginForm.validate(valid => {
        if (valid) {
          // 登入
          this.login({
            vm: this,
            username: this.formLogin.username,
            password: this.formLogin.password,
            imageCode: this.formLogin.code
          }).then((res) => {
            // 修改使用者登入狀態
            this.$vp.modifyLoginState(true);
            //...
            const authorizeResources = parseAuthorizePaths(res.principal.admin.authorizeResources);
            this.$vp.rabcUpdateAuthorizeResourceAlias(authorizeResources.alias);
            const authorizeInterfaces = parseAuthorizeInterfaces(res.principal.admin.authorizeInterfaces);
            this.$vp.rabcUpdateAuthorizeInterfaces(authorizeInterfaces);
          	//...
        }
      })
    }
複製程式碼

這裡的parseAuthorizePathsparseAuthorizeInterfaces的作用是解析後端返回的登入使用者資源和介面列表,這個因人而異,就不貼了;

還需要注意的一點就是,this.$vp.modifyLoginState(true),是vue-viewplus外掛登入身份控制模組所提供的一個介面,其可以為應用維護登入狀態,比如在監控到後端返回會話超時時候自動將狀態設定為false,更多請檢視*這裡*,這也是邏輯複用的一個好處了;

當然如果你只是想實現自己的許可權控制模組,並不想抽象的這麼簡單,也可以硬編碼到專案中;

這樣我們就完成了第二個目標;

哦哦哦忘了寫一下,我們怎麼用這個指令了,補充一下:

<el-form v-access="{url: 'admin/search/*/*/*', method: 'POST'}" slot="search-inner-box" :inline="true" :model="searchForm" :rules="searchRules" ref="ruleSearchForm" class="demo-form-inline">
      //...
    </el-form>
複製程式碼

上面是一個最簡單的例子,即宣告,如果要使用該檢索功能,需要使用者擁有:{url: 'admin/search/*/*/*', method: 'POST'這個介面許可權;

另外指令的更多宣告方式,請檢視這裡

要【實現傳送請求前對待請求介面進行許可權檢查,如果使用者不具有訪問該後端介面的許可權,則不傳送請求,而是友好的提示使用者】這個目標,我們的方案是:
  1. 獲得登入使用者的:

    • 被授權角色所擁有的資源列表(或資源)所對應的後端介面集合,這一步在實現第二個目標的時候已經完成,即在登入成功之後:this.$vp.rabcUpdateAuthorizeInterfaces(authorizeInterfaces);,這裡只要複用即可
  2. 攔截請求,這裡我們應用請求都是基於vue-viewplus的util-http.js 針對axios進行了二次封裝的ajax模組來傳送,它的好處是我80%的請求介面不用單獨寫錯誤處理程式碼,而是由改模組自動處理了,回到正題,我們怎麼攔截請求,因為該ajax外掛底層使用的是axios,對應的其提供了我們攔截請求的鉤子https://github.com/Jiiiiiin/jiiiiiin-security#表結構和許可權說明)

    在具備以上條件之後我們好像就可以寫程式碼了,嘿嘿:)

我們繼續往上面的程式碼中新增邏輯,下面是程式碼實現:

const rbacModel = {
  //...
  install(Vue, {
    //...
    /**
     * [*] `$vp::onPathCheckFail(to, from, next)`
     * <p>
     * 傳送ajax請求時許可權檢查失敗時被回撥
     */
    onAjaxReqCheckFail = null
  } = {}) {
    _onAjaxReqCheckFail = onAjaxReqCheckFail;
    this::_rbacAjaxCheck()
  }
};
複製程式碼

還是在外掛物件中,首先宣告瞭所需配置的onAjaxReqCheckFail,其次呼叫_rbacAjaxCheck進行axios攔截宣告:


/**
 * 用於在傳送ajax請求之前,對待請求的介面和當前集合進行匹配,如果匹配失敗說明使用者就沒有請求許可權,則直接不傳送後臺請求,減少後端不必要的資源浪費
 * @private
 */
const _rbacAjaxCheck = function() {
  this.getAjaxInstance().interceptors.request.use(
    (config) => {
      const { url, method } = config
      const statementAuth = []
      let isAllow
      if (_isRESTfulInterfaces) {
        const _method = _.toUpper(method)
        statementAuth.push({ url, method: _method });
        isAllow = _checkPermissionRESTful(statementAuth, _authorizeInterfaces)
        // TODO 因為攔截到的請求`{url: "admin/0/1/10", method: "GET"}` 沒有找到類似java中org.springframework.util.AntPathMatcher;
        // 那樣能匹配`{url: "admin/*/*/*", method: "GET"}`,的方法`temp = antPathMatcher.match(anInterface.getUrl(), reqURI)`
        // 故這個需求暫時沒法實現 :)
        console.log('statementAuth', isAllow, statementAuth, _authorizeInterfaces)
      } else {
        isAllow = _checkPermission(statementAuth, _authorizeInterfaces)
      }
      if (isAllow) {
        return config;
      } else {
        if (_debug) {
          console.warn(`[v+] RBAC ajax許可權檢測不通過:使用者無權傳送請求【${method}-${url}】`);
        }
        if (_.isFunction(_onAjaxReqCheckFail)) {
          this::_onAjaxReqCheckFail(config);
        } else {
          throw new Error('check_authorize_ajax_req_fail');
        }
      }
    },
    error => {
      return Promise.reject(error)
    }
  )
}
複製程式碼

這裡可能this.getAjaxInstance()不知道是什麼,在呼叫_rbacAjaxCheck是我們指定了this,即this::_rbacAjaxCheck(),而這個this就是$vp物件,即vue-viewplus繫結到Vue例項的$vp屬性;

其他的就很簡單了,根據配置的_isRESTfulInterfaces屬性看我們要校驗的是RESTful介面還是普通介面,如果校驗通過則返回axios所需請求config,如果失敗則呼叫配置的_onAjaxReqCheckFail通知應用,讓應用去處理許可權失敗的情況,一般也是彈出一個toast提示使用者許可權不足。

這樣好像我們就完成了所有目標,哈哈哈。

寫文章真是比敲程式碼累得多呀。

但是不幸的是我們並沒有實現第三個目標,問題就在於,上面程式碼片段的TODO中所描述,我沒有解決RESTful PathValue型別介面的許可權對比,後端我用的庫是通過:

 log.debug("內管許可權校驗開始:{} {} {}", admin.getUsername(), reqURI, reqMethod);
                for (Role role : roles) {
                    boolean temp;
                    for (Resource resource : role.getResources()) {
                        for (Interface anInterface : resource.getInterfaces()) {
                            temp = antPathMatcher.match(anInterface.getUrl(), reqURI) && reqMethod.equalsIgnoreCase(anInterface.getMethod());
                            if (temp) {
                                hasPermission = true;
                                break;
                            }
                        }
                    }
                }
複製程式碼

org.springframework.util.AntPathMatcher提供的方法來完成的,但是js我沒有找到合適的庫來對比:

{url: "admin/*/*/*", method: "GET"} <> {url: "admin/0/1/10", method: "GET"}
複製程式碼

這樣的兩個物件,所以有耐心看到這裡的朋友,如果你解決了這個問題,請聯絡我,謝謝。

謝謝你耐心的看到這裡,如果覺得對你有所幫助,請幫忙支援一下我的兩個專案:

vue-viewplus

jiiiiiin-security

動動小手,求star,哎,哈哈哈。

相關文章