1. 程式人生 > 資訊 >今日父親節,父愛最無聲且也最濃厚

今日父親節,父愛最無聲且也最濃厚

1. SpringSecurity框架簡介

1.1概要

Spring 是非常流行和成功的 Java 應用開發框架,SpringSecurity 正是 Spring 家族中的成員。SpringSecurity 基於 Spring 框架,提供了一套 Web 應用安全性的完整解決方案。

正如你可能知道的關於安全方面的兩個主要區域是“認證”和“授權”(或者訪問控制),一般來說,Web 應用的安全性包括使用者認證(Authentication)和使用者授權(Authorization)兩個部分,這兩點也是 Spring Security 重要核心功能。

(1)使用者認證指的是:驗證某個使用者是否為系統中的合法主體,也就是說使用者能否訪問該系統。使用者認證一般要求使用者提供使用者名稱和密碼。系統通過校驗使用者名稱和密碼來完成認證過程。通俗點說就是系統認為使用者是否能登入

(2)使用者授權指的是驗證某個使用者是否有許可權執行某個操作。在一個系統中,不同使用者所具有的許可權是不同的。比如對一個檔案來說,有的使用者只能進行讀取,而有的使用者可以進行修改。一般來說,系統會為不同的使用者分配不同的角色,而每個角色則對應一系列的許可權。通俗點講就是系統判斷使用者是否有許可權去做某些事情。

1.2歷史

SpringSecurity 開始於 2003 年年底,““spring 的 acegi 安全系統”。 起因是 Spring開發者郵件列表中的一個問題,有人提問是否考慮提供一個基於 spring 的安全實現。

SpringSecurity 以“The Acegi Secutity System forSpring” 的名字始於 2013 年晚些時候。一個問題提交到 Spring 開發者的郵件列表,詢問是否已經有考慮一個機遇 Spring的安全性社群實現。那時候 Spring 的社群相對較小(相對現在)。實際上 Spring 自己在2013 年只是一個存在於 ScourseForge 的專案,這個問題的回答是一個值得研究的領域,雖然目前時間的缺乏組織了我們對它的探索。

考慮到這一點,一個簡單的安全實現建成但是並沒有釋出。幾周後,Spring 社群的其他成員詢問了安全性,這次這個程式碼被髮送給他們。其他幾個請求也跟隨而來。到 2014 年一月大約有 20 萬人使用了這個程式碼。這些創業者的人提出一個 SourceForge 專案加入是為了,這是在 2004 三月正式成立。

在早些時候,這個專案沒有任何自己的驗證模組,身份驗證過程依賴於容器管理的安全性和 Acegi 安全性。而不是專注於授權。開始的時候這很適合,但是越來越多的使用者請求額外的容器支援。容器特定的認證領域介面的基本限制變得清晰。還有一個相關的問題增加新的容器的路徑,這是終端使用者的困惑和錯誤配置的常見問題。

Acegi 安全特定的認證服務介紹。大約一年後,Acegi 安全正式成為了Spring 框架的子專案。1.0.0 最終版本是出版於 2006 -在超過兩年半的大量生產的軟體專案和數以百計的改進和積極利用社群的貢獻。

Acegi 安全 2007 年底正式成為了 Spring 組合專案,更名為"Spring Security"。

1.3同款產品對比

1.3.1 Spring Security

Spring 技術棧的組成部分。

通過提供完整可擴充套件的認證和授權支援保護你的應用程式。

https://spring.io/projects/spring-security

SpringSecurity特點:

  • 和 Spring 無縫整合。

  • 全面的許可權控制。

  • 專門為 Web 開發而設計。

    • 舊版本不能脫離Web 環境使用。
    • 新版本對整個框架進行了分層抽取,分成了核心模組和Web 模組。單獨引入核心模組就可以脫離Web 環境。
  • 重量級。

1.3.2 Shiro

Apache 旗下的輕量級許可權控制框架。

特點:

  • 輕量級。Shiro 主張的理念是把複雜的事情變簡單。針對對效能有更高要求

的網際網路應用有更好表現。

  • 通用性。

    • 好處:不侷限於Web 環境,可以脫離 Web 環境使用。
    • 缺陷:在Web 環境下一些特定的需求需要手動編寫程式碼定製。

Spring Security 是 Spring家族中的一個安全管理框架,實際上,在 Spring Boot 出現之前,Spring Security 就已經發展了多年了,但是使用的並不多,安全管理這個領域,一直是 Shiro 的天下。

相對於 Shiro,在 SSM 中整合Spring Security 都是比較麻煩的操作,所以,SpringSecurity 雖然功能比Shiro 強大,但是使用反而沒有 Shiro 多(Shiro 雖然功能沒有Spring Security 多,但是對於大部分專案而言,Shiro 也夠用了)。

自從有了 Spring Boot 之後,Spring Boot 對於 Spring Security 提供了自動化配置方案,可以使用更少的配置來使用 SpringSecurity。因此,一般來說,常見的安全管理技術棧的組合是這樣的:

  • SSM + Shiro
  • Spring Boot/Spring Cloud + SpringSecurity

以上只是一個推薦的組合而已,如果單純從技術上來說,無論怎麼組合,都是可以執行的。

1.4 模組劃分

....

2.SpringSecurity入門案例

2.1 建立一個專案

2.2 執行這個專案

訪問 localhost:8080

預設的使用者名稱:user

密碼在專案啟動的時候在控制檯會列印,注意每次啟動的時候密碼都回發生變化!

輸入使用者名稱,密碼,這樣表示可以訪問了,404 表示我們沒有這個控制器,但是我們可以 訪問了。

2.3 許可權管理中的相關概念

2.3.1 主體

英文單詞:principal

使用系統的使用者或裝置或從其他系統遠端登入的使用者等等。簡單說就是誰使用系 統誰就是主體。

2.3.2 認證

英文單詞:authentication

許可權管理系統確認一個主體的身份,允許主體進入系統。簡單說就是“主體”證 明自己是誰。 籠統的認為就是以前所做的登入操作。

2.3.3 授權

英文單詞:authorization

將作業系統的“權力”“授予”“主體”,這樣主體就具備了作業系統中特定功 能的能力。 所以簡單來說,授權就是給使用者分配許可權。

2.4 新增一個控制器進行訪問

package com.tuniu.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("test")
public class HelloSecurity {

    @GetMapping("/hello")
    public String hello() {
        return "hello Security;";
    }
}

2.5 SpringSecurity 基本原理

2.5.1 SpringSecurity 本質

是一個過濾器鏈

從啟動是可以獲取到過濾器鏈:

org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFil
ter
org.springframework.security.web.context.SecurityContextPersistenceFilter
org.springframework.security.web.header.HeaderWriterFilter
org.springframework.security.web.csrf.CsrfFilter
org.springframework.security.web.authentication.logout.LogoutFilter
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter
org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter
org.springframework.security.web.savedrequest.RequestCacheAwareFilter
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter
org.springframework.security.web.authentication.AnonymousAuthenticationFilter
org.springframework.security.web.session.SessionManagementFilter
org.springframework.security.web.access.ExceptionTranslationFilter
org.springframework.security.web.access.intercept.FilterSecurityInterceptor

程式碼底層流程:

重點看三個過濾器:

FilterSecurityInterceptor

ExceptionTranslationFilter

UsernamePasswordAuthenticationFilter

FilterSecurityInterceptor::是一個方法級的許可權過濾器, 基本位於過濾鏈的最底部

super.beforeInvocation(fi) 表示檢視之前的 filter 是否通過。

fi.getChain().doFilter(fi.getRequest(), fi.getResponse());表示真正的呼叫後臺的服務。

ExceptionTranslationFilter::是個異常過濾器,用來處理在認證授權過程中丟擲的異常

UsernamePasswordAuthenticationFilter :對/login 的 POST 請求做攔截,校驗表單中使用者 名,密碼。

2.5.2 SpringSecurity過濾器啟動原理

1.使用springsecurity配置過濾器(DelegatingFilterProxy);

2.該類的doFilter方法會通過initDelegate(wac)先獲取到過濾器bean(FilterChainProxy),通過他去初始化其他的filter(delegate.init(this.getFilterConfig()););

3.FilterChainProxy的doFilter方法會通過getFilters(fwRequest)方法獲取到WebApplicationContext配置中的所有配置過的的filter;

4.緊接著開始往後載入其他的filter

2.6 UserDetailsService 介面講解

當什麼也沒有配置的時候,賬號和密碼是由 Spring Security 定義生成的。而在實際專案中 賬號和密碼都是從資料庫中查詢出來的。 所以我們要通過自定義邏輯控制認證邏輯。

如果需要自定義邏輯時,只需要實現 UserDetailsService 介面即可。介面定義如下:

返回值 UserDetails

這個類是系統預設的使用者"主體"

// 表示獲取登入使用者所有許可權
Collection<? extends GrantedAuthority> getAuthorities();
// 表示獲取密碼
String getPassword();
// 表示獲取使用者名稱
String getUsername();
// 表示判斷賬戶是否過期
boolean isAccountNonExpired();
// 表示判斷賬戶是否被鎖定
boolean isAccountNonLocked();
// 表示憑證{密碼}是否過期
boolean isCredentialsNonExpired();
// 表示當前使用者是否可用
boolean isEnabled();

以下是 UserDetails 實現類

以後我們只需要使用 User 這個實體類即可!

方法引數 username:

表示使用者名稱。此值是客戶端表單傳遞過來的資料。預設情況下必須叫 username,否則無 法接收。

2.7 PasswordEncoder 介面講解

// 表示把引數按照特定的解析規則進行解析
String encode(CharSequence rawPassword);
// 表示驗證從儲存中獲取的編碼密碼與編碼後提交的原始密碼是否匹配。如果密碼匹
配,則返回 true;如果不匹配,則返回 false。第一個引數表示需要被解析的密碼。第二個
引數表示儲存的密碼。
boolean matches(CharSequence rawPassword, String encodedPassword);
// 表示如果解析的密碼能夠再次進行解析且達到更安全的結果則返回 true,否則返回
false。預設返回 false。
default boolean upgradeEncoding(String encodedPassword) {
return false;
}

介面實現類:

BCryptPasswordEncoder 是 Spring Security 官方推薦的密碼解析器,平時多使用這個解析器。

