37000 字 + 代碼,艿艿肝的 Shiro 從入門到實戰,直接收藏喫灰!

點擊上方“芋道源碼”,選擇“設爲星標

管她前浪,還是後浪?

能浪的浪,纔是好浪!

每天 8:55 更新文章,每天掉億點點頭髮...

源碼精品專欄

 

大家好,我是艿艿,一個讓你禿頭的小胖子。。。

最近狀態有點小好,摳腳一算, https://github.com/YunaiV/SpringBoot-Labs 倉庫的 Spring Boot、Spring Cloud、Dubbo 的示例代碼,竟然要破 60000 行了 = = 默默擼了 2 年了要~

摘要: 原創出處 http://www.iocoder.cn/Spring-Boot/Shiro/ 「芋道源碼」歡迎轉載,保留摘要,謝謝!

  • 1. 概述

  • 2. 快速入門

  • 3. Shiro 註解

  • 4. 項目實戰

  • 666. 彩蛋


本文在提供完整代碼示例,可見 https://github.com/YunaiV/SpringBoot-Labs 的 lab-33 目錄。

原創不易,給點個 Star 嘿,一起衝鴨!

1. 概述

艿艿:本文是《芋道 Spring Boot 安全框架 Spring Security 入門》 的姊妹篇,所以開頭就“重複”再來一遍,嘿嘿。

基本上,在所有的開發的系統中,都必須做認證(authentication)和授權(authorization),以保證系統的安全性。???? 考慮到很多胖友對認證和授權有點分不清楚,艿艿這裏引用一個網上有趣的例子:

