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

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

管她前浪,還是後浪?

能浪的浪,纔是好浪!

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

源碼精品專欄

 

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

  • 1. 概述

  • 2. 快速入門

  • 3. 進階使用

  • 4. 整合 Spring Session

  • 5. 整合 OAuth2

  • 6. 整合 JWT

  • 7. 項目實戰

  • 666. 彩蛋


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

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

1. 概述

基本上,在所有的開發的系統中,都必須做認證(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 兩個安全框架,可以完成認證和授權的功能。本文,我們先來學習下 Spring Security 。其官方對自己介紹如下:

FROM 《Spring Security 官網》

Spring Security is a powerful and highly customizable authentication and access-control framework. It is the de-facto standard for securing Spring-based applications.
Spring Security 是一個功能強大且高度可定製的身份驗證和訪問控制框架。它是用於保護基於 Spring 的應用程序。

Spring Security is a framework that focuses on providing both authentication and authorization to Java applications. Like all Spring projects, the real power of Spring Security is found in how easily it can be extended to meet custom requirements
Spring Security 是一個框架,側重於爲 Java 應用程序提供身份驗證和授權。與所有 Spring 項目一樣,Spring 安全性的真正強大之處,在於它很容易擴展以滿足定製需求。

2. 快速入門

示例代碼對應倉庫:lab-01-springsecurity-demo 。

在本小節中,我們來快速入門下 Spring Security ,實現訪問 API 接口時,需要首先進行登陸,才能進行訪問。

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-01-springsecurity-demo</artifactId>

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

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

</project>

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

2.2 Application

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

// Application.java

@SpringBootApplication
public class Application {

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

2.3 配置文件

application.yml 中,添加 Spring Security 配置,如下:

spring:
  # Spring Security 配置項,對應 SecurityProperties 配置類
  security:
    # 配置默認的 InMemoryUserDetailsManager 的用戶賬號與密碼。
    user:
      name: user # 賬號
      password: user # 密碼
      roles: ADMIN # 擁有角色
  • spring.security 配置項,設置 Spring Security 的配置,對應 SecurityProperties 配置類。

  • 默認情況下,Spring Boot UserDetailsServiceAutoConfiguration 自動化配置類,會創建一個內存級別的 InMemoryUserDetailsManager Bean 對象,提供認證的用戶信息。

    • 這裏,我們添加了 spring.security.user 配置項,UserDetailsServiceAutoConfiguration 會基於配置的信息創建一個用戶 User 在內存中。

    • 如果,我們未添加 spring.security.user 配置項,UserDetailsServiceAutoConfiguration 會自動創建一個用戶名爲 "user" ,密碼爲 UUID 隨機的用戶 User 在內存中。

2.4 AdminController

cn.iocoder.springboot.lab01.springsecurity.controller 包路徑下,創建 AdminController 類,提供管理員 API 接口。代碼如下:

// AdminController.java

@RestController
@RequestMapping("/admin")
public class AdminController {

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

}
  • 這裏,我們先提供一個 "/admin/demo" 接口,用於測試未登陸時,會被攔截到登陸界面。

2.5 簡單測試

執行 Application#main(String[] args) 方法,運行項目。

項目啓動成功後,瀏覽器訪問 http://127.0.0.1:8080/admin/demo 接口。因爲未登陸,所以被 Spring Security 攔截到登陸界面。如下圖所示:

因爲我們沒有自定義登陸界面,所以默認會使用 DefaultLoginPageGeneratingFilter 類,生成上述界面。

輸入我們在「2.3 配置文件」中配置的「user/user」賬號,進行登陸。登陸完成後,因爲 Spring Security 會記錄被攔截的訪問地址,所以瀏覽器自動動跳轉 http://127.0.0.1:8080/admin/demo 接口。訪問結果如下圖所示:

3. 進階使用

示例代碼對應倉庫:lab-01-springsecurity-demo-role 。

在「2. 快速入門」中,我們很快速的完成了 Spring Security 的入門。本小節,我們將會自定義 Spring Security 的配置,實現權限控制

考慮到不污染上述的示例,我們新建一個 lab-01-springsecurity-demo-role 項目。

3.1 引入依賴

和 「2.1 引入依賴」 一致,見 pom.xml 文件。

3.2 示例一

示例一中,我們會看看如何自定義 Spring Security 的配置,實現權限控制

3.2.1 SecurityConfig

cn.iocoder.springboot.lab01.springsecurity.config 包下,創建 SecurityConfig 配置類,繼承 WebSecurityConfigurerAdapter 抽象類,實現 Spring Security 在 Web 場景下的自定義配置。代碼如下:

// SecurityConfig.java

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    // ...

}
  • 我們可以通過重寫 WebSecurityConfigurerAdapter 的方法,實現自定義的 Spring Security 的配置。

首先,我們重寫 #configure(AuthenticationManagerBuilder auth) 方法,實現 AuthenticationManager 認證管理器。代碼如下:

// SecurityConfig.java

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.
            // <X> 使用內存中的 InMemoryUserDetailsManager
            inMemoryAuthentication()
            // <Y> 不使用 PasswordEncoder 密碼編碼器
            .passwordEncoder(NoOpPasswordEncoder.getInstance())
            // <Z> 配置 admin 用戶
            .withUser("admin").password("admin").roles("ADMIN")
            // <Z> 配置 normal 用戶
            .and().withUser("normal").password("normal").roles("NORMAL");
}
  • <X> 處,調用 AuthenticationManagerBuilder#inMemoryAuthentication() 方法,使用內存級別的 InMemoryUserDetailsManager Bean 對象,提供認證的用戶信息。

    • InMemoryUserDetailsManager,和「2. 快速入門」是一樣的。

    • JdbcUserDetailsManager ,基於 JDBC的 JdbcUserDetailsManager 。

    • Spring 內置了兩種 UserDetailsManager 實現:

    • 實際項目中,我們更多采用調用 AuthenticationManagerBuilder#userDetailsService(userDetailsService) 方法,使用自定義實現的 UserDetailsService 實現類,更加靈活自由的實現認證的用戶信息的讀取。

  • <Y> 處,調用 AbstractDaoAuthenticationConfigurer#passwordEncoder(passwordEncoder) 方法,設置 PasswordEncoder 密碼編碼器。

    • 在這裏,爲了方便,我們使用 NoOpPasswordEncoder 。實際上,等於不使用 PasswordEncoder ,不配置的話會報錯。

    • 生產環境下,推薦使用 BCryptPasswordEncoder 。更多關於 PasswordEncoder 的內容,推薦閱讀《該如何設計你的 PasswordEncoder?》文章。

  • <Z> 處,配置了「admin/admin」和「normal/normal」兩個用戶,分別對應 ADMIN 和 NORMAL 角色。相比「2. 快速入門」來說,可以配置更多的用戶。

然後,我們重寫 #configure(HttpSecurity http) 方法,主要配置 URL 的權限控制。代碼如下:

// SecurityConfig.java

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
            // <X> 配置請求地址的權限
            .authorizeRequests()
                .antMatchers("/test/echo").permitAll() // 所有用戶可訪問
                .antMatchers("/test/admin").hasRole("ADMIN") // 需要 ADMIN 角色
                .antMatchers("/test/normal").access("hasRole('ROLE_NORMAL')") // 需要 NORMAL 角色。
                // 任何請求,訪問的用戶都需要經過認證
                .anyRequest().authenticated()
            .and()
            // <Y> 設置 Form 表單登陸
            .formLogin()
//                    .loginPage("/login") // 登陸 URL 地址
                .permitAll() // 所有用戶可訪問
            .and()
            // 配置退出相關
            .logout()
