Shiro-授權(RBAC)

atd681發表於2018-08-16

0. 前言

[Shiro-認證]中講解了如何使用Shiro實現登入後訪問URL, 對於大部分系統來說, 登入只是安全的第一道屏障, 系統中的某些頁面需要登入後訪問, 而有些是需要有特定許可權才可以訪問, 比如刪除, 凍結, 檢視賬號收益等敏感的操作.

本文將帶你實現基於Shiro的許可權控制, Shiro中叫做授權

1. 什麼是許可權

系統中有A,B,C三個使用者, 其中A使用者是管理員, B和C是普通使用者. 系統中的所有刪除操作必須由管理員賬號登入才能完成. 普通使用者是無法刪除資料甚至連刪除按鈕都看不見. 我們說A,B,C三個使用者在系統中有不同的許可權. A有刪除資料的許可權, B和C沒有刪除資料的許可權. 試想一下如果沒有許可權設計, 所有使用者都可以刪除資料, 假設B是新手不小心誤操作刪除了資料... 後果將不堪設想.

又例如銀行的金庫, 如果沒有許可權控制所有人都可以刷卡進入, 那豈不是要亂套. 生活中許可權無處不在: 進出小區刷卡, 電梯刷卡到指定樓層, 視訊網站中會員不需要看廣告, 這些都是許可權.

2. 許可權設計方案

假如你做了一個交友網站, 裡面有檢視異性的基本資訊, 檢視微信, 檢視電話, 檢視家庭住址幾個功能, 普通的使用者只能檢視基本資訊, 不能檢視聯絡方式等. 充值100元可以檢視微信, 充值200元可以檢視電話, 充值500元可以檢視家庭住址.

你必須要做許可權控制, 否則使用者通過其他手段(比如知道URL)就可以檢視聯絡方式, 也就沒有人給你付費了. 最初, 你可能想到這麼處理許可權: 用一張資料表記錄每個使用者可以做什麼事. 當使用者檢視微信時找到登入使用者的許可權判斷是否可以檢視微信.

使用者 基本資訊 檢視微信 檢視電話 檢視住址
張三
李四
王五

隨著時間的增加會員越來越多, 有一天你新加了一個功能: 檢視對方視訊介紹, 只有充值500的人才能檢視. 於是你需要把上表中所有使用者的許可權都修改一遍. 如果有幾十萬會員, 可能你就會累到吐血....

聰明的你想到了一個辦法, 設定會員等級, 充值100為普通會員, 充值200元為VIP, 充值500為VIP中P. 給每一個會員設定會員等級. 此時你的資料表結構如下:

  • 會員等級-許可權
會員等級 基本資訊 檢視微信 檢視電話 檢視住址 檢視視訊
充值100元: 初級會員
充值200元: VIP
充值500元: VIP中P
  • 會員-會員等級
使用者名稱 會員等級
張三 普通會員
李四 VIP
王五 VIP中P
趙六 VIP中P

這時, 當使用者檢視微信時, 根據使用者找到會員等級, 在找到對應的許可權. 雖然多了一步操作, 但:

  • 新加入功能時, 只需要對會員等級設定相應的許可權即可. 不需要對使用者進行許可權設定
  • 使用者會員等級升級時, 修改使用者的會員等級即可. 不需要額外修改會員的許可權
  • 會員等級的許可權需要發生變化時, 只需要修改會員等級對應的許可權, 對會員沒有影響

總之, 許可權只針對會員等級, 和會員並無直接關聯. 這裡的會員等級就相當於系統中的角色, 基於角色的許可權方案被很多系統所採用, 有了一個專有名詞: RBAC-基於角色的許可權訪問控制.

通俗的說就是根據使用者的角色來判斷是否有許可權訪問某個資源或URL. RBAC的模型是經典的5張表:

  • 使用者: 記錄系統的使用者資訊, 登入時使用. 例: 張三, 李四...
  • 角色: 記錄系統中存在的角色. 例: 普通會員, VIP...
  • 資源: 記錄系統中的有哪些可以做的事. 在WEB中對應的就是URL, 例: 檢視資料URL, 檢視微信URL, 檢視電話....
  • 使用者角色關係: 記錄使用者所屬的角色, 例: 張三是普通會員, 李四是VIP...
  • 角色資源關係: 記錄了某個角色可以做什麼事, 例: VIP可以檢視資料, 檢視微信...