FROM 《認證 (authentication) 和授權 (authorization) 的區別》

  • authentication [ɔ,θɛntɪ'keʃən] 認證

  • authorization [,ɔθərɪ'zeʃən] 授權

打飛機舉例子:

  • 【認證】你要登機,你需要出示你的 passport 和 ticket,passport 是爲了證明你張三確實是你張三,這就是 authentication。

  • 【授權】而機票是爲了證明你張三確實買了票可以上飛機,這就是 authorization。

論壇舉例子:

  • 【認證】你要登陸論壇,輸入用戶名張三,密碼 1234,密碼正確,證明你張三確實是張三,這就是 authentication。

  • 【授權】再一 check 用戶張三是個版主,所以有權限加精刪別人帖,這就是 authorization 。

所以簡單來說:認證解決“你是誰”的問題,授權解決“你能做什麼”的問題。另外,在推薦閱讀下《認證、授權、鑑權和權限控制》 文章,更加詳細明確

在 Java 生態中,目前有 Spring Security 和 Apache Shiro 兩個安全框架,可以完成認證和授權的功能。本文,我們再來學習下 Apache Shiro 。其官方對自己介紹如下:

FROM 《Apache Shiro 官網》

Apache Shiro™ is a powerful and easy-to-use Java security framework that performs authentication, authorization, cryptography, and session management.
Apache Shiro™ 是一個功能強大且易於使用的 Java 安全框架,它可以提供身份驗證、授權、加密和會話管理的功能。

With Shiro’s easy-to-understand API, you can quickly and easily secure any application – from the smallest mobile applications to the largest web and enterprise applications.
通過 Shiro 易於理解的 API ,你可以快速、輕鬆地保護任何應用程序 —— 從最小的移動端應用程序到大型的的 Web 和企業級應用程序。

更多關於 Shiro 的介紹,胖友可以自行閱讀《Apache Shiro 1.2.x 參考手冊》 ,雖然目前 Shiro 截止到目前已經發布到 1.4.2 版本,但是該手冊依然很有參考價值。

2. 快速入門

示例代碼對應倉庫:lab-33-shiro-demo 。

在本小節中,我們來對 Shiro 進行快速的入門,實現一個最小化的使用示例。

2.1 引入依賴

pom.xml 文件中,引入相關依賴。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.10.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>lab-33-shiro-demo</artifactId>

    <dependencies>
        <!-- 實現對 Spring MVC 的自動化配置 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- 實現對 Shiro 的自動化配置 -->
        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-spring-boot-starter</artifactId>
            <version>1.4.2</version>
        </dependency>
    </dependencies>

</project>
  • 具體每個依賴的作用,胖友自己認真看下艿艿添加的所有註釋噢。

不過實際上,shiro-spring-boot-starter 依賴對 Shiro 的自動化配置基本沒啥用,需要自己來主動實現對 Shiro 的配置。

2.2 ShiroConfig

cn.iocoder.springboot.lab01.shirodemo.config 包下,創建 ShiroConfig 抽象類,實現 Shiro 的自定義配置。代碼如下:

// ShiroConfig.java

@Configuration
public class ShiroConfig {

    @Bean
    public Realm realm() { /**省略代碼**/ }

    @Bean
    public DefaultWebSecurityManager securityManager() { /**省略代碼**/ }

    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean() { /**省略代碼**/ }

}
  • 一共有三個 Bean 的配置,我們逐個來看看。

2.2.1 Realm

我們先來看看 Realm 的定義。

《Apache Shiro 1.2.x 參考手冊 —— Realms》

Realm 是可以訪問程序特定的安全數據如用戶、角色、權限等的一個組件。Realm 會將這些程序特定的安全數據轉換成一種 Shiro 可以理解的形式,Shiro 就可以依次提供容易理解的 Subject 程序API而不管有多少數據源或者程序中你的數據如何組織。

Realm 通常和數據源是一對一的對應關係,如關係數據庫,LDAP 目錄,文件系統,或其他類似資源。因此,Realm 接口的實現使用數據源特定的API 來展示授權數據(角色,權限等),如JDBC,文件IO,Hibernate 或JPA,或其他數據訪問API。

Realm 實質上就是一個特定安全的 DAO

因爲這些數據源大多通常存儲身份驗證數據(如密碼的憑證)以及授權數據(如角色或權限),每個 Shiro Realm 能夠執行身份驗證和授權操作。

  • ???? 好長一段描述,抓重點,最後一句的“身份驗證”(認證)和“授權”,這個就是 Realm 的職責。

Realm 整體的類圖如下:

  • Realm 接口,主要定義了**“認證”**方法。代碼如下:

    // Realm.java
    
    AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException;
    
  • AuthorizingRealm 抽象類,主要額外定義了授權方法。代碼如下:

    // AuthorizingRealm.java
    
    protected abstract AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals);
    
    • AuthorizingRealm 同時實現了 Authorizer 接口,提供判斷經過認證過的 Subject 是否具有指定的角色、權限的方法。

  • 從圖中我們可以看出,Shiro 提供了多種 AuthorizingRealm 的實現類,提供從不同的數據源獲取數據。不過一般在項目中,我們會自定義實現 AuthorizingRealm ,從自己定義的表結構中讀取用戶、角色、權限等數據。雖然說,Shiro 提供了 JdbcRealm 可以訪問數據庫,但是它的表結構是固定的,所說我們纔要自定義定義實現 AuthorizingRealm

本示例中,在 #realm() 方法,我們創建了 SimpleAccountRealm Bean 對象。代碼如下:

// ShiroConfig.java

@Bean
public Realm realm() {
    // 創建 SimpleAccountRealm 對象
    SimpleAccountRealm realm = new SimpleAccountRealm();
    // 添加兩個用戶。參數分別是 username、password、roles 。
    realm.addAccount("admin", "admin", "ADMIN");
    realm.addAccount("normal", "normal", "NORMAL");
    return realm;
}
  • SimpleAccountRealm 是使用內存作爲數據源,我們可以手動往裏面添加用戶、角色、權限等數據。???? 畢竟作爲一個示例,艿艿不想引入數據庫,增加複雜性。不過我們在「3. 項目實戰」中,我們會看到我們使用自定義的 AuthorizingRealm 實現類。

  • 在該方法裏,我們添加了「admin/admin」和「normal/normal」兩個用戶,分別對應 ADMIN 和 NORMAL 角色。

2.2.2 SecurityManager

我們再來看看 SecurityManager 的定義。

《Apache Shiro 1.2.x 參考手冊 —— Session Management》

SecurityManager 是 Shiro 架構的核心,配合內部安全組件共同組成安全傘。

本示例中,在 #securityManager() 方法,我們創建了 DefaultWebSecurityManager Bean 對象。代碼如下:

// ShiroConfig.java

@Bean
public DefaultWebSecurityManager securityManager() {
    // 創建 DefaultWebSecurityManager 對象
    DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
    // 設置其使用的 Realm
    securityManager.setRealm(this.realm());
    return securityManager;
}
  • 不用特別去糾結 SecurityManager ,創建好 DefaultWebSecurityManager Bean 就完事了~等後續我們入門完 Shiro 之後,胖友可以在慢慢細細去研究。

2.2.3 ShiroFilter

通過 AbstractShiroFilter 過濾器,實現對請求的攔截,從而實現 Shiro 的功能。AbstractShiroFilter 整體的類圖如下:

本示例中,在 #shiroFilterFactoryBean() 方法,我們創建了 ShiroFilterFactoryBean Bean 對象。代碼如下:

// ShiroConfig.java

@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean() {
    // <1> 創建 ShiroFilterFactoryBean 對象,用於創建 ShiroFilter 過濾器
    ShiroFilterFactoryBean filterFactoryBean = new ShiroFilterFactoryBean();

    // <2> 設置 SecurityManager
    filterFactoryBean.setSecurityManager(this.securityManager());

    // <3> 設置 URL 們
    filterFactoryBean.setLoginUrl("/login"); // 登陸 URL
    filterFactoryBean.setSuccessUrl("/login_success"); // 登陸成功 URL
    filterFactoryBean.setUnauthorizedUrl("/unauthorized"); // 無權限 URL

    // <4> 設置 URL 的權限配置
    filterFactoryBean.setFilterChainDefinitionMap(this.filterChainDefinitionMap());

    return filterFactoryBean;
}
  • <1> 處,創建 ShiroFilterFactoryBean 對象,用於創建 SpringShiroFilter 過濾器。

  • <2> 處,設置其 SecurityManager 屬性。

  • <3> 處,設置各種 URL 。

    • #setLoginUrl(String loginUrl) 方法,設置登陸 URL 。在 Shiro 中,約定 GET loginUrl 爲登陸頁面,POST loginUrl 爲登陸請求。

    • #setSuccessUrl(String successUrl) 方法,設置登陸成功 URL 。在登陸成功時,會重定向到該 URL 上。

    • #etUnauthorizedUrl(String unauthorizedUrl) 方法,設置無權限的 URL 。在請求校驗權限不通過時,會重定向到該 URL 上。

    • 上述的 URL 對應的接口,都需要我們自己來實現。具體可見「2.3 SecurityController」小節。

  • <4> 處,調用 #setFilterChainDefinitionMap(Map<String, String> filterChainDefinitionMap) 方法,設置 URL 的權限配置。

在看 #filterChainDefinitionMap() 方法的具體 URL 的權限配置之前,我們先來了解下 Shiro 內置的過濾器們。在 Shiro DefaultFilter 枚舉類中,枚舉了這些過濾器,以及其配置名。整理表格如下:

Filter NameClass
anonorg.apache.shiro.web.filter.authc.AnonymousFilter
authcorg.apache.shiro.web.filter.authc.FormAuthenticationFilter
authcBasicorg.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter
logoutorg.apache.shiro.web.filter.authc.LogoutFilter
noSessionCreationorg.apache.shiro.web.filter.session.NoSessionCreationFilter
permsorg.apache.shiro.web.filter.authz.PermissionsAuthorizationFilter
portorg.apache.shiro.web.filter.authz.PortFilter
restorg.apache.shiro.web.filter.authz.HttpMethodPermissionFilter
rolesorg.apache.shiro.web.filter.authz.RolesAuthorizationFilter
sslorg.apache.shiro.web.filter.authz.SslFilter
userorg.apache.shiro.web.filter.authc.UserFilter

比較常用的過來器有:

  • anon :AnonymousFilter :允許匿名訪問,即無需登陸。

  • authc :FormAuthenticationFilter :需要經過認證的用戶,纔可以訪問。如果是匿名用戶,則根據 URL 不同,會有不同的處理:

    • 如果攔截的 URL 是 GET loginUrl 登陸頁面,則進行該請求,跳轉到登陸頁面。

    • 如果攔截的 URL 是 POST loginUrl 登陸請求,則基於請求表單的 usernamepassword 進行認證。認證通過後,默認重定向到 GET loginSuccessUrl 地址。

    • 如果攔截的 URL 是其它 URL 時,則記錄該 URL 到 Session 中。在用戶登陸成功後,重定向到該 URL 上。

  • logout :LogoutFilter :攔截的 URL ,執行退出操作。退出完成後,重定向GET loginUrl 登陸頁面。

  • roles :RolesAuthorizationFilter :擁有指定角色的用戶可訪問。

  • perms :PermissionsAuthorizationFilter :擁有指定權限的用戶可以訪問。

下面,讓我們回過頭來看看 #filterChainDefinitionMap() 方法的具體 URL 的權限配置。代碼如下:

// ShiroConfig.java

private Map<String, String> filterChainDefinitionMap() {
    Map<String, String> filterMap = new LinkedHashMap<>(); // 注意要使用有序的 LinkedHashMap ,順序匹配
    filterMap.put("/test/echo", "anon"); // 允許匿名訪問
    filterMap.put("/test/admin", "roles[ADMIN]"); // 需要 ADMIN 角色
    filterMap.put("/test/normal", "roles[NORMAL]"); // 需要 NORMAL 角色
    filterMap.put("/logout", "logout"); // 退出
    filterMap.put("/**", "authc"); // 默認剩餘的 URL ,需要經過認證
    return filterMap;
}
  • /test/echo :我們設置爲 anon ,允許匿名訪問。

  • /test/admin/test/normal :我們設置爲 roles[...] ,需要指定角色的用戶可以訪問。其中 ... 處爲需要添加的角色名。

  • /logout :我們設置爲 logout ,實現退出操作。

  • /** :剩餘的 URL ,我們設置爲 authc ,需要登陸的用戶纔可以訪問。同時,對於 loginUrl 需要執行登陸相關的攔截。

另外,這裏在補充一點,請求在 ShiroFilter 攔截之後,會根據該請求的情況,匹配到配置的內置的 Shiro Filter 們,逐個進行處理。也就是說,ShiroFilter 實際內部有一個由 內置的 Shiro Filter 組成的過濾器

至此,我們已經完成了 Shiro 的自定義配置。雖然篇幅有點長,但是可以等我們跑完整個「2. 快速入門」的示例之後,胖友再自己回過頭來看看,會發現還是比較清晰明瞭的。

2.3 SecurityController

cn.iocoder.springboot.lab01.shirodemo.controller 包路徑下,創建 SecurityController 類,提供登陸、登陸成功等接口。代碼如下:

// SecurityController.java

@Controller
@RequestMapping("/")
public class SecurityController {

    private Logger logger = LoggerFactory.getLogger(getClass());

    @GetMapping("/login")
    public String loginPage() { /**省略代碼**/ }

    @ResponseBody
    @PostMapping("/login")
    public String login(HttpServletRequest request) { /**省略代碼**/ }

    @ResponseBody
    @GetMapping("/login_success")
    public String loginSuccess() { /**省略代碼**/ }

    @ResponseBody
    @GetMapping("/unauthorized")
    public String unauthorized() { /**省略代碼**/ }

}
  • 一共有 4 個接口,我們逐個來看看。

2.3.1 登陸頁面

GET /login 地址,跳轉登陸頁面。代碼如下:

// SecurityController.java

@GetMapping("/login")
public String loginPage() {
    return "login.html";
}
  • 返回 resources/static/login.html 靜態頁面。代碼如下:

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>登陸頁面</title>
    </head>
    <body>
        <form action="/login" method="post">
            用戶名:<input type="text" name="username"/> <br />
            密碼:<input type="password" name="password"/> <br />
            <input type="submit" value="登錄"/>
        </form>
    </body>
    </html>
    
    • 一個簡單的登陸的表單,POST 提交登陸請求到 /login 地址上。