//                    .logoutUrl("/logout") // 退出 URL 地址
                .permitAll(); // 所有用戶可訪問
}
  • <X> 處,調用 HttpSecurity#authorizeRequests() 方法,開始配置 URL 的權限控制。注意看艿艿配置的四個權限控制的配置。下面,是配置權限控制會使用到的方法:

    • #(String... antPatterns) 方法,配置匹配的 URL 地址,基於 Ant 風格路徑表達式 ,可傳入多個。

    • 【常用】#permitAll() 方法,所有用戶可訪問。

    • 【常用】#denyAll() 方法,所有用戶不可訪問。

    • 【常用】#authenticated() 方法,登陸用戶可訪問。

    • #anonymous() 方法,無需登陸,即匿名用戶可訪問。

    • #rememberMe() 方法,通過 remember me 登陸的用戶可訪問。

    • #fullyAuthenticated() 方法,非 remember me 登陸的用戶可訪問。

    • #hasIpAddress(String ipaddressExpression) 方法,來自指定 IP 表達式的用戶可訪問。

    • 【常用】#hasRole(String role) 方法, 擁有指定角色的用戶可訪問。

    • 【常用】#hasAnyRole(String... roles) 方法,擁有指定任一角色的用戶可訪問。

    • 【常用】#hasAuthority(String authority) 方法,擁有指定權限(authority)的用戶可訪問。

    • 【常用】#hasAuthority(String... authorities) 方法,擁有指定任一權限(authority)的用戶可訪問。

    • 【最牛】#access(String attribute) 方法,當 Spring EL 表達式的執行結果爲 true 時,可以訪問。

  • <Y> 處,調用 HttpSecurity#formLogin() 方法,設置 Form 表單登陸

    • 如果胖友想要自定義登陸頁面,可以通過 #loginPage(String loginPage) 方法,來進行設置。不過這裏我們希望像「2. 快速入門」一樣,使用默認的登陸界面,所以不進行設置。

  • <Z> 處,調用 HttpSecurity#logout() 方法,配置退出相關。

    • 如果胖友想要自定義退出頁面,可以通過 #logoutUrl(String logoutUrl) 方法,來進行設置。不過這裏我們希望像「2. 快速入門」一樣,使用默認的退出界面,所以不進行設置。

3.2.2 TestController

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

// TestController.java

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

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

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

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

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

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

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

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

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

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

3.3 示例二

示例二中,我們會看看如何使用 Spring Security 的註解,實現權限控制。

3.3.1 SecurityConfig

修改 SecurityConfig 配置類,增加 @EnableGlobalMethodSecurity 註解,開啓對 Spring Security 註解的方法,進行權限驗證。代碼如下:

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter 

3.3.2 DemoController

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

// DemoController.java

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

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

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

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

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

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

  • @PermitAll 註解,等價於 #permitAll() 方法,所有用戶可訪問。

    重要!!!因爲在「3.2.1 SecurityConfig」中,配置了 .anyRequest().authenticated() ,任何請求,訪問的用戶都需要經過認證。所以這裏 @PermitAll 註解實際是不生效的

    也就是說,Java Config 配置的權限,和註解配置的權限,兩者是疊加的。

  • @PreAuthorize 註解,等價於 #access(String attribute) 方法,,當 Spring EL 表達式的執行結果爲 true 時,可以訪問。

Spring Security 還有其它註解,不過不太常用,可見《區別:@Secured(), @PreAuthorize() 及 @RolesAllowed()》文章。

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

4. 整合 Spring Session

參見《芋道 Spring Boot 分佈式 Session 入門》文章的「5. 整合 Spring Security」小節。

5. 整合 OAuth2

參見《芋道 Spring Security OAuth2 入門》文章,詳細到爆炸。

6. 整合 JWT

參見《前後端分離 SpringBoot + SpringSecurity + JWT + RBAC 實現用戶無狀態請求驗證》文章,寫的很不錯。

7. 項目實戰

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

  • 基於 Spring Security 實現。

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

  • 基於 Redis 服務,實現登陸用戶的信息緩存。

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

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

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

7.1 表結構

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

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

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

實體說明
SysUsersys_user用戶信息
SysRolesys_role用戶信息
SysUserRolesys_user_role用戶和角色關聯
SysMenusys_menu菜單權限
SysRoleMenusys_role_menu角色和菜單關聯

5 個表的關係比較簡單:

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

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

7.1.1 SysUser

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

// SysUser.java

public class SysUser extends BaseEntity {
   
    private static final long serialVersionUID = 1L;

    @Excel(name = "用戶序號", cellType = ColumnType.NUMERIC, prompt = "用戶編號")
    private Long userId;

    @Excel(name = "部門編號", type = Type.IMPORT)
    private Long deptId;

    @Excel(name = "登錄名稱")
    private String userName;

    @Excel(name = "用戶名稱")
    private String nickName;

    @Excel(name = "用戶郵箱")
    private String email;
    
    @Excel(name = "手機號碼")
    private String phonenumber;

    @Excel(name = "用戶性別", readConverterExp = "0=男,1=女,2=未知")
    private String sex;

    /** 用戶頭像 */
    private String avatar;

    /** 密碼 */
    private String password;

    /** 鹽加密 */
    private String salt;

    @Excel(name = "帳號狀態", readConverterExp = "0=正常,1=停用")
    private String status;

    /** 刪除標誌(0代表存在 2代表刪除) */
    private String delFlag;

    @Excel(name = "最後登陸IP", type = Type.EXPORT)
    private String loginIp;

    @Excel(name = "最後登陸時間", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss", type = Type.EXPORT)
    private Date loginDate;

    @Excels({
            @Excel(name = "部門名稱", targetAttr = "deptName", type = Type.EXPORT),
            @Excel(name = "部門負責人", targetAttr = "leader", type = Type.EXPORT)
    })
    @Transient
    private SysDept dept;

    /** 角色對象 */
    @Transient
    private List<SysRole> roles;

    /** 角色組 */
    @Transient
    private Long[] roleIds;

    /** 崗位組 */
    @Transient
    private Long[] postIds;
    
    // ...省略 set/get 方法
    
}
  • 添加 @Transient 註解的字段,非存儲字段。後續的實體,補充重複贅述。

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

對應表的創建 SQL 如下:

create table sys_user (
  user_id           bigint(20)      not null auto_increment    comment '用戶ID',
  dept_id           bigint(20)      default null               comment '部門ID',
  user_name         varchar(30)     not null                   comment '用戶賬號',
  nick_name         varchar(30)     not null                   comment '用戶暱稱',
  user_type         varchar(2)      default '00'               comment '用戶類型(00系統用戶)',
  email             varchar(50)     default ''                 comment '用戶郵箱',
  phonenumber       varchar(11)     default ''                 comment '手機號碼',
  sex               char(1)         default '0'                comment '用戶性別(0男 1女 2未知)',
  avatar            varchar(100)    default ''                 comment '頭像地址',
  password          varchar(100)    default ''                 comment '密碼',
  status            char(1)         default '0'                comment '帳號狀態(0正常 1停用)',
  del_flag          char(1)         default '0'                comment '刪除標誌(0代表存在 2代表刪除)',
  login_ip          varchar(50)     default ''                 comment '最後登陸IP',
  login_date        datetime                                   comment '最後登陸時間',
  create_by         varchar(64)     default ''                 comment '創建者',
  create_time       datetime                                   comment '創建時間',
  update_by         varchar(64)     default ''                 comment '更新者',
  update_time       datetime                                   comment '更新時間',
  remark            varchar(500)    default null               comment '備註',
  primary key (user_id)
) engine=innodb auto_increment=100 comment = '用戶信息表';

7.1.2 SysRole

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

// SysRole.java

public class SysRole extends BaseEntity {

    private static final long serialVersionUID = 1L;

    @Excel(name = "角色序號", cellType = ColumnType.NUMERIC)
    private Long roleId;
    