系統預先設計好角色, 資源, 角色資源關係. 當新建使用者時只需要新增使用者角色關係即可實現對該使用者的許可權控制. 例如: 孫七註冊了使用者並充值200元, 我們可以直接設定孫七為VIP, 通過孫七的角色VIP就可以從角色資源關係中找到對應的可操作的URL.

3. Shiro中實現RBAC

3.0 內建過濾器

本文中的操作是基於[Shiro-認證]之上完成的, 建議先看完Shiro認證部分. Shiro的認證是通過內建的認證過濾器(authc)完成的, 同時也提供了一些授權相關的過濾器:

3.0.1 埠過濾器: port

訪問的埠不是定義的埠時重定向至定義的埠,對應類為org.apache.shiro.web.filter.authz.PortFilter

filterChainDefinitionMap = [
    "/**" : "port[9090]" // 如果不是通過9090埠將會重定向至9090埠訪問
]
複製程式碼

訪問http://localhost:8080/user/list, 埠為8080, 該請求被port過濾器攔截, 重定向至9090埠, 即http://localhost:9090/user/list, port過濾器適用於專案埠變更期間相容原有使用者訪問或將老版本系統自動切換到新版本(8080部署老版本, 9090部署新版本)

3.0.2 SSL過濾器: ssl

非https訪問443埠時, 重定向使用https訪問443埠. 對應類為org.apache.shiro.web.filter.authz.SslFilter

filterChainDefinitionMap = [
    "/**" : "ssl" // 不可以設定埠號,非https訪問443埠會被重定向以https方式訪問443埠
]
複製程式碼

訪問http://localhost:456/user/list, 由於http方式訪問456埠, 該請求被port過濾器攔截重定向至https://localhost/user/list(80,443埠預設不顯示), 適用於新增SSL證書後需要https訪問, 相容原有使用http訪問的使用者.

3.0.3 角色過濾器: roles

使用者必須具有配置的角色才可以訪問. Shiro會呼叫Realm中查詢授權資訊的方法獲取使用者的角色. 對應類為org.apache.shiro.web.filter.authz.RolesAuthorizationFilter

filterChainDefinitionMap = [
    "/**" : "roles['admin,guest']" // 訪問使用者必須同時具備admin和guest角色才可以訪問
]
複製程式碼

如配置成roles["admin"]代表只要是admin角色就可以訪問, 兩個及以上角色代表必須同時滿足.

3.0.4 許可權過濾器: perms

filterChainDefinitionMap = [
    "/user/add" : "perms['user:add']" // 訪問使用者必須擁有user模組的add許可權
]
複製程式碼

使用者必須具有配置的許可權才可以訪問, Shiro會呼叫Realm中查詢授權資訊的方法查詢使用者的許可權. 對應類為org.apache.shiro.web.filter.authz.PermissionsAuthorizationFilter

user:add代表user模組的add許可權, 許可權設計可以按模組劃分並細化到模組的每個功能點, 比如使用者(user)模組中Admin角色有新增(add)使用者許可權, 刪除(delete)使用者許可權, 資料庫中可儲存Admin擁有的許可權為user:add, user:delete, 當訪問/user/add請求時, Shiro會通過Realm獲取對應的許可權, 如果含有user:add即可訪問該請求, 沒有該許可權禁止訪問.

如shiro中只配置到模組級別可以使用user:*進行萬用字元驗證. perms[user:*:add]代表訪問許可權為user模組下所有子模組(*匹配子模組)的新增(add)許可權

3.0.5 REST風格許可權過濾器: rest

對應類為org.apache.shiro.web.filter.authz.HttpMethodPermissionFilter

filterChainDefinitionMap = [
    // 訪問使用者必須擁有user模組的對應許可權, GET請求代表read
    // 已GET方式的請求必須擁有user:read許可權才可以訪問
    "/user/*" : "rest[user]" 
]
複製程式碼

將請求方式與增刪改查操作對應, 當以POST方式訪問URL時, 過濾器認為需要對模組進行create操作, 使用者必須擁有user:create許可權, 不同的請求方式對應不同的許可權. 具體如下表:

HTTP請求方式 Shiro對應的操作 系統中需要授予使用者的許可權(以user模組為例)
delete delete user:delete
head read user:read
get read user:read
put update user:update
post create user:create
mkcol create user:create
options read user:read
trace read user:read

此過濾器將http請求方式和許可權進行繫結, 可以算是perms過濾器的另一種實現方式. 由於瀏覽器對部分HTTP請求方式支援的不友好, 此過濾器應用較少.