BCryptPasswordEncoder 是對 bcrypt 強雜湊方法的具體實現。是基於 Hash 演算法實現的單向加密。可以通過 strength 控制加密強度,預設 10.

查用方法演示:

@Test
public void test01(){
// 建立密碼解析器
BCryptPasswordEncoder bCryptPasswordEncoder = new
BCryptPasswordEncoder();
// 對密碼進行加密
String atguigu = bCryptPasswordEncoder.encode("atguigu");
// 列印加密之後的資料
System.out.println("加密之後資料:\t"+atguigu);
//判斷原字元加密後和加密之前是否匹配
boolean result = bCryptPasswordEncoder.matches("atguigu", atguigu);
// 列印比較結果
System.out.println("比較結果:\t"+result);
}

2.8 SpringBoot 對 Security 的自動配置

https://docs.spring.io/springsecurity/site/docs/5.3.4.RELEASE/reference/html5/#servlet-hello

3. SpringSecurity Web 許可權方案

3.1 設定登入系統的賬號、密碼

方式一:在 application.properties

spring.security.user.name=atguigu
spring.security.user.password=atguigu

方式二:編寫類實現介面

package com.tuniu.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        String encode = passwordEncoder.encode("123");
        auth.inMemoryAuthentication().withUser("lzg").password(encode).roles("admin");
    }

    @Bean
    PasswordEncoder getPasswordEncoder(){
        return new BCryptPasswordEncoder();
    }
}

3.2 實現資料庫認證來完成使用者登入

完成自定義登入

3.2.1 準備 sql

CREATE TABLE `users` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `username` varchar(20) NOT NULL,
  `password` varchar(100) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `username` (`username`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4

--密碼:123
insert into users(username,password) values('admin','$2a$10$onNtpOIhtMf.4IVyecahWOGD9/1phA/I1lT4LdU9lMQrUbTyyH3n2');--id = 1
insert into users(username,password) values('luy','$2a$10$MVQfFUNDNTGtZQYeV1aD/el3nZ2Fq.puiDR0B8FMCe1wBYtIYJbGS');--id = 2

create table role(
id bigint primary key auto_increment,
name varchar(20)
);

insert into role values(1,'管理員');
insert into role values(2,'普通使用者');

create table role_user(
uid bigint,
rid bigint
);
insert into role_user values(1,1);
insert into role_user values(2,2);

create table menu(
id bigint primary key auto_increment,
name varchar(20),
url varchar(100),
parentid bigint,
permission varchar(20)
);

insert into menu values(1,'系統管理','',0,'menu:system');
insert into menu values(2,'使用者管理','',0,'menu:user');

create table role_menu(
mid bigint,
rid bigint
);
insert into role_menu values(1,1);

insert into role_menu values(2,1);
insert into role_menu values(2,2);

3.2.2 新增依賴

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!--mybatis-plus-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.0.5</version>
        </dependency>
        <!--mysql-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
        <!--lombok 用來簡化實體類-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

3.2.3 製作實體類

@Data
public class Users {
    private Integer id;
    private String username;
    private String password;
}

3.2.4 整合 MybatisPlus 製作 mapper

@Mapper
public interface UsersMapper extends BaseMapper<Users> {
}

3.2.5 製作登入實現類

@Service("userDetailsService")
public class MyUserDetailsService implements UserDetailsService {
    @Resource
    private UsersMapper usersMapper;

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        QueryWrapper<Users> wrapper = new QueryWrapper<>();
        wrapper.eq("username",s);
        Users users = usersMapper.selectOne(wrapper);
        if (users == null) {
            throw new UsernameNotFoundException("使用者名稱不存在!");
        }
        System.out.println(users);
        List<GrantedAuthority> list = AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_admin");
        return new User(users.getUsername(),users.getPassword(),list);
    }
}

3.2.6 配置資料庫連結

#mysql8 資料庫連線
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/demo?serverTimezone=GMT%2B8
spring.datasource.username=root
spring.datasource.password=root

3.2.7 測試訪問

輸入使用者名稱,密碼

3.3 未認證請求跳轉到登入頁

3.3.1 引入前端模板依賴

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

3.3.2 引入登入頁面

將準備好的登入頁面匯入專案中,放入templates資料夾下

<!DOCTYPE html>
<!-- 需要新增
<html  xmlns:th="http://www.thymeleaf.org">
這樣在後面的th標籤就不會報錯
 -->
<html  xmlns:th="http://www.thymeleaf.org">
<head lang="en">
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
    <title>xx</title>
</head>
<body>


<h1>表單提交</h1>
<!-- 表單提交使用者資訊,注意欄位的設定,直接是*{} -->
<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 請求,所以上面的頁面不能使用,使用者名稱,密碼必須為 username,password 原因: 在執行登入的時候會走一個過濾器 UsernamePasswordAuthenticationFilter

3.3.3 編寫控制器

@Controller
public class LoginController {
    @GetMapping("/loginPage")
    public String login(){
        return "login";
    }

    @RequestMapping("/fail")
    public String fail(){
        return "fail";
    }

    @RequestMapping("/success")
    public String success(){
        return "success";
    }
    
  	@GetMapping("findAll")
    @ResponseBody
    public String findAll(){
        return "findAll";
    }
}

3.3.4 編寫配置類放行登入頁面以及靜態資源

在SecurityConfig配置類:

@Configuration
public class SecurityConfiger extends WebSecurityConfigurerAdapter {
    @Resource
    private MyUserDetailsService userDetailsService;

    //將密碼加密器加到容器中
    @Bean
    public PasswordEncoder getPasswordEncoder(){
        return new BCryptPasswordEncoder();
    }

    //重寫configure配置,將我們自己的校驗密碼器注入到該bean中
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(getPasswordEncoder());
    }

    //重寫configure配置,編寫許可權校驗規則
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests() // 認證配置
                .antMatchers("/","/login","/hello").permitAll() //設定哪些路徑可以直接訪問,不需要認證
                .anyRequest() // 任何請求
                .authenticated() // 都需要身份驗證
                .and().csrf().disable();//關閉cors
    }
}

3.3.5 測試

訪問:http://127.0.0.1:8081/loginPage

訪問http://127.0.0.1:8081/findAll會提示 403 錯誤 表示沒有這個許可權。

3.3.6 設定未授權的請求跳轉到登入頁

配置類configure(HttpSecurity http)方法加入:

@Configuration
public class SecurityConfiger extends WebSecurityConfigurerAdapter {
    @Resource
    private MyUserDetailsService userDetailsService;

    //將密碼加密器加到容器中
    @Bean
    public PasswordEncoder getPasswordEncoder(){
        return new BCryptPasswordEncoder();
    }

    //重寫configure配置,將我們自己的校驗密碼器注入到該bean中
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(getPasswordEncoder());
    }

    //重寫configure配置,編寫許可權校驗規則
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
                .loginPage("/loginPage").permitAll() //登入頁面跳轉請求
                .loginProcessingUrl("/login").permitAll() //登入發起的post請求方法
                .successForwardUrl("/success").permitAll() //登入成功頁面
                .failureForwardUrl("/fail"); //無許可權頁面

        http.authorizeRequests() // 認證配置
                .antMatchers("/","/login","/hello").permitAll() //設定哪些路徑可以直接訪問,不需要認證
                .anyRequest() // 任何請求
                .authenticated() // 都需要身份驗證
                .and().csrf().disable(); //關閉cors
    }
}

如果修改配置可以呼叫 usernameParameter()和 passwordParameter()方法。

<form action="/login" method="post">
使用者名稱:<input type="text" name="loginAcct"/><br/>
密碼:<input type="password" name="userPswd"/><br/>
<input type="submit" value="提交"/>
</form>

小記:

使用者資訊查詢使用的類是:UserDetailsService,需要對它進行繼承並重寫,需要將重寫後的bean通過configure(AuthenticationManagerBuilder auth)注入到配置類中

密碼加密使用的類是:PasswordEncoder,需要將它進行例項化,使用BCryptPasswordEncoder例項化,並放入spring容器中

許可權規則的修改類是:繼承WebSecurityConfigurerAdapter,並重寫裡面的configure(HttpSecurity http)方法

3.4 基於角色或許可權進行訪問控制

3.4.1 hasAuthority 方法

如果當前的主體具有指定的許可權,則返回 true,否則返回 false

只能對一個許可權進行驗證

  • 修改配置類
http.authorizeRequests() // 認證配置
        .antMatchers("/","/login","/hello").permitAll() //設定哪些路徑可以直接訪問,不需要認證
        .antMatchers("/findAll").hasAuthority("admin") //hasAuthority(),當前登入的使用者,只有具有了admin許可權才可以訪問這個路徑
        .anyRequest() // 任何請求
        .authenticated() // 都需要身份驗證
        .and().csrf().disable();
  • 新增一個控制器
@GetMapping("/find")
@ResponseBody
public String find(){
return "find";
}
  • 給使用者登入主體賦予許可權
  • 測試結果:

先要登入,並且使用者必須要admin許可權才能進行訪問:

List<GrantedAuthority> list = AuthorityUtils.commaSeparatedStringToAuthorityList("admin");

定義使用者的許可權。

3.4.2 hasAnyAuthority 方法

如果當前的主體有任何提供的許可權(給定的作為一個逗號分隔的字串列表)的話,返回 true.

http.authorizeRequests() // 認證配置
        .antMatchers("/","/login","/hello").permitAll() //設定哪些路徑可以直接訪問,不需要認證
        .antMatchers("/findAll").hasAnyAuthority("admin,manager") //hasAnyAuthority(),當前登入的使用者,有其中一個許可權就能訪問這個路徑
        .anyRequest() // 任何請求
        .authenticated() // 都需要身份驗證
        .and().csrf().disable();

3.4.3 hasRole 方法

如果使用者具備給定角色就允許訪問,否則出現 403。

如果當前主體具有指定的角色,則返回 true。

底層原始碼:

給使用者新增角色:

AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_admin");

@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
    QueryWrapper<Users> wrapper = new QueryWrapper<>();
    wrapper.eq("username",s);
    Users users = usersMapper.selectOne(wrapper);
    if (users == null) {
        throw new UsernameNotFoundException("使用者名稱不存在!");
    }
    System.out.println(users);
    List<GrantedAuthority> list = AuthorityUtils.commaSeparatedStringToAuthorityList("admin,ROLE_sale");
    return new User(users.getUsername(),users.getPassword(),list);
}