    @Excel(name = "角色名稱")
    private String roleName;

    @Excel(name = "角色權限")
    private String roleKey;

    @Excel(name = "角色排序")
    private String roleSort;

    @Excel(name = "數據範圍", readConverterExp = "1=所有數據權限,2=自定義數據權限,3=本部門數據權限,4=本部門及以下數據權限")
    private String dataScope;

    @Excel(name = "角色狀態", readConverterExp = "0=正常,1=停用")
    private String status;

    /** 刪除標誌(0代表存在 2代表刪除) */
    private String delFlag;

    /** 用戶是否存在此角色標識 默認不存在 */
    @Transient
    private boolean flag = false;

    /** 菜單組 */
    @Transient
    private Long[] menuIds;

    /** 部門組(數據權限) */
    @Transient
    private Long[] deptIds;
    
    // ...省略 set/get 方法
    
}
  • 每個字段比較簡單,胖友自己根據註釋理解下即可。

對應表的創建 SQL 如下:

create table sys_role (
  role_id           bigint(20)      not null auto_increment    comment '角色ID',
  role_name         varchar(30)     not null                   comment '角色名稱',
  role_key          varchar(100)    not null                   comment '角色權限字符串',
  role_sort         int(4)          not null                   comment '顯示順序',
  data_scope        char(1)         default '1'                comment '數據範圍(1:全部數據權限 2:自定數據權限 3:本部門數據權限 4:本部門及以下數據權限)',
  status            char(1)         not null                   comment '角色狀態(0正常 1停用)',
  del_flag          char(1)         default '0'                comment '刪除標誌(0代表存在 2代表刪除)',
  create_by         varchar(64)     default ''                 comment '創建者',
  create_time       datetime                                   comment '創建時間',
  update_by         varchar(64)     default ''                 comment '更新者',
  update_time       datetime                                   comment '更新時間',
  remark            varchar(500)    default null               comment '備註',
  primary key (role_id)
) engine=innodb auto_increment=100 comment = '角色信息表';

7.1.3 SysUserRole

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

// SysUserRole.java

public class SysUserRole {

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

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

  • roleKey 屬性,對應的角色標識字符串,可以對應多個角色標識,使用逗號分隔。例如說:"admin,normal"

對應表的創建 SQL 如下:

create table sys_user_role (
  user_id   bigint(20) not null comment '用戶ID',
  role_id   bigint(20) not null comment '角色ID',
  primary key(user_id, role_id)
) engine=innodb comment = '用戶和角色關聯表';

7.1.4 SysMenu

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

// SysMenu.java

public class SysMenu extends BaseEntity {

    private static final long serialVersionUID = 1L;

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

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

    /** 父菜單名稱 */
    private String parentName;

    /** 父菜單ID */
    private Long parentId;

    /** 顯示順序 */
    private String orderNum;

    /** 路由地址 */
    private String path;

    /** 組件路徑 */
    private String component;

    /** 是否爲外鏈(0是 1否) */
    private String isFrame;

    /** 類型(M目錄 C菜單 F按鈕) */
    private String menuType;

    /** 菜單狀態:0顯示,1隱藏 */
    private String visible;

    /** 權限字符串 */
    private String perms;

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

    /** 子菜單 */
    @Transient
    private List<SysMenu> children = new ArrayList<SysMenu>();
    
    // ...省略 set/get 方法
    
}
  • ???? 個人感覺,這個實體改成 SysResource 資源,更加合適,菜單僅僅是其中的一種。

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

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

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

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

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

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

對應表的創建 SQL 如下:

create table sys_menu (
  menu_id           bigint(20)      not null auto_increment    comment '菜單ID',
  menu_name         varchar(50)     not null                   comment '菜單名稱',
  parent_id         bigint(20)      default 0                  comment '父菜單ID',
  order_num         int(4)          default 0                  comment '顯示順序',
  path              varchar(200)    default ''                 comment '路由地址',
  component         varchar(255)    default null               comment '組件路徑',
  is_frame          int(1)          default 1                  comment '是否爲外鏈(0是 1否)',
  menu_type         char(1)         default ''                 comment '菜單類型(M目錄 C菜單 F按鈕)',
  visible           char(1)         default 0                  comment '菜單狀態(0顯示 1隱藏)',
  perms             varchar(100)    default null               comment '權限標識',
  icon              varchar(100)    default '#'                comment '菜單圖標',
  create_by         varchar(64)     default ''                 comment '創建者',
  create_time       datetime                                   comment '創建時間',
  update_by         varchar(64)     default ''                 comment '更新者',
  update_time       datetime                                   comment '更新時間',
  remark            varchar(500)    default ''                 comment '備註',
  primary key (menu_id)
) engine=innodb auto_increment=2000 comment = '菜單權限表';

7.1.5 SysRoleMenu

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

// SysRoleMenu.java

public class SysRoleMenu {
    
    /** 角色ID */
    private Long roleId;

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

對應表的創建 SQL 如下:

create table sys_role_menu (
  role_id   bigint(20) not null comment '角色ID',
  menu_id   bigint(20) not null comment '菜單ID',
  primary key(role_id, menu_id)
) engine=innodb comment = '角色和菜單關聯表';

7.2 SecurityConfig

在 SecurityConfig 配置類,繼承 WebSecurityConfigurerAdapter 抽象類,實現 Spring Security 在 Web 場景下的自定義配置。代碼如下:

// SecurityConfig.java

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    // ...

}
  • 涉及到的配置方法較多,我們逐個來看看。

重寫 #configure(AuthenticationManagerBuilder auth) 方法,實現 AuthenticationManager 認證管理器。代碼如下:

// SecurityConfig.java

/**
 * 自定義用戶認證邏輯
 */
@Autowired
private UserDetailsService userDetailsService;

/**
 * 身份認證接口
 */
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(userDetailsService) // <X>
            .passwordEncoder(bCryptPasswordEncoder()); // <Y>
}

/**
 * 強散列哈希加密實現
 */
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
    return new BCryptPasswordEncoder();
}
  • <X> 處,調用 AuthenticationManagerBuilder#userDetailsService(userDetailsService) 方法,使用自定義實現的 UserDetailsService 實現類,更加靈活自由的實現認證的用戶信息的讀取。在「7.3.1 加載用戶信息」中,我們會看到 RuoYi-Vue 對 UserDetailsService 的自定義實現類。

  • <Y> 處,調用 AbstractDaoAuthenticationConfigurer#passwordEncoder(passwordEncoder) 方法,設置 PasswordEncoder 密碼編碼器。這裏,就使用了 bCryptPasswordEncoder 強散列哈希加密實現。

重寫 #configure(HttpSecurity httpSecurity) 方法,主要配置 URL 的權限控制。代碼如下:

// SecurityConfig.java

/**
 * 認證失敗處理類
 */
@Autowired
private AuthenticationEntryPointImpl unauthorizedHandler;

/**
 * 退出處理類
 */
@Autowired
private LogoutSuccessHandlerImpl logoutSuccessHandler;

/**
 * token 認證過濾器
 */
@Autowired
private JwtAuthenticationTokenFilter authenticationTokenFilter;