2.3.2 登陸請求

對於登陸請求,會被我們配置的 Shiro FormAuthenticationFilter 過濾器進行攔截,進行用戶的身份認證。整個過程如下:

  • FormAuthenticationFilter 解析請求的 usernamepassword 參數,創建 UsernamePasswordToken 對象。

  • 然後,調用 SecurityManager 的 #login(Subject subject, AuthenticationToken authenticationToken) 方法,執行登陸操作,進行“身份驗證”(認證)。

  • 在這內部中,調用 Realm 的 #getAuthenticationInfo(AuthenticationToken token) 方法,進行認證。此時,根據認證的是否成功,會有不同的處理:

    • 如果認證通過,則 FormAuthenticationFilter 會將請求重定向GET loginSuccess 地址上。

    • 【重要】如果認證失敗,則會將認證失敗的原因設置到請求的 attributes 中,後續該請求會繼續請求到 POST login 地址上。這樣,在 POST loginUrl 地址上,我們可以從 attributes 中獲取到失敗的原因,提示給用戶。

所以,POST loginUrl 的目的,實際是爲了處理認真失敗的情況。也因此,POST login 地址,實現代碼如下:

// SecurityController.java

@ResponseBody
@PostMapping("/login")
public String login(HttpServletRequest request) {
    // <1> 判斷是否已經登陸
    Subject subject = SecurityUtils.getSubject();
    if (subject.getPrincipal() != null) {
        return "你已經登陸賬號:" + subject.getPrincipal();
    }

    // <2> 獲得登陸失敗的原因
    String shiroLoginFailure = (String) request.getAttribute(FormAuthenticationFilter.DEFAULT_ERROR_KEY_ATTRIBUTE_NAME);
    // 翻譯成人類看的懂的提示
    String msg = "";
    if (UnknownAccountException.class.getName().equals(shiroLoginFailure)) {
        msg = "賬號不存在";
    } else if (IncorrectCredentialsException.class.getName().equals(shiroLoginFailure)) {
        msg = "密碼不正確";
    } else if (LockedAccountException.class.getName().equals(shiroLoginFailure)) {
        msg = "賬號被鎖定";
    } else if (ExpiredCredentialsException.class.getName().equals(shiroLoginFailure)) {
        msg = "賬號已過期";
    } else {
        msg = "未知";
        logger.error("[login][未知登陸錯誤:{}]", shiroLoginFailure);
    }
    return "登陸失敗,原因:" + msg;
}
  • <1> 處,對於已經登陸成功的用戶,如果我們再次請求 POST loginUrl 地址,依然會直接跳轉到該地址上。此處,我們是提供用戶已經的登陸。???? 可能有部分胖友會希望重新進行一次登陸的邏輯,那麼就需要重寫 FormAuthenticationFilter 過濾器。

  • <2> 處,從請求的 attributes 中,獲取 FormAuthenticationFilter.DEFAULT_ERROR_KEY_ATTRIBUTE_NAME 對應的值,即登陸失敗的原因。從代碼中,我們可以看出,失敗原因爲異常的全類名,我們需要進行翻譯成人類可讀的提示。

2.3.3 登陸成功

GET login_success 地址,登陸成功響應。代碼如下:

// SecurityController.java

@ResponseBody
@GetMapping("/login_success")
public String loginSuccess() {
    return "登陸成功";
}
  • 如果是 AJAX 請求的情況下,我們可以返回 JSON 字符串。例如說,用戶、角色、權限等等信息。

  • 如果非 AJAX 請求的情況下,重定向到登陸成功的頁面。例如說,管理後臺的 HOME 頁面。

2.3.4 未授權

GET unauthorized 地址,未授權響應。代碼如下:

// SecurityController.java

@ResponseBody
@GetMapping("/unauthorized")
public String unauthorized() {
    return "你沒有權限";
}
  • 如果是 AJAX 請求的情況下,我們可以返回 JSON 字符串。例如說,你沒有權限。

  • 如果非 AJAX 請求的情況下,重定向到登陸成功的頁面。例如說,未授權的頁面。

2.4 TestController

cn.iocoder.springboot.lab01.shirodemo.controller 包路徑下,創建 TestController 類,提供測試 API 接口。代碼如下:

// TestController.java

@RestController
@RequestMapping("/test")
public class TestController {

    @GetMapping("/demo")
    public String demo() {
        return "示例返回";
    }

    @GetMapping("/home")
    public String home() {
        return "我是首頁";
    }

    @GetMapping("/admin")
    public String admin() {
        return "我是管理員";
    }

    @GetMapping("/normal")
    public String normal() {
        return "我是普通用戶";
    }

}
  • 對於 /test/demo 接口,直接訪問,無需登陸。

  • 對於 /test/home 接口,無法直接訪問,需要進行登陸。

  • 對於 /test/admin 接口,需要登陸「admin/admin」用戶,因爲需要 ADMIN 角色。

  • 對於 /test/normal 接口,需要登陸「user/user」用戶,因爲需要 USER 角色。

胖友可以按照如上的說明,進行各種測試。例如說,登陸「user/user」用戶後,去訪問 /test/admin 接口,會返回無權限的提示~

2.5 Application

創建 Application.java 類,配置 @SpringBootApplication 註解即可。代碼如下:

// Application.java

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
    
}

至此,我們已經完成了 Shiro 的入門。美滋滋,胖友可以自己多多測試一下。

3. Shiro 註解

在 Shiro 中,提供瞭如下五個註解,可以直接添加在 SpringMVC 的 URL 對應的方法上,實現權限配置。下面,我們來分別看看。

3.1 @RequiresGuest

@RequiresGuest 註解,和 anon 等價。

3.2 @RequiresAuthentication

@RequiresAuthentication 註解,和 authc 等價。

3.3 @RequiresUser

@RequiresUser 註解,和 user 等價,要求必須登陸。

3.4 @RequiresRoles

@RequiresRoles 註解,和 roles 等價。代碼如下:

// RequiresRoles.java

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresRoles {

    /**
     * A single String role name or multiple comma-delimited role names required in order for the method
     * invocation to be allowed.
     */
    String[] value();
    
    /**
     * The logical operation for the permission check in case multiple roles are specified. AND is the default
     * @since 1.1.0
     * 當有多個角色時,AND 表示要擁有全部角色,OR 表示擁有任一角色即可
     */
    Logical logical() default Logical.AND; 
}

使用示例如下:

// 屬於 NORMAL 角色
@RequiresRoles("NORMAL")

// 要同時擁有 ADMIN 和 NORMAL 角色
@RequiresRoles({"ADMIN", "NORMAL"})

// 擁有 ADMIN 或 NORMAL 任一角色即可
@RequiresRoles(value = {"ADMIN", "NORMAL"}, logical = Logical.OR)

如果驗證權限不通過,則會拋出 AuthorizationException 異常。此時,我們可以基於 Spring MVC 提供的 @RestControllerAdvice + @ExceptionHandler 註解,實現全局異常的處理。不瞭解的胖友,可以看看《芋道 Spring Boot SpringMVC 入門》的「5. 全局異常處理」小節。

3.5 @RequiresPermissions

@RequiresPermissions 註解,和 perms 等價。代碼如下:

// RequiresPermissions.java

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresPermissions {

    /**
     * The permission string which will be passed to {@link org.apache.shiro.subject.Subject#isPermitted(String)}
     * to determine if the user is allowed to invoke the code protected by this annotation.
     */
    String[] value();
    
    /**
     * The logical operation for the permission checks in case multiple roles are specified. AND is the default
     * @since 1.1.0
     * 當有多個權限時,AND 表示要擁有全部權限,OR 表示擁有任一權限即可
     */
    Logical logical() default Logical.AND; 

}

使用示例如下:

// 擁有 user:add 權限
@RequiresPermissions("user:add")