修改配置檔案:

注意配置檔案中不需要新增”ROLE_“,因為上述的底層程式碼會自動新增與之進行匹配。

http.authorizeRequests() // 認證配置
        .antMatchers("/","/login","/hello").permitAll() //設定哪些路徑可以直接訪問,不需要認證
        .antMatchers("/findAll").hasRole("sale") //hasRole(),只有具有了sale角色的才能訪問該路徑
        .anyRequest() // 任何請求
        .authenticated() // 都需要身份驗證
        .and().csrf().disable();

3.4.4 hasAnyRole

表示使用者具備任何一個角色都可以訪問。

給使用者新增角色:

List<GrantedAuthority> list = AuthorityUtils.commaSeparatedStringToAuthorityList("admin,ROLE_admin,ROLE_sale");

修改配置檔案:

http.authorizeRequests() // 認證配置
        .antMatchers("/","/login","/hello").permitAll() //設定哪些路徑可以直接訪問,不需要認證
        .antMatchers("/findAll").hasAnyRole("sale,admin") //hasAnyRole(),只有具有了sale或admin角色的才能訪問該路徑
        .anyRequest() // 任何請求
        .authenticated() // 都需要身份驗證
        .and().csrf().disable();

3.5 基於資料庫實現許可權認證

3.5.1 新增實體類

@Data
public class Menu {
    private Long id;
    private String name;
    private String url;
    private Long parentId;
    private String permission;
}
@Data
public class Role {
    private Long id;
    private String name;
}

3.5.2 編寫介面與實現

UserInfoMapper:

@Mapper
public interface UserInfoMapper extends BaseMapper<Role> {
    /**
     * 根據使用者 Id 查詢使用者角色
     * @param userId
     * @return
     */
    List<Role> selectRoleByUserId(Long userId);
    /**
     * 根據使用者 Id 查詢選單
     * @param userId
     * @return
     */
    List<Menu> selectMenuByUserId(Long userId);
}

上述介面需要進行多表管理查詢:

需要在 resource/mapper 目錄下自定義 UserInfoMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.tuniu.springsecurity02.mapper.UserInfoMapper">

    <!--根據使用者 Id 查詢角色資訊-->
    <select id="selectRoleByUserId" resultType="com.tuniu.springsecurity02.entity.Role">
         SELECT r.id,r.name FROM role r
         INNER JOIN role_user ru ON ru.rid=r.id
         where ru.uid=#{0}
    </select>

    <!--根據使用者 Id 查詢許可權資訊-->
    <select id="selectMenuByUserId" resultType="com.tuniu.springsecurity02.entity.Menu">
        SELECT m.id,m.name,m.url,m.parentid,m.permission
        FROM menu m
        INNER JOIN role_menu rm ON m.id=rm.mid
        INNER JOIN role r ON r.id=rm.rid
        INNER JOIN role_user ru ON r.id=ru.rid
        WHERE ru.uid=#{0}
    </select>
</mapper>

修改UsersServiceImpl類,主要是加了查詢資料庫和解析返回資料的動作:

@Service("userDetailsService")
public class MyUserDetailsService implements UserDetailsService {
    @Resource
    private UsersMapper usersMapper;
    @Resource
    private UserInfoMapper userInfoMapper;

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        QueryWrapper<Users> wrapper = new QueryWrapper<>();
        wrapper.eq("username",s);
        Users users = usersMapper.selectOne(wrapper);
        if (users == null) {
            throw new UsernameNotFoundException("使用者名稱不存在!");
        }
        System.out.println(users);
        //許可權集合
        List<GrantedAuthority> list = new ArrayList<>();
        List<Menu> menus = userInfoMapper.selectMenuByUserId(Long.valueOf(users.getId()));
        List<Role> roles = userInfoMapper.selectRoleByUserId(Long.valueOf(users.getId()));
        //處理許可權
        for (Menu menu : menus) {
            list.add(new SimpleGrantedAuthority(menu.getPermission()));
        }
        //處理角色
        for (Role role : roles) {
            list.add(new SimpleGrantedAuthority("ROLE_"+role.getName()));
        }
//        List<GrantedAuthority> list = AuthorityUtils.commaSeparatedStringToAuthorityList("admin,ROLE_admin,ROLE_role");
        return new User(users.getUsername(),users.getPassword(),list);
    }
}

3.5.3 在配置檔案中新增對映

在配置檔案中 application.properties 新增

#配置xml檔案所在的位置
mybatis-plus.mapper-locations=classpath*:/mapper/**/*.xml

3.5.4 修改訪問配置類

http.authorizeRequests() // 認證配置
        .antMatchers("/","/login","/hello").permitAll() //設定哪些路徑可以直接訪問,不需要認證
        .antMatchers("/findAll").hasRole("管理員")
        .antMatchers("/findAll").hasAnyAuthority("menu:system")
        .anyRequest() // 任何請求
        .authenticated() // 都需要身份驗證
        .and().csrf().disable();

3.5.5 使用管理員與非管理員進行測試

如果非管理員賬號(luy)測試會提示 403 沒有許可權

管理員賬號(admin)登入能正常訪問

3.6 自定義 403 頁面

3.6.1 修改訪問配置類

//配置沒有許可權訪問跳轉自定義頁面
http.exceptionHandling().accessDeniedPage("/fail");

3.6.2 新增對應控制器

@RequestMapping("/fail")
public String fail(){
	return "fail";
}

fail.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <h1>沒有訪問許可權!</h1>
</body>
</html>

3.6.3 測試

3.7 註解使用

3.7.1 @Secured

判斷是否具有角色,另外需要注意的是這裡匹配的字串需要新增字首“ROLE_“

進入方法前校驗許可權

使用註解先要開啟註解功能!

@EnableGlobalMethodSecurity(securedEnabled=true)

@SpringBootApplication
@MapperScan("com.tuniu.springsecurity02.mapper")
@EnableGlobalMethodSecurity(securedEnabled = true)
public class Springsecurity02Application {
    public static void main(String[] args) {
        SpringApplication.run(Springsecurity02Application.class, args);
    }
}

在控制器方法上添加註解

@GetMapping("/testSecured")
@Secured({"ROLE_管理員","ROLE_普通使用者"}) //校驗許可權的註解
public String testSecured(){
    return "hello,Secured";
}

@GetMapping("/testSecured1")
@Secured({"ROLE_普通使用者"}) //校驗許可權的註解
public String testSecured1(){
    return "hello,Secured1";
}

@GetMapping("/testSecured2")
@Secured({"ROLE_管理員"}) //校驗許可權的註解
public String testSecured2(){
    return "hello,Secured2";
}

結果:

luy,只有‘’普通使用者‘’角色,只能訪問/testSecured,/testSecured1請求

admin,只有‘管理員‘角色,只能訪問/testSecured,/testSecured2請求

3.7.2 @PreAuthorize

@PreAuthorize:註解適合進入方法前的許可權驗證, @PreAuthorize 可以將登入用 戶的 roles/permissions 引數傳到方法中。

進入方法前校驗許可權

先開啟註解功能:

@EnableGlobalMethodSecurity(prePostEnabled = true)

@SpringBootApplication
@MapperScan("com.tuniu.springsecurity02.mapper")
@EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true)
public class Springsecurity02Application {
    public static void main(String[] args) {
        SpringApplication.run(Springsecurity02Application.class, args);
    }
}
@GetMapping("/testPreAuthorize")
@PreAuthorize("hasAnyAuthority('menu:system')")
public String testPreAuthorize(){
    System.out.println("PreAuthorize");
    return "hello PreAuthorize";
}

@GetMapping("/testPreAuthorize1")
@PreAuthorize("hasRole('ROLE_普通使用者')")
public String testPreAuthorize1(){
    System.out.println("PreAuthorize1");
    return "hello PreAuthorize1";
}

測試結果:

luy,只有‘’普通使用者‘’角色,能訪問/testPreAuthorize1;只有menu:user許可權,不能訪問/testPreAuthorize

Admin,只有‘’管理員‘’角色,不能訪問/testPreAuthorize1;有menu:system許可權,能訪問/testPreAuthorize

3.7.3 @PostAuthorize

先開啟註解功能:

@EnableGlobalMethodSecurity(prePostEnabled = true)

@PostAuthorize 註解使用並不多,在方法執行後再進行許可權驗證,適合驗證帶有返回值的許可權.

進入方法後,對出引數進行校驗許可權

@GetMapping("/testPostAuthorize")
@PostAuthorize("hasAnyAuthority('menu:system')")
public String preAuthorize(){
    System.out.println("test--PostAuthorize");
    return "PostAuthorize";
}

測試結果:

luy,只有menu:user許可權,不能訪問/testPostAuthorize,會列印test--PostAuthorize

Admin,有menu:system許可權,能訪問/testPostAuthorize,會列印test--PostAuthorize

3.7.4 @PostFilter

@PostFilter :許可權驗證之後對資料進行過濾 留下使用者名稱是 admin1 的資料

表示式中的 filterObject 引用的是方法返回值 List 中的某一個元素

@RequestMapping("/getAll")
@PreAuthorize("hasRole('ROLE_管理員')")
@PostFilter("filterObject.username == 'admin1'")
public List<Users> getAllUser(){
    System.out.println("PostFilter");
    List<Users> list = new ArrayList<>();
    list.add(new Users(1,"admin1","6666"));
    list.add(new Users(2,"admin2","888"));
    return list;
}

測試結果:

luy,只有‘’普通使用者‘’角色,不能訪問/getAll;不會列印PostFilter;

Admin,只有‘’管理員‘’角色,能訪問/getAll;會列印PostFilter;

前端返回結果是:

3.7.5 @PreFilter

@PreFilter: 進入控制器之前對資料進行過濾

@RequestMapping("getTestPreFilter")
@PreAuthorize("hasRole('ROLE_管理員')")
@PreFilter(value = "filterObject.id%2==0")
public List<Users> getTestPreFilter(@RequestBody List<Users> list){
    list.forEach(t-> {
        System.out.println(t.toString());
    });
    return list;
}

先登入,然後使用apiPost 進行測試,拷貝登入後的cookie

測試的 Json 資料:

[
  {
    "id": 1,
    "username": "admin",
    "password": "666"
  },
  {
    "id": 2,
    "username": "admins",
    "password": "888"
  },
  {
    "id": 3,
    "username": "admins11",
    "password": "11888"
  },
  {
    "id": 4,
    "username": "admins22",
    "password": "22888"
  }
]