@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
    httpSecurity
            // CRSF禁用,因爲不使用session
            .csrf().disable()
            // <X> 認證失敗處理類
            .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
            // 基於token,所以不需要session
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
            // 過濾請求
            .authorizeRequests()
            // <Y> 對於登錄login 驗證碼captchaImage 允許匿名訪問
            .antMatchers("/login", "/captchaImage").anonymous()
            .antMatchers(
                    HttpMethod.GET,
                    "/*.html",
                    "/**/*.html",
                    "/**/*.css",
                    "/**/*.js"
            ).permitAll()
            .antMatchers("/profile/**").anonymous()
            .antMatchers("/common/download**").anonymous()
            .antMatchers("/swagger-ui.html").anonymous()
            .antMatchers("/swagger-resources/**").anonymous()
            .antMatchers("/webjars/**").anonymous()
            .antMatchers("/*/api-docs").anonymous()
            .antMatchers("/druid/**").anonymous()
            // 除上面外的所有請求全部需要鑑權認證
            .anyRequest().authenticated()
            .and()
            .headers().frameOptions().disable();
    httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler); // <Z>
    // <P> 添加 JWT filter
    httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
  • 比較長,我們選擇重點的來看。

  • <X> 處,設置認證失敗時的處理器爲 unauthorizedHandler 。詳細解析,見「7.6.1 AuthenticationEntryPointImpl」。

  • <Y> 處,設置用於登陸的 /login 接口,允許匿名訪問。這樣,後續我們就可以使用自定義的登陸接口。詳細解析,見「7.3 登陸 API 接口」。

  • <Z> 處,設置登出成功的處理器爲 logoutSuccessHandler 。詳細解析,見「7.6.3 LogoutSuccessHandlerImpl」。

  • <P> 處,添加 JWT 認證過濾器 authenticationTokenFilter ,用於用戶使用用戶名與密碼登陸完成後,後續請求基於 JWT 來認證。詳細解析,見「7.4 JwtAuthenticationTokenFilter」。

重寫 #authenticationManagerBean 方法,解決無法直接注入 AuthenticationManager 的問題。代碼如下:

// SecurityConfig.java

@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
    return super.authenticationManagerBean();
}
  • 在方法上,額外添加了 @Bean 註解,保證創建出 AuthenticationManager Bean 。

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

7.3 登陸 API 接口

SysLoginController#login(...)

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

// SysLoginController.java

@Autowired
private SysLoginService loginService;

/**
 * 登錄方法
 *
 * @param username 用戶名
 * @param password 密碼
 * @param code 驗證碼
 * @param uuid 唯一標識
 * @return 結果
 */
@PostMapping("/login")
public AjaxResult login(String username, String password, String code, String uuid) {
    AjaxResult ajax = AjaxResult.success();
    // 生成令牌
    String token = loginService.login(username, password, code, uuid);
    ajax.put(Constants.TOKEN, token);
    return ajax;
}
  • 在內部,會調用 loginService#login(username, password, code, uuid) 方法,會進行基於用戶名與密碼的登陸認證。認證通過後,返回身份 TOKEN 。

  • 登陸成功後,該接口響應示例如下

    {
        "msg": "操作成功", 
        "code": 200, 
        "token": "eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6ImJkN2Q4OTZiLTU2NTAtNGIyZS1iNjFjLTc0MjlkYmRkNzA1YyJ9.lkU8ot4GecLHs7VAcRAo1fLMOaFryd4W5Q_a2wzPwcOL0Kiwyd4enpnGd79A_aQczXC-JB8vELNcNn7BrtJn9A"
    }
    
    • 後續,前端在請求後端接口時,在請求頭上帶頭該 token 值,作爲用戶身份標識。

SysLoginService#login(...)

SysLoginService 中,定義了 #login(username, password, code, uuid) 方法,進行基於用戶名與密碼的登陸認證。認證通過後,返回身份 TOKEN 。代碼如下:

// SysLoginService.java

@Autowired
private TokenService tokenService;

@Resource
private AuthenticationManager authenticationManager;

@Autowired
private RedisCache redisCache;

/**
 * 登錄驗證
 *
 * @param username 用戶名
 * @param password 密碼
 * @param code     驗證碼
 * @param uuid     唯一標識
 * @return 結果
 */
public String login(String username, String password, String code, String uuid) {
    // <1> 驗證圖片驗證碼的正確性
    String verifyKey = Constants.CAPTCHA_CODE_KEY + uuid; // uuid 的作用,是獲得對應的圖片驗證碼
    String captcha = redisCache.getCacheObject(verifyKey); // 從 Redis 中,獲得圖片驗證碼
    redisCache.deleteObject(verifyKey); // 從 Redis 中,刪除圖片驗證碼
    if (captcha == null) { // 圖片驗證碼不存在
        AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.error")));
        throw new CaptchaExpireException();
    }
    if (!code.equalsIgnoreCase(captcha)) { // 圖片驗證碼不正確
        AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")));
        throw new CaptchaException();
    }
    // <2> 用戶驗證
    Authentication authentication;
    try {
        // 該方法會去調用 UserDetailsServiceImpl.loadUserByUsername
        authentication = authenticationManager
                .authenticate(new UsernamePasswordAuthenticationToken(username, password));
    } catch (Exception e) {
        // <2.1> 發生異常,說明驗證不通過,記錄相應的登陸失敗日誌
        if (e instanceof BadCredentialsException) {
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
            throw new UserPasswordNotMatchException();
        } else {
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
            throw new CustomException(e.getMessage());
        }
    }
    // <2.2> 驗證通過,記錄相應的登陸成功日誌
    AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
    // <3> 生成 Token
    LoginUser loginUser = (LoginUser) authentication.getPrincipal();
    return tokenService.createToken(loginUser);
}
  • <1> 處,驗證圖片驗證碼的正確性。該驗證碼會存儲在 Redis 緩存中,通過 uuid 作爲對應的標識。生成的邏輯,胖友自己看 CaptchaController 提供的 /captchaImage 接口。

  • <2> 處,調用 Spring Security 的 AuthenticationManager#authenticate(UsernamePasswordAuthenticationToken authentication) 方法,基於用戶名與密碼的登陸認證。在其內部,會調用我們定義的 UserDetailsServiceImpl 的 #loadUserByUsername(String username) 方法,獲得指定用戶名對應的用戶信息。詳細解析,見「7.3.1 加載用戶信息」。

    • <2.1> 處,發生異常,說明認證通過,記錄相應的登陸失敗日誌。

    • <2.2> 處,發生異常,說明認證通過,記錄相應的登陸成功日誌。

    • 關於上述日誌,我們在「7.7 登陸日誌」來講。

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

7.3.1 加載用戶信息

在 UserDetailsServiceImpl 中,實現 Spring Security UserDetailsService 接口,實現了該接口定義的 #loadUserByUsername(String username) 方法,獲得指定用戶名對應的用戶信息。代碼如下:

// UserDetailsServiceImpl.java

private static final Logger log = LoggerFactory.getLogger(UserDetailsServiceImpl.class);

@Autowired
private ISysUserService userService;

@Autowired
private SysPermissionService permissionService;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    // <1> 查詢指定用戶名對應的 SysUser
    SysUser user = userService.selectUserByUserName(username);
    // <2> 各種校驗
    if (StringUtils.isNull(user)) {
        log.info("登錄用戶:{} 不存在.", username);
        throw new UsernameNotFoundException("登錄用戶:" + username + " 不存在");
    } else if (UserStatus.DELETED.getCode().equals(user.getDelFlag())) {
        log.info("登錄用戶:{} 已被刪除.", username);
        throw new BaseException("對不起,您的賬號:" + username + " 已被刪除");
    } else if (UserStatus.DISABLE.getCode().equals(user.getStatus())) {
        log.info("登錄用戶:{} 已被停用.", username);
        throw new BaseException("對不起,您的賬號:" + username + " 已停用");
    }

    // <3> 創建 Spring Security UserDetails 用戶明細
    return createLoginUser(user);
}