// 要同時擁有 user:add 和 user:update 權限
@RequiresPermissions({"user:add", "user:update"})

// 擁有 user:add 和 user:update 任一權限即可
@RequiresPermissions(value = {"user:add", "user:update"}, logical = Logical.OR)

如果驗證權限不通過,則會拋出 AuthorizationException 異常。此時,我們可以基於 Spring MVC 提供的 @RestControllerAdvice + @ExceptionHandler 註解,實現全局異常的處理。不瞭解的胖友,可以看看《芋道 Spring Boot SpringMVC 入門》的「5. 全局異常處理」小節。

3.6 使用示例

在 lab-33-shiro-demo 示例的基礎上,我們進行修改,增加 Shiro 註解的使用。

cn.iocoder.springboot.lab01.shirodemo.controller 包路徑下,創建 DemoController 類,提供示例 API 接口。代碼如下:

// DemoController.java

@RestController
@RequestMapping("/demo")
public class DemoController {

    @RequiresGuest
    @GetMapping("/echo")
    public String demo() {
        return "示例返回";
    }

    @GetMapping("/home")
    public String home() {
        return "我是首頁";
    }

    @RequiresRoles("ADMIN")
    @GetMapping("/admin")
    public String admin() {
        return "我是管理員";
    }

    @RequiresRoles("NORMAL")
    @GetMapping("/normal")
    public String normal() {
        return "我是普通用戶";
    }

}
  • 每個 URL 的權限驗證,和「3.2.2 TestController」是一一對應的。

胖友可以按照如上的說明,進行各種測試。例如說,登陸「user/user」用戶後,去訪問 /demo/admin 接口,會返回無權限的提示~

4. 項目實戰

在開源項目翻了一圈,找到一個相對合適項目 renren-fast 。主要以下幾點原因:

  • 基於 Shiro 實現。

  • 基於 RBAC 權限模型,並且支持動態的權限配置。

  • 基於 OAuth2 授權認證。

  • 前後端分離。同時前端採用 Vue ,相對來說後端會 Vue 的比 React 的多。

考慮到方便自己添加註釋,艿艿 Fork 出一個倉庫, 地址是 https://github.com/YunaiV/renren-fast 。

下面,來跟着艿艿一起走讀下 renren-fast 的權限相關功能。

4.1 表結構

基於 RBAC 權限模型,一共有 5 個表。

對 RBAC 權限模型不瞭解的胖友,可以看看《到底什麼是RBAC權限模型?!》

???? 嘻嘻,艿艿的大學畢業設計,做的就是統一認證中心,2011 年的時候,前後端分離。前端採用 ExtJS 框架,後端自己參考 Spring Security 造的權限框架的輪子,提供 SDK 接入統一認證中心,使用 HTTP 通信。

實體說明
SysUserEntitysys_user用戶信息
SysRoleEntitysys_role用戶信息
SysUserRoleEntitysys_user_role用戶和角色關聯
SysMenuEntitysys_menu菜單權限
SysRoleMenuEntitysys_role_menu角色和菜單關聯

5 個表的關係比較簡單:

  • 一個 SysUse ,可以擁有多個 SysRole ,通過 SysUserRole 存儲關聯。

  • 一個 SysRole ,可以擁有多個 SysMenu ,通過 SysRoleMenu 存儲關聯。

4.1.1 SysUserEntity

SysUserEntity ,用戶實體類。代碼如下:

// SysUserEntity.java

@Data
@TableName("sys_user")
public class SysUserEntity implements Serializable {
    
    private static final long serialVersionUID = 1L;

    /** 用戶ID */
    @TableId
    private Long userId;

    @NotBlank(message = "用戶名不能爲空", groups = {AddGroup.class, UpdateGroup.class})
    private String username;

    @NotBlank(message = "密碼不能爲空", groups = AddGroup.class)
    private String password;

    /** 鹽  */
    private String salt;

    @NotBlank(message = "郵箱不能爲空", groups = {AddGroup.class, UpdateGroup.class})
    @Email(message = "郵箱格式不正確", groups = {AddGroup.class, UpdateGroup.class})
    private String email;

    /** 手機號 */
    private String mobile;

    /** 狀態  0:禁用   1:正常 */
    private Integer status;

    /** 創建者ID */
    private Long createUserId;

    /** 創建時間 */
    private Date createTime;

    /** 角色ID列表 */
    @TableField(exist = false)
    private List<Long> roleIdList;

}
  • 添加 @TableField(exist = false) 註解的字段,非存儲字段。後續的實體,補充重複贅述。

  • 每個字段比較簡單,胖友自己根據註釋理解下即可。

  • renren-fast 的 DAO 採用 MyBatis-Plus 訪問數據庫。感興趣的胖友,可以看看《芋道 Spring Boot MyBatis 入門》的「4. MyBatis-Plus」小節。

對應表的創建 SQL 如下:

-- 系統用戶
CREATE TABLE `sys_user` (
  `user_id` bigint NOT NULL AUTO_INCREMENT,
  `username` varchar(50) NOT NULL COMMENT '用戶名',
  `password` varchar(100) COMMENT '密碼',
  `salt` varchar(20) COMMENT '鹽',
  `email` varchar(100) COMMENT '郵箱',
  `mobile` varchar(100) COMMENT '手機號',
  `status` tinyint COMMENT '狀態  0:禁用   1:正常',
  `create_user_id` bigint(20) COMMENT '創建者ID',
  `create_time` datetime COMMENT '創建時間',
  PRIMARY KEY (`user_id`),
  UNIQUE INDEX (`username`)
) ENGINE=`InnoDB` DEFAULT CHARACTER SET utf8mb4 COMMENT='系統用戶';

4.1.2 SysRoleEntity

SysRole ,角色實體類。代碼如下:

// SysRoleEntity.java

@Data
@TableName("sys_role")
public class SysRoleEntity implements Serializable {
    
 private static final long serialVersionUID = 1L;

 /** 角色ID */
 @TableId
 private Long roleId;

 @NotBlank(message = "角色名稱不能爲空")
 private String roleName;

 /** 備註 */
 private String remark;

 /** 創建者ID  */
 private Long createUserId;

 /** 創建時間 */
 private Date createTime;

 @TableField(exist=false)
 private List<Long> menuIdList;
    
}
  • 每個字段比較簡單,胖友自己根據註釋理解下即可。

對應表的創建 SQL 如下:

CREATE TABLE `sys_role` (
  `role_id` bigint NOT NULL AUTO_INCREMENT,
  `role_name` varchar(100) COMMENT '角色名稱',
  `remark` varchar(100) COMMENT '備註',
  `create_user_id` bigint(20) COMMENT '創建者ID',
  `create_time` datetime COMMENT '創建時間',
  PRIMARY KEY (`role_id`)
) ENGINE=`InnoDB` DEFAULT CHARACTER SET utf8mb4 COMMENT='角色';

4.1.3 SysUserRoleEntity

SysUserRoleEntity ,用戶和角色關聯實體類。代碼如下:

// SysUserRoleEntity.java

public class SysUserRoleEntity {

    /** 用戶ID */
    private Long userId;

    /** 角色ID */
    private Long roleId;
    
    // ...省略 set/get 方法
    
}
  • 每個字段比較簡單,胖友自己根據註釋理解下即可。

對應表的創建 SQL 如下:

-- 用戶與角色對應關係
CREATE TABLE `sys_user_role` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `user_id` bigint COMMENT '用戶ID',
  `role_id` bigint COMMENT '角色ID',
  PRIMARY KEY (`id`)
) ENGINE=`InnoDB` DEFAULT CHARACTER SET utf8mb4 COMMENT='用戶與角色對應關係';

4.1.4 SysMenuEntity

SysMenuEntity ,菜單權限實體類。代碼如下:

// SysMenuEntity.java

@Data
@TableName("sys_menu")
public class SysMenuEntity implements Serializable {

    private static final long serialVersionUID = 1L;

    /** 菜單ID */
    @TableId
    private Long menuId;