測試返回結果:

[
	{
		"id": 2,
		"username": "admins",
		"password": "888"
	},
	{
		"id": 4,
		"username": "admins22",
		"password": "22888"
	}
]

3.7.6 許可權表示式

https://docs.spring.io/spring-security/site/docs/5.3.4.RELEASE/reference/html5/#el-access

3.8 基於資料庫的記住我

3.8.1 建立表

CREATE TABLE persistent_logins (
 username varchar(64) NOT NULL,
 series varchar(64) NOT NULL,
 token varchar(64) NOT NULL,
 last_used timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE
CURRENT_TIMESTAMP,
 PRIMARY KEY (series)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

3.8.2 新增資料庫的配置檔案

spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/spring5?serverTimezone=GMT%2B8
spring.datasource.username=root
spring.datasource.password=root

3.8.3 編寫配置類

建立配置類:

@Configuration
public class BrowserSecurityConfig {
    @Resource
    private DataSource dataSource;

    //注入操作的類PersistentTokenRepository
    @Bean
    public PersistentTokenRepository getPersistentTokenRepository(){
        JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
        // 賦值資料來源
        jdbcTokenRepository.setDataSource(dataSource);
        // 自動建立表,第一次執行會建立,以後要執行就要刪除掉!
        //jdbcTokenRepository.setCreateTableOnStartup(true);
        return jdbcTokenRepository;
    }
}

3.8.4 修改安全配置類

在配置類SecurityConfiger中加入:

@Resource
private PersistentTokenRepository persistentTokenRepository;

// 開啟記住我功能
http.rememberMe()
        .tokenRepository(persistentTokenRepository) //令牌操作類
        .userDetailsService(userDetailsService); //使用者資訊查詢類

3.8.5 頁面新增記住我複選框

記住我:<input type="checkbox"name="remember-me"title="記住密碼"/><br/>

此處:name 屬性值必須位 remember-me.不能改為其他值,這個是預設的名字

3.8.6 測試記住我

登入成功之後,關閉瀏覽器再次訪問 http://localhost:8090/findAll,發現依然可以使用!

3.8.7 設定有效期

預設 2 周時間。但是可以通過設定狀態有效時間,即使專案重新啟動下次也可以正常登 錄。

在配置檔案SecurityConfiger中設定:

// 開啟記住我功能
http.rememberMe()
        .tokenValiditySeconds(60) //過期時間,單位是秒
        .tokenRepository(persistentTokenRepository) //令牌操作類
        .userDetailsService(userDetailsService); //使用者資訊查詢類

3.9 使用者登出

3.9.1 在登入頁面新增一個退出連線

success.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    登入成功!
    <a href="/logout">退出</a>
</body>
</html>

3.9.2 在配置類中新增退出對映地址

//退出
http.logout()
        .logoutUrl("/logout") //退出的請求
        .logoutSuccessUrl("/loginPage").permitAll(); //退出成功之後跳轉的頁面

3.9.3 測試

退出之後,是無法訪問需要登入時才能訪問的控制器!

3.10 CSRF

3.10.1 CSRF 理解

跨站請求偽造(英語:Cross-site request forgery),也被稱為 one-click attack 或者 session riding,通常縮寫為 CSRF 或者 XSRF, 是一種挾制使用者在當前已 登入的 Web 應用程式上執行非本意的操作的攻擊方法。跟跨網站指令碼(XSS)相比,XSS 利用的是使用者對指定網站的信任CSRF 利用的是網站對使用者網頁瀏覽器的信任

跨站請求攻擊,簡單地說,是攻擊者通過一些技術手段欺騙使用者的瀏覽器去訪問一個 自己曾經認證過的網站並執行一些操作(如發郵件,發訊息,甚至財產操作如轉賬和購買 商品)。由於瀏覽器曾經認證過,所以被訪問的網站會認為是真正的使用者操作而去執行。 這利用了 web 中使用者身份驗證的一個漏洞:簡單的身份驗證只能保證請求發自某個使用者的瀏覽器,卻不能保證請求本身是使用者自願發出的

Spring Security 4.0 開始,預設情況下會啟用 CSRF 保護,以防止 CSRF 攻擊應用 程式,Spring Security CSRF 會針對 PATCH,POST,PUT 和 DELETE 方法進行防護。

3.10.2 案例

在登入頁面新增一個隱藏域:

<input type="hidden" th:if="${_csrf}!=null" th:value="${_csrf.token}" name="_csrf"/>

註釋安全配置的類中的 關閉csrf防護(預設是開著的)的程式碼

// http.csrf().disable();

3.10.3 Spring Security 實現 CSRF 的原理:

  1. 生成 csrfToken 儲存到 HttpSession 或者 Cookie 中。

SaveOnAccessCsrfToken 類有個介面 CsrfTokenRepository

當前介面實現類:HttpSessionCsrfTokenRepository,CookieCsrfTokenRepository

  1. 請求到來時,從請求中提取 csrfToken,和儲存的 csrfToken 做比較,進而判斷當 前請求是否合法。主要通過 CsrfFilter 過濾器來完成。

4. SpringSecurity 微服務許可權方案

4.1 什麼是微服務

1、微服務由來

微服務最早由 Martin Fowler 與 James Lewis 於 2014 年共同提出,微服務架構風格是一種 使用一套小服務來開發單個應用的方式途徑,每個服務執行在自己的程序中,並使用輕量 級機制通訊,通常是 HTTP API,這些服務基於業務能力構建,並能夠通過自動化部署機制 來獨立部署,這些服務使用不同的程式語言實現,以及不同資料儲存技術,並保持最低限 度的集中式管理。

2、微服務優勢

(1)微服務每個模組就相當於一個單獨的專案,程式碼量明顯減少,遇到問題也相對來說比 較好解決。

(2)微服務每個模組都可以使用不同的儲存方式(比如有的用 redis,有的用 mysql 等),資料庫也是單個模組對應自己的資料庫。

(3)微服務每個模組都可以使用不同的開發技術,開發模式更靈活。

3、微服務本質

(1)微服務,關鍵其實不僅僅是微服務本身,而是系統要提供一套基礎的架構,這種架構 使得微服務可以獨立的部署、執行、升級,不僅如此,這個系統架構還讓微服務與微服務 之間在結構上“鬆耦合”,而在功能上則表現為一個統一的整體。這種所謂的“統一的整 體”表現出來的是統一風格的介面,統一的許可權管理,統一的安全策略,統一的上線過 程,統一的日誌和審計方法,統一的排程方式,統一的訪問入口等等。

(2)微服務的目的是有效的拆分應用,實現敏捷開發和部署。

4.2 微服務認證與授權實現思路

1、認證授權過程分析

(1)如果是基於 Session,那麼 Spring-security 會對 cookie 裡的 sessionid 進行解析,找到伺服器儲存的 session 資訊,然後判斷當前使用者是否符合請求的要求。

(2)如果是 token,則是解析出 token,然後將當前請求加入到 Spring-security 管理的許可權資訊中去

如果系統的模組眾多,每個模組都需要進行授權與認證,所以我們選擇基於 token 的形式 進行授權與認證,使用者根據使用者名稱密碼認證成功,然後獲取當前使用者角色的一系列許可權值,並以使用者名稱為 key,許可權列表為 value 的形式存入 redis 快取中,根據使用者名稱相關資訊 生成 token 返回,瀏覽器將 token 記錄到 cookie 中,每次呼叫 api 介面都預設將 token 攜帶到 header 請求頭中,Spring-security 解析 header 頭獲取 token 資訊,解析 token 獲取當前 使用者名稱,根據使用者名稱就可以從 redis 中獲取許可權列表,這樣 Spring-security 就能夠判斷當前請求是否有許可權訪問

2、許可權管理資料模型

4.3 jwt 介紹

1、訪問令牌的型別

2、JWT 的組成

典型的,一個 JWT 看起來如下圖:

該物件為一個很長的字串,字元之間通過"."分隔符分為三個子串。

每一個子串表示了一個功能塊,總共有以下三個部分:JWT 頭、有效載荷和簽名

JWT 頭

JWT 頭部分是一個描述 JWT 元資料的 JSON 物件,通常如下所示。

{
"alg": "HS256",
"typ": "JWT"
}

在上面的程式碼中,

alg 屬性表示簽名使用的演算法,預設為 HMAC SHA256(寫為 HS256);

typ 屬性表示令牌的型別,JWT 令牌統一寫為 JWT。

最後,使用 Base64 URL 演算法將上述 JSON 物件轉換為字串儲存。

有效載荷

有效載荷部分,是 JWT 的主體內容部分,也是一個 JSON 物件,包含需要傳遞的資料。 JWT 指定七個預設欄位供選擇。

iss:發行人

exp:到期時間

sub:主題

aud:使用者

nbf:在此之前不可用

iat:釋出時間

jti:JWT ID 用於標識該 JWT

除以上預設欄位外,我們還可以自定義私有欄位,如下例:

{
"sub": "1234567890",
"name": "Helen",
"admin": true
}

請注意,預設情況下 JWT 是未加密的,任何人都可以解讀其內容,因此不要構建隱私資訊 欄位,存放保密資訊,以防止資訊洩露

JSON 物件也使用 Base64 URL 演算法轉換為字串儲存。

簽名雜湊

簽名雜湊部分是對上面兩部分資料簽名,通過指定的演算法生成雜湊,以確保資料不會被篡 改。

首先,需要指定一個密碼(secret)。該密碼僅僅為儲存在伺服器中,並且不能向用戶公開。然後使用標頭中指定的簽名演算法(預設情況下為 HMAC SHA256)根據以下公式生成簽名。

HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(claims), secret)

在計算出簽名雜湊後,JWT 頭,有效載荷和簽名雜湊的三個部分組合成一個字串,每個 部分用"."分隔,就構成整個 JWT 物件。

Base64URL 演算法

如前所述,JWT 頭和有效載荷序列化的演算法都用到了 Base64URL。該演算法和常見 Base64 算 法類似,稍有差別。

作為令牌的 JWT 可以放在 URL 中(例如 api.example/?token=xxx)。 Base64 中用的三個 字元是"+","/"和"=",由於在 URL 中有特殊含義,因此 Base64URL 中對他們做了替換: "="去掉,"+"用"-"替換,"/"用"_"替換,這就是 Base64URL 演算法