public UserDetails createLoginUser(SysUser user) {
    return new LoginUser(user, permissionService.getMenuPermission(user));
}
  • <1> 處,調用 ISysUserService 的 #selectUserByUserName(String userName) 方法,查詢指定用戶名對應的 SysUser 。代碼如下:

    // SysUserServiceImpl.java
    @Autowired
    private SysUserMapper userMapper;
    
    @Override
    public SysUser selectUserByUserName(String userName) {
        return userMapper.selectUserByUserName(userName);
    }
    
    // SysUserMapper.XML
    <sql id="selectUserVo">
        select u.user_id, u.dept_id, u.user_name, u.nick_name, u.email, u.avatar, u.phonenumber, u.password, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.create_by, u.create_time, u.remark,
        d.dept_id, d.parent_id, d.dept_name, d.order_num, d.leader, d.status as dept_status,
        r.role_id, r.role_name, r.role_key, r.role_sort, r.data_scope, r.status as role_status
        from sys_user u
         left join sys_dept d on u.dept_id = d.dept_id
         left join sys_user_role ur on u.user_id = ur.user_id
         left join sys_role r on r.role_id = ur.role_id
    </sql>
    
    <select id="selectUserByUserName" parameterType="String" resultMap="SysUserResult">
        <include refid="selectUserVo"/>
     where u.user_name = #{userName}
    </select>
    
    • 通過查詢 sys_user 表,同時連接 sys_deptsys_user_rolesys_role 表,將 username 對應的 SysUser 相關信息都一次性查詢出來。

    • 返回結果 SysUserResult 的具體定義,點擊 傳送門 查看,實際就是 SysUser 實體類。

  • <2> 處,各種校驗。如果校驗不通過,拋出 UsernameNotFoundException 或 BaseException 異常。

  • <3> 處,調用 SysPermissionService 的 #getMenuPermission(SysUser user) 方法,獲得用戶的 SysRoleMenu 的權限標識字符串的集合。代碼如下:

    // SysPermissionService.java
    @Autowired
    private ISysMenuService menuService;
      
    public Set<String> getMenuPermission(SysUser user) {
        Set<String> roles = new HashSet<String>();
        // 管理員擁有所有權限
        if (user.isAdmin()) {
            roles.add("*:*:*"); // 所有模塊
        } else {
            // 讀取
            roles.addAll(menuService.selectMenuPermsByUserId(user.getUserId()));
        }
        return roles;
    }
    
    // SysMenuServiceImpl.java
    @Autowired
    private SysMenuMapper menuMapper;
    
    @Override
    public Set<String> selectMenuPermsByUserId(Long userId) {
        // 讀取 SysMenu 的權限標識數組
        List<String> perms = menuMapper.selectMenuPermsByUserId(userId);
        // 逐個,按照“逗號”分隔
        Set<String> permsSet = new HashSet<>();
        for (String perm : perms) {
            if (StringUtils.isNotEmpty(perm)) {
                permsSet.addAll(Arrays.asList(perm.trim().split(",")));
            }
        }
        return permsSet;
    }
    
    // SysMenuMapper.xml
    <select id="selectMenuPermsByUserId" parameterType="Long" resultType="String">
     select distinct m.perms
     from sys_menu m
       left join sys_role_menu rm on m.menu_id = rm.menu_id
       left join sys_user_role ur on rm.role_id = ur.role_id
     where ur.user_id = #{userId}
    </select>
    
    • 雖然代碼很長,但是核心的並不多。

    • 首先,如果 SysUser 是超級管理員,則其權限標識集合就是 *:*:* ,標識可以所有模塊的所有操作。

    • 然後,查詢 sys_menu 表,同時連接 sys_role_menusys_user_role 表,將 SysUser 擁有的 SysMenu 的權限標識數組,然後使用 "," 分隔每個 SysMenu 對應的權限標識。

這裏,我們看到最終返回的是 LoginUser ,實現 Spring Security UserDetails 接口,自定義的用戶明細。代碼如下:

// LoginUser.java

public class LoginUser implements UserDetails {
   
    private static final long serialVersionUID = 1L;

    /** 用戶唯一標識 */
    private String token;

    /** 登陸時間 */
    private Long loginTime;

    /** 過期時間 */
    private Long expireTime;

    /** 登錄IP地址 */
    private String ipaddr;

    /** 登錄地點 */
    private String loginLocation;

    /** 瀏覽器類型 */
    private String browser;

    /** 操作系統 */
    private String os;

    /** 權限列表 */
    private Set<String> permissions;

    /** 用戶信息 */
    private SysUser user;
    
    // ...省略 set/get 方法,以及各種實現方法
    
}

7.3.2 創建認證 Token

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

// TokenService.java

/**
 * 創建令牌
 *
 * @param loginUser 用戶信息
 * @return 令牌
 */
public String createToken(LoginUser loginUser) {
    // <1> 設置 LoginUser 的用戶唯一標識。注意,這裏雖然變量名叫 token ,其實不是身份認證的 Token
    String token = IdUtils.fastUUID();
    loginUser.setToken(token);
    // <2> 設置用戶終端相關的信息,包括 IP、城市、瀏覽器、操作系統
    setUserAgent(loginUser);

    // <3> 記錄緩存
    refreshToken(loginUser);

    // <4> 生成 JWT 的 Token
    Map<String, Object> claims = new HashMap<>();
    claims.put(Constants.LOGIN_USER_KEY, token);
    return createToken(claims);
}
  • 注意,這個方法不僅僅會生成認證 Token ,還會緩存 LoginUser 到 Redis 緩存中。

  • <1> 處,設置 LoginUser 的用戶唯一標識,即 LoginUser.token。注意,這裏雖然變量名叫 token ,其實不是身份認證的 Token 。

  • <2> 處,調用 #setUserAgent(LoginUser loginUser) 方法,設置用戶終端相關的信息,包括 IP、城市、瀏覽器、操作系統。代碼如下:

    // TokenService.java
    
    public void setUserAgent(LoginUser loginUser) {
        UserAgent userAgent = UserAgent.parseUserAgentString(ServletUtils.getRequest().getHeader("User-Agent"));
        String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
        loginUser.setIpaddr(ip);
        loginUser.setLoginLocation(AddressUtils.getRealAddressByIP(ip));
        loginUser.setBrowser(userAgent.getBrowser().getName());
        loginUser.setOs(userAgent.getOperatingSystem().getName());
    }
    
  • <3> 處,調用 #refreshToken(LoginUser loginUser) 方法,緩存 LoginUser 到 Redis 緩存中。代碼如下:

    // application.yaml
    # token配置
    token:
        # 令牌有效期(默認30分鐘)
        expireTime: 30
    
    // Constants.java
    /**
     * 登錄用戶 redis key
     */
    public static final String LOGIN_TOKEN_KEY = "login_tokens:";
    
    // TokenService.java
    // 令牌有效期(默認30分鐘)
    @Value("${token.expireTime}")
    private int expireTime;
    
    @Autowired
    private RedisCache redisCache;
    
    public void refreshToken(LoginUser loginUser) {
        loginUser.setLoginTime(System.currentTimeMillis());
        loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
        // 根據 uuid 將 loginUser 緩存
        String userKey = getTokenKey(loginUser.getToken());
        redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
    }
    
    private String getTokenKey(String uuid) {
        return Constants.LOGIN_TOKEN_KEY + uuid;
    }
    
    • 緩存的 Redis Key 的統一前綴"login_tokens:" ,使用 Login 的用戶唯一標識(LoginUser.token)作爲後綴

  • <4> 處,調用 #createToken(Map<String, Object> claims) 方法,生成 JWT 的 Token 。代碼如下:

    // application.yaml
    # token配置
    token:
        # 令牌祕鑰
        secret: abcdefghijklmnopqrstuvwxyz
        
    // TokenService.java
    // 令牌祕鑰
    @Value("${token.secret}")
    private String secret;
    
    private String createToken(Map<String, Object> claims) {
        return Jwts.builder()
                .setClaims(claims)
                .signWith(SignatureAlgorithm.HS512, secret).compact();
    }
    
    • 因爲 LoginUser.token 添加到 claims 中,最終生成了 JWT 的 Token 。所以,後續我們可以通過解碼該 JWT 的 Token ,從而獲得 claims ,最終獲得到對應的 LoginUser.token

    • 在 JWT 的 Token 中,使用 LoginUser.token 而不是 userId 的好處,可以帶來更好的安全性,避免萬一 secret 祕鑰泄露之後,黑客可以順序生成 userId 從而生成對應的 JWT 的 Token ,後續直接可以操作該用戶的數據。不過,這麼做之後就不是純粹的 JWT ,解析出來的 LoginUser.token 需要查詢對應的 LoginUser 的 Redis 緩存。詳細的,我們在「7.4 JwtAuthenticationTokenFilter」會看到這個過程。

    • 這裏,我們採用了 jjwt 庫。

    • 對 JWT 不瞭解的胖友,可以閱讀下《JSON Web Token - 在Web應用間安全地傳遞信息》和《八幅漫畫理解使用 JSON Web Token 設計單點登錄系統》文章。

    • 注意,不要把這裏生成的 JWT 的 Token ,和我們上面的 LoginUser.token 混淆在一起。