    /** 父菜單ID,一級菜單爲0 */
    private Long parentId;

    /** 父菜單名稱 */
    @TableField(exist = false)
    private String parentName;

    /** 菜單名稱 */
    private String name;

    /** 菜單URL */
    private String url;

    /** 授權(多個用逗號分隔,如:user:list,user:create) */
    private String perms;

    /** 類型     0:目錄   1:菜單   2:按鈕 */
    private Integer type;

    /** 菜單圖標 */
    private String icon;

    /** 排序 */
    private Integer orderNum;

    /** ztree 屬性 */
    @TableField(exist = false)
    private Boolean open;

    @TableField(exist = false)
    private List<?> list;

}
  • ???? 個人感覺,這個實體改成 SysResourceEntity 資源,更加合適,菜單僅僅是其中的一種。

  • 每個字段比較簡單,胖友自己根據資源理解下即可。我們來重點看幾個字段。

  • type 屬性,定義了三種類型。其中,2 代表按鈕,是爲了做頁面中的功能級的權限。

  • perms 屬性,對應的權限標識字符串。一般格式爲 ${大模塊}:${小模塊}:{操作} 。示例如下:

    用戶查詢:system:user:query
    用戶新增:system:user:add
    用戶修改:system:user:edit
    用戶刪除:system:user:remove
    用戶導出:system:user:export
    用戶導入:system:user:import
    重置密碼:system:user:resetPwd
    
    • 對於前端來說,每個按鈕在展示時,可以判斷用戶是否有該按鈕的權限。如果沒有,則進行隱藏。當然,前端在首次進入系統的時候,會請求一次權限列表到本地進行緩存。

    • 對於後端來說,每個接口上會添加 Shiro @RequiresPermissions("system:user:query") 註解。在請求接口時,會校驗用戶是否有該 URL 對應的權限。如果沒有,則會拋出權限驗證失敗的異常。

    • 一個 perms 屬性,可以對應多個權限標識,使用逗號分隔。例如說:"system:user:query,system:user:add"

對應表的創建 SQL 如下:

-- 菜單
CREATE TABLE `sys_menu` (
  `menu_id` bigint NOT NULL AUTO_INCREMENT,
  `parent_id` bigint COMMENT '父菜單ID,一級菜單爲0',
  `name` varchar(50) COMMENT '菜單名稱',
  `url` varchar(200) COMMENT '菜單URL',
  `perms` varchar(500) COMMENT '授權(多個用逗號分隔,如:user:list,user:create)',
  `type` int COMMENT '類型   0:目錄   1:菜單   2:按鈕',
  `icon` varchar(50) COMMENT '菜單圖標',
  `order_num` int COMMENT '排序',
  PRIMARY KEY (`menu_id`)
) ENGINE=`InnoDB` DEFAULT CHARACTER SET utf8mb4 COMMENT='菜單管理';

4.1.5 SysRoleMenuEntity

SysRoleMenuEntity ,角色和菜單關聯實體類。代碼如下:

// SysRoleMenu.java

@Data
@TableName("sys_role_menu")
public class SysRoleMenuEntity implements Serializable {

 private static final long serialVersionUID = 1L;

 @TableId
 private Long id;

 /** 角色ID */
 private Long roleId;

 /** 菜單ID */
 private Long menuId;

}
  • 每個字段比較簡單,胖友自己根據註釋理解下即可。

對應表的創建 SQL 如下:

-- 角色與菜單對應關係
CREATE TABLE `sys_role_menu` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `role_id` bigint COMMENT '角色ID',
  `menu_id` bigint COMMENT '菜單ID',
  PRIMARY KEY (`id`)
) ENGINE=`InnoDB` DEFAULT CHARACTER SET utf8mb4 COMMENT='角色與菜單對應關係';

4.1.6 SysUserTokenEntity

SysUserTokenEntity ,用戶 Token 實體類。代碼如下:

// SysUserTokenEntity.java

@Data
@TableName("sys_user_token")
public class SysUserTokenEntity implements Serializable {

 private static final long serialVersionUID = 1L;

 //用戶ID
 @TableId(type = IdType.INPUT)
 private Long userId;
 //token
 private String token;
 //過期時間
 private Date expireTime;
 //更新時間
 private Date updateTime;

}
  • 每個字段比較簡單,胖友自己根據註釋理解下即可。

  • 用戶使用 usernamepassword 登陸成功後,會生成 SysUserTokenEntity 記錄到數據庫中。後續的請求,使用 SysUserTokenEntity.token 作爲身份標識。

對應表的創建 SQL 如下:

-- 系統用戶Token
CREATE TABLE `sys_user_token` (
  `user_id` bigint(20) NOT NULL,
  `token` varchar(100) NOT NULL COMMENT 'token',
  `expire_time` datetime DEFAULT NULL COMMENT '過期時間',
  `update_time` datetime DEFAULT NULL COMMENT '更新時間',
  PRIMARY KEY (`user_id`),
  UNIQUE KEY `token` (`token`)
) ENGINE=`InnoDB` DEFAULT CHARACTER SET utf8mb4 COMMENT='系統用戶Token';

4.2 ShiroConfig

在 ShiroConfig 配置類,實現 Shiro 的自定義配置。代碼如下:

// ShiroConfig.java

@Configuration
public class ShiroConfig {

    @Bean("securityManager")
    public SecurityManager securityManager(OAuth2Realm oAuth2Realm) { /**省略代碼**/ }

    @Bean("shiroFilter")
    public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) { /**省略代碼**/ }

    @Bean("lifecycleBeanPostProcessor")
    public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() { /**省略代碼**/ }

    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) { /**省略代碼**/ }

}
  • 一共有四個 Bean 的配置,我們逐個來看看。

4.2.1 Realm

在 renren-fast 中,自定義 AuthorizingRealm 的實現類 OAuth2Realm ,讀取我們自定義的數據庫表結構,提供認證和授權功能。

因爲 OAuth2Realm 的類上,已經添加了 @Component 註解,所以就不需要在 ShiroConfig 中進行 Bean 的配置。

關於 OAuth2Realm 的代碼詳細解析,我們見「4.4 OAuth2Filter」和「4.5 權限驗證」 。

4.2.2 SecurityManager

#securityManager() 方法,我們創建了 DefaultWebSecurityManager Bean 對象。代碼如下:

// ShiroConfig.java

@Bean("securityManager")
public SecurityManager securityManager(OAuth2Realm oAuth2Realm) {
    // 創建 DefaultWebSecurityManager 對象
    DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
    // 設置其使用的 Realm 爲 OAuth2Realm
    securityManager.setRealm(oAuth2Realm);
    // 無需使用記住密碼功能
    securityManager.setRememberMeManager(null);
    return securityManager;
}
  • 和「2.2.2 SecurityManager」基本一致。

4.2.3 ShiroFilter

#shiroFilterFactoryBean() 方法,我們創建了 ShiroFilterFactoryBean Bean 對象。代碼如下:

// ShiroConfig.java