4.4 具體程式碼實現(核心部分)

service_base下的程式碼直接拷貝;

spring_security下的程式碼需要編寫:

4.3.1 建立認證授權相關的工具類

(1)DefaultPasswordEncoder:密碼處理的方法

//預設密碼處理器
@Component
public class DefaultPasswordEncoder implements PasswordEncoder {
    public DefaultPasswordEncoder() {
        this(-1);
    }
    /**
     * @param strength
     * the log rounds to use, between 4 and 31
     */
    public DefaultPasswordEncoder(int strength) {
    }
    //進行md5加密
    @Override
    public String encode(CharSequence rawPassword) {
        String encrypt = MD5.encrypt(rawPassword.toString());
        return encrypt;
    }

    /**
     * 進行密碼比對
     * @param rawPassword 資料庫的加密過的密碼
     * @param encodedPassword 要加密的密碼
     * @return
     */
    @Override
    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        if (encodedPassword == null || encodedPassword.length() == 0) {
            return false;
        }
        boolean result = encodedPassword.equals(MD5.encrypt(rawPassword.toString()));
        System.out.println("密碼對比結果:"+result);
        return result;
    }
}

(2)TokenManager:token 操作的工具類

//token生成器
@Component
public class TokenManager {
    //token有效時長
    private long tokenEcpiration = 24*60*60*1000;
    //編碼祕鑰
    private String tokenSignKey = "123456";

    //1 使用jwt根據使用者名稱生成token
    public String createToken(String username) {
        //例項化
        JwtBuilder jwtBuilder = Jwts.builder();
        //jwt的唯一標識
        jwtBuilder.setId(UUID.randomUUID().toString());
        //生成的時間
        jwtBuilder.setIssuedAt(new Date());
        //主題,就行郵件的主體一樣
        jwtBuilder.setSubject(username);
        //相當於playload,只是這個會將map轉成json,而那個會是一個字串
        jwtBuilder.setClaims(new HashMap<>(1));
        //加密演算法
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
        //設定簽名
        jwtBuilder.signWith(signatureAlgorithm,tokenSignKey);
        //設定過期時間,其中的時間戳要大於生成時間
        jwtBuilder.setExpiration(new Date(System.currentTimeMillis() + tokenEcpiration));
        //壓縮生成token
        return jwtBuilder.compact();
    }

    //2 根據token字串得到使用者資訊
    public String getUserInfoFromToken(String token) {
        JwtParser parser = Jwts.parser();
        //設定解密鹽
        parser.setSigningKey(tokenSignKey);
        //設定需要解密的token,並獲取DefaultJwtParser物件
        Claims claims = parser.parseClaimsJws(token).getBody();
        //獲取token中的主體資訊
        String subject = claims.getSubject();
        return subject;
    }

    //3 刪除token
    public void removeToken(String token) { }
}

(3)TokenLogoutHandler:退出實現

//退出處理器
public class TokenLogoutHandler implements LogoutHandler {
    private TokenManager tokenManager;
    private RedisTemplate redisTemplate;

    public TokenLogoutHandler(TokenManager tokenManager, RedisTemplate redisTemplate) {
        this.tokenManager = tokenManager;
        this.redisTemplate = redisTemplate;
    }

    @Override
    public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        //1 從header裡面獲取token
        //2 token不為空,移除token,從redis刪除token
        String token = request.getHeader("token");
        if (token != null) {
            //移除
            tokenManager.removeToken(token);
            //從token獲取使用者名稱
            String username = tokenManager.getUserInfoFromToken(token);
            redisTemplate.delete(username);
        }
        ResponseUtil.out(response, R.ok());
    }
}

(4)UnauthorizedEntryPoint:未授權統一處理

//未授權統一處理類
public class UnauthEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        ResponseUtil.out(httpServletResponse, R.error());
    }
}

4.3.2 建立認證授權實體類

(1) User使用者實體類

@Data
@ApiModel(description = "使用者實體類")
public class User implements Serializable {

    private static final long serialVersionUID = 1L;

    @ApiModelProperty(value = "微信openid")
    private String username;

    @ApiModelProperty(value = "密碼")
    private String password;

    @ApiModelProperty(value = "暱稱")
    private String nickName;

    @ApiModelProperty(value = "使用者頭像")
    private String salt;

    @ApiModelProperty(value = "使用者簽名")
    private String token;

}

(2) SecutityUser

@Data
public class SecurityUser implements UserDetails {
    //當前登入使用者
    private transient User currentUserInfo;
    //當前許可權
    private List<String> permissionValueList;

    public SecurityUser() {
    }

    public SecurityUser(User user) {
        if (user != null) {
            this.currentUserInfo = user;
        }
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        for(String permissionValue : permissionValueList) {
            if(StringUtils.isEmpty(permissionValue)) continue;
            SimpleGrantedAuthority authority = new SimpleGrantedAuthority(permissionValue);
            authorities.add(authority);
        }
        return authorities;
    }

    @Override
    public String getPassword() {
        return currentUserInfo.getPassword();
    }

    @Override
    public String getUsername() {
        return currentUserInfo.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

4.3.3 建立認證和授權的 filter

(1)TokenLoginFilter:認證的 filter

//使用者登入的過濾器
public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter {
    private TokenManager tokenManager;
    private RedisTemplate redisTemplate;
    private AuthenticationManager authenticationManager;

    public TokenLoginFilter(AuthenticationManager authenticationManager, TokenManager tokenManager, RedisTemplate redisTemplate) {
        this.tokenManager = tokenManager;
        this.redisTemplate = redisTemplate;
        this.authenticationManager = authenticationManager;
        this.setPostOnly(true);
        this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/admin/acl/login","POST"));
    }

    //1 獲取表單提交使用者名稱和密碼,並驗證和返回認證結果
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request,
                                                HttpServletResponse response) throws AuthenticationException {
        if (!request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException(
                    "Authentication method not supported: " + request.getMethod());
        }
        //獲取表單提交資料
        try {
            User user = new ObjectMapper().readValue(request.getInputStream(), User.class);
            UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
                    user.getUsername().trim(), user.getPassword());
            Authentication authenticate = authenticationManager.authenticate(authRequest);
            return authenticate;
        } catch (IOException e) {
            e.printStackTrace();
            throw new RuntimeException();
        }
    }

    //2 認證成功呼叫的方法,認證成功生產token,並加token放入到Redis 中
    /**
     *
     * @param request
     * @param response
     * @param chain
     * @param authResult attemptAuthentication()返回的認證結果
     * @throws IOException
     * @throws ServletException
     */
    protected void successfulAuthentication(HttpServletRequest request,
                                            HttpServletResponse response, FilterChain chain, Authentication authResult)
            throws IOException, ServletException {
        // Fire event
        if (this.eventPublisher != null) {
            eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
                    authResult, this.getClass()));
        }

        //認證成功,得到認證成功之後使用者資訊
        SecurityUser user = (SecurityUser)authResult.getPrincipal();
        //根據使用者名稱生成token
        String token = tokenManager.createToken(user.getCurrentUserInfo().getUsername());
        //把使用者名稱稱和使用者許可權列表放到redis
        redisTemplate.opsForValue().set(user.getCurrentUserInfo().getUsername(),user.getPermissionValueList());
        //返回token
        ResponseUtil.out(response, R.ok().data("token",token));
    }

    //3 認證失敗呼叫的方法
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request,
                                              HttpServletResponse response, AuthenticationException failed)
            throws IOException, ServletException {
        ResponseUtil.out(response,R.error());
    }
}

(2)TokenAuthenticationFilter:授權 filter

public class TokenAuthFilter extends BasicAuthenticationFilter {
    private TokenManager tokenManager;
    private RedisTemplate redisTemplate;
    public TokenAuthFilter(AuthenticationManager authenticationManager,TokenManager tokenManager,RedisTemplate redisTemplate) {
        super(authenticationManager);
        this.tokenManager = tokenManager;
        this.redisTemplate = redisTemplate;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        if(req.getRequestURI().indexOf("admin") == -1) {
            chain.doFilter(req, res);
            return;
        }
        UsernamePasswordAuthenticationToken authentication = null;
        try {
            //獲取當前認證成功使用者許可權資訊
            authentication = getAuthentication(req);
        } catch (Exception e) {
            ResponseUtil.out(res, R.error());
        }
        //判斷如果有許可權資訊,放到許可權上下文中
        if (authentication != null) {

            SecurityContextHolder.getContext().setAuthentication(authentication);
        } else {
            ResponseUtil.out(res, R.error());
        }
        chain.doFilter(req, res);
    }

    private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
        // token 置於 header 裡
        String token = request.getHeader("token");
        if (token != null && !"".equals(token.trim())) {
            String userName = tokenManager.getUserInfoFromToken(token);//我們只在token的主體中放入了username
            List<String> permissionValueList = (List<String>)
                    redisTemplate.opsForValue().get(userName);
            Collection<GrantedAuthority> authorities = new ArrayList<>();
            for(String permissionValue : permissionValueList) {
                if(StringUtils.isEmpty(permissionValue)) continue;
                SimpleGrantedAuthority authority = new
                        SimpleGrantedAuthority(permissionValue);
                authorities.add(authority);
            }
            if (!StringUtils.isEmpty(userName)) {
                return new UsernamePasswordAuthenticationToken(userName, token,
                        authorities);
            }
            return null;
        }
        return null;
    }
}

4.3.4 編寫核心配置類