至此,我們完成了基於用戶名與密碼的登陸認證的整個過程。雖然整個過程的代碼有小几百行,不過邏輯還是比較清晰明瞭的。如果不太理解的胖友,可以在反覆看兩遍。

7.4 JwtAuthenticationTokenFilter

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

// JwtAuthenticationTokenFilter.java

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Autowired
    private TokenService tokenService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        // <1> 獲得當前 LoginUser
        LoginUser loginUser = tokenService.getLoginUser(request);
        // 如果存在 LoginUser ,並且未認證過
        if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication())) {
            // <2> 校驗 Token 有效性
            tokenService.verifyToken(loginUser);
            // <3> 創建 UsernamePasswordAuthenticationToken 對象,設置到 SecurityContextHolder 中
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
            authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        }
        // <4> 繼續過濾器
        chain.doFilter(request, response);
    }
    
}
  • <1> 處,調用 TokenService 的 #getLoginUser(request) 方法,獲得當前 LoginUser 。代碼如下:

    // application.yaml
    # token配置
    token:
        # 令牌自定義標識
        header: Authorization
    
    // TokenService.jav
    // 令牌自定義標識
    @Value("${token.header}")
    private String header;
    
    /**
     * 獲取用戶身份信息
     *
     * @return 用戶信息
     */
    public LoginUser getLoginUser(HttpServletRequest request) {
        // <1.1> 獲取請求攜帶的令牌
        String token = getToken(request);
        if (StringUtils.isNotEmpty(token)) {
            // <1.2> 解析 JWT 的 Token
            Claims claims = parseToken(token);
            // <1.3> 從 Redis 緩存中,獲得對應的 LoginUser
            String uuid = (String) claims.get(Constants.LOGIN_USER_KEY);
            String userKey = getTokenKey(uuid);
            return redisCache.getCacheObject(userKey);
        }
        return null;
    }
    
    private String getToken(HttpServletRequest request) {
        String token = request.getHeader(header);
        if (StringUtils.isNotEmpty(token) && token.startsWith(Constants.TOKEN_PREFIX)) {
            token = token.replace(Constants.TOKEN_PREFIX, "");
        }
        return token;
    }
    
    private Claims parseToken(String token) {
        return Jwts.parser()
                .setSigningKey(secret)
                .parseClaimsJws(token)
                .getBody();
    }
    
    • <1.1> 處,調用 #getToken(request) 方法,從請求頭 "Authorization" 中,獲得身份認證的 Token 。

    • <1.2> 處,調用 #parseToken(token) 方法,解析 JWT 的 Token ,獲得 Claims 對象,從而獲得用戶唯一標識(LoginUser.token)。

    • <1.3> 處,從 Redis 緩存中,獲得對應的 LoginUser 。

  • <2> 處,調用 TokenService 的 #verifyToken(LoginUser loginUser) 方法,驗證令牌有效期。代碼如下:

    // TokenService.java
    protected static final long MILLIS_SECOND = 1000;
    protected static final long MILLIS_MINUTE = 60 * MILLIS_SECOND;
    private static final Long MILLIS_MINUTE_TEN = 20 * 60 * 1000L;
    
    /**
     * 驗證令牌有效期,相差不足 20 分鐘,自動刷新緩存
     *
     * @param loginUser 用戶
     */
    public void verifyToken(LoginUser loginUser) {
        long expireTime = loginUser.getExpireTime();
        long currentTime = System.currentTimeMillis();
        // 相差不足 20 分鐘,自動刷新緩存
        if (expireTime - currentTime <= MILLIS_MINUTE_TEN) {
            String token = loginUser.getToken();
            loginUser.setToken(token);
            refreshToken(loginUser);
        }
    }
    
    • 實際上,這個方法的目的不是驗證 Token 的有效性,而是刷新對應的 LoginUser 的緩存的過期時間。

    • 考慮到避免每次請求都去刷新緩存的過期時間,所以過期時間不足 20 分鐘,纔會去刷新。

  • <3> 處,手動創建 UsernamePasswordAuthenticationToken 對象,設置到 SecurityContextHolder 中。???? 因爲,我們已經通過 Token 來完成認證了。

  • <4> 處,繼續過濾器。

嚴格來說,RuoYi-Vue 並不是採用的無狀態的 JWT ,而是使用 JWT 的 Token 的生成方式。

7.5 權限驗證

在「3. 進階使用」中,我們看到可以通過 Spring Security 提供的 @PreAuthorize 註解,實現基於 Spring EL 表達式的執行結果爲 true 時,可以訪問,從而實現靈活的權限校驗。

在 RuoYi-Vue 中,通過 @PreAuthorize 註解的特性,使用其 PermissionService 提供的權限驗證的方法。使用示例如下:

// SysDictDataController.java

@PreAuthorize("@ss.hasPermi('system:dict:list')")
@GetMapping("/list")
  • 請求 /system/dict/data/list 接口,會調用 PermissionService 的 #hasPermi(String permission) 方法,校驗用戶是否有指定的權限。

  • 爲什麼這裏會有一個 @ss 呢?在 Spring EL 表達式中,調用指定 Bean 名字的方法時,使用 @ + Bean 的名字。在 RuoYi-Vue 中,聲明 PermissionService 的 Bean 名字爲 ss

7.5.1 判斷是否有權限

在 PermissionService 中,定義了 #hasPermi(String permission) 方法,判斷當前用戶是否指定的權限。代碼如下:

// PermissionService.java

/**
 * 所有權限標識
 */
private static final String ALL_PERMISSION = "*:*:*";

@Autowired
private TokenService tokenService;

/**
 * 驗證用戶是否具備某權限
 *
 * @param permission 權限字符串
 * @return 用戶是否具備某權限
 */
public boolean hasPermi(String permission) {
    // 如果未設置需要的權限,強制不具備。
    if (StringUtils.isEmpty(permission)) {
        return false;
    }
    // 獲得當前 LoginUser
    LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
    // 如果不存在,或者沒有任何權限,說明權限驗證不通過
    if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions())) {
        return false;
    }
    // 判斷是否包含權限
    return hasPermissions(loginUser.getPermissions(), permission);
}

/**
 * 判斷是否包含權限
 *
 * @param permissions 權限列表
 * @param permission  權限字符串
 * @return 用戶是否具備某權限
 */
private boolean hasPermissions(Set<String> permissions, String permission) {
    return permissions.contains(ALL_PERMISSION) || permissions.contains(StringUtils.trim(permission));
}
  • 比較簡單,胖友看看艿艿添加的代碼註釋,就能夠明白。