@Bean("shiroFilter")
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
    // 創建 ShiroFilterFactoryBean 對象,用於創建 ShiroFilter 過濾器
    ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();

    // 設置 SecurityManager
    shiroFilter.setSecurityManager(securityManager);

    // <1> 創建 OAuth2Filter 過濾器,並設置名字爲 oauth2 。
    Map<String, Filter> filters = new HashMap<>();
    filters.put("oauth2", new OAuth2Filter());
    shiroFilter.setFilters(filters);

    // <2> ...

    Map<String, String> filterMap = new LinkedHashMap<>();
    filterMap.put("/webjars/**", "anon");
    filterMap.put("/druid/**", "anon");
    filterMap.put("/app/**", "anon");
    filterMap.put("/sys/login", "anon"); // <3> 登陸接口
    filterMap.put("/swagger/**", "anon");
    filterMap.put("/v2/api-docs", "anon");
    filterMap.put("/swagger-ui.html", "anon");
    filterMap.put("/swagger-resources/**", "anon");
    filterMap.put("/captcha.jpg", "anon");
    filterMap.put("/aaa.txt", "anon");
    filterMap.put("/**", "oauth2"); // <4> 默認剩餘的 URL ,需要經過認證
    shiroFilter.setFilterChainDefinitionMap(filterMap);

    return shiroFilter;
}
  • 和「2.2.3 ShiroFilter」略有差別,我們逐個來說說。

  • <1> 處,創建 OAuth2Filter 過濾器,並設置名字爲 "oauth2" 。該過濾器,用於對請求頭帶的 OAuth2 的 Token 進行認證。

  • <2> 處,我們無需設置各種 URL 。因爲在前後端分離之後,我們可以結合前端一起,實現自定義的登陸流程。當然,如果繼續使用 Shiro 定義的登陸流程,實際也是沒問題的。

  • <3> 處,設置登陸接口 /sys/login 允許匿名訪問,不然咱沒法實現登陸邏輯哈。詳細解析,見「4.3 登陸 API 接口」。

  • <4> 處,剩餘的 URL ,我們設置爲 oauth2 ,使用 OAuth2Filter 來基於請求頭帶的 OAuth2 的 Token 進行認證。如果認證不通過,則返回未認證的錯誤提示。詳細解析,見「4.4 OAuth2Filter」。

下面,我們詳細的來看看,各個配置的 Bean 的邏輯。

4.3 登陸 API 接口

SysLoginController#login(...)

在 SysLoginController 中,定義了 /login 接口,提供登陸功能。代碼如下:

// SysLoginController.java

@Autowired
private SysUserService sysUserService;
@Autowired
private SysUserTokenService sysUserTokenService;
@Autowired
private SysCaptchaService sysCaptchaService;

@PostMapping("/sys/login")
public Map<String, Object> login(@RequestBody SysLoginForm form) {
    // <1> 驗證圖片驗證碼的正確性
    boolean captcha = sysCaptchaService.validate(form.getUuid(), form.getCaptcha());
    if (!captcha) {
        return R.error("驗證碼不正確");
    }

    // <2> 獲得之地當用戶名的 SysUserEntity
    SysUserEntity user = sysUserService.queryByUserName(form.getUsername());
    if (user == null || !user.getPassword().equals(new Sha256Hash(form.getPassword(), user.getSalt()).toHex())) { // 賬號不存在、密碼錯誤
        return R.error("賬號或密碼不正確");
    }
    if (user.getStatus() == 0) { // 賬號鎖定
        return R.error("賬號已被鎖定,請聯繫管理員");
    }

    // <3> 生成 Token ,並返回結果
    return sysUserTokenService.createToken(user.getUserId());
}
  • <1> 處,驗證圖片驗證碼的正確性。該驗證碼會存儲在 MySQL 數據庫中,通過 uuid 作爲對應的標識。生成的邏輯,胖友自己看 SysLoginController 提供的 /captcha.jpg 接口。

  • <2> 處,調用 SysUserService 的 #queryByUserName(String username) 方法,獲得指定用戶名的 SysUserEntity ,然後進行校驗。詳細解析,見「4.3.1 加載用戶信息」。

  • <3> 處,調用 SysUserTokenService 的 #createToken(long userId) 方法,給認證通過的用戶,生成其對應的認證 Token 。這樣,該用戶的後續請求,就使用該 Token 作爲身份標識進行認證。

4.3.1 加載用戶信息

在 SysUserServiceImpl 中,實現 SysUserService 接口定義的 #queryByUserName(String username) 方法,獲得指定用戶名的 SysUserEntity 。代碼如下:

// SysUserServiceImpl.java

@Override
public SysUserEntity queryByUserName(String username) {
    // baseMapper 由 MyBatis-Plus 提供
 return baseMapper.queryByUserName(username);
}

// SysUserDao.XML
<select id="queryByUserName" resultType="io.renren.modules.sys.entity.SysUserEntity">
 select * from sys_user where username = #{username}
</select>
  • 通過查詢 sys_user 表,將 username 對應的 SysUser 查詢出來。

4.3.2 創建認證 Token

在 SysUserTokenServiceImpl 中,實現 SysUserTokenService 接口定義的 #createToken(LoginUser loginUser) 方法,給認證通過的用戶,生成其對應的認證 Token 。代碼如下:

// SysUserTokenServiceImpl.java

// 12小時後過期
private final static int EXPIRE = 3600 * 12;

@Override
public R createToken(long userId) {
    // <1> 生成一個 token
    String token = TokenGenerator.generateValue();

    // <2> 當前時間
    Date now = new Date();
    // <2> 過期時間
    Date expireTime = new Date(now.getTime() + EXPIRE * 1000);

    // <3> 判斷是否生成過 token
    SysUserTokenEntity tokenEntity = this.getById(userId);
    if (tokenEntity == null) { // 新增 SysUserTokenEntity
        tokenEntity = new SysUserTokenEntity();
        tokenEntity.setUserId(userId);
        tokenEntity.setToken(token);
        tokenEntity.setUpdateTime(now);
        tokenEntity.setExpireTime(expireTime);

        // 保存 token
        this.save(tokenEntity);
    } else { // 更新 SysUserTokenEntity
        tokenEntity.setToken(token);
        tokenEntity.setUpdateTime(now);
        tokenEntity.setExpireTime(expireTime);

        // 更新 token
        this.updateById(tokenEntity);
    }

    // <4> 返回 token 和過期時間
    return R.ok().put("token", token).put("expire", EXPIRE);
}
  • <1> 處,調用 TokenGenerator 的 #generateValue() 方法,生成一個 token 。其內部邏輯是生成 UUID 後,再進行一次 MD5 編碼。感興趣的胖友,自己去瞅瞅。

  • <2> 處,獲得當前時間,並計算 token 的過期時間爲 12 小時後。

  • <3> 處,根據該用戶是否已經有存在的 SysUserTokenEntity ,進行插入或更新。在 renren-fast 項目中,一個 SysUserEntity 有且僅有一個對應的 SysUserTokenEntity 。如果胖友希望用戶登陸後,老的 token 不要作廢,則這裏可以改成插入 SysUserTokenEntity 即可。

  • <4> 處,返回 token 和過期時間。

4.4 OAuth2Filter

在 OAuth2Filter 中,繼承 Shiro AuthenticatingFilter 過濾器,實現了基於 Token 的認證。代碼如下:

// OAuth2Filter.java

public class OAuth2Filter extends AuthenticatingFilter {

    @Override
    protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) { /**省略代碼**/ }

    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { /**省略代碼**/ }

    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { /**省略代碼**/ }

    @Override
    protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) { /**省略代碼**/ }

}
  • 通過繼承 Shiro AuthenticatingFilter 過濾器,可以簡化實現整個認證過程的代碼。FormAuthenticationFilter 和 BasicHttpAuthenticationFilter 就是繼承自 AuthenticatingFilter 。

下面,我們逐個看看 OAuth2Filter 的每一個方法的實現。

4.4.1 isAccessAllowed

#isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) 方法,判斷是否允許訪問。代碼如下:

// OAuth2Filter.java

@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
    return ((HttpServletRequest) request).getMethod().equals(RequestMethod.OPTIONS.name());
}
  • 在這裏,只允許 OPTIONS 類型的請求可以直接允許訪問。

  • 在返回 false 時,就可以進入「4.4.3 onAccessDenied」的流程,根據請求帶的 Token 進行認證。如果認證通過,說明可以訪問。

4.4.2 createToken

#createToken(ServletRequest request, ServletResponse response) 方法,創建認真使用的 AuthenticationToken 。代碼如下:

// OAuth2Filter.java