3.1 自定義許可權過濾器

上述內建過濾器中可以支援RBAC的有roles, perms, rest, 其中roles只定義了角色, perms, rest的規則也是需要在Shiro配置檔案中進行配置模組及許可權. 如果系統增加功能並設定許可權時還需要同步修改配置檔案(修改後需要重新啟動Tomcat). 有沒有一種靈活的方式可以實現增加功能時不需要修改系統程式碼呢, 參考下面的思路:

WEB應用中所有的操作都是基於URL的, 例如: /user/add是新增使用者, /article/delete是刪除文章. 如果我們將URL設定給角色. 當使用者訪問某一個URL時, 我們只需要對比該使用者擁有的許可權集中是否含有該URL即可.

例: 張三的角色為部門經理, 擁有新增使用者(/user/add)和編輯使用者(/user/edit)許可權, 當張三登入系統後訪問/user/add, 通過Realm獲取張三的許可權後對比發現URL(/user/add)在其許可權列表中, Shiro允許訪問. 當訪問/user/delete時由於URL不在其許可權中, 因此Shiro拒絕訪問.

所有的URL請求都使用上述方式實現, 配置檔案中就不需要定義每個URL對應的許可權了. 因此新增功能時也就不需要修改系統程式碼了.

Shiro並沒有內建這種形式的過濾器, 需要我們自己實現, 新建類繼承AuthorizationFilter類重寫isAccessAllowed方法. 後面文章會講到isAccessAllowed是Shiro過濾器的一個核心方法: 判斷當前過濾器的驗證是否成功, 如果成功則放行(訪問控制器).

  • 認證過濾器: 驗證指的是否已經登入
  • 授權過濾器: 驗證指的是使用者是否有許可權訪問
/**
 * 自定義基於URL的授權過濾器
 * 通過使用者訪問的URL,從資料庫中查詢使用者是否有訪問該URL的許可權
 */
public class URLAuthorizationFilter extends AuthorizationFilter {