在 PermissionService 中,定義了 #lacksPermi(String permission) 方法,判斷當前用戶是否沒有指定的權限。代碼如下:

// PermissionService.java

/**
 * 驗證用戶是否不具備某權限,與 hasPermi邏輯相反
 *
 * @param permission 權限字符串
 * @return 用戶是否不具備某權限
 */
public boolean lacksPermi(String permission) {
    return !hasPermi(permission);
}

在 PermissionService 中,定義了 #hasAnyPermi(String permissions) 方法,判斷當前用戶是否指定的任一權限。代碼如下:

// PermissionService.java

private static final String PERMISSION_DELIMETER = ",";

/**
 * 驗證用戶是否具有以下任意一個權限
 *
 * @param permissions 以 PERMISSION_NAMES_DELIMETER 爲分隔符的權限列表
 * @return 用戶是否具有以下任意一個權限
 */
public boolean hasAnyPermi(String permissions) {
    // 如果未設置需要的權限,強制不具備。
    if (StringUtils.isEmpty(permissions)) {
        return false;
    }
    // 獲得當前 LoginUser
    LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
    // 如果不存在,或者沒有任何權限,說明權限驗證不通過
    if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions())) {
        return false;
    }
    // 判斷是否包含指定的任一權限
    Set<String> authorities = loginUser.getPermissions();
    for (String permission : permissions.split(PERMISSION_DELIMETER)) {
        if (permission != null && hasPermissions(authorities, permission)) {
            return true;
        }
    }
    return false;
}

7.5.2 判斷是否有角色

在 PermissionService 中,定義了 #hasRole(String role) 方法,判斷當前用戶是否指定的角色。代碼如下:

// PermissionService.java

/**
 * 判斷用戶是否擁有某個角色
 *
 * @param role 角色字符串
 * @return 用戶是否具備某角色
 */
public boolean hasRole(String role) {
    // 如果未設置需要的角色,強制不具備。
    if (StringUtils.isEmpty(role)) {
        return false;
    }
    // 獲得當前 LoginUser
    LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
    // 如果不存在,或者沒有任何角色,說明權限驗證不通過
    if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getUser().getRoles())) {
        return false;
    }
    // 判斷是否包含指定角色
    for (SysRole sysRole : loginUser.getUser().getRoles()) {
        String roleKey = sysRole.getRoleKey();
        if (SUPER_ADMIN.contains(roleKey) // 超級管理員的特殊處理
                || roleKey.contains(StringUtils.trim(role))) {
            return true;
        }
    }
    return false;
}
  • 比較簡單,胖友看看艿艿添加的代碼註釋,就能夠明白。

在 PermissionService 中,定義了 #lacksRole(String role) 方法,判斷當前用戶是否沒有指定的角色。代碼如下:

// PermissionService.java

/**
 * 驗證用戶是否不具備某角色,與 isRole邏輯相反。
 *
 * @param role 角色名稱
 * @return 用戶是否不具備某角色
 */
public boolean lacksRole(String role) {
    return !hasRole(role);
}

在 PermissionService 中,定義了 #hasAnyRoles(String roles) 方法,判斷當前用戶是否指定的任一角色。代碼如下:

// PermissionService.java

private static final String ROLE_DELIMETER = ",";

/**
 * 驗證用戶是否具有以下任意一個角色
 *
 * @param roles 以 ROLE_NAMES_DELIMETER 爲分隔符的角色列表
 * @return 用戶是否具有以下任意一個角色
 */
public boolean hasAnyRoles(String roles) {
    // 如果未設置需要的角色,強制不具備。
    if (StringUtils.isEmpty(roles)) {
        return false;
    }
    // 獲得當前 LoginUser
    LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
    // 如果不存在,或者沒有任何角色,說明權限驗證不通過
    if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getUser().getRoles())) {
        return false;
    }
    // 判斷是否包含指定的任一角色
    for (String role : roles.split(ROLE_DELIMETER)) {
        if (hasRole(role)) { // 這裏實現有點問題,會循環調用 hasRole 方法,重複從 Redis 中讀取當前 LoginUser
            return true;
        }
    }
    return false;
}

7.6 各種處理器

在 Ruoyi-Vue 中,提供了各種處理器,處理各種情況,所以我們彙總在「7.6 各種處理器」 中,一起來瞅瞅。

7.6.1 AuthenticationEntryPointImpl

在 AuthenticationEntryPointImpl 中,實現 Spring Security AuthenticationEntryPoint 接口,處理認失敗的 AuthenticationException 異常。代碼如下:

// AuthenticationEntryPointImpl.java

// 認證失敗處理類 返回未授權
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint, Serializable {

    private static final long serialVersionUID = -8970718410437077606L;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) {
        // 響應認證不通過
        int code = HttpStatus.UNAUTHORIZED;
        String msg = StringUtils.format("請求訪問:{},認證失敗,無法訪問系統資源", request.getRequestURI());
        ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg)));
    }

}
  • 響應認證不通過的 JSON 字符串。

7.6.2 GlobalExceptionHandler

在 GlobalExceptionHandler 中,定義了對 Spring Security 的異常處理。代碼如下:

// GlobalExceptionHandler.java

@RestControllerAdvice
public class GlobalExceptionHandler {

   @ExceptionHandler(AccessDeniedException.class) // 沒有訪問權限。使用 @PreAuthorize 校驗權限不通過時,就會拋出 AccessDeniedException 異常
    public AjaxResult handleAuthorizationException(AccessDeniedException e) {
        log.error(e.getMessage());
        return AjaxResult.error(HttpStatus.FORBIDDEN, "沒有權限,請聯繫管理員授權");
    }

    @ExceptionHandler(AccountExpiredException.class) // 賬號已過期
    public AjaxResult handleAccountExpiredException(AccountExpiredException e) {
        log.error(e.getMessage(), e);
        return AjaxResult.error(e.getMessage());
    }

    @ExceptionHandler(UsernameNotFoundException.class) // 用戶名不存在
    public AjaxResult handleUsernameNotFoundException(UsernameNotFoundException e) {
        log.error(e.getMessage(), e);
        return AjaxResult.error(e.getMessage());
    }

    // ... 省略對其它的異常類的處理的方法
}
  • 基於 Spring MVC 提供的 @RestControllerAdvice + @ExceptionHandler 註解,實現全局異常的處理。不瞭解的胖友,可以看看《芋道 Spring Boot SpringMVC 入門》的「5. 全局異常處理」小節。

7.6.3 LogoutSuccessHandlerImpl

在 LogoutSuccessHandlerImpl 中,實現 Spring Security LogoutSuccessHandler 接口,自定義退出的處理,主動刪除 LoginUser 在 Redis 中的緩存。代碼如下:

// LogoutSuccessHandlerImpl.java

// 自定義退出處理類 返回成功
@Configuration
public class LogoutSuccessHandlerImpl implements LogoutSuccessHandler {

    @Autowired
    private TokenService tokenService;

    /**
     * 退出處理
     */
    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        // <1> 獲得當前 LoginUser
        LoginUser loginUser = tokenService.getLoginUser(request);
        // 如果有登陸的情況下
        if (StringUtils.isNotNull(loginUser)) {
            String userName = loginUser.getUsername();
            // <2> 刪除用戶緩存記錄
            tokenService.delLoginUser(loginUser.getToken());
            // <3> 記錄用戶退出日誌
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(userName, Constants.LOGOUT, "退出成功"));
        }
        // <4> 響應退出成功
        ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(HttpStatus.SUCCESS, "退出成功")));
    }

}
  • <1> 處,調用 TokenService 的 #getLoginUser(request) 方法,獲得當前 LoginUser 。

  • <2> 處,調用 TokenService 的 #delLoginUser(String token) 方法,刪除 LoginUser 的 Redis 緩存。代碼如下:

    // TokenService.java
    
    public void delLoginUser(String token) {
        if (StringUtils.isNotEmpty(token)) {
            String userKey = getTokenKey(token);
            // 刪除緩存
            redisCache.deleteObject(userKey);
        }
    }
    
  • <3> 處,記錄相應的退出成功日誌。

  • <4> 處,響應退出成功的 JSON 字符串。