@Override
protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) {
    // <1> 獲取請求中的 token
    String token = getRequestToken((HttpServletRequest) request);
    // 如果不存在,則返回 null
    if (StringUtils.isBlank(token)) {
        return null;
    }

    // <2> 創建 OAuth2Token 對象
    return new OAuth2Token(token);
}
  • <1> 處,調用 #getRequestToken(HttpServletRequest httpRequest) 方法,獲得請求中的 token 。代碼如下:

    // OAuth2Filter.java
    
    private String getRequestToken(HttpServletRequest httpRequest) {
        // 優先,從 header 中獲取 token
        String token = httpRequest.getHeader("token");
    
        // 次之,如果 header 中不存在 token ,則從參數中獲取 token
        if (StringUtils.isBlank(token)) {
            token = httpRequest.getParameter("token");
        }
    
        return token;
    }
    
  • <2> 處,創建自定義的 OAuth2Token 。

4.4.3 onAccessDenied

#onAccessDenied(ServletRequest request, ServletResponse response) 方法,根據請求帶的 Token 進行認證。如果認證通過,說明可以訪問。代碼如下:

// OAuth2Filter.java
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
    // <1> 獲取請求中的 token 。如果 token 不存在,直接返回 401 ,認證不通過
    String token = getRequestToken((HttpServletRequest) request);
    if (StringUtils.isBlank(token)) {
        // 設置響應 Header
        HttpServletResponse httpResponse = (HttpServletResponse) response;
        httpResponse.setHeader("Access-Control-Allow-Credentials", "true");
        httpResponse.setHeader("Access-Control-Allow-Origin", HttpContextUtils.getOrigin());

        // 返回認證不通過
        String json = new Gson().toJson(R.error(HttpStatus.SC_UNAUTHORIZED, "invalid token"));
        httpResponse.getWriter().print(json);

        // 返回 false
        return false;
    }

    // <2> 執行登陸邏輯,實際執行的是基於 Token 進行認證。
    return executeLogin(request, response);
}
  • <1> 處,獲取請求中的 token 。如果 token 不存在,直接返回 401 ,認證不通過的 JSON 提示。

  • <2> 處,調用父類 AuthenticatingFilter 的 #executeLogin(request, response) 方法,執行登陸邏輯。實際上在方法內部,調用 OAuth2Realm 的 #doGetAuthenticationInfo(AuthenticationToken token) 方法,執行基於 Token 進行認證。代碼如下:

    // OAuth2Realm.java
    
    @Autowired
    private ShiroService shiroService;
        
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        String accessToken = (String) token.getPrincipal();
        // <1> 根據 accessToken ,查詢用戶信息
        SysUserTokenEntity tokenEntity = shiroService.queryByToken(accessToken);
        // token 失效
        if (tokenEntity == null || tokenEntity.getExpireTime().getTime() < System.currentTimeMillis()) {
            throw new IncorrectCredentialsException("token失效,請重新登錄");
        }
    
        // <2> 查詢用戶信息
        SysUserEntity user = shiroService.queryUser(tokenEntity.getUserId());
        // 賬號鎖定
        if (user.getStatus() == 0) {
            throw new LockedAccountException("賬號已被鎖定,請聯繫管理員");
        }
    
        // <3> 創建 SimpleAuthenticationInfo 對象
        return new SimpleAuthenticationInfo(user, accessToken, getName());
    }
    
    • <1> 處,調用 ShiroService 的 #queryByToken(String token) 方法,查詢 Token (在 OAuth2 中,Token 爲訪問令牌 accessToken )對應的 SysUserTokenEntity 。如果不存在或者已過期,拋出 IncorrectCredentialsException 異常。代碼如下:

      // ShiroServiceImpl.java
      @Autowired
      private SysUserTokenDao sysUserTokenDao;
      
      @Override
      public SysUserTokenEntity queryByToken(String token) {
          return sysUserTokenDao.queryByToken(token);
      }
      
      // SysUserTokenDao.XML
      <select id="queryByToken" resultType="io.renren.modules.sys.entity.SysUserTokenEntity">
       select * from sys_user_token where token = #{value}
      </select>
      
    • <2> 處,調用 ShiroService 的 #queryUser(Long userId)  方法,查詢用戶編號對應的 SysUserEntity 。如果已禁用,拋出 LockedAccountException 異常。代碼如下:

      // ShiroServiceImpl.java
      @Autowired
      private SysUserDao sysUserDao;
          
      @Override
      public SysUserEntity queryUser(Long userId) {
          return sysUserDao.selectById(userId);
      }
      
    • <3> 處,創建 Shiro SimpleAuthenticationInfo 對象,爲當前用戶的認證信息。

至此,我們完成了基於 Token 進行認證的代碼,胖友可以自己在理一理,順一瞬。

4.4.4 onLoginFailure

#onLoginFailure(ServletRequest request, ServletResponse response) 方法,處理「4.4.3 onAccessDenied」 中,認證失敗的時候,返回 401 ,認證不通過的 JSON 提示。代碼如下:

// OAuth2Filter.java

@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
    // 設置響應 Header
    HttpServletResponse httpResponse = (HttpServletResponse) response;
    httpResponse.setContentType("application/json;charset=utf-8");
    httpResponse.setHeader("Access-Control-Allow-Credentials", "true");
    httpResponse.setHeader("Access-Control-Allow-Origin", HttpContextUtils.getOrigin());
    try {
        // 處理登錄失敗的異常
        Throwable throwable = e.getCause() == null ? e : e.getCause();
        R r = R.error(HttpStatus.SC_UNAUTHORIZED, throwable.getMessage());

        // 返回認證不通過
        String json = new Gson().toJson(r);
        httpResponse.getWriter().print(json);
    } catch (IOException ignored) {
    }

    // 返回 false
    return false;
}
  • 代碼實現上,和「4.4.3 onAccessDenied」的請求 Token 不存在時的邏輯是一樣的。

4.5 權限驗證

在 renren-fast 中,使用「3. Shiro 註解」,實現每個 URL 的自定義權限。例如:

// SysConfigController.java

@GetMapping("/list")
@RequiresPermissions("sys:config:list")
public R list(@RequestParam Map<String, Object> params) { /**省略代碼**/ }

因爲要驗證權限,所以會調用到 OAuth2Realm 的 #doGetAuthorizationInfo(PrincipalCollection principals) 方法,進行鑑權,獲得用戶擁有的權限。代碼如下:

// OAuth2Realm.java

@Autowired
private ShiroService shiroService;
    
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
    // <1> 獲得 SysUserEntity 對象
    SysUserEntity user = (SysUserEntity) principals.getPrimaryPrincipal();
    Long userId = user.getUserId();

    // <2> 用戶權限列表
    Set<String> permsSet = shiroService.getUserPermissions(userId);

    // <3> 創建 SimpleAuthorizationInfo 對象
    SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
    info.setStringPermissions(permsSet);
    return info;
}
  • <1> 處,獲得 SysUserEntity 對象。該對象就是我們在 OAuth2Realm 的 #doGetAuthenticationInfo(AuthenticationToken token) 方法中,所認證獲得的。

  • <2> 處,調用 ShiroService 的 getUserPermissions(long userId) 方法,獲得該用戶擁有的權限數組。代碼如下:

    // OAuth2Realm.java
    
    @Autowired
    private SysMenuDao sysMenuDao;
    @Autowired
    private SysUserDao sysUserDao;
    
    @Override
    public Set<String> getUserPermissions(long userId) {
        List<String> permsList;
        // <1.1> 系統管理員,擁有最高權限
        if (userId == Constant.SUPER_ADMIN) {
            // 如果是管理員,則查詢所有 SysMenuEntity 數組
            List<SysMenuEntity> menuList = sysMenuDao.selectList(null);
            permsList = new ArrayList<>(menuList.size());
            for (SysMenuEntity menu : menuList) {
                permsList.add(menu.getPerms());
            }
        // <1.2>
        } else {
            // 如果是普通用戶,則查詢其擁有的 SysMenuEntity 數組
            permsList = sysUserDao.queryAllPerms(userId);
        }
        // <2> 用戶權限列表
        Set<String> permsSet = new HashSet<>();
        for (String perms : permsList) {
            if (StringUtils.isBlank(perms)) {
                continue;
            }
            // 使用逗號分隔,每一個 perms
            permsSet.addAll(Arrays.asList(perms.trim().split(",")));
        }
        return permsSet;
    }
    
    • 通過 sys_user_role 連接 sys_role_menusys_menu 表,實現查詢用戶的所有權限。

    • <1.1> 處,如果是管理員( id == Constant.SUPER_ADMIN == 1 )時,調用 SysMenuDao 的 #selectList(Wrapper<T> queryWrapper) 方法,查詢所有 SysMenuEntity 數組,從而實現管理員擁有全部權限。

    • <1.2> 處,如果是普通用戶,調用 SysMenuDao 的 #queryAllPerms(Long userId) 方法,查詢該用戶擁有的 SysMenuEntity 數組。代碼如下:

      // SysMenuDao.java
      
      <select id="queryAllPerms" resultType="string">
       select m.perms from sys_user_role ur 
        LEFT JOIN sys_role_menu rm on ur.role_id = rm.role_id 
        LEFT JOIN sys_menu m on rm.menu_id = m.menu_id 
       where ur.user_id = #{userId}
      </select>
      
    • <2> 處,返回用戶權限列表。因爲一個 SysMenuEntity.perms 可能對應多個權限,使用逗號分隔,所以這裏需要做處理。

  • <3> 處,創建 Shiro SimpleAuthorizationInfo 對象,爲當前用戶的授權信息。

  • 另外,如果胖友想要使用 Shiro 的 @RequiresRoles 註解,需要讀取用戶擁有的角色。因爲 renren-fast 目前暫時未使用該註解,所以並沒有實現該邏輯。