    /**
     * 是否允許訪問資源
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest request, 
                                      ServletResponse response, 
                                      Object mappedValue) throws Exception {

        // 獲取訪問的URL
        String requestUrl = WebUtils.toHttp(request).getRequestURI();
        // 判斷使用者是否有許可權訪問該URL
        // 呼叫isPermitted方法時Shiro會通過Realm獲取使用者擁有的許可權集合
        // 並判斷URL是否在許可權集合中, 如果在許可權集合中返回true
        return getSubject(request, response).isPermitted(requestUrl);

    }

}
複製程式碼

3.2 配置許可權過濾器

自定義的過濾器需要在Shiro中進行定義, 並配置URL需要授權才能訪問

// 配置自定義過濾器,名稱為authz
authz(URLAuthorizationFilter) {
    // 無許可權頁面: 使用者無許可權時重定向至該頁面
    unauthorizedUrl = "/unauthorized.jsp"
}
複製程式碼
// 配置URL規則
// 有請求訪問時Shiro會根據此規則找到對應的過濾器處理
filterChainDefinitionMap = [
    "/unauthorized.jsp" : "anon", // 未授權頁不需要授權即可訪問
    "/logout" : "logout", // 登出使用logout過濾器
    "/login": "authc", // 登入頁不配置授權
    "/**": "authc, authz" // 其餘所有頁面需要認證和授權(順序:先認證後授權)
]
複製程式碼
  • 授權和認證的順序, 先認證後授權, 如果使用者未登入, 無法獲取使用者所擁有的許可權資訊(授權時發現未認證會跳轉登入頁).
  • 登入頁不需要授權: authc不會處理登入頁, 如果配置到authz中, 會出現死迴圈(authz認為未登入重定向到登入頁)

3.3 Realm實現獲取許可權

Shiro需要使用Realm獲取使用者的許可權集合, 因此需要在Realm中增加一個獲取許可權的方法

// 自定義查詢使用者資訊的Realm
// 授權需要繼承AuthorizingRealm(只認證繼承AuthenticatingRealm即可)
public class UserRealm extends AuthorizingRealm {

    // 獲取使用者許可權資訊
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        // 獲取當前登入使用者的使用者名稱
        // Shiro會將doGetAuthenticationInfo返回的使用者資訊儲存至PrincipalCollection中
        String username = ((User) principals.getPrimaryPrincipal()).getUsername();
        // 模擬資料庫查詢, 根據使用者名稱查詢可以訪問的許可權URL集合
        Set<String> permSet = getPermissions(username);

        // 將許可權URL集合設定至Shiro中,授權時會從此處獲取許可權URL
        SimpleAuthorizationInfo authz = new SimpleAuthorizationInfo();
        authz.setStringPermissions(permSet);

        return authz;
    }

    // 模擬根據使用者名稱在資料庫中查詢使用者所有的許可權URL
    // 資料庫中可根據使用者找到角色,角色找到資源
    private Set<String> getPermissions(String username) {
        Set<String> permSet = new HashSet<String>();

        // "atd681"有下列頁面的訪問許可權
        if ("atd681".equals(username)) {
            permSet.add("/page/a");
            permSet.add("/page/b");
        }
        // 其他使用者有下列頁面的訪問許可權
        else {
            permSet.add("/page/x");
        }

        return permSet;
    }

    // 獲取使用者資訊的方法
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token)
        throws AuthenticationException {
       // shiro-認證中的登入邏輯
    }

    // 模擬根據使用者名稱在資料庫查詢使用者資訊
    private User getUser(String username) {
        // shiro-認證中的模擬獲取使用者資訊
    }

}
複製程式碼
  • 獲取許可權資訊的Realm必須繼承AuthorizingRealm實現doGetAuthorizationInfo方法
  • 獲取許可權時根據關係(使用者>角色>資源)找到使用者所擁有的資源(可訪問的URL)
  • 需要將獲取的許可權集合(Set)設定到SimpleAuthorizationInfo類中並返回至Shiro
  • 本例中使用者atd681可以訪問a,b頁面, 不可訪問x頁面

4. 測試

啟動專案, 使用atd681登入後分別訪問/page/a/page/b

Shiro-授權(RBAC)

可以正常訪問. 訪問/page/x時重定向至未授權頁面

Shiro-授權(RBAC)

5. 檢視層控制許可權

上述許可權控制是當使用者訪問URL時在服務端進行授權校驗. 在頁面中我們並沒有根據許可權控制連結或按鈕是否顯示, 不控制連結或按鈕的顯示會存在以下問題:

  • 使用者無許可權時點選連結或按鈕後無法訪問, 使用者體驗較差.
  • 暴露了系統該功能的URL, 引起不必要的安全隱患

因此, 當使用者沒有某功能許可權時頁面中不應該顯示功能對應的連結或按鈕(刻意顯示連結吸引使用者付費等場景除外), 我們需要在JSP中對連結或按鈕進行許可權判斷, 沒有許可權時不顯示對應的連結或按鈕.

Shiro為我們提供了一套在JSP中可以判斷認證或授權的標籤, 在/page/a的JSP中新增如下程式碼:

JSP頭部增加Shiro標籤的引用

<%@ tagliburi ="http://shiro.apache.org/tags" prefix="shiro"%>
複製程式碼

JSP中使用shiro:hasPermission根據使用者的許可權來控制是否顯示連結或按鈕

<body>
	系統選單:
	
	<!-- 
		該標籤根據name值判斷當前使用者是否有該頁面的訪問許可權
		無許可權時不顯示該連結(呼叫subject.isPermitted方法進行驗證)
	 -->
	<shiro:hasPermission name="/page/a">
		<a href="/page/a">A</a>
	</shiro:hasPermission>
	<shiro:hasPermission name="/page/b">
		<a href="/page/b">B</a>
	</shiro:hasPermission>
	<shiro:hasPermission name="/page/x">
		<a href="/page/x">X</a>
	</shiro:hasPermission>
	
	
	<br> PAGE_A, 當前登入使用者ID: ${userId}, 使用者名稱: ${userName}

	<a href="/logout">登出</a>
</body>
複製程式碼
  • <shiro:hasPermission>中的name屬性為連結的URL, 判斷使用者是否有許可權訪問URL
  • 只有當<shiro:hasPermission>返回true的時候, 標籤內的HTML才會被返回到客戶端
  • 所有標籤的實現程式碼在org.apache.shiro.web.tags目錄下, 有興趣可以自己檢視

啟動專案, 使用atd681登入後訪問/page/a, 由於使用者atd681有訪問/page/a/page/b的許可權, 連結A,B被顯示. 沒有訪問/page/x的許可權, 連結X沒有顯示.

6. 示例程式碼

至此, 基於Shiro授權的示例配置完成. 有興趣的同學可以多建立幾個使用者測試一下.

相關文章