7.7 登陸日誌

SysLogininfor ,登陸日誌實體。代碼如下:

// SysLogininfor.java

public class SysLogininfor extends BaseEntity  {

    private static final long serialVersionUID = 1L;

    @Excel(name = "序號", cellType = ColumnType.NUMERIC)
    private Long infoId;

    @Excel(name = "用戶賬號")
    private String userName;

    @Excel(name = "登錄狀態", readConverterExp = "0=成功,1=失敗")
    private String status;

    @Excel(name = "登錄地址")
    private String ipaddr;

    @Excel(name = "登錄地點")
    private String loginLocation;

    @Excel(name = "瀏覽器")
    private String browser;

    @Excel(name = "操作系統")
    private String os;

    @Excel(name = "提示消息")
    private String msg;

    @Excel(name = "訪問時間", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
    private Date loginTime;
    
    // ...省略 set/get 方法
}
  • 每個字段比較簡單,胖友自己根據註釋理解下即可。

對應表的創建 SQL 如下:

create table sys_logininfor (
  info_id        bigint(20)     not null auto_increment   comment '訪問ID',
  user_name      varchar(50)    default ''                comment '用戶賬號',
  ipaddr         varchar(50)    default ''                comment '登錄IP地址',
  login_location varchar(255)   default ''                comment '登錄地點',
  browser        varchar(50)    default ''                comment '瀏覽器類型',
  os             varchar(50)    default ''                comment '操作系統',
  status         char(1)        default '0'               comment '登錄狀態(0成功 1失敗)',
  msg            varchar(255)   default ''                comment '提示消息',
  login_time     datetime                                 comment '訪問時間',
  primary key (info_id)
) engine=innodb auto_increment=100 comment = '系統訪問記錄';

在 RuoYi-Vue 中,記錄 SysLogininfor 的過程如下:

  • 首先,手動調用 AsyncFactory#recordLogininfor(username, status, message, args) 方法,創建一個 Java TimerTask 任務。

  • 然後調用 AsyncManager#execute(TimerTask task) 方法,提交到定時任務的線程中,延遲 OPERATE_DELAY_TIME = 10 秒後,存儲該記錄到數據庫中。

這樣的好處,是可以實現異步存儲日誌到數據庫中,提升 API 接口的性能。不過實際上,Spring 提供了 @Async 註解,方便的實現異步操作。不瞭解的胖友,可以看看《芋道 Spring Boot 異步任務入門》。

另外,在 RuoYi-Vue 中還定義了 SysOperLog ,操作日誌實體類。感興趣的胖友,自己去瞅瞅。

7.8 獲得用戶信息 API 接口

在 SysLoginController 中,定義了 /getInfo 接口,獲取登陸的用戶信息。代碼如下:

// SysLoginController.java

/**
 * 獲取用戶信息
 *
 * @return 用戶信息
 */
@GetMapping("getInfo")
public AjaxResult getInfo() {
    // <1> 獲得當前 LoginUser
    LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
    SysUser user = loginUser.getUser();
    // <2> 角色標識的集合
    Set<String> roles = permissionService.getRolePermission(user);
    // <3> 權限集合
    Set<String> permissions = permissionService.getMenuPermission(user);
    // <4> 返回結果
    AjaxResult ajax = AjaxResult.success();
    ajax.put("user", user);
    ajax.put("roles", roles);
    ajax.put("permissions", permissions);
    return ajax;
}
  • <1> 處,調用 TokenService 的 #getLoginUser(request) 方法,獲得當前 LoginUser 。

  • <2> 處,調用 PermissionService 的 #getRolePermission(SysUser user) 方法,獲得 LoginUser 擁有的角色標識的集合。代碼如下:

    // SysPermissionService.java
    @Autowired
    private ISysRoleService roleService;
    
    /**
     * 獲取角色數據權限
     *
     * @param user 用戶信息
     * @return 角色權限信息
     */
    public Set<String> getRolePermission(SysUser user) {
        Set<String> roles = new HashSet<String>();
        // 管理員擁有所有權限
        if (user.isAdmin()) { // 如果是管理員,強制添加 admin 角色
            roles.add("admin");
        } else { // 如果非管理員,進行查詢
            roles.addAll(roleService.selectRolePermissionByUserId(user.getUserId()));
        }
        return roles;
    }
    
    // SysRoleServiceImpl.java
    
    @Autowired
    private SysRoleMapper roleMapper;
        
    /**
     * 根據用戶ID查詢權限
     *
     * @param userId 用戶ID
     * @return 權限列表
     */
    @Override
    public Set<String> selectRolePermissionByUserId(Long userId) {
        // 獲得 userId 擁有的 SysRole 數組
        List<SysRole> perms = roleMapper.selectRolePermissionByUserId(userId);
        // 遍歷 SysRole 數組,生成角色標識數組
        Set<String> permsSet = new HashSet<>();
        for (SysRole perm : perms) {
            if (StringUtils.isNotNull(perm)) {
                permsSet.addAll(Arrays.asList(perm.getRoleKey().trim().split(",")));
            }
        }
        return permsSet;
    }
    
    // SysRoleMapper.xml
    <sql id="selectRoleVo">
        select distinct r.role_id, r.role_name, r.role_key, r.role_sort, r.data_scope,
            r.status, r.del_flag, r.create_time, r.remark 
        from sys_role r
            left join sys_user_role ur on ur.role_id = r.role_id
            left join sys_user u on u.user_id = ur.user_id
            left join sys_dept d on u.dept_id = d.dept_id
    </sql>
    
    <select id="selectRolePermissionByUserId" parameterType="Long" resultMap="SysRoleResult">
     <include refid="selectRoleVo"/>
     WHERE r.del_flag = '0' and ur.user_id = #{userId}
    </select>
    
    • 通過查詢 sys_role 表,同時連接 sys_user_rolesys_usersys_dept 表,將 userId 對應的 SysRole 相關信息都一次性查詢出來。

    • 返回結果 SysRoleResult 的具體定義,點擊 傳送門 查看,實際就是 SysRole 實體類。

  • <3> 處,調用 SysPermissionService 的 #getMenuPermission(SysUser user) 方法,獲得用戶的 SysRoleMenu 的權限標識字符串的集合。

  • <4> 處,返回用戶信息的 AjaxResult 結果。

通過調用該 /getInfo 接口,前端就可以根據角色標識、又或者權限標識,實現對頁面級別的按鈕實現權限控制,進行有權限時顯示,無權限時隱藏。

7.9 獲取路由信息

在 SysLoginController 中,定義了 /getRouters 接口,獲取獲取路由信息。代碼如下:

// SysLoginController.java

@GetMapping("getRouters")
public AjaxResult getRouters() {
    // 獲得當前 LoginUser
    LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
    // 獲得用戶的 SysMenu 數組
    SysUser user = loginUser.getUser();
    List<SysMenu> menus = menuService.selectMenuTreeByUserId(user.getUserId());
    // 構建路由 RouterVo 數組。可用於 Vue 構建管理後臺的左邊菜單
    return AjaxResult.success(menuService.buildMenus(menus));
}
  • 具體的代碼,比較簡單,胖友自己去閱讀下,嘿嘿。

通過調用該 /getRouters 接口,前端就可以構建管理後臺的左邊菜單。

7.10 權限管理

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

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

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

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

7.11 小小的建議

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

666. 彩蛋

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

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

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

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

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



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

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

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

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

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

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

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