Spring Security 的核心配置就是繼承 WebSecurityConfigurerAdapter 並註解 @EnableWebSecurity 的配置。這個配置指明瞭使用者名稱密碼的處理方式、請求路徑、登入 登出控制等和安全相關的配置

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class TokenWebSecurityConfig extends WebSecurityConfigurerAdapter {
    //自定義查詢資料庫使用者名稱密碼和許可權資訊
    private UserDetailsService userDetailsService;
    //token 管理工具類(生成 token)
    private TokenManager tokenManager;
    //密碼管理工具類
    private DefaultPasswordEncoder defaultPasswordEncoder;
    //redis 操作工具類
    private RedisTemplate redisTemplate;
    @Autowired
    public TokenWebSecurityConfig(UserDetailsService userDetailsService,
                                  DefaultPasswordEncoder defaultPasswordEncoder,
                                  TokenManager tokenManager, RedisTemplate
                                          redisTemplate) {
        this.userDetailsService = userDetailsService;
        this.defaultPasswordEncoder = defaultPasswordEncoder;
        this.tokenManager = tokenManager;
        this.redisTemplate = redisTemplate;
    }

    /**
     * 配置設定
     * @param http
     * @throws Exception
     */
    //設定退出的地址和token,redis操作地址
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.exceptionHandling()
                .authenticationEntryPoint(new UnauthEntryPoint())//沒有許可權訪問
                .and()
                .csrf().disable()
                .authorizeRequests()
                .anyRequest().authenticated()
                .and().logout().logoutUrl("/admin/acl/index/logout") //退出路徑
                .addLogoutHandler(new TokenLogoutHandler(tokenManager,redisTemplate))
                .and()
                .addFilter(new TokenLoginFilter(authenticationManager(),tokenManager,redisTemplate))
                .addFilter(new TokenAuthFilter(authenticationManager(),tokenManager,redisTemplate))
                .httpBasic();
    }

    //呼叫userDetailsService和密碼處理
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(defaultPasswordEncoder);
    }

    //不進行認證的路徑,可以直接訪問
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/api/**");
    }
}

5. SpringSecurity 原理總結

5.1 SpringSecurity 的過濾器介紹

SpringSecurity 採用的是責任鏈的設計模式,它有一條很長的過濾器鏈。現在對這條過濾 器鏈的 15 個過濾器進行說明:

(1) WebAsyncManagerIntegrationFilter:將 Security 上下文與 Spring Web 中用於 處理非同步請求對映的 WebAsyncManager 進行整合。

(2) SecurityContextPersistenceFilter:在每次請求處理之前將該請求相關的安全上 下文資訊載入到 SecurityContextHolder 中,然後在該次請求處理完成之後,將 SecurityContextHolder 中關於這次請求的資訊儲存到一個“倉儲”中,然後將 SecurityContextHolder 中的資訊清除,例如在 Session 中維護一個使用者的安全信 息就是這個過濾器處理的。

(3) HeaderWriterFilter:用於將頭資訊加入響應中。

(4) CsrfFilter:用於處理跨站請求偽造。

(5)LogoutFilter:用於處理退出登入。

(6)UsernamePasswordAuthenticationFilter:用於處理基於表單的登入請求,從表單中 獲取使用者名稱和密碼。預設情況下處理來自 /login 的請求。從表單中獲取使用者名稱和密碼 時,預設使用的表單 name 值為 username 和 password,這兩個值可以通過設定這個 過濾器的 usernameParameter 和 passwordParameter 兩個引數的值進行修改。

(7)DefaultLoginPageGeneratingFilter:如果沒有配置登入頁面,那系統初始化時就會 配置這個過濾器,並且用於在需要進行登入時生成一個登入表單頁面。

(8)BasicAuthenticationFilter:檢測和處理 http basic 認證。

(9)RequestCacheAwareFilter:用來處理請求的快取。

(10)SecurityContextHolderAwareRequestFilter:主要是包裝請求物件 request。

(11)AnonymousAuthenticationFilter:檢測 SecurityContextHolder 中是否存在 Authentication 物件,如果不存在為其提供一個匿名 Authentication。

(12)SessionManagementFilter:管理 session 的過濾器

(13)ExceptionTranslationFilter:處理 AccessDeniedException 和 AuthenticationException 異常。

(14)FilterSecurityInterceptor:可以看做過濾器鏈的出口。

(15)RememberMeAuthenticationFilter:當用戶沒有登入而直接訪問資源時, 從 cookie 裡找出使用者的資訊, 如果 Spring Security 能夠識別出使用者提供的remember me cookie, 使用者將不必填寫使用者名稱和密碼, 而是直接登入進入系統,該過濾器預設不開啟。

5.2 SpringSecurity 基本流程

Spring Security 採取過濾鏈實現認證與授權,只有當前過濾器通過,才能進入下一個 過濾器:

綠色部分是認證過濾器,需要我們自己配置,可以配置多個認證過濾器。認證過濾器可以 使用 Spring Security 提供的認證過濾器,也可以自定義過濾器(例如:簡訊驗證)。認證過濾器要在 configure(HttpSecurity http)方法中配置,沒有配置不生效。下面會重點介紹以下三個過濾器:

UsernamePasswordAuthenticationFilter 過濾器:該過濾器會攔截前端提交的 POST 方式 的登入表單請求,並進行身份認證。

ExceptionTranslationFilter 過濾器:該過濾器不需要我們配置,對於前端提交的請求會直接放行,捕獲後續丟擲的異常並進行處理(例如:許可權訪問限制)

FilterSecurityInterceptor 過濾器:該過濾器是過濾器鏈的最後一個過濾器,根據資源許可權配置來判斷當前請求是否有許可權訪問對應的資源。如果訪問受限會丟擲相關異常,並由 ExceptionTranslationFilter 過濾器進行捕獲和處理。

5.3 SpringSecurity 認證流程

認證流程是在 UsernamePasswordAuthenticationFilter 過濾器中處理的,具體流程如下 所示:

5.3.1UsernamePasswordAuthenticationFilter 原始碼

認證流程總覽:

當前端提交的是一個 POST 方式的登入表單請求,就會被該過濾器攔截,並進行身份認 證。該過濾器的 doFilter() 方法實現在其抽象父類 AbstractAuthenticationProcessingFilter 中,檢視相關原始碼

	//過濾器doFilter方法
	public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
			throws IOException, ServletException {

		HttpServletRequest request = (HttpServletRequest) req;
		HttpServletResponse response = (HttpServletResponse) res;

		if (!requiresAuthentication(request, response)) {
			//(1) 判斷請求是否是post方式的登入表單提交請求,如果不是則直接放行,進入下一個過濾器
			chain.doFilter(request, response);
			return;
		}

		if (logger.isDebugEnabled()) {
			logger.debug("Request is to process authentication");
		}

		//Authentication是用來儲存使用者認證資訊的類,後續會進行詳細介紹
		Authentication authResult;

		try {
			//(2) 呼叫子類 UsernamePasswordAuthenticationFilter 重寫的方法進行身份認證,
			//	  返回的 authResult 物件封裝認證後的使用者資訊
			authResult = attemptAuthentication(request, response);
			if (authResult == null) {
				// return immediately as subclass has indicated that it hasn't completed
				// authentication
				return;
			}
			//(3) Session 策略處理(如果配置了使用者 Session 最大併發數,就是在此處進行判斷並處理)
			sessionStrategy.onAuthentication(authResult, request, response);
		}
		catch (InternalAuthenticationServiceException failed) {
			logger.error(
					"An internal error occurred while trying to authenticate the user.",
					failed);
			//(4) 認證失敗,呼叫認證失敗的處理器
			unsuccessfulAuthentication(request, response, failed);
			return;
		}
		catch (AuthenticationException failed) {
			//(4) 認證失敗,呼叫認證失敗的處理器
			unsuccessfulAuthentication(request, response, failed);
			return;
		}

		//(4) 認證成功的處理
		if (continueChainBeforeSuccessfulAuthentication) {
			//預設 continueChainBeforeSuccessfulAuthentication 為false,所以認證成功之後不會進行下一個過濾器
			chain.doFilter(request, response);
		}
		// 呼叫認證成功的處理器
		successfulAuthentication(request, response, chain, authResult);
	}

上述的 第二 過程呼叫了 UsernamePasswordAuthenticationFilter 的 attemptAuthentication() 方法,原始碼如下:

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
	public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username"; //預設表單的使用者名稱引數是:username
	public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password"; //預設表單的密碼引數是:password

	private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;
	private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;
	private boolean postOnly = true; //預設的請求方式是: post

	public UsernamePasswordAuthenticationFilter() {
		//預設登入表單提交的路徑是 /login ,方式是 post 提交
		super(new AntPathRequestMatcher("/login", "POST"));
	}

	//上述doFilter方法中的(3)步驟呼叫了該方法進行身份驗證
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
		if (postOnly && !request.getMethod().equals("POST")) {
			//(1) 預設情況下,如果請求方式不是post,會丟擲異常
			throw new AuthenticationServiceException(
					"Authentication method not supported: " + request.getMethod());
		}

		//(2) 獲取請求攜帶的 username 和 password
		String username = obtainUsername(request);
		String password = obtainPassword(request);
		if (username == null) { username = ""; }
		if (password == null) {  password = ""; }
		username = username.trim();
		//(3) 使用前端傳入的 username , password 構造 Authentication 物件,標記物件是未認證狀態
		UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);

		//(4) 將請求的一些屬性資訊設定到 Authentication物件中,如:remoteAddress,sessionId
		setDetails(request, authRequest);

		//(5) 呼叫 ProviderManager 的authenticate()方法進行身份驗證
		return this.getAuthenticationManager().authenticate(authRequest);
	}

上述的(3)過程建立的 UsernamePasswordAuthenticationToken 是 Authentication 介面的實現類,該類有兩個構造器,一個用於封裝前端請求傳入的未認證的使用者資訊,一個用於封裝認證成功後的使用者資訊:

public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
	private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
	private final Object principal;
	private Object credentials;

	//用於封裝前端請求傳入的未認證的使用者資訊,前面的 authResult 物件就是使用該構造器進行構造的
	public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
		super(null); 		//使用者許可權為null
		this.principal = principal; 	//前端傳入的使用者名稱
		this.credentials = credentials; //前端傳入的密碼
		setAuthenticated(false); 		//標記為未認證
	}

	//用於封裝認證成功後的使用者資訊
	public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
		super(authorities); 			//使用者許可權集合
		this.principal = principal; 	//封裝認證使用者資訊的 UserDetail 物件,不再是使用者名稱
		this.credentials = credentials; //前端傳入的密碼
		super.setAuthenticated(true); 	// 標記認證成功
	}	

Authentication 介面的實現類用於儲存使用者認證資訊,檢視該介面具體定義:

//使用者認定資訊介面
public interface Authentication extends Principal, Serializable {
	//使用者許可權集合
	Collection<? extends GrantedAuthority> getAuthorities();
	//使用者密碼
	Object getCredentials();
	//請求攜帶的一些屬性資訊 (例如:remoteAddress,sessionId,ip地址、證書序列號)
	Object getDetails();
	//未認證時為前端傳入的使用者名稱; 認證成功後,為封裝認證使用者資訊的 UserDetails 物件
	Object getPrincipal();
	//是否被認證(true: 認證成功,false: 未認證)
	boolean isAuthenticated();
	//設定是否被認證(true: 認證成功,false: 未認證)
	void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}

5.3.2 ProviderManager 原始碼

真正認證的執行者

上述過程中,UsernamePasswordAuthenticationFilter 過濾器的 attemptAuthentication() 方法的(5)過程將未認證的 Authentication 物件傳入 ProviderManager 類的 authenticate() 方法進行身份認證。

ProviderManager 是 AuthenticationManager 介面的實現類,該介面是認證相關的核心接 口,也是認證的入口。在實際開發中,我們可能有多種不同的認證方式,例如:使用者名稱+ 密碼、郵箱+密碼、手機號+驗證碼等,而這些認證方式的入口始終只有一個,那就是 AuthenticationManager。在該介面的常用實現類 ProviderManager 內部會維護一個 ‘List< AuthenticationProvider >’列表,存放多種認證方式,實際上這是委託者模式 (Delegate)的應用。每種認證方式對應著一個 AuthenticationProvider, AuthenticationManager 根據認證方式的不同(根據傳入的 Authentication 型別判斷)委託 對應的 AuthenticationProvider 進行使用者認證。

//認證動作的執行者
public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
	//.....
	//傳入未認證的Authentication物件
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		//(1) 獲取傳入的 Authentication 物件型別,即 UsernamePasswordAuthenticationToken.class
		Class<? extends Authentication> toTest = authentication.getClass();
		AuthenticationException lastException = null;
		AuthenticationException parentException = null;
		Authentication result = null;
		Authentication parentResult = null;
		boolean debug = logger.isDebugEnabled();

		//(2) 獲取認證方式列表 List<AuthenticationProvider> =  getProviders()並迴圈
		for (AuthenticationProvider provider : getProviders()) {
			//(3) 判斷當前 AuthenticationProvider 是否適用 UsernamePasswordAuthenticationToken.class 型別的AuthenticationProvider
			if (!provider.supports(toTest)) {
				continue;
			}

			if (debug) {
				logger.debug("Authentication attempt using "
						+ provider.getClass().getName());
			}

			//成功找到適配當前認證方式的 AuthenticationProvider ,此處為 DaoAuthenticationProvider
			try {
				//(4) 呼叫 DaoAuthenticationProvider 的 authenticate() 方法進行認證;
				//如果認證成功,會返回一個標記已認證的 Authentication 物件
				result = provider.authenticate(authentication);

				if (result != null) {
					//(5) 認證成功後,將傳入的 Authentication 物件中的 details 資訊拷貝到已認證的 Authentication 物件中
					copyDetails(authentication, result);
					break;
				}
			}
			catch (AccountStatusException | InternalAuthenticationServiceException e) {
				prepareException(e, authentication);
				throw e;
			} catch (AuthenticationException e) {
				lastException = e;
			}
		}

		if (result == null && parent != null) {
			try {
				//(5) 認證失敗,使用父型別 AuthenticationManager 進行驗證
				result = parentResult = parent.authenticate(authentication);
			}
			catch (ProviderNotFoundException e) {
			}
			catch (AuthenticationException e) {
				lastException = parentException = e;
			}
		}

		if (result != null) {
			//(6) 認證成功之後,去除 result 的敏感資訊,要求相關類實現 CredentialsContainer 介面
			if (eraseCredentialsAfterAuthentication && (result instanceof CredentialsContainer)) {
				//去除過程就是呼叫 CredentialsContainer 介面的 eraseCredentials() 方法
				((CredentialsContainer) result).eraseCredentials();
			}

			//(7) 釋出認證成功的事件
			if (parentResult == null) {
				eventPublisher.publishAuthenticationSuccess(result);
			}
			return result;
		}
		//(8) 認證失敗之後,丟擲失敗的異常資訊
		if (lastException == null) {
			lastException = new ProviderNotFoundException(messages.getMessage(
					"ProviderManager.providerNotFound",
					new Object[] { toTest.getName() },
					"No AuthenticationProvider found for {0}"));
		}
		
		if (parentException == null) {
			prepareException(lastException, authentication);
		}
		throw lastException;
	}

上述認證成功之後的(6)過程,呼叫 CredentialsContainer 介面定義的 eraseCredentials() 方法去除敏感資訊。檢視 UsernamePasswordAuthenticationToken 實現的 eraseCredentials() 方法,該方法實現在其父類中:

public abstract class AbstractAuthenticationToken implements Authentication, CredentialsContainer {
  //父類實現了 CredentialsContainer 介面
	public void eraseCredentials() {
		//credentials (前端傳入的密碼) 會置為null
		eraseSecret(getCredentials());
		//principal 在已認證的 Acthentication 中是 UserDetails 實現類;如果該實現類想要去除敏感資訊,
		//需要實現 CredentialsContainer 介面的 eraseCredentials() 方法;
		//ps:由於我們自定義的User類沒有實現該介面,所以不進行任何操作.
		eraseSecret(getPrincipal());
		eraseSecret(details);
	}

	private void eraseSecret(Object secret) {
		if (secret instanceof CredentialsContainer) {
			((CredentialsContainer) secret).eraseCredentials();
		}
	}

5.3.3 認證成功/失敗處理

上述過程就是認證流程的最核心部分,接下來重新回到 UsernamePasswordAuthenticationFilter 過濾器的 doFilter() 方法,檢視認證成 功/失敗的處理:

public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware {
	//過濾器doFilter方法
	public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
			throws IOException, ServletException {
    //...
		try {
			//此處 authResult 物件就是上述 DaoAuthenticationProvider 類的authenticate()方法的返回值
			authResult = attemptAuthentication(request, response);
    catch (AuthenticationException failed) {
			// 認證失敗,呼叫認證失敗的處理器
			unsuccessfulAuthentication(request, response, failed);
			return;
		}
    //...
    
    // 呼叫認證成功的處理器
		successfulAuthentication(request, response, chain, authResult);

檢視successfulAuthentication()unsuccessfulAuthentication() 的原始碼:

public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware {
	//認證成功的方法
	protected void successfulAuthentication(HttpServletRequest request,
			HttpServletResponse response, FilterChain chain, Authentication authResult)
			throws IOException, ServletException {
		if (logger.isDebugEnabled()) {
			logger.debug("Authentication success. Updating SecurityContextHolder to contain: "
					+ authResult);
		}

		//(1) 將認證成功的使用者資訊物件 Authentication 封裝進 SecurityContext 物件中,並存入 SecurityContext
		//SecurityContextHolder是對 ThreadLocal 的一個封裝,後續會介紹
		SecurityContextHolder.getContext().setAuthentication(authResult);

		//(2) rememberMe 的處理
		rememberMeServices.loginSuccess(request, response, authResult);

		if (this.eventPublisher != null) {
			//(3) 釋出認證成功的事件
			eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
					authResult, this.getClass()));
		}

		//呼叫認證成功的處理器
		successHandler.onAuthenticationSuccess(request, response, authResult);
	}

	//認證失敗後的處理
	protected void unsuccessfulAuthentication(HttpServletRequest request,
			HttpServletResponse response, AuthenticationException failed)
			throws IOException, ServletException {
		//(1) 清除該執行緒在 SecurityContextHolder 中對應的 SecurityContext 物件
		SecurityContextHolder.clearContext();

		if (logger.isDebugEnabled()) {
			logger.debug("Authentication request failed: " + failed.toString(), failed);
			logger.debug("Updated SecurityContextHolder to contain null Authentication");
			logger.debug("Delegating to authentication failure handler " + failureHandler);
		}
		//(2) rememberMe 的處理
		rememberMeServices.loginFail(request, response);
		//(3) 呼叫認證失敗處理器
		failureHandler.onAuthenticationFailure(request, response, failed);
	}

5.4 SpringSecurity 許可權訪問流程

上一個部分通過原始碼的方式介紹了認證流程,下面介紹許可權訪問流程,主要是對 ExceptionTranslationFilter 過濾器和 FilterSecurityInterceptor 過濾器進行介紹。

5.4.1ExceptionTranslationFilter 過濾器

該過濾器是用於處理異常的,不需要我們配置,對於前端提交的請求會直接放行,捕獲後 續丟擲的異常並進行處理(例如:許可權訪問限制)。具體原始碼如下:

public class ExceptionTranslationFilter extends GenericFilterBean {
  	public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
			throws IOException, ServletException {
		HttpServletRequest request = (HttpServletRequest) req;
		HttpServletResponse response = (HttpServletResponse) res;

		try {
			//(1) 對前端的請求直接放行,不必要攔截
			chain.doFilter(request, response);
			logger.debug("Chain processed normally");
		}
		catch (IOException ex) {
			throw ex;
		}
		catch (Exception ex) {
			//(2) 捕獲後續出現的異常進行處理
			Throwable[] causeChain = throwableAnalyzer.determineCauseChain(ex);
			//訪問需要認證的資源,但當前請求未認證所丟擲的異常
			RuntimeException ase = (AuthenticationException) throwableAnalyzer
					.getFirstThrowableOfType(AuthenticationException.class, causeChain);

			if (ase == null) {
				//訪問許可權受限的資源所丟擲的異常
				ase = (AccessDeniedException) throwableAnalyzer.getFirstThrowableOfType(
						AccessDeniedException.class, causeChain);
			}

			if (ase != null) {
				if (response.isCommitted()) {
					throw new ServletException("Unable to handle the Spring Security Exception because the response is already committed.", ex);
				}
				handleSpringSecurityException(request, response, chain, ase);
			}
			else {
				if (ex instanceof ServletException) {
					throw (ServletException) ex;
				}
				else if (ex instanceof RuntimeException) {
					throw (RuntimeException) ex;
				}
				throw new RuntimeException(ex);
			}
		}
	}

5.4.2FilterSecurityInterceptor 過濾器

FilterSecurityInterceptor 是過濾器鏈的最後一個過濾器,該過濾器是過濾器鏈 的最後一個過濾器,根據資源許可權配置來判斷當前請求是否有許可權訪問對應的資源。如果訪問受限會丟擲相關異常,最終所丟擲的異常會由前一個過濾器 ExceptionTranslationFilter 進行捕獲和處理。具體原始碼如下:

public class FilterSecurityInterceptor extends AbstractSecurityInterceptor implements Filter {
  //...
	//過濾器的 dofilter() 方法
	public void doFilter(ServletRequest request, ServletResponse response,
			FilterChain chain) throws IOException, ServletException {
		FilterInvocation fi = new FilterInvocation(request, response, chain);
		//執行 invoke()方法
		invoke(fi);
	}
  //...
  
	public void invoke(FilterInvocation fi) throws IOException, ServletException {
		if ((fi.getRequest() != null)
				&& (fi.getRequest().getAttribute(FILTER_APPLIED) != null)
				&& observeOncePerRequest) {
			fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
		}
		else {
			// first time this request being called, so perform security checking
			if (fi.getRequest() != null && observeOncePerRequest) {
				fi.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
			}

			//(1) 根據資源許可權配置來判斷當前請求是否有許可權訪問對應的資源.如果不能訪問,則丟擲相應的異常
			InterceptorStatusToken token = super.beforeInvocation(fi);

			try {
				//(2) 訪問相關資源,通過 SpringMVC 的核心元件 DispatcherServlet 進行訪問
				fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
			}
			finally {
				super.finallyInvocation(token);
			}
			super.afterInvocation(token, null);
		}
	}

需要注意,Spring Security 的過濾器鏈是配置在 SpringMVC 的核心元件 DispatcherServlet 執行之前。也就是說,請求通過 Spring Security 的所有過濾器, 不意味著能夠正常訪問資源,該請求還需要通過 SpringMVC 的攔截器鏈。

5.5 SpringSecurity 請求間共享認證資訊

一般認證成功後的使用者資訊是通過 Session 在多個請求之間共享,那麼 Spring Security 中是如何實現將已認證的使用者資訊物件 Authentication 與 Session 繫結的進行 具體分析。

  • 在前面講解認證成功的處理方法 successfulAuthentication() 時,有以下程式碼:
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware {
	//...
	//認證成功的方法
	protected void successfulAuthentication(HttpServletRequest request,
			HttpServletResponse response, FilterChain chain, Authentication authResult)
			throws IOException, ServletException {

		//(1) 將認證成功的使用者資訊物件 Authentication 封裝進 SecurityContext 物件中,並存入 SecurityContext
		//SecurityContextHolder是對 ThreadLocal 的一個封裝,後續會介紹
		SecurityContextHolder.getContext().setAuthentication(authResult);
  • 查 看 SecurityContext 接 口 及 其 實 現 類 SecurityContextImpl , 該 類 其 實 就 是 對 Authentication 的封裝:
public class SecurityContextImpl implements SecurityContext {
  • 查 看 SecurityContextHolder 類 , 該 類 其 實 是 對 ThreadLocal 的 封 裝 , 存 儲 SecurityContext 物件:
public class SecurityContextHolder {
	// ~ Static fields/initializers
	// =====================================================================================

	public static final String MODE_THREADLOCAL = "MODE_THREADLOCAL";
	public static final String MODE_INHERITABLETHREADLOCAL = "MODE_INHERITABLETHREADLOCAL";
	public static final String MODE_GLOBAL = "MODE_GLOBAL";
	public static final String SYSTEM_PROPERTY = "spring.security.strategy";
	private static String strategyName = System.getProperty(SYSTEM_PROPERTY);
	private static SecurityContextHolderStrategy strategy;
	private static int initializeCount = 0;

	//(1) 最先執行
	static {
		initialize();
	}

	private static void initialize() {
		if (!StringUtils.hasText(strategyName)) {
			//預設使用 MODE_THREADLOCAL 模式
			strategyName = MODE_THREADLOCAL;
		}

		if (strategyName.equals(MODE_THREADLOCAL)) {
			//預設使用 ThreadLocalSecurityContextHolderStrategy 建立 strategy ,其內部使用 ThreadLocal 對 SecurityContext 進行儲存
			strategy = new ThreadLocalSecurityContextHolderStrategy();
		}
		else if (strategyName.equals(MODE_INHERITABLETHREADLOCAL)) {
			strategy = new InheritableThreadLocalSecurityContextHolderStrategy();
		}
		else if (strategyName.equals(MODE_GLOBAL)) {
			strategy = new GlobalSecurityContextHolderStrategy();
		}
		else {
			try {
				Class<?> clazz = Class.forName(strategyName);
				Constructor<?> customStrategy = clazz.getConstructor();
				strategy = (SecurityContextHolderStrategy) customStrategy.newInstance();
			}
			catch (Exception ex) {
				ReflectionUtils.handleReflectionException(ex);
			}
		}
		initializeCount++;
	}
  
	public static void clearContext() {
		//清空當前執行緒對應的 ThreadLocal<SecurityContext> 的儲存
		strategy.clearContext();
	}
	
	public static SecurityContext getContext() {
		//注意:如果當前執行緒對應的 ThreadLocal<SecurityContext> 沒有任何物件儲存,
		//strategy.getContext() 會建立並返回一個空的 SecurityContext 物件,
		//並且該空的 SecurityContext 物件會存入 ThreadLocal<SecurityContext>
		return strategy.getContext();
	}

	public static void setContext(SecurityContext context) {
		//設定當前執行緒對應的 ThreadLocal<SecurityContext> 的儲存
		strategy.setContext(context);
	}

ThreadLocalSecurityContextHolderStrategy類:

final class ThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {
	//使用 ThreadLocal 對 SecurityContext 進行儲存
	private static final ThreadLocal<SecurityContext> contextHolder = new ThreadLocal<>();

	public void clearContext() {
		// 清空當前執行緒對應的 ThreadLocal<SecurityContext> 的儲存
		contextHolder.remove();
	}

	public SecurityContext getContext() {
		//注意:如果當前執行緒對應的 ThreadLocal<SecurityContext> 沒有任何物件儲存,
		//strategy.getContext() 會建立並返回一個空的 SecurityContext 物件,
		//並且該空的 SecurityContext 物件會存入 ThreadLocal<SecurityContext>
		SecurityContext ctx = contextHolder.get();

		if (ctx == null) {
			ctx = createEmptyContext();
			contextHolder.set(ctx);
		}

		return ctx;
	}

	public void setContext(SecurityContext context) {
		//設定當前執行緒物件的 ThreadLocal<SecurityContext 的儲存
		Assert.notNull(context, "Only non-null SecurityContext instances are permitted");
		contextHolder.set(context);
	}

	public SecurityContext createEmptyContext() {
		//建立一個空的 SecurityContext 物件
		return new SecurityContextImpl();
	}
}

5.5.1SecurityContextPersistenceFilter 過濾器

前面提到過,在 UsernamePasswordAuthenticationFilter 過濾器認證成功之 後,會在認證成功的處理方法中將已認證的使用者資訊物件 Authentication 封裝進 SecurityContext,並存入 SecurityContextHolder。

之後,響應會通過 SecurityContextPersistenceFilter 過濾器,該過濾器的位置 在所有過濾器的最前面,請求到來先進它,響應返回最後一個通過它,所以在該過濾器中 處理已認證的使用者資訊物件 Authentication 與 Session 繫結。

認證成功的響應通過 SecurityContextPersistenceFilter 過濾器時,會從 SecurityContextHolder取出封裝了已認證使用者資訊物件 Authentication 的 SecurityContext,放進 Session 中。當請求再次到來時,請求首先經過該過濾器,該過濾器會判斷當前請求的 Session 是否存有 SecurityContext 物件,如果有則將該物件取出再次 放入SecurityContextHolder 中,之後該請求所在的執行緒獲得認證使用者資訊,後續的資源訪 問不需要進行身份認證;當響應再次返回時,該過濾器同樣從 SecurityContextHolder 取出 SecurityContext 物件,放入 Session 中。具體原始碼如下:

public class SecurityContextPersistenceFilter extends GenericFilterBean {

	static final String FILTER_APPLIED = "__spring_security_scpf_applied";

	private SecurityContextRepository repo;

	private boolean forceEagerSessionCreation = false;

	public SecurityContextPersistenceFilter() {
		this(new HttpSessionSecurityContextRepository());
	}

	public SecurityContextPersistenceFilter(SecurityContextRepository repo) {
		this.repo = repo;
	}

	//過濾器 doFilter() 方法
	public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
			throws IOException, ServletException {
		HttpServletRequest request = (HttpServletRequest) req;
		HttpServletResponse response = (HttpServletResponse) res;

		if (request.getAttribute(FILTER_APPLIED) != null) {
			//判斷屬性 FILTER_APPLIED, 確保每個請求只應用一次篩選器
			chain.doFilter(request, response);
			return;
		}

		final boolean debug = logger.isDebugEnabled();

		//該請求經過該過濾器後,就對屬性 FILTER_APPLIED 設定一個值
		request.setAttribute(FILTER_APPLIED, Boolean.TRUE);

		if (forceEagerSessionCreation) {
			HttpSession session = request.getSession();

			if (debug && session.isNew()) {
				logger.debug("Eagerly created session: " + session.getId());
			}
		}

		HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request,
				response);
		//(1) 請求到來時,檢查當前 Session 中是否有 SecurityContext 物件,
		//如果有,從 Session 中取出該物件;
		//如果沒有,會建立一個空的 SecurityContext 物件;
		SecurityContext contextBeforeChainExecution = repo.loadContext(holder);

		try {
			//(2) 將上述獲得 SecurityContext 物件放入 SecurityContextHolder 中
			SecurityContextHolder.setContext(contextBeforeChainExecution);

			//(3) 進入下一個過濾器
			chain.doFilter(holder.getRequest(), holder.getResponse());

		}
		finally {
			//(4) 響應返回時,從 SecurityContextHolder 中取出SecurityContext
			SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext();
			//(5) 移除 SecurityContextHolder 中的 SecurityContext 物件
			SecurityContextHolder.clearContext();
			//(6) 將取出的 SecurityContext 物件放入 Session
			repo.saveContext(contextAfterChainExecution, holder.getRequest(),
					holder.getResponse());
			request.removeAttribute(FILTER_APPLIED);

			if (debug) {
				logger.debug("SecurityContextHolder now cleared, as request processing completed");
			}
		}
	}

	public void setForceEagerSessionCreation(boolean forceEagerSessionCreation) {
		this.forceEagerSessionCreation = forceEagerSessionCreation;
	}
}