4.6 獲得權限 API 接口

在 SysMenuController 中,定義了 /sys/menu/nav 接口,獲得當前登陸用戶的菜單權限。代碼如下:

// SysMenuController.java

@Autowired
private SysMenuService sysMenuService;
@Autowired
private ShiroService shiroService;

/**
 * 導航菜單
 */
@GetMapping("/nav")
public R nav() {
    // <1> 獲得用戶的菜單數組
    List<SysMenuEntity> menuList = sysMenuService.getUserMenuList(getUserId());
    // <2> 獲得用戶的權限集合
    Set<String> permissions = shiroService.getUserPermissions(getUserId());
    // <3> 返回
    return R.ok().put("menuList", menuList)
            .put("permissions", permissions);
}
  • <1> 處,調用 SysMenuService 的 #getUserMenuList(Long userId) 方法,獲得用戶的菜單數組。代碼如下:

    // SysMenuServiceImpl.java
    
    @Autowired
    private SysUserService sysUserService;
        
    @Override
    public List<SysMenuEntity> getUserMenuList(Long userId) {
        // 系統管理員,擁有最高權限
        if (userId == Constant.SUPER_ADMIN) {
            return getAllMenuList(null);
        }
    
        // 用戶菜單列表
        List<Long> menuIdList = sysUserService.queryAllMenuId(userId);
        return getAllMenuList(menuIdList);
    }
    
    /**
     * 獲取所有菜單列表
     */
    private List<SysMenuEntity> getAllMenuList(List<Long> menuIdList) {
        // 查詢根菜單列表
        List<SysMenuEntity> menuList = queryListParentId(0L, menuIdList);
        // 遞歸獲取子菜單
        getMenuTreeList(menuList, menuIdList);
        return menuList;
    }
    
    /**
     * 遞歸
     */
    private List<SysMenuEntity> getMenuTreeList(List<SysMenuEntity> menuList, List<Long> menuIdList) {
        List<SysMenuEntity> subMenuList = new ArrayList<SysMenuEntity>();
        for (SysMenuEntity entity : menuList) {
            // 目錄
            if (entity.getType() == Constant.MenuType.CATALOG.getValue()) {
                entity.setList(getMenuTreeList(queryListParentId(entity.getMenuId(), menuIdList), menuIdList)); // <X>
            }
            subMenuList.add(entity);
        }
        return subMenuList;
    }
    
    • 這塊代碼寫的比較糟糕,在 <X> 處存在遞歸查詢,在菜單量大的時候,會導致性能較差。可以考慮將用戶擁有的菜單一次性查詢出來,然後在內存中拼接樹形結構。

  • <2> 處,調用 ShiroService 的 getUserPermissions(long userId) 方法,獲得該用戶擁有的權限數組。

  • <3> 處,返回用戶擁有的菜單和權限。

4.7 退出 API 接口

在 SysLoginController 中,定義了 /logout 接口,提供退出功能。代碼如下:

// SysLoginController.java

@Autowired
private SysUserTokenService sysUserTokenService;

@PostMapping("/sys/logout")
public R logout() {
    sysUserTokenService.logout(getUserId());
    return R.ok();
}
  • 調用 SysUserTokenServiceImpl 的 #logout(long userId) 方法,實現用戶的退出。代碼如下:

    // SysUserTokenServiceImpl.java
    
    @Override
    public void logout(long userId) {
        // 生成一個token
        String token = TokenGenerator.generateValue();
    
        // 修改token
        SysUserTokenEntity tokenEntity = new SysUserTokenEntity();
        tokenEntity.setUserId(userId);
        tokenEntity.setToken(token);
        this.updateById(tokenEntity);
    }
    
    • 通過創建一個新的 token 值,修改該用戶的 SysUserTokenEntity ,從而使用戶當前的 Token 失效。

    • ???? 有點尷尬的實現~胖友可以給 SysUserTokenEntity 增加一個標記刪除的字段,或者修改過期時間。

4.8 權限管理

如下的 Controller ,提供了 renren-fast 的權限管理功能,比較簡單,胖友自己去瞅瞅即可。

  • 用戶管理 SysUserController :用戶是系統操作者,該功能主要完成系統用戶配置。

  • 角色管理 SysRoleController :角色菜單權限分配、設置角色按機構進行數據範圍權限劃分。

  • 菜單管理 SysMenuController :配置系統菜單,操作權限,按鈕權限標識等。

4.9 小小的建議

至此,我們完成了對 renren-fast 權限相關功能的源碼進行解讀,希望對胖友有一定的胖友。如果胖友項目中需要權限相關的功能,建議不要直接拷貝 renren-fast 的代碼,而是按照自己的理解,一點點“重新”實現一遍。在這個過程中,我們會有更加深刻的理解,甚至會有自己的一些小創新。

另外,RuoYi 也是一個基於 Shiro 實現權限管理的開源項目。胖友也可以去借鑑學習下。a

666. 彩蛋

相對還是比較良心的一篇文章,胖友你說是不是,嘿嘿。

這裏額外在推薦一些 Shiro 不錯的內容:

  • 《Shiro 實現原理與源碼解析系統 —— 精品合集》

  • 《如何設計權限管理模塊(附表結構)?》

  • 《Spring Boot + Vue + Shiro 實現前後端分離、權限控制》

  • 《學習如何使用 Shiro,從架構談起,到框架集成!》

  • 《SpringBoot + Shiro + Redis 共享 Session 實例》

  • 《SpringBoot 整合 Shiro 實現動態權限加載更新+ Session 共享 + 單點登錄》

不過艿艿實際項目中,並未採用 Spring Security 或是 Shiro 安全框架,而是自己團隊開發了一個相對輕量級的組件。主要考慮,目前前後端分離之後,Shiro 內置的很多功能,已經不太需要,在加上拓展一些功能不是非常方便,有點“曲折”,所以才選擇自己開發。



歡迎加入我的知識星球,一起探討架構,交流源碼。加入方式,長按下方二維碼噢

已在知識星球更新源碼解析如下:

最近更新《芋道 SpringBoot 2.X 入門》系列,已經 20 餘篇,覆蓋了 MyBatis、Redis、MongoDB、ES、分庫分表、讀寫分離、SpringMVC、Webflux、權限、WebSocket、Dubbo、RabbitMQ、RocketMQ、Kafka、性能測試等等內容。

提供近 3W 行代碼的 SpringBoot 示例,以及超 4W 行代碼的電商微服務項目。

獲取方式:點“在看”,關注公衆號並回復 666 領取,更多內容陸續奉上。

兄弟,一口,點個????

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章