1. 程式人生 > 程式設計 >Spring Security 技術棧開發企業級認證授權

Spring Security 技術棧開發企業級認證授權

個人部落格:www.zhenganwen.top,文末有驚喜!

環境準備

本文中所有例項程式碼已託管碼雲:gitee.com/zhenganwen/…

文末有驚喜!

開發環境

  • JDK1.8
  • Maven

專案結構

image.png

  • spring-security-demo

    父工程,用於整個專案的依賴

  • security-core

    安全認證核心模組,security-browsersecurity-app都基於其來構建

  • security-browser

    PC端瀏覽器授權,主要通過Session

  • security-app

    移動端授權

  • security-demo

    應用security-browser

    security-app

依賴

spring-security-demo

新增spring依賴自動相容依賴和編譯外掛

<packaging>pom</packaging>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>io.spring.platform</groupId>
            <artifactId>platform-bom</artifactId>
<version>Brussels-SR4</version> <type>pom</type> <scope>import</scope> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId
>
<version>Dalston.SR2</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>2.3.2</version> <configuration> <source>1.8</source> <target>1.8</target> <encoding>UTF-8</encoding> </configuration> </plugin> </plugins> </build> 複製程式碼

security-core

新增持久化、OAuth認證、social認證以及commons工具類等依賴,一些依賴只是先加進來以備後用

<dependencies>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-oauth2</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.social</groupId>
        <artifactId>spring-social-config</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.social</groupId>
        <artifactId>spring-social-core</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.social</groupId>
        <artifactId>spring-social-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.social</groupId>
        <artifactId>spring-social-web</artifactId>
    </dependency>
    <dependency>
        <groupId>commons-lang</groupId>
        <artifactId>commons-lang</artifactId>
    </dependency>
    <dependency>
        <groupId>commons-collections</groupId>
        <artifactId>commons-collections</artifactId>
    </dependency>
    <dependency>
        <groupId>commons-beanutils</groupId>
        <artifactId>commons-beanutils</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.16.22</version>
        <scope>compile</scope>
    </dependency>
</dependencies>
複製程式碼

security-browser

新增security-core和叢集管理依賴

<dependencies>
    <dependency>
        <groupId>top.zhenganwen</groupId>
        <artifactId>security-core</artifactId>
        <version>1.0-SNAPSHOT</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.session</groupId>
        <artifactId>spring-session</artifactId>
    </dependency>
</dependencies>
複製程式碼

security-app

新增security-core

<dependencies>
    <dependency>
        <groupId>top.zhenganwen</groupId>
        <artifactId>security-core</artifactId>
        <version>1.0-SNAPSHOT</version>
    </dependency>
</dependencies>
複製程式碼

security-demo

暫時引用security-browser做PC端的驗證

<artifactId>security-demo</artifactId>
<dependencies>
    <dependency>
        <groupId>top.zhenganwen</groupId>
        <artifactId>security-browser</artifactId>
        <version>1.0-SNAPSHOT</version>
    </dependency>
</dependencies>
複製程式碼

配置

security-demo中新增啟動類如下

package top.zhenganwen.securitydemo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author zhenganwen
 * @date 2019/8/18
 * @desc SecurityDemoApplication
 */
@SpringBootApplication
@RestController
public class SecurityDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(SecurityDemoApplication.class,args);
    }

    @RequestMapping("/hello")
    public String hello() {
        return "hello spring security";
    }
}
複製程式碼

根據報錯資訊新增mysql連線資訊

spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/test?useUnicode=yes&characterEncoding=UTF-8&useSSL=false
spring.datasource.username=root
spring.datasource.password=123456
複製程式碼

暫時用不到session叢集共享和redis,先禁用掉

spring.session.store-type=none
複製程式碼
@SpringBootApplication(exclude = {RedisAutoConfiguration.class,RedisRepositoriesAutoConfiguration.class})
@RestController
public class SecurityDemoApplication {
複製程式碼

然後發現能夠啟動成功了,然而訪問/hello去發現提示我們要登入,這是Spring Security的預設認證策略在起作用,我們也先禁用它

security.basic.enabled = false
複製程式碼

重啟訪問/hello,頁面顯示hello spring security,環境搭建成功

Restful

Restful VS 傳統

Restful是一種HTTP介面編寫風格,而不是一種標準或規定。使用Restful風格和傳統方式的區別主要如下

  • URL
    • 傳統方式一般通過在URL中新增表明介面行為的字串和查詢引數,如/user/get?username=xxx
    • Restful風格則推薦一個URL代表一個系統資源,/user/1應表示訪問系統中id為1的使用者
  • 請求方式
    • 傳統方式一般通過get提交,弊端是get提交會將請求引數附在URL上,而URL有長度限制,並且若不特殊處理,引數在URL上是明文顯示的,不安全。對上述兩點有要求的請求會使用post提交
    • Restful風格推崇使用提交方式描述請求行為,如POSTDELETEPUTGET應對應增、刪、改、查型別的請求
  • 通訊媒介
    • 傳統方式中,對請求的響應結果是一個頁面,如此針對不同的終端需要開發多個系統,且前後端邏輯耦合
    • Restful風格提倡使用JSON作為前後端通訊媒介,前後端分離;通過響應狀態碼來標識響應結果型別,如200表示請求被成功處理,404表示沒有找到相應資源,500表示服務端處理異常。

Restful詳解參考:www.runoob.com/w3cnote/res…

SpringMVC高階特性與REST服務

Jar包方式執行

上述搭建的環境已經能通過IDE執行並訪問/hello,但是生產環境一般是將專案打成一個可執行的jar包,能夠通過java -jar直接執行。

此時如果我們右鍵父工程執行maven命令clean package你會發現security-demo/target中生成的jar只有7KB,這是因為maven預設的打包方式是不會將其依賴的jar進來並且設定springboot啟動類的。這時我們需要在security-demopom中新增一個打包外掛

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <version>1.3.3.RELEASE</version>
            <executions>
                <execution>
                    <goals>
                        <goal>repackage</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
    <!-- 生成的jar檔名 -->
    <finalName>demo</finalName>
</build>
複製程式碼

這樣再執行clean package就會發現target下生產了一個demo.jardemo.jar.original,其中demo.jar是可執行的,而demo.jar.original是保留了maven預設打包方式

使用MockMVC編寫介面測試用例

秉著測試先行的原則(提倡先寫測試用例再寫介面,驗證程式按照我們的想法執行),我們需要藉助spring-boot-starter-test測試框架和其中相關的MockMvcAPI。mock為打樁的意思,意為使用測試用例將程式打造牢固。

首先在security-demo中新增測試依賴

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
</dependency>
複製程式碼

然後在src/test/java中新建測試類如下

package top.zhenganwen.securitydemo;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.c.status;

/**
 * @author zhenganwen
 * @date 2019/8/18
 * @desc SecurityDemoApplicationTest
 */
@RunWith(SpringRunner.class)
@SpringBootTest
public class SecurityDemoApplicationTest {

    @Autowired
    WebApplicationContext webApplicationContext;

    private MockMvc mockMvc;

    @Before
    public void before() {
        mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
    }

    @Test
    public void hello() throws Exception {
        mockMvc.perform(get("/hello").contentType(MediaType.APPLICATION_JSON_UTF8))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$").value("hello spring security"));
    }
}
複製程式碼

因為是測試HTTP介面,因此需要注入web容器WebApplicationContext。其中get()status()jsonPath()都是靜態匯入的方法,測試程式碼的意思是通過GET提交方式請求/helloget("/hello"))並附加請求頭為Content-Type: application/json(這樣引數就會以json的方式附在請求體中,是的沒錯,GET請求也是可以附帶請求體的!)

andExpect(status().isOk())期望響應狀態碼為200(參見HTTP狀態碼),andExpect((jsonPath("$").value("hello spring security"))期望響應的JSON資料是一個字串且內容為hello spring security(該方法依賴JSON解析框架jsonpath$表示JSON本體在Java中對應的資料型別物件,更多API詳見:github.com/search?q=js…

其中比較重要的API為MockMvcMockMvcRequestBuildersMockMvcRequestBuilders

  • MockMvc,呼叫perform指定介面地址
  • MockMvcRequestBuilders,構建請求(包括請求路徑、提交方式、請求頭、請求體等)
  • MockMvcRequestBuilders,斷言響應結果,如響應狀態碼、響應體

MVC註解細節

@RestController

用於標識一個ControllerRestful Controller,其中方法的返回結果會被SpringMVC自動轉換為JSON並設定響應頭為Content-Type=application/json

@RequestMapping

用於將URL對映到方法上,並且SpringMVC會自動將請求引數按照按照引數名對應關係繫結到方法入參上

package top.zhenganwen.securitydemo.dto;

import lombok.Data;

import java.io.Serializable;

/**
 * @author zhenganwen
 * @date 2019/8/18
 * @desc User
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements Serializable {

    private String username;
    private String password;
}

複製程式碼
package top.zhenganwen.securitydemo.web.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import top.zhenganwen.securitydemo.dto.User;

import java.util.Arrays;
import java.util.List;

/**
 * @author zhenganwen
 * @date 2019/8/18
 * @desc UserController
 */
@RestController
public class UserController {

    @GetMapping("/user")
    public List<User> query(String username) {
        System.out.println(username);
        List<User> users = Arrays.asList(new User(),new User(),new User());
        return users;
    }
}
複製程式碼
package top.zhenganwen.securitydemo.web.controller;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

/**
 * @author zhenganwen
 * @date 2019/8/18
 * @desc UserControllerTest
 */
@RunWith(SpringRunner.class)
@SpringBootTest
public class UserControllerTest {

    @Autowired
    private WebApplicationContext webApplicationContext;

    private MockMvc mockMvc;

    @Before
    public void setUp() throws Exception {
        mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
    }

    @Test
    public void query() throws Exception {
        mockMvc.perform(get("/user").
                contentType(MediaType.APPLICATION_JSON_UTF8)
                .param("username","tom"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.length()").value(3));
    }
}
複製程式碼

通過MockMvcRequestBuilders.param可以為請求附帶URL形式引數。

指定提交方式

如果沒有通過method屬性指定提交方式,那麼所有的提交方式都會被受理,但如果設定@RequestMapping(method = RequestMethod.GET),那麼只有GET請求會被受理,其他提交方式都會導致405 unsupported request method

@RequestParam

必填引數

上例程式碼,如果請求不附帶引數username,那麼Controller的引數就會被賦予資料型別預設值。如果你想請求必須攜帶該引數,否則不予處理,那麼就可以使用@RequestParam並指定required=true(不指定也可以,預設就是)

Controller

@GetMapping("/user")
public List<User> query(@RequestParam String username) {
    System.out.println(username);
    List<User> users = Arrays.asList(new User(),new User());
    return users;
}
複製程式碼

ControllerTest

@Test
public void testBadRequest() throws Exception {
    mockMvc.perform(get("/user").
                    contentType(MediaType.APPLICATION_JSON_UTF8))
        .andExpect(status().is4xxClientError());
}
複製程式碼

因為請求沒有附帶引數username,所以會報錯400 bad request,我們可以使用is4xxClientError()對響應狀態碼為400的請求進行斷言

引數名對映

SpringMVC預設是按引數名相同這一規則對映引數值得,如果你想將請求中引數username的值繫結到方法引數userName上,可以通過name屬性或value屬性

@GetMapping("/user")
public List<User> query(@RequestParam(name = "username") String userName) {
    System.out.println(userName);
    List<User> users = Arrays.asList(new User(),new User());
    return users;
}

@GetMapping("/user")
public List<User> query(@RequestParam("username") String userName) {
    System.out.println(userName);
    List<User> users = Arrays.asList(new User(),new User());
    return users;
}
複製程式碼
@Test
public void testParamBind() throws Exception {
    mockMvc.perform(get("/user").
                    contentType(MediaType.APPLICATION_JSON_UTF8)
                    .param("username","tom"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.length()").value(3));
}
複製程式碼

預設引數值

如果希望不強制請求攜帶某引數,但又希望方法引數在沒有接收到引數值時能有個預設值(例如“”null更不容易報錯),那麼可以通過defaultValue屬性

@GetMapping("/user")
public List<User> query(@RequestParam(required = false,defaultValue = "") String userName) {
    Objects.requireNonNull(userName);
    List<User> users = Arrays.asList(new User(),new User());
    return users;
}
複製程式碼
@Test
public void testDefaultValue() throws Exception {
    mockMvc.perform(get("/user").
                    contentType(MediaType.APPLICATION_JSON_UTF8))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.length()").value(3));
}
複製程式碼

Bean繫結

如果請求附帶的引數較多,並且各引數都隸屬於某個物件的屬性,那麼將它們一一寫在方法參列比較冗餘,我們可以將它們統一封裝到一個資料傳輸物件(Data Transportation Object DTO)中,如

package top.zhenganwen.securitydemo.dto;

import lombok.Data;

/**
 * @author zhenganwen
 * @date 2019/8/19
 * @desc UserCondition
 */
@Data
public class UserQueryConditionDto {

    private String username;
    private String password;
    private String phone;
}
複製程式碼

然後在方法入參填寫該物件即可,SpringMVC會幫我們實現請求引數到物件屬性的繫結(預設繫結規則是引數名一致)

@GetMapping("/user")
public List<User> query(@RequestParam("username") String userName,UserQueryConditionDto userQueryConditionDto) {
    System.out.println(userName);
    System.out.println(ReflectionToStringBuilder.toString(userQueryConditionDto,ToStringStyle.MULTI_LINE_STYLE));
    List<User> users = Arrays.asList(new User(),new User());
    return users;
}
複製程式碼

ReflectionToStringBuilder反射工具類能夠在物件沒有重寫toString方法時通過反射幫我們檢視物件的屬性。

@Test
public void testDtoBind() throws Exception {
    mockMvc.perform(get("/user").
                    contentType(MediaType.APPLICATION_JSON_UTF8)
                    .param("username","tom")
                    .param("password","123456")
                    .param("phone","12345678911"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.length()").value(3));
}
複製程式碼

Bean繫結不影響@RequestParam繫結

並且不用擔心會和@RequestParam衝突,輸出如下

tom
top.zhenganwen.securitydemo.dto.UserQueryConditionDto@440ef8d[
  username=tom
  password=123456
  phone=12345678911
]
複製程式碼

Bean繫結優先於基本型別引數繫結

但是,如果不給userName新增@RequestParam註解,那麼它接收到的將是一個null

null
top.zhenganwen.securitydemo.dto.UserQueryConditionDto@440ef8d[
  username=tom
  password=123456
  phone=12345678911
]
複製程式碼

分頁引數繫結

spring-data家族(如spring-boot-data-redis)幫我們封裝了一個分頁DTOPageable,會將我們傳遞的分頁引數size(每頁行數)、page(當前頁碼)、sort(排序欄位和排序策略)自動繫結到自動注入的Pageable例項中

@GetMapping("/user")
public List<User> query(String userName,UserQueryConditionDto userQueryConditionDto,Pageable pageable) {
    System.out.println(userName);
    System.out.println(ReflectionToStringBuilder.toString(userQueryConditionDto,ToStringStyle.MULTI_LINE_STYLE));
    System.out.println(pageable.getPageNumber());
    System.out.println(pageable.getPageSize());
    System.out.println(pageable.getSort());
    List<User> users = Arrays.asList(new User(),new User());
    return users;
}
複製程式碼
@Test
public void testPageable() throws Exception {
    mockMvc.perform(get("/user").
                    contentType(MediaType.APPLICATION_JSON_UTF8)
                    .param("username","12345678911")
                    .param("page","2")
                    .param("size","30")
                    .param("sort","age,desc"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.length()").value(3));
}
複製程式碼
null
top.zhenganwen.securitydemo.dto.UserQueryConditionDto@24e5389c[
  username=tom
  password=123456
  phone=12345678911
]
2
30
age: DESC
複製程式碼

@PathVariable

變數佔位

最常見的Restful URL,像GET /user/1獲取id1的使用者的資訊,這時我們在編寫介面時需要將路徑中的1替換成一個佔位符如{id},根據實際的URL請求動態的繫結到方法引數id

@GetMapping("/user/{id}")
public User info(@PathVariable("id") Long id) {
    System.out.println(id);
    return new User("jack","123");
}
複製程式碼
@Test
public void testPathVariable() throws Exception {
    mockMvc.perform(get("/user/1").
                    contentType(MediaType.APPLICATION_JSON_UTF8))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.username").value("jack"));
}

1
複製程式碼

當方法引數名和URL佔位符變數名一致時,可以省去@PathVariablevalue屬性

正則匹配

有時我們需要對URL的匹配做細粒度的控制,例如/user/1會匹配到/user/{id},而/user/xxx則不會匹配到/user/{id}

@GetMapping("/user/{id:\\d+}")
public User getInfo(@PathVariable("id") Long id) {
    System.out.println(id);
    return new User("jack","123");
}
複製程式碼
@Test
public void testRegExSuccess() throws Exception {
    mockMvc.perform(get("/user/1").
                    contentType(MediaType.APPLICATION_JSON_UTF8))
        .andExpect(status().isOk());
}

@Test
public void testRegExFail() throws Exception {
    mockMvc.perform(get("/user/abc").
                    contentType(MediaType.APPLICATION_JSON_UTF8))
        .andExpect(status().is4xxClientError());
}
複製程式碼

@JsonView

應用場景

有時我們需要對響應物件的某些欄位進行過濾,例如查詢所有使用者時不顯示password欄位,根據id查詢使用者時則顯示password欄位,這時可以通過@JsonView註解實現此類功能

使用方法

1、宣告檢視介面,每個介面代表響應資料時物件欄位可見策略

這裡檢視指的就是一種欄位包含策略,後面新增@JsonView時會用到

@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements Serializable {

    /**
     * 普通檢視,返回使用者基本資訊
     */
    public interface UserOrdinaryView {

    }

    /**
     * 詳情檢視,除了普通檢視包含的欄位,還返回密碼等詳細資訊
     */
    public interface UserDetailsView extends UserOrdinaryView{
        
    }

    private String username;
    
    private String password;
}
複製程式碼

檢視和檢視之間可以存在繼承關係,繼承檢視後會繼承該檢視包含的欄位

2、在響應物件的欄位上新增檢視,表示該欄位包含在該檢視中

@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements Serializable {

    /**
     * 普通檢視,返回使用者基本資訊
     */
    public interface UserOrdinaryView {

    }

    /**
     * 詳情檢視,除了普通檢視包含的欄位,還返回密碼等詳細資訊
     */
    public interface UserDetailsView extends UserOrdinaryView{
        
    }

    @JsonView(UserOrdinaryView.class)
    private String username;
    
    @JsonView(UserDetailsView.class)
    private String password;
}
複製程式碼

3、在Controller方法上新增檢視,表示該方法返回的物件資料僅顯示該檢視包含的欄位

@GetMapping("/user")
@JsonView(User.UserBasicView.class)
public List<User> query(String userName,ToStringStyle.MULTI_LINE_STYLE));
    System.out.println(pageable.getPageNumber());
    System.out.println(pageable.getPageSize());
    System.out.println(pageable.getSort());
    List<User> users = Arrays.asList(new User("tom","123"),new User("jack","456"),new User("alice","789"));
    return users;
}

@GetMapping("/user/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
    System.out.println(id);
    return new User("jack","123");
}
複製程式碼

測試

@Test
public void testUserBasicViewSuccess() throws Exception {
    MvcResult mvcResult = mockMvc.perform(get("/user").
                                          contentType(MediaType.APPLICATION_JSON_UTF8))
        .andExpect(status().isOk())
        .andReturn();
    System.out.println(mvcResult.getResponse().getContentAsString());
}

[{"username":"tom"},{"username":"jack"},{"username":"alice"}]

@Test
public void testUserDetailsViewSuccess() throws Exception {
    MvcResult mvcResult = mockMvc.perform(get("/user/1").
                                          contentType(MediaType.APPLICATION_JSON_UTF8))
        .andExpect(status().isOk())
        .andReturn();
    System.out.println(mvcResult.getResponse().getContentAsString());
}

{"username":"jack","password":"123"}
複製程式碼

階段性重構

重構需要 小步快跑,即每寫完一部分功能都要回頭來看一下有哪些需要優化的地方

程式碼中兩個方法都的RequestMapping都用了/user,我們可以將其提至類上以供複用

@RestController
@RequestMapping("/user")
public class UserController {

    @GetMapping
    @JsonView(User.UserBasicView.class)
    public List<User> query(String userName,Pageable pageable) {
        System.out.println(userName);
        System.out.println(ReflectionToStringBuilder.toString(userQueryConditionDto,ToStringStyle.MULTI_LINE_STYLE));
        System.out.println(pageable.getPageNumber());
        System.out.println(pageable.getPageSize());
        System.out.println(pageable.getSort());
        List<User> users = Arrays.asList(new User("tom","789"));
        return users;
    }

    @GetMapping("/{id:\\d+}")
    @JsonView(User.UserDetailsView.class)
    public User getInfo(@PathVariable("id") Long id) {
        System.out.println(id);
        return new User("jack","123");
    }
}
複製程式碼

雖然是一個很細節的問題,但是一定要有這個思想和習慣

別忘了重構後重新執行一遍所有的測試用例,確保重構沒有更改程式行為

處理請求體

@RequestBody對映請求體到Java方法的引數

SpringMVC預設不會解析請求體中的引數並繫結到方法引數

@PostMapping
public void createUser(User user) {
    System.out.println(user);
}
複製程式碼
@Test
public void testCreateUser() throws Exception {
    mockMvc.perform(post("/user").
                    contentType(MediaType.APPLICATION_JSON_UTF8)
                    .content("{\"username\":\"jack\",\"password\":\"123\"}"))
        .andExpect(status().isOk());
}

User(id=null,username=null,password=null)
複製程式碼

使用@RequestBody可以將請求體中的JSON資料解析成Java物件並繫結到方法入參

@PostMapping
public void createUser(@RequestBody User user) {
    System.out.println(user);
}
複製程式碼
@Test
public void testCreateUser() throws Exception {
    mockMvc.perform(post("/user").
                    contentType(MediaType.APPLICATION_JSON_UTF8)
                    .content("{\"username\":\"jack\",username=jack,password=123)
複製程式碼

日期型別引數處理

如果需要將時間型別資料繫結到BeanDate欄位上,網上常見的解決方案是加一個json訊息轉換器進行格式化,這樣的話就將日期的顯示邏輯寫死在後端的。

比較好的做法應該是後端只儲存時間戳,傳給前端時也只傳時間戳,將格式化顯示的責任交給前端,前端愛怎麼顯示怎麼顯示

@PostMapping
public void createUser(@RequestBody User user) {
    System.out.println(user);
}
複製程式碼
@Test
public void testDateBind() throws Exception {
    Date date = new Date();
    System.out.println(date.getTime());
    mockMvc.perform(post("/user").
                    contentType(MediaType.APPLICATION_JSON_UTF8)
                    .content("{\"username\":\"jack\",\"password\":\"123\",\"birthday\":\"" + date.getTime() + "\"}"))
        .andExpect(status().isOk());
}

1566212381139
User(id=null,password=123,birthday=Mon Aug 19 18:59:41 CST 2019)
複製程式碼

@Valid註解驗證請求引數的合法性

抽離校驗邏輯

Controller方法中,我們經常需要對請求引數進行合法性校驗後再執行處理邏輯,傳統的寫法是使用if判斷

@PostMapping
public void createUser(@RequestBody User user) {
    if (StringUtils.isBlank(user.getUsername())) {
        throw new IllegalArgumentException("使用者名稱不能為空");
    }
    if (StringUtils.isBlank(user.getPassword())) {
        throw new IllegalArgumentException("密碼不能為空");
    }
    System.out.println(user);
}
複製程式碼

但是如果其他地方也需要校驗就需要編寫重複的程式碼,一旦校驗邏輯發生改變就需要改變多處,並且如果有所遺漏還會給程式埋下隱患。有點重構意識的可能會將每個校驗邏輯單獨封裝一個方法,但仍顯冗餘。

SpringMVC Restful則推薦使用@Valid來實現引數的校驗,並且未通過校驗的會響應400 bad request給前端,以狀態碼錶示處理結果(及請求格式不對),而不是像上述程式碼一樣直接拋異常導致前端收到的狀態碼是500

首先我們要使用hibernate-validator校驗框架提供的一些約束註解來約束Bean欄位

@NotBlank
@JsonView(UserBasicView.class)
private String username;

@NotBlank
@JsonView(UserDetailsView.class)
private String password;
複製程式碼

僅新增這些註解,SpringMVC是不會幫我們校驗的

@PostMapping
public void createUser(@RequestBody User user) {
    System.out.println(user);
}
複製程式碼
@Test
public void testConstraintValidateFail() throws Exception {
    mockMvc.perform(post("/user").
                    contentType(MediaType.APPLICATION_JSON_UTF8)
                    .content("{\"username\":\"\"}"))
        .andExpect(status().isOk());
}

User(id=null,username=,password=null,birthday=null)
複製程式碼

我們還要在需要校驗的Bean前新增@Valid註解,這樣SpringMVC會根據我們在該Bean中新增的約束註解進行校驗,在校驗不通過時響應400 bad request

@PostMapping
public void createUser(@Valid @RequestBody User user) {
    System.out.println(user);
}
複製程式碼
@Test
public void testConstraintValidateSuccess() throws Exception {
    mockMvc.perform(post("/user").
                    contentType(MediaType.APPLICATION_JSON_UTF8)
                    .content("{\"username\":\"\"}"))
        .andExpect(status().is4xxClientError());
}
複製程式碼

約束註解

hibernate-validator提供的約束註解如下

image.png

image.png

例如,建立使用者時限制請求引數中的birthday的值是一個過去時間

首先在Bean的欄位新增約束註解

@Past
private Date birthday;
複製程式碼

然後在要驗證的Bean前新增@Valid註解

@PostMapping
public void createUser(@Valid @RequestBody User user) {
    System.out.println(user);
}
複製程式碼
@Test
public void testValidatePastTimeSuccess() throws Exception {
    // 獲取一年前的時間點
    Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
    mockMvc.perform(post("/user").
                    contentType(MediaType.APPLICATION_JSON_UTF8)
                    .content("{\"username\":\"jack\",\"birthday\":\"" + date.getTime() + "\"}"))
        .andExpect(status().isOk());
}

@Test
public void testValidatePastTimeFail() throws Exception {
    // 獲取一年後的時間點
    Date date = new Date(LocalDateTime.now().plusYears(1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
    mockMvc.perform(post("/user").
                    contentType(MediaType.APPLICATION_JSON_UTF8)
                    .content("{\"username\":\"jack\",\"birthday\":\"" + date.getTime() + "\"}"))
        .andExpect(status().is4xxClientError());
}
複製程式碼

複用校驗邏輯

這樣,如果我們需要對修改使用者的方法新增校驗,只需新增@Valid即可

@PutMapping("/{id}")
public void update(@Valid @RequestBody User user,@PathVariable Long id) {
    System.out.println(user);
    System.out.println(id);
}
複製程式碼
@Test
public void testUpdateSuccess() throws Exception {
    mockMvc.perform(put("/user/1").
                    contentType(MediaType.APPLICATION_JSON_UTF8)
                    .content("{\"username\":\"jack\",\"password\":\"789\"}"))
        .andExpect(status().isOk());
}

User(id=null,password=789,birthday=null)
1

@Test
public void testUpdateFail() throws Exception {
    mockMvc.perform(put("/user/1").
                    contentType(MediaType.APPLICATION_JSON_UTF8)
                    .content("{\"username\":\"jack\",\"password\":\" \"}"))
        .andExpect(status().is4xxClientError());
}
複製程式碼

約束邏輯只需在Bean中通過約束註解宣告一次,其他任何需要使用到該約束校驗的地方只需新增@Valid即可

BindingResult處理校驗結果

上述處理方式還是不夠完美,我們只是通過響應狀態碼告訴前端請求資料格式不對,但是沒有明確指明哪裡不對,我們需要給前端一些更明確的資訊

上例中,如果沒有通過校驗,那麼方法就不會被執行而直接返回了,我們想要插入一些提示資訊都沒有辦法編寫。這時可以使用BindingResult,它能夠幫助我們獲取校驗失敗資訊並返回給前端,同時響應狀態碼會變為200

@PostMapping
public void createUser(@Valid @RequestBody User user,BindingResult errors) {
    if (errors.hasErrors()) {
        errors.getAllErrors().stream().forEach(error -> System.out.println(error.getDefaultMessage()));
    }
    System.out.println(user);
}

@PutMapping("/{id}")
public void update(@PathVariable Long id,@Valid @RequestBody User user,BindingResult errors) {
    if (errors.hasErrors()) {
        errors.getAllErrors().stream().forEach(error -> System.out.println(error.getDefaultMessage()));
    }
    System.out.println(user);
    System.out.println(id);
}
複製程式碼
@Test
public void testBindingResult() throws Exception {
    Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
    mockMvc.perform(post("/user").
                    contentType(MediaType.APPLICATION_JSON_UTF8)
                    .content("{\"username\":\"jack\",\"password\":null,\"birthday\":\"" + date.getTime() + "\"}"))
        .andExpect(status().isOk());
}

may not be empty
User(id=null,birthday=Sun Aug 19 20:44:02 CST 2018)

@Test
public void testBindingResult2() throws Exception {
    Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
    mockMvc.perform(put("/user/1").
                    contentType(MediaType.APPLICATION_JSON_UTF8)
                    .content("{\"username\":\"jack\",birthday=Sun Aug 19 20:42:56 CST 2018)
1
複製程式碼

值得注意的是,BindingResult必須和@Valid一起使用,並且在參列中的位置必須緊跟在@Valid修飾的引數後面,否則會出現如下令人困惑的結果

@PutMapping("/{id}")
public void update(@Valid @RequestBody User user,@PathVariable Long id,BindingResult errors) {
    if (errors.hasErrors()) {
        errors.getAllErrors().stream().forEach(error -> System.out.println(error.getDefaultMessage()));
    }
    System.out.println(user);
    System.out.println(id);
}
複製程式碼

上述程式碼中,在校驗的BeanBindingResult之間插入了一個id,你會發現BindingResult不起作用了

@Test
public void testBindingResult2() throws Exception {
    Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
    mockMvc.perform(put("/user/1").
                    contentType(MediaType.APPLICATION_JSON_UTF8)
                    .content("{\"username\":\"jack\",\"birthday\":\"" + date.getTime() + "\"}"))
        .andExpect(status().isOk());
}

java.lang.AssertionError: Status 
Expected :200
Actual   :400
複製程式碼

校驗

自定義訊息

現在我們可以通過BindingResult得到校驗失敗資訊了

@PutMapping("/{id:\\d+}")
public void update(@PathVariable Long id,BindingResult errors) {
    if (errors.hasErrors()) {
        errors.getAllErrors().stream().forEach(error -> {
            FieldError fieldError = (FieldError) error;
            System.out.println(fieldError.getField() + " " + fieldError.getDefaultMessage());
        });
    }
    System.out.println(user);
}
複製程式碼
@Test
public void testBindingResult3() throws Exception {
    Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
    mockMvc.perform(put("/user/1").
                    contentType(MediaType.APPLICATION_JSON_UTF8)
                    .content("{\"username\":\" \",\"birthday\":\"" + date.getTime() + "\"}"))
        .andExpect(status().isOk());
}

password may not be empty
username may not be empty
User(id=null,birthday=Sun Aug 19 20:56:35 CST 2018)
複製程式碼

但是預設的訊息提示不太友好並且還需要我們自己拼接,這時我們需要自定義訊息提示,只需要使用約束註解的message屬性指定驗證未通過的提示訊息即可

@NotBlank(message = "使用者名稱不能為空")
@JsonView(UserBasicView.class)
private String username;

@NotBlank(message = "密碼不能為空")
@JsonView(UserDetailsView.class)
private String password;
複製程式碼
@Test
public void testBindingResult3() throws Exception {
    Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
    mockMvc.perform(put("/user/1").
                    contentType(MediaType.APPLICATION_JSON_UTF8)
                    .content("{\"username\":\" \",\"birthday\":\"" + date.getTime() + "\"}"))
        .andExpect(status().isOk());
}

password 密碼不能為空
username 使用者名稱不能為空
User(id=null,birthday=Sun Aug 19 21:03:18 CST 2018)
複製程式碼

自定義校驗註解

雖然hibernate-validator提供了一些常用的約束註解,但是對於複雜的業務場景還是需要我們自定義一個約束註解,畢竟有時僅僅是非空或格式合法的校驗是不夠的,可能我們需要去資料庫查詢進行校驗

下面我們就參考已有的約束註解照葫蘆畫瓢自定義一個“使用者名稱不可重複”的約束註解

1、新建約束註解類

我們希望該註解標註在Bean的某些欄位上,使用@Target({FIELD});此外,要想該註解在執行期起作用,還要新增@Retention(RUNTIME)

package top.zhenganwen.securitydemo.annotation.valid;

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

/**
 * @author zhenganwen
 * @date 2019/8/20
 * @desc Unrepeatable
 */
@Target({FIELD})
@Retention(RUNTIME)
public @interface Unrepeatable {
    
}

複製程式碼

參考已有的約束註解如NotNullNotBlank,它們都有三個方法

String message() default "{org.hibernate.validator.constraints.NotBlank.message}";

Class<?>[] groups() default { };

Class<? extends Payload>[] payload() default { };
複製程式碼

於是我們也宣告這三個方法

@Target({FIELD})
@Retention(RUNTIME)
public @interface Unrepeatable {
    String message() default "使用者名稱已被註冊";

    Class<?>[] groups() default { };

    Class<? extends Payload>[] payload() default { };
}
複製程式碼

2、編寫校驗邏輯類

依照已有註解,它們都還有一個註解@Constraint

@Documented
@Constraint(validatedBy = { })
@Target({ METHOD,FIELD,ANNOTATION_TYPE,CONSTRUCTOR,PARAMETER })
@Retention(RUNTIME)
@ReportAsSingleViolation
@NotNull
public @interface NotBlank {
複製程式碼

按住Ctrl點選validateBy屬性進行檢視,發現它需要一個ConstraintValidator的實現類,現在我們需要編寫一個ConstraintValidator自定義校驗邏輯並通過validatedBy屬性將其繫結到我們的Unrepeatable註解上

package top.zhenganwen.securitydemo.annotation.valid;

import org.springframework.beans.factory.annotation.Autowired;
import top.zhenganwen.securitydemo.service.UserService;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

/**
 * @author zhenganwen
 * @date 2019/8/20
 * @desc UsernameUnrepeatableValidator
 */
public class UsernameUnrepeatableValidator implements ConstraintValidator<Unrepeatable,String> {

    @Autowired
    private UserService userService;

    @Override
    public void initialize(Unrepeatable unrepeatableAnnotation) {
        System.out.println(unrepeatableAnnotation);
        System.out.println("UsernameUnrepeatableValidator initialized===================");
    }

    @Override
    public boolean isValid(String value,ConstraintValidatorContext context) {
        System.out.println("the request username is " + value);
        boolean ifExists = userService.checkUsernameIfExists( value);
        // 如果使用者名稱存在,則拒絕請求並提示使用者名稱已被註冊,否則處理請求
        return ifExists == true ? false : true;
    }
}
複製程式碼

其中,ConstraintValidator<A,T>泛型A指定為要繫結到的註解,T指定要校驗欄位的型別;isValid用來編寫自定義校驗邏輯,如查詢資料庫是否存在該使用者名稱的記錄,返回true表示校驗通過,false校驗失敗

@ComponentScan掃描範圍內的ConstraintValidator實現類會被Spring注入到容器中,因此你無須在該類上標註Component即可在類中注入其他Bean,例如本例中注入了一個UserService

package top.zhenganwen.securitydemo.service;

import org.springframework.stereotype.Service;

import java.util.Objects;

/**
 * @author zhenganwen
 * @date 2019/8/20
 * @desc UserService
 */
@Service
public class UserService {

    public boolean checkUsernameIfExists(String username) {
        // select count(username) from user where username=?
        // as if username "tom" has been registered
        if (Objects.equals(username,"tom")) {
            return true;
        }
        return false;
    }
}
複製程式碼

3、在約束註解上指定校驗類

通過validatedBy屬性指定該註解繫結的一系列校驗類(這些校驗類必須是ConstraintValidator<A,T>的實現類

@Target({FIELD})
@Retention(RUNTIME)
@Constraint(validatedBy = { UsernameUnrepeatableValidator.class})
public @interface Unrepeatable {
    String message() default "使用者名稱已被註冊";

    Class<?>[] groups() default { };

    Class<? extends Payload>[] payload() default { };
}
複製程式碼

4、測試

@PostMapping
public void createUser(@Valid @RequestBody User user,BindingResult errors) {
    if (errors.hasErrors()) {
        errors.getAllErrors().stream().forEach(error -> System.out.println(error.getDefaultMessage()));
    }
    System.out.println(user);
}
複製程式碼
@Test
public void testCreateUserWithNewUsername() throws Exception {
    Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
    mockMvc.perform(post("/user").
                    contentType(MediaType.APPLICATION_JSON_UTF8)
                    .content("{\"username\":\"alice\",\"birthday\":\"" + date.getTime() + "\"}"))
        .andExpect(status().isOk());
}

the request username is alice
User(id=null,username=alice,birthday=Mon Aug 20 08:25:11 CST 2018)

    
@Test
public void testCreateUserWithExistedUsername() throws Exception {
    Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
    mockMvc.perform(post("/user").
                    contentType(MediaType.APPLICATION_JSON_UTF8)
                    .content("{\"username\":\"tom\",\"birthday\":\"" + date.getTime() + "\"}"))
        .andExpect(status().isOk());
}

the request username is tom
使用者名稱已被註冊
User(id=null,username=tom,birthday=Mon Aug 20 08:25:11 CST 2018)
複製程式碼

刪除使用者

@Test
public void testDeleteUser() throws Exception {
    mockMvc.perform(delete("/user/1").
                    contentType(MediaType.APPLICATION_JSON_UTF8))
        .andExpect(status().isOk());
}

java.lang.AssertionError: Status 
Expected :200
Actual   :405
複製程式碼

測試先行,即先寫測試用例後寫功能程式碼,即使我們知道沒有編寫該功能測試肯定不會通過,但測試程式碼也是需要檢驗的,確保測試邏輯的正確性

Restful提倡以響應狀態碼來表示請求處理結果,例如200表示刪除成功,若沒有特別要求需要返回某些資訊,那麼無需新增響應體

@DeleteMapping("/{id:\\d+}")
public void delete(@PathVariable Long id) {
    System.out.println(id);
    // delete user
}
複製程式碼
@Test
public void testDeleteUser() throws Exception {
    mockMvc.perform(delete("/user/1").
                    contentType(MediaType.APPLICATION_JSON_UTF8))
        .andExpect(status().isOk());
}

1
複製程式碼

錯誤處理

SpringBoot預設的錯誤處理機制

區分客戶端進行響應

當請求處理髮生錯誤時,SpringMVC根據客戶端的型別會有不同的響應結果,例如瀏覽器訪問localhost:8080/xxx會返回如下錯誤頁面

image.png

而使用Postman請求則會得到如下響應

{
    "timestamp": 1566268880358,"status": 404,"error": "Not Found","message": "No message available","path": "/xxx"
}
複製程式碼

該機制對應的原始碼在BasicErrorController中(發生4xx500異常時,會將請求轉發到/error,由BasicErrorController決定異常響應邏輯)

@RequestMapping(produces = "text/html")
public ModelAndView errorHtml(HttpServletRequest request,HttpServletResponse response) {
    HttpStatus status = getStatus(request);
    Map<String,Object> model = Collections.unmodifiableMap(getErrorAttributes(
        request,isIncludeStackTrace(request,MediaType.TEXT_HTML)));
    response.setStatus(status.value());
    ModelAndView modelAndView = resolveErrorView(request,response,status,model);
    return (modelAndView == null ? new ModelAndView("error",model) : modelAndView);
}

@RequestMapping
@ResponseBody
public ResponseEntity<Map<String,Object>> error(HttpServletRequest request) {
    Map<String,Object> body = getErrorAttributes(request,MediaType.ALL));
    HttpStatus status = getStatus(request);
    return new ResponseEntity<Map<String,Object>>(body,status);
}
複製程式碼

如果是瀏覽器發出的請求,它的請求頭會附帶Accept: text/html...,而Postman發出的請求則是Accept: */*,因此前者會執行errorHtml響應錯誤頁面,而error會收集異常資訊以map的形式返回

自定義錯誤頁面

對於客戶端是瀏覽器的錯誤響應,例如404/500,我們可以在src/main/resources/resources/error資料夾下編寫自定義錯誤頁面,SpringMVC會在發生相應異常時返回該資料夾下的404.html500.html

建立src/main/resources/resources/error資料夾並新增404.html500.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>頁面找不到了</title>
</head>
<body>
抱歉,頁面找不到了!
</body>
</html>
複製程式碼
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>服務異常</title>
</head>
<body>
服務端內部錯誤
</body>
</html>
複製程式碼

模擬處理請求時發生異常

@GetMapping("/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
    throw new RuntimeException("id不存在");
    //        System.out.println(id);
    //        return new User(1L,"jack","123");
    //        return null;
}
複製程式碼

訪問localhost:8080/xxx顯示404.html頁面,訪問localhost:8080/user/1顯示500.html頁面

值得注意的是,自定義異常頁面並不會導致非瀏覽器請求也會響應該頁面

自定義異常處理

對於4XX的客戶端錯誤,SpringMVC會直接返回錯誤響應和不會執行Controller方法;對於500的服務端丟擲異常,則會收集異常類的message欄位值返回

預設異常響應結果

例如客戶端錯誤,GET /user/1

{
    "timestamp": 1566270327128,"status": 500,"error": "Internal Server Error","exception": "java.lang.RuntimeException","message": "id不存在","path": "/user/1"
}
複製程式碼

例如服務端錯誤

@PostMapping
public void createUser(@Valid @RequestBody User user) {
    System.out.println(user);
}
複製程式碼
POST	localhost:8080/user
Body	{}
複製程式碼
{
    "timestamp": 1566272056042,"status": 400,"error": "Bad Request","exception": "org.springframework.web.bind.MethodArgumentNotValidException","errors": [
        {
            "codes": [
                "NotBlank.user.username","NotBlank.username","NotBlank.java.lang.String","NotBlank"
            ],"arguments": [
                {
                    "codes": [
                        "user.username","username"
                    ],"arguments": null,"defaultMessage": "username","code": "username"
                }
            ],"defaultMessage": "使用者名稱不能為空","objectName": "user","field": "username","rejectedValue": null,"bindingFailure": false,"code": "NotBlank"
        },{
            "codes": [
                "NotBlank.user.password","NotBlank.password","arguments": [
                {
                    "codes": [
                        "user.password","password"
                    ],"defaultMessage": "password","code": "password"
                }
            ],"defaultMessage": "密碼不能為空","field": "password","code": "NotBlank"
        }
    ],"message": "Validation failed for object='user'. Error count: 2","path": "/user"
}
複製程式碼

自定義異常響應結果

有時我們需要經常在處理請求時丟擲異常以終止對該請求的處理,例如

package top.zhenganwen.securitydemo.web.exception.response;

import lombok.Data;

import java.io.Serializable;

/**
 * @author zhenganwen
 * @date 2019/8/20
 * @desc IdNotExistException
 */
@Data
public class IdNotExistException extends RuntimeException {

    private Serializable id;

    public IdNotExistException(Serializable id) {
        super("id不存在");
        this.id = id;
    }
}
複製程式碼
@GetMapping("/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
    throw new IdNotExistException(id);
}
複製程式碼

GET /user/1

{
    "timestamp": 1566270990177,"status": 500,"error": "Internal Server Error","exception": "top.zhenganwen.securitydemo.exception.response.IdNotExistException","message": "id不存在","path": "/user/1"
}
複製程式碼

SpringMVC預設只會將異常的message返回,如果我們需要將IdNotExistExceptionid也返回以給前端更明確的提示,就需要我們自定義異常處理

  1. 自定義的異常處理類需要新增@ControllerAdvice
  2. 在處理異常的方法上使用@ExceptionHandler宣告該方法要截獲哪些異常,所有的Controller若丟擲這些異常中的一個則會轉為執行該方法
  3. 捕獲到的異常會作為方法的入參
  4. 方法返回的結果與Controller方法返回的結果意義相同,如果需要返回json則需在方法上新增@ResponseBody註解,如果在類上新增該註解則表示每個方法都有該註解
package top.zhenganwen.securitydemo.web.exception.handler;

import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import top.zhenganwen.securitydemo.web.exception.response.IdNotExistException;

import java.util.HashMap;
import java.util.Map;

/**
 * @author zhenganwen
 * @date 2019/8/20
 * @desc UserControllerExceptionHandler
 */
@ControllerAdvice
@ResponseBody
public class UserControllerExceptionHandler {

    @ExceptionHandler(IdNotExistException.class)
    public Map<String,Object> handleIdNotExistException(IdNotExistException e) {
        Map<String,Object> jsonResult = new HashMap<>();
        jsonResult.put("message",e.getMessage());
        jsonResult.put("id",e.getId());
        return jsonResult;
    }
}

複製程式碼

重啟後使用Postman GET /user/1得到響應如下

{
    "id": 1,"message": "id不存在"
}
複製程式碼

攔截

需求:記錄所有請求 的處理時間

過濾器Filter

過濾器是JavaEE中的標準,是不依賴SpringMVC的,要想在SpringMVC中使用過濾器需要兩步

1、實現Filter介面並注入到Spring容器

package top.zhenganwen.securitydemo.web.filter;

import org.springframework.stereotype.Component;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;

/**
 * @author zhenganwen
 * @date 2019/8/20
 * @desc TimeFilter
 */
@Component
public class TimeFilter implements Filter {

    // 在web容器啟動時執行
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        System.out.println("TimeFilter init");
    }

    // 在收到請求時執行,這時請求還未到達SpringMVC的入口DispatcherServlet
    // 單次請求只會執行一次(不論期間發生了幾次請求轉發)
    @Override
    public void doFilter(ServletRequest servletRequest,ServletResponse servletResponse,FilterChain filterChain) throws IOException,ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");

        String service = "【" + request.getMethod() + " " + request.getRequestURI() + "】";
        System.out.println("[TimeFilter] 收到服務呼叫:" + service);

        Date start = new Date();
        System.out.println("[TimeFilter] 開始執行服務" + service + simpleDateFormat.format(start));

        filterChain.doFilter(servletRequest,servletResponse);

        Date end = new Date();
        System.out.println("[TimeFilter] 服務" + service + "執行完畢 " + simpleDateFormat.format(end) +
                ",共耗時:" + (end.getTime() - start.getTime()) + "ms");
    }

    // 在容器銷燬時執行
    @Override
    public void destroy() {
        System.out.println("TimeFilter destroyed");
    }
}
複製程式碼

2、配置FilterRegistrationBean,這一步相當於傳統方式在web.xml中新增一個<Filter>節點

package top.zhenganwen.securitydemo.web.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import top.zhenganwen.securitydemo.web.filter.TimeFilter;

/**
 * @author zhenganwen
 * @date 2019/8/20
 * @desc WebConfig
 */
@Configuration
public class WebConfig {

    @Autowired
    TimeFilter timeFilter;

    // 新增這個bean相當於在web.xml中新增一個Fitler節點
    @Bean
    public FilterRegistrationBean registerTimeFilter() {
        FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
        filterRegistrationBean.setFilter(timeFilter);
        return filterRegistrationBean;
    }
}
複製程式碼

3、測試

訪問GET /user/1,控制檯日誌如下

@GetMapping("/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
    //        throw new IdNotExistException(id);
    User user = new User();
    return user;
}
複製程式碼
[TimeFilter] 收到服務呼叫:【GET /user/1】
[TimeFilter] 開始執行服務【GET /user/1】2019-08-20 02:13:44
[TimeFilter] 服務【GET /user/1】執行完畢 2019-08-20 02:13:44,共耗時:4ms
複製程式碼

由於FilterJavaEE中的標準,所以它僅依賴servlet-api而不依賴任何第三方類庫,因此它自然也不知道Controller的存在,自然也就無法知道本次請求將被對映到哪個方法上,SpringMVC通過引入攔截器彌補了這一缺點

通過filterRegistrationBean.addUrlPattern可以為過濾器新增攔截規則,預設的攔截規則是所有URL

@Bean
public FilterRegistrationBean registerTimeFilter() {
    FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
    filterRegistrationBean.setFilter(timeFilter);
    filterRegistrationBean.addUrlPatterns("/*");
    return filterRegistrationBean;
}
複製程式碼

攔截器Interceptor

攔截器與Filter的有如下不同之處

  • Filter是基於請求的,Interceptor是基於Controller的,一次請求可能會執行多個Controller(通過轉發),因此一次請求只會執行一次Filter但可能執行多次Interceptor
  • InterceptorSpringMVC中的元件,因此它知道Controller的存在,能夠獲取相關資訊(如該請求對映的方法,方法所在的bean等)

使用SpringMVC提供的攔截器也需要兩步

1、實現HandlerInterceptor介面

package top.zhenganwen.securitydemo.web.interceptor;

import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.text.SimpleDateFormat;
import java.util.Date;

/**
 * @author zhenganwen
 * @date 2019/8/20
 * @desc TimeInterceptor
 */
@Component
public class TimeInterceptor implements HandlerInterceptor {

    /**
     * 在Controller方法執行前被執行
     * @param httpServletRequest
     * @param httpServletResponse
     * @param handler 處理器(Controller方法的封裝)
     * @return  true    會接著執行Controller方法
     *          false   不會執行Controller方法,直接響應200
     * @throws Exception
     */
    @Override
    public boolean preHandle(HttpServletRequest httpServletRequest,HttpServletResponse httpServletResponse,Object handler) throws Exception {
        HandlerMethod handlerMethod = (HandlerMethod) handler;
        String service = "【" + handlerMethod.getBean() + "#" + handlerMethod.getMethod().getName() + "】";
        Date start = new Date();
        System.out.println("[TimeInterceptor # preHandle] 服務" + service + "被呼叫 " + new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(start));
        httpServletRequest.setAttribute("start",start.getTime());
        return true;
    }

    /**
     * 在Controller方法正常執行完畢後執行,如果Controller方法丟擲異常則不會執行此方法
     * @param httpServletRequest
     * @param httpServletResponse
     * @param handler
     * @param modelAndView  Controller方法返回的檢視
     * @throws Exception
     */
    @Override
    public void postHandle(HttpServletRequest httpServletRequest,Object handler,ModelAndView modelAndView) throws Exception {
        HandlerMethod handlerMethod = (HandlerMethod) handler;
        String service = "【" + handlerMethod.getBean() + "#" + handlerMethod.getMethod().getName() + "】";
        Date end = new Date();
        System.out.println("[TimeInterceptor # postHandle] 服務" + service + "呼叫結束 " + new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(end)
                + " 共耗時:" + (end.getTime() - (Long) httpServletRequest.getAttribute("start")) + "ms");
    }

    /**
     * 無論Controller方法是否丟擲異常,都會被執行
     * @param httpServletRequest
     * @param httpServletResponse
     * @param handler
     * @param e 如果Controller方法丟擲異常則為對應丟擲的異常,否則為null
     * @throws Exception
     */
    @Override
    public void afterCompletion(HttpServletRequest httpServletRequest,Exception e) throws Exception {
        HandlerMethod handlerMethod = (HandlerMethod) handler;
        String service = "【" + handlerMethod.getBean() + "#" + handlerMethod.getMethod().getName() + "】";
        Date end = new Date();
        System.out.println("[TimeInterceptor # afterCompletion] 服務" + service + "呼叫結束 " + new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(end)
                + " 共耗時:" + (end.getTime() - (Long) httpServletRequest.getAttribute("start")) + "ms");
        if (e != null) {
            System.out.println("[TimeInterceptor#afterCompletion] 服務" + service + "呼叫異常:" + e.getMessage());
        }
    }
}
複製程式碼

2、配置類繼承WebMvcConfigureAdapter並重寫addInterceptor方法新增自定義攔截器

@Configuration
public class WebConfig extends WebMvcConfigurerAdapter {

    @Autowired
    TimeFilter timeFilter;

    @Autowired
    TimeInterceptor timeInterceptor;

    @Bean
    public FilterRegistrationBean registerTimeFilter() {
        FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
        filterRegistrationBean.setFilter(timeFilter);
        filterRegistrationBean.addUrlPatterns("/*");
        return filterRegistrationBean;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(timeInterceptor);
    }
}
複製程式碼

多次呼叫addInterceptor可新增多個攔截器

3、測試

  • GET /user/1
[TimeFilter] 收到服務呼叫:【GET /user/1】
[TimeFilter] 開始執行服務【GET /user/1】2019-08-20 02:59:00
[TimeInterceptor # preHandle] 服務【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】被呼叫 2019-08-20 02:59:00
[TimeFilter] 服務【GET /user/1】執行完畢 2019-08-20 02:59:00,共耗時:2ms
複製程式碼
  • preHandle返回值改為true
[TimeFilter] 收到服務呼叫:【GET /user/1】
[TimeFilter] 開始執行服務【GET /user/1】2019-08-20 02:59:20
[TimeInterceptor # preHandle] 服務【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】被呼叫 2019-08-20 02:59:20
[TimeInterceptor # postHandle] 服務【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】呼叫結束 2019-08-20 02:59:20 共耗時:39ms
[TimeInterceptor # afterCompletion] 服務【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】呼叫結束 2019-08-20 02:59:20 共耗時:39ms
[TimeFilter] 服務【GET /user/1】執行完畢 2019-08-20 02:59:20,共耗時:42ms
複製程式碼
  • 在Controller方法中丟擲異常
@GetMapping("/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
    throw new IdNotExistException(id);
    //        User user = new User();
    //        return user;
}
複製程式碼
[TimeFilter] 收到服務呼叫:【GET /user/1】
[TimeFilter] 開始執行服務【GET /user/1】2019-08-20 03:05:56
[TimeInterceptor # preHandle] 服務【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】被呼叫 2019-08-20 03:05:56
[TimeInterceptor # afterCompletion] 服務【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】呼叫結束 2019-08-20 03:05:56 共耗時:11ms
[TimeFilter] 服務【GET /user/1】執行完畢 2019-08-20 03:05:56,共耗時:14ms
複製程式碼

發現afterCompletion中的異常列印邏輯並未被執行,這是因為IdNotExistException被我們之前自定義的異常處理器處理掉了,沒有丟擲來。我們改為丟擲RuntimeException再試一下

@GetMapping("/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
    throw new RuntimeException("id not exist");
}
複製程式碼
[TimeFilter] 收到服務呼叫:【GET /user/1】
[TimeFilter] 開始執行服務【GET /user/1】2019-08-20 03:09:38
[TimeInterceptor # preHandle] 服務【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】被呼叫 2019-08-20 03:09:38
[TimeInterceptor # afterCompletion] 服務【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】呼叫結束 2019-08-20 03:09:38 共耗時:7ms
[TimeInterceptor#afterCompletion] 服務【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】呼叫異常:id not exist

java.lang.RuntimeException: id not exist
	at top.zhenganwen.securitydemo.web.controller.UserController.getInfo(UserController.java:42)
	...

[TimeInterceptor # preHandle] 服務【org.springframework.boot.autoconfigure.web.BasicErrorController@33f17289#error】被呼叫 2019-08-20 03:09:38
[TimeInterceptor # postHandle] 服務【org.springframework.boot.autoconfigure.web.BasicErrorController@33f17289#error】呼叫結束 2019-08-20 03:09:38 共耗時:7ms
[TimeInterceptor # afterCompletion] 服務【org.springframework.boot.autoconfigure.web.BasicErrorController@33f17289#error】呼叫結束 2019-08-20 03:09:38 共耗時:7ms
複製程式碼

方法呼叫時序圖大致如下

image.png

切片Aspect

應用場景

Interceptor仍然有它的侷限性,即無法獲取呼叫Controller方法的入參資訊,例如我們需要對使用者下單請求的訂單物品資訊記錄日誌以便為推薦系統提供資料,那麼這時Interceptor就無能為力了

追蹤原始碼DispatcherServlet -> doService -> doDispatch可發現Interceptor無法獲取入參的原因:

if (!mappedHandler.applyPreHandle(processedRequest,response)) {
    return;
}

// Actually invoke the handler.
mv = ha.handle(processedRequest,mappedHandler.getHandler());
複製程式碼

mappedHandler.applyPreHandle其實就是呼叫HandlerInterceptorpreHandle方法,而在此之後才呼叫ha.handle(processedRequest,mappedHandler.getHandler())將請求引數processedRequest注入到handler入參上

使用方法

面向切面程式設計(Aspect-Oriented Program AOP)是基於動態代理的一種物件增強設計模式,能夠實現在不修改現有程式碼的前提下新增可插拔的功能。

SpringMVC中使用AOP我們需要三步

  • 編寫切片/切面類,將切入點和增強結合在一起
    • 新增@Component,注入Spring容器
    • 新增@Aspect,啟動切面程式設計開關
  • 編寫切入點,使用註解可以完成,切入點包含兩部分:哪些方法需要增強以及增強的時機
    • 切入時機
      • @Before,方法執行前
      • @AfterReturning,方法正常執行結束後
      • @AfterThrowing,方法丟擲異常後
      • @After,方法正常執行結束return前,相當於在return前插入了一段finally
      • @Around,可利用注入的入參ProceedingJoinPoint靈活的實現上述4種時機,它的作用與攔截器方法中的handler類似,只不過提供了更多有用的執行時資訊
    • 切入點,可以使用execution表示式,具體詳見:docs.spring.io/spring/docs…
  • 編寫增強方法,
    • 其中只有@Around可以有入參,能拿到ProceedingJoinPoint例項
    • 通過呼叫ProceedingJoinPointpoint.proceed()能夠呼叫對應的Controller方法並拿到返回值
package top.zhenganwen.securitydemo.web.aspect;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Date;

/**
 * @author zhenganwen
 * @date 2019/8/20
 * @desc GlobalControllerAspect
 */
@Aspect
@Component
public class GlobalControllerAspect {

    // top.zhenganwen.securitydemo.web.controller包下的所有Controller的所有方法
    @Around("execution(* top.zhenganwen.securitydemo.web.controller.*.*(..))")
    public Object handleControllerMethod(ProceedingJoinPoint point) throws Throwable {

        // handler對應的方法簽名(哪個類的哪個方法,引數列表是什麼)
        String service = "【"+point.getSignature().toLongString()+"】";
        // 傳入handler的引數值
        Object[] args = point.getArgs();

        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
        Date start = new Date();
        System.out.println("[GlobalControllerAspect]開始呼叫服務" + service + " 請求引數: " + Arrays.toString(args) + "," + simpleDateFormat.format(start));

        Object result = null;
        try {
            // 呼叫實際的handler並取得結果
            result = point.proceed();
        } catch (Throwable throwable) {
            System.out.println("[GlobalControllerAspect]呼叫服務" + service + "發生異常,message=" + throwable.getMessage());
            throw throwable;
        }

        Date end = new Date();
        System.out.println("[GlobalControllerAspect]服務" + service + "呼叫結束,響應結果為: " + result+","+simpleDateFormat.format(end)+",共耗時: "+(end.getTime()-start.getTime())+
                "ms");

        // 返回響應結果,不一定要和handler的處理結果一致
        return result;
    }
}
複製程式碼

測試

@GetMapping("/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
    System.out.println("[UserController # getInfo]query user by id");
    return new User();
}
複製程式碼

GET /user/1

[TimeFilter] 收到服務呼叫:【GET /user/1】
[TimeFilter] 開始執行服務【GET /user/1】2019-08-20 05:21:48
[TimeInterceptor # preHandle] 服務【top.zhenganwen.securitydemo.web.controller.UserController@49433c98#getInfo】被呼叫 2019-08-20 05:21:48
[GlobalControllerAspect]開始呼叫服務【public top.zhenganwen.securitydemo.dto.User top.zhenganwen.securitydemo.web.controller.UserController.getInfo(java.lang.Long)】 請求引數: [1],2019-08-20 05:21:48
[UserController # getInfo]query user by id
[GlobalControllerAspect]服務【public top.zhenganwen.securitydemo.dto.User top.zhenganwen.securitydemo.web.controller.UserController.getInfo(java.lang.Long)】呼叫結束,響應結果為: User(id=null,username=null,password=null,birthday=null),2019-08-20 05:21:48,共耗時: 0ms
[TimeInterceptor # postHandle] 服務【top.zhenganwen.securitydemo.web.controller.UserController@49433c98#getInfo】呼叫結束 2019-08-20 05:21:48 共耗時:4ms
[TimeInterceptor # afterCompletion] 服務【top.zhenganwen.securitydemo.web.controller.UserController@49433c98#getInfo】呼叫結束 2019-08-20 05:21:48 共耗時:4ms
[TimeFilter] 服務【GET /user/1】執行完畢 2019-08-20 05:21:48,共耗時:6ms
複製程式碼
[TimeFilter] 收到服務呼叫:【GET /user/1】
[TimeFilter] 開始執行服務【GET /user/1】2019-08-20 05:24:40
[TimeInterceptor # preHandle] 服務【top.zhenganwen.securitydemo.web.controller.UserController@49433c98#getInfo】被呼叫 2019-08-20 05:24:40
[GlobalControllerAspect]開始呼叫服務【public top.zhenganwen.securitydemo.dto.User top.zhenganwen.securitydemo.web.controller.UserController.getInfo(java.lang.Long)】 請求引數: [1],2019-08-20 05:24:40
[UserController # getInfo]query user by id
[GlobalControllerAspect]呼叫服務【public top.zhenganwen.securitydemo.dto.User top.zhenganwen.securitydemo.web.controller.UserController.getInfo(java.lang.Long)】發生異常,message=id not exist
[TimeInterceptor # afterCompletion] 服務【top.zhenganwen.securitydemo.web.controller.UserController@49433c98#getInfo】呼叫結束 2019-08-20 05:24:40 共耗時:2ms
[TimeInterceptor#afterCompletion] 服務【top.zhenganwen.securitydemo.web.controller.UserController@49433c98#getInfo】呼叫異常:id not exist

java.lang.RuntimeException: id not exist
	at top.zhenganwen.securitydemo.web.controller.UserController.getInfo(UserController.java:42)
    ...
 
[TimeInterceptor # preHandle] 服務【org.springframework.boot.autoconfigure.web.BasicErrorController@445821a6#error】被呼叫 2019-08-20 05:24:40
[TimeInterceptor # postHandle] 服務【org.springframework.boot.autoconfigure.web.BasicErrorController@445821a6#error】呼叫結束 2019-08-20 05:24:40 共耗時:2ms
[TimeInterceptor # afterCompletion] 服務【org.springframework.boot.autoconfigure.web.BasicErrorController@445821a6#error】呼叫結束 2019-08-20 05:24:40 共耗時:3ms
複製程式碼

總結

請求過程

image.png

響應過程

image.png

檔案上傳下載及Mock測試

檔案上傳

老規矩,測試先行,不過使用MockMvc模擬檔案上傳請求還是有些不一樣的,請求需要使用靜態方法fileUpload且要設定contentTypemultipart/form-data

	@Test
    public void upload() throws Exception {
        File file = new File("C:\\Users\\zhenganwen\\Desktop","hello.txt");
        FileInputStream fis = new FileInputStream(file);
        byte[] content = new byte[fis.available()];
        fis.read(content);
        String fileKey = mockMvc.perform(fileUpload("/file")
                /**
                 * name         請求引數,相當於<input>標籤的的`name`屬性
                 * originalName 上傳的檔名稱
                 * contentType  上傳檔案需指定為`multipart/form-data`
                 * content      位元組陣列,上傳檔案的內容
                 */
                .file(new MockMultipartFile("file","hello.txt","multipart/form-data",content)))
                .andExpect(status().isOk())
                .andReturn().getResponse().getContentAsString();
        System.out.println(fileKey);
    }
複製程式碼

檔案管理Controller

package top.zhenganwen.securitydemo.web.controller;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.util.Date;

/**
 * @author zhenganwen
 * @date 2019/8/21
 * @desc FileController
 */
@RestController
@RequestMapping("/file")
public class FileController {

    public static final String FILE_STORE_FOLDER = "C:\\Users\\zhenganwen\\Desktop\\";

    @PostMapping
    public String upload(MultipartFile file) throws IOException {

        System.out.println("[FileController]檔案請求引數: " + file.getName());
        System.out.println("[FileController]檔名稱: " + file.getName());
        System.out.println("[FileController]檔案大小: "+file.getSize()+"位元組");

        
        String fileKey = new Date().getTime() + "_" + file.getOriginalFilename();
        File storeFile = new File(FILE_STORE_FOLDER,fileKey);

        // 可以通過file.getInputStream將檔案上傳到FastDFS、雲OSS等儲存系統中
//        InputStream inputStream = file.getInputStream();
//        byte[] content = new byte[inputStream.available()];
//        inputStream.read(content);

        file.transferTo(storeFile);

        return fileKey;
    }
}
複製程式碼

測試結果

[FileController]檔案請求引數: file
[FileController]檔名稱: file
[FileController]檔案大小: 12位元組
1566349460611_hello.txt
複製程式碼

檢視桌面發現多了一個1566349460611_hello.txt並且其中的內容為hello upload

檔案下載

引入apache io工具包

<dependency>
    <groupId>commons-io</groupId>
    <artifactId>commons-io</artifactId>
    <version>2.5</version>
</dependency>
複製程式碼

檔案下載介面

@GetMapping("/{fileKey:.+}")
public void download(@PathVariable String fileKey,HttpServletResponse response) throws IOException {

    try (
        InputStream is = new FileInputStream(new File(FILE_STORE_FOLDER,fileKey));
        OutputStream os = response.getOutputStream()
    ) {
        // 下載需要設定響應頭為 application/x-download
        response.setContentType("application/x-download");
        // 設定下載詢問框中的檔名
        response.setHeader("Content-Disposition","attachment;filename=" + fileKey);

        IOUtils.copy(is,os);
        os.flush();
    }
}
複製程式碼

測試:瀏覽器訪問http://localhost:8080/file/1566349460611_hello.txt

對映寫成/{fileKey:.+}而不是/{fileKey}的原因是SpringMVC會忽略對映中.符號之後的字元。正則.+表示匹配任意個非\n的字元,不加該正則的話,方法入參fileKey獲取到的值將是1566349460611_hello而不是1566349460611_hello.txt

非同步處理REST服務

我們之前都是客戶端每傳送一個請求,tomcat執行緒池就派一個執行緒進行處理,直到請求處理完成響應結果,該執行緒都是被佔用的。一旦系統併發量上來了,那麼tomcat執行緒池會顯得分身乏力,這時我們可以採取非同步處理的方式。

為避免前文新增的過濾器、攔截器、切片日誌的幹擾,我們暫時先註釋掉

//@Component
public class TimeFilter implements Filter {
複製程式碼

突然發現實現過濾器好像繼承了Filter介面並新增@Component就能生效,因為僅註釋掉WebConfig中的registerTimeFilter方法,發現TimeFilter還是列印了日誌

//@Configuration
public class WebConfig extends WebMvcConfigurerAdapter {
複製程式碼
//@Aspect
//@Component
public class GlobalControllerAspect {
複製程式碼

Callable非同步處理

Controller中,如果將一個Callable作為方法的返回值,那麼tomcat執行緒池中的執行緒在響應結果時會新建一個執行緒執行該Callable並將其返回結果返回給客戶端

package top.zhenganwen.securitydemo.web.controller;

import org.apache.commons.lang.RandomStringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;


/**
 * @author zhenganwen
 * @date 2019/8/7
 * @desc AsyncController
 */
@RestController
@RequestMapping("/order")
public class AsyncOrderController {

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

    // 建立訂單
    @PostMapping
    public Callable<String> createOrder() {
        // 生成12位單號
        String orderNumber = RandomStringUtils.randomNumeric(12);
        logger.info("[主執行緒]收到建立訂單請求,訂單號=>" + orderNumber);
        Callable<String> result = () -> {
            logger.info("[副執行緒]建立訂單開始,訂單號=>"+orderNumber);
            // 模擬建立訂單邏輯
            TimeUnit.SECONDS.sleep(3);
            logger.info("[副執行緒]建立訂單完成,訂單號=>" + orderNumber+",返回結果給客戶端");
            return orderNumber;
        };
        logger.info("[主執行緒]已將請求委託副執行緒處理(訂單號=>" + orderNumber + "),繼續處理其它請求");
        return result;
    }
}
複製程式碼

使用Postman測試結果如下

image.png

控制檯日誌:

2019-08-21 21:10:39.059  INFO 17044 --- [nio-8080-exec-2] t.z.s.w.controller.AsyncOrderController  : [主執行緒]收到建立訂單請求,訂單號=>719547514079
2019-08-21 21:10:39.059  INFO 17044 --- [nio-8080-exec-2] t.z.s.w.controller.AsyncOrderController  : [主執行緒]已將請求委託副執行緒處理(訂單號=>719547514079),繼續處理其它請求
2019-08-21 21:10:39.063  INFO 17044 --- [      MvcAsync1] t.z.s.w.controller.AsyncOrderController  : [副執行緒]建立訂單開始,訂單號=>719547514079
2019-08-21 21:10:42.064  INFO 17044 --- [      MvcAsync1] t.z.s.w.controller.AsyncOrderController  : [副執行緒]建立訂單完成,訂單號=>719547514079,返回結果給客戶端
複製程式碼

觀察可知主執行緒並沒有執行Callable下單任務而直接跑去繼續監聽其他請求了,下單任務由SpringMVC新啟了一個執行緒MvcAsync1執行,Postman的響應時間也是在Callable執行完畢後得到了它的返回值。對於客戶端來說,後端的非同步處理是透明的,與同步時沒有什麼區別;但是對於後端來說,tomcat監聽請求的執行緒被佔用的時間很短,大大提高了自身的併發能力

DeferredResult非同步處理

Callable非同步處理的缺陷是,只能通過在本地新建副執行緒的方式進行非同步處理,但現在隨著微服務架構的盛行,我們經常需要跨系統的非同步處理。例如在秒殺系統中,併發下單請求量較大,如果後端對每個下單請求做同步處理(即在請求執行緒中處理訂單)後再返回響應結果,會導致服務假死(傳送下單請求沒有任何響應);這時我們可能會利用訊息中介軟體,請求執行緒只負責監聽下單請求,然後發訊息給MQ,讓訂單系統從MQ中拉取訊息(如單號)進行下單處理並將處理結果返回給秒殺系統;秒殺系統獨立設一個監聽訂單處理結果訊息的執行緒,將處理結果返回給客戶端。如圖所示

image.png

要實現類似上述的效果,需要使用Future模式(可參考《Java多執行緒程式設計實戰(設計模式篇)》),即我們可以設定一個處理結果憑證DeferredResult,如果我們直接呼叫它的getResult是獲取不到處理結果的(會被阻塞,表現為雖然請求執行緒繼續處理請求了,但是客戶端仍在pending,只有當某個執行緒呼叫它的setResult(result),才會將對應的result響應給客戶端

本例中,為降低複雜性,使用本地記憶體中的LinkedList代替分散式訊息中介軟體,使用本地新建執行緒代替訂單系統執行緒,各類之間的關係如下

image.png

秒殺系統AsyncOrderController

package top.zhenganwen.securitydemo.web.async;

import org.apache.commons.lang.RandomStringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.async.DeferredResult;

import java.util.concurrent.TimeUnit;


/**
 * @author zhenganwen
 * @date 2019/8/7
 * @desc AsyncController
 */
@RestController
@RequestMapping("/order")
public class AsyncOrderController {

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

    @Autowired
    private DeferredResultHolder deferredResultHolder;

    @Autowired
    private OrderProcessingQueue orderProcessingQueue;

    // 秒殺系統下單請求
    @PostMapping
    public DeferredResult<String> createOrder() {

        logger.info("【請求執行緒】收到下單請求");

        // 生成12位單號
        String orderNumber = RandomStringUtils.randomNumeric(12);

        // 建立處理結果憑證放入快取,以便監聽(訂單系統向MQ傳送的訂單處理結果訊息的)執行緒向憑證中設定結果,這會觸發該結果響應給客戶端
        DeferredResult<String> deferredResult = new DeferredResult<>();
        deferredResultHolder.placeOrder(orderNumber,deferredResult);

        // 異步向MQ傳送下單訊息,假設需要200ms
        new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(500);
                synchronized (orderProcessingQueue) {
                    while (orderProcessingQueue.size() >= Integer.MAX_VALUE) {
                        try {
                            orderProcessingQueue.wait();
                        } catch (Exception e) {
                        }
                    }
                    orderProcessingQueue.addLast(orderNumber);
                    orderProcessingQueue.notifyAll();
                }
                logger.info("向MQ傳送下單訊息,單號: {}",orderNumber);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        },"本地臨時執行緒-向MQ傳送下單訊息")
        .start();

        logger.info("【請求執行緒】繼續處理其它請求");

        // 並不會立即將deferredResult序列化成JSON並返回給客戶端,而會等deferredResult的setResult被呼叫後,將傳入的result轉成JSON返回
        return deferredResult;
    }
}
複製程式碼

兩個MQ

package top.zhenganwen.securitydemo.web.async;

import org.springframework.stereotype.Component;

import java.util.LinkedList;

/**
 * @author zhenganwen
 * @date 2019/8/22
 * @desc OrderProcessingQueue   下單訊息MQ
 */
@Component
public class OrderProcessingQueue extends LinkedList<String> {
}
複製程式碼
package top.zhenganwen.securitydemo.web.async;

import org.springframework.stereotype.Component;

import java.util.LinkedList;

/**
 * @author zhenganwen
 * @date 2019/8/22
 * @desc OrderCompletionQueue   訂單處理完成MQ
 */
@Component
public class OrderCompletionQueue extends LinkedList<OrderCompletionResult> {
}
複製程式碼
package top.zhenganwen.securitydemo.web.async;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * @author zhenganwen
 * @date 2019/8/22
 * @desc OrderCompletionResult  訂單處理完成結果資訊,包括單號和是否成功
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class OrderCompletionResult {
    private String orderNumber;
    private String result;
}
複製程式碼

憑證快取

package top.zhenganwen.securitydemo.web.async;

import org.hibernate.validator.constraints.NotBlank;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.async.DeferredResult;

import javax.validation.constraints.NotNull;
import java.util.HashMap;
import java.util.Map;

/**
 * @author zhenganwen
 * @date 2019/8/22
 * @desc DeferredResultHolder   訂單處理結果憑證快取,通過憑證可以在未來的時間點獲取處理結果
 */
@Component
public class DeferredResultHolder {

    private Map<String,DeferredResult<String>> holder = new HashMap<>();

    // 將訂單處理結果憑證放入快取
    public void placeOrder(@NotBlank String orderNumber,@NotNull DeferredResult<String> result) {
        holder.put(orderNumber,result);
    }

    // 向憑證中設定訂單處理完成結果
    public void completeOrder(@NotBlank String orderNumber,String result) {
        if (!holder.containsKey(orderNumber)) {
            throw new IllegalArgumentException("orderNumber not exist");
        }
        DeferredResult<String> deferredResult = holder.get(orderNumber);
        deferredResult.setResult(result);
    }
}
複製程式碼

兩個佇列對應的兩個監聽

package top.zhenganwen.securitydemo.web.async;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

/**
 * @author zhenganwen
 * @date 2019/8/22
 * @desc OrderProcessResultListener
 */
@Component
public class OrderProcessingListener implements ApplicationListener<ContextRefreshedEvent> {

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

    @Autowired
    OrderProcessingQueue orderProcessingQueue;

    @Autowired
    OrderCompletionQueue orderCompletionQueue;

    @Autowired
    DeferredResultHolder deferredResultHolder;

    // spring容器啟動或重新整理時執行此方法
    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {

        // 本系統(秒殺系統)啟動時,啟動一個監聽MQ下單完成訊息的執行緒
        new Thread(() -> {

            while (true) {
                String finishedOrderNumber;
                OrderCompletionResult orderCompletionResult;
                synchronized (orderCompletionQueue) {
                    while (orderCompletionQueue.isEmpty()) {
                        try {
                            orderCompletionQueue.wait();
                        } catch (InterruptedException e) { }
                    }
                    orderCompletionResult = orderCompletionQueue.pollFirst();
                    orderCompletionQueue.notifyAll();
                }
                finishedOrderNumber = orderCompletionResult.getOrderNumber();
                logger.info("收到訂單處理完成訊息,單號為: {}",finishedOrderNumber);
                deferredResultHolder.completeOrder(finishedOrderNumber,orderCompletionResult.getResult());
            }

        },"本地監聽執行緒-監聽訂單處理完成")
        .start();


        // 假設是訂單系統監聽MQ下單訊息的執行緒
        new Thread(() -> {

            while (true) {
                String orderNumber;
                synchronized (orderProcessingQueue) {
                    while (orderProcessingQueue.isEmpty()) {
                        try {
                            orderProcessingQueue.wait();
                        } catch (InterruptedException e) {
                        }
                    }
                    orderNumber = orderProcessingQueue.pollFirst();
                    orderProcessingQueue.notifyAll();
                }

                logger.info("收到下單請求,開始執行下單邏輯,單號為: {}",orderNumber);
                boolean status;
                // 模擬執行下單邏輯
                try {
                    TimeUnit.SECONDS.sleep(2);
                    status = true;
                } catch (Exception e) {
                    logger.info("下單失敗=>{}",e.getMessage());
                    status = false;
                }
                // 向 訂單處理完成MQ 傳送訊息
                synchronized (orderCompletionQueue) {
                    orderCompletionQueue.addLast(new OrderCompletionResult(orderNumber,status == true ? "success" : "error"));
                    logger.info("傳送訂單完成訊息,orderNumber);
                    orderCompletionQueue.notifyAll();
                }
            }

        },"訂單系統執行緒-監聽下單訊息")
        .start();
    }
}
複製程式碼

測試

image.png

2019-08-22 13:22:05.520  INFO 21208 --- [nio-8080-exec-2] t.z.s.web.async.AsyncOrderController     : 【請求執行緒】收到下單請求
2019-08-22 13:22:05.521  INFO 21208 --- [nio-8080-exec-2] t.z.s.web.async.AsyncOrderController     : 【請求執行緒】繼續處理其它請求
2019-08-22 13:22:06.022  INFO 21208 --- [  訂單系統執行緒-監聽下單訊息] t.z.s.web.async.OrderProcessingListener  : 收到下單請求,開始執行下單邏輯,單號為: 104691998710
2019-08-22 13:22:06.022  INFO 21208 --- [地臨時執行緒-向MQ傳送下單訊息] t.z.s.web.async.AsyncOrderController     : 向MQ傳送下單訊息,單號: 104691998710
2019-08-22 13:22:08.023  INFO 21208 --- [  訂單系統執行緒-監聽下單訊息] t.z.s.web.async.OrderProcessingListener  : 傳送訂單完成訊息,單號: 104691998710
2019-08-22 13:22:08.023  INFO 21208 --- [本地監聽執行緒-監聽訂單處理完成] t.z.s.web.async.OrderProcessingListener  : 收到訂單處理完成訊息,單號為: 104691998710
複製程式碼

configu reSync非同步處理攔截、超時、執行緒池配置

在我們之前擴充套件WebMvcConfigureAdapter的子類WebConfig中可以通過重寫configureAsyncSupport方法對非同步處理進行一些配置

image.png

registerCallableInterceptors & registerDeferredResultInterceptors

我們之前通過重寫addInterceptors方法註冊的攔截器對CallableDeferredResult兩種非同步處理是無效的,如果想為這兩者配置攔截器需重寫這兩個方法

setDefaultTimeout

設定非同步處理的超時時間,超過該時間就直接響應而不會等非同步任務結束了

setTaskExecutor

SpringBoot預設是通過新建執行緒的方式執行非同步任務的,執行完後執行緒就被銷燬了,要想通過複用執行緒(執行緒池)的方式執行非同步任務,你可以通過此方法傳入一個自定義的執行緒池

前後端分離

Swagger介面檔案

swagger專案能夠根據我們所寫的介面自動生成介面檔案,方便我們前後端分離開發

依賴

<dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-swagger2</artifactId>
    <version>2.7.0</version>
</dependency>
<dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-swagger-ui</artifactId>
    <version>2.7.0</version>
</dependency>
複製程式碼

在啟動類SecurityDemoApplication上新增@@EnableSwagger2註解開啟介面檔案自動生成開關,啟動後訪問localhost:8080/swagger-ui.html

常用註解

  • @ApiOperation,註解在Controller方法上,用來描述方法的行為

    @GetMapping
    @JsonView(User.UserBasicView.class)
    @ApiOperation("使用者查詢服務")
    public List<User> query(String userName,Pageable pageable) {
    複製程式碼
  • @ApiModelProperty,註解在Bean的欄位上,用來描述欄位的含義

    @Data
    public class UserQueryConditionDto {
    
        @ApiModelProperty("使用者名稱")
        private String username;
        @ApiModelProperty("密碼")
        private String password;
        @ApiModelProperty("電話號碼")
        private String phone;
    }
    複製程式碼
  • @ApiParam,註解在Controller方法引數上,用來描述引數含義

    @DeleteMapping("/{id:\\d+}")
    public void delete(@ApiParam("使用者id") @PathVariable Long id) {
        System.out.println(id);
    }
    複製程式碼

重啟後介面檔案會重新生成

image.png

image.png

WireMock

為了方便前後端並行開發,我們可以使用WireMock作為虛擬介面伺服器

在後端介面沒開發完成時,前端可能會通過本地檔案的方式偽造一些靜態資料(例如JSON檔案)作為請求的響應結果,這種方式在前端只有一種終端時是沒問題的。但是當前端有多種,如PC、H5、APP、小程式等時,每種都去在自己的本地偽造資料,那麼就顯得有些重複,而且每個人按照自己的想法偽造資料可能會導致最終和真實介面無法無縫對接

這時wiremock的出現就解決了這一痛點,wiremock是用Java開發的一個獨立伺服器,能夠對外提供HTTP服務,我們可以通過wiremock客戶端去編輯/配置wiremock伺服器使它能像web服務一樣提供各種各樣的介面,而且無需重新部署

下載 & 啟動wiremock服務

wiremock可以以jar方式執行,下載地址,下載完成後切換到其所在目錄cmd執行以下命令啟動wiremock伺服器,--port=指定執行埠

java -jar wiremock-standalone-2.24.1.jar --port=8062
複製程式碼

依賴

引入wiremock客戶端依賴及其依賴的httpclient

<dependency>
    <groupId>com.github.tomakehurst</groupId>
    <artifactId>wiremock</artifactId>
</dependency>
<dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>httpclient</artifactId>
</dependency>
複製程式碼

由於在父工程中已經使用了依賴自動相容,所以無需指定版本號。接著通過客戶端API去編輯wiremock伺服器,為其新增介面

package top.zhenganwen.securitydemo.wiremock;

import static com.github.tomakehurst.wiremock.client.WireMock.*;

/**
 * @author zhenganwen
 * @date 2019/8/22
 * @desc MockServer
 */
public class MockServer {

    public static void main(String[] args) {
        configureFor("127.0.0.1",8062);
        removeAllMappings();    // 移除所有舊的配置

        // 新增配置,一個stub代表一個介面
        stubFor(
                get(urlEqualTo("/order/1")).
                        // 設定響應結果
                        willReturn(
                                aResponse()
                                        .withBody("{\"id\":1,\"orderNumber\":\"545616156\"}")
                                        .withStatus(200)
                        )
        );
    }
}
複製程式碼

你可以先將JSON資料存在resources中,然後通過ClassPathResource#getFileFileUtils#readLines將資料讀成字串

訪問localhost:8062/order/1

{
    id: 1,orderNumber: "545616156"
}
複製程式碼

通過WireMockAPI,你可以為虛擬伺服器配置各種各樣的介面服務

使用Spring Security開發基於表單的認證

Summary

Spring Security核心功能

  • 認證(你是誰)
  • 授權(你能幹什麼)
  • 攻擊防護(防止偽造身份,如果黑客能 偽造身份登入系統,上述兩個功能就不起作用了)

本章內容

  • Spring Security基本原理
  • 實現使用者名稱 + 密碼認證
  • 使用手機號 + 簡訊認證

Spring Security第一印象

Security有一個預設的基礎認證機制,我們註釋掉配置項security.basic.enabled=false(預設值為true),重啟檢視日誌會發現一條資訊

Using default security password: f84e3dea-d231-47a2-b20a-48bac8ed5f1e
複製程式碼

然後我們訪問GET /user,彈出登入框讓我們登入,security預設內建了一個使用者名稱為user,密碼為上述日誌中Using default security password: f84e3dea-d231-47a2-b20a-48bac8ed5f1e的使用者(該密碼每次重啟都會重新生成),我們使用這兩者登入表單後頁面重新跳轉到了我們要訪問的服務

formLogin

從本節開始我們將在security-browser模組中編寫我們的瀏覽器認證邏輯

我們可以通過新增配置類的方式(新增Configuration,並擴充套件WebSecurityConfigureAdapter)來配置驗證方式、驗證邏輯等,如設定驗證方式為表單驗證:

package top.zhenganwen.securitydemo.browser.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

/**
 * @author zhenganwen
 * @date 2019/8/22
 * @desc SecurityConfig
 */
@Configuration
public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            //設定認證方式為表單登入,若未登入而訪問受保護的URL則跳轉到表單登入頁(security幫我們寫了一個預設的登入頁)
            .formLogin()
            // 新增其他配置
            .and()
            // 驗證方式配置結束,開始配置驗證規則
            .authorizeRequests()
            // 設定任何請求都需要通過認證
            .anyRequest()
            .authenticated();
    }
}
複製程式碼

訪問/user,跳轉到預設的登入頁/login(該登入頁和登入URL我們可以自定義),使用者名稱user,密碼還是日誌中的,登入成功跳轉到/user

httpBasic

如果將認證方式由formLogin改為httpBasic就是security最預設的配置(相當於引入security依賴後什麼都不配的效果),即彈出登入框

Spring Security基本原理

三種過濾器

image.png

如圖所示,Spring Security的核心其實就是一串過濾器鏈,所以它是非侵入式可插拔的。過濾器鏈中的過濾器分3種:

  • 認證過濾器XxxAuthenticationFilter,如上圖中標註為綠色的,它們的類名以AuthenticationFilter結尾,作用是將登入的資訊儲存起來。這些過濾器是根據我們的配置動態生效的,如我們之前呼叫formLogin()其實就是啟用了UsernamePasswordAuthenticationFilter,呼叫httpBaisc()就是啟用了BasicAuthenticationFilter

    後面最貼近Controller的兩個過濾器ExceptionTranslationFilterFilterSecurityInterceptor包含了最核心的認證邏輯,預設是啟用的,而且我們也無法禁用它們

  • FilterSecurityInterceptor,雖然命名以Interceptor結尾,但其實還是一個Filter,它是最貼近Controller的一個過濾器,它會根據我們配置的攔截規則(哪些URL需要登入後才能訪問,哪些URL需要某些特定的許可權才能訪問等)對訪問相應URL的請求進行攔截,以下是它的部分原始碼

    public void doFilter(ServletRequest request,ServletResponse response,FilterChain chain) throws IOException,ServletException {
        FilterInvocation fi = new FilterInvocation(request,chain);
        invoke(fi);
    }
    
    public void invoke(FilterInvocation fi) throws IOException,ServletException {
        ...
            InterceptorStatusToken token = super.beforeInvocation(fi);
        ...
            fi.getChain().doFilter(fi.getRequest(),fi.getResponse());
        ...
    }
    複製程式碼

    doFilter就是真正呼叫我們的Controller了(因為它是過濾器鏈的末尾),但在此之前它會呼叫beforeInvocation對請求進行攔截校驗是否有相關的身份和許可權,校驗失敗對應會丟擲未經認證異常(Unauthenticated)和未經授權異常(Unauthorized),這些異常會被ExceptionTranslationFilter捕獲到

  • ExceptionTranslationFilter,顧名思義就是解析異常的,其部分原始碼如下

    public void doFilter(ServletRequest req,ServletResponse res,FilterChain chain)
        throws IOException,ServletException {
        HttpServletRequest request = (HttpServletRequest) req;
        HttpServletResponse response = (HttpServletResponse) res;
    
        try {
            chain.doFilter(request,response);
        }
        catch (Exception ex) {
            // Try to extract a SpringSecurityException from the stacktrace
            ...
        }
    }
    複製程式碼

    它呼叫chain.doFilter其實就是去到了FilterSecurityInterceptor,它會對FilterSecurityInterceptor.doFilter中丟擲的SpringSecurityException異常進行捕獲並解析處理,例如FilterSecurityInterceptor丟擲了Unauthenticated異常,那麼ExceptionTranslationFilter就會重定向到登入頁或是彈出登入框(取決於我們配置了什麼認證過濾器),當我們成功登入後,認證過濾又會重定向到我們最初要訪問的URL

斷點除錯

我們可以通過斷點除錯的方式來驗證上述所說,將驗證方式設為formLogin,然後在3個過濾器和Controller中分別打斷點,重啟服務訪問/user

image.png

自定義使用者認證邏輯

處理使用者資訊獲取邏輯——UserDetailsService

到此為止我們登入都是通過user和啟動日誌生成的密碼,這是security內建了一個user使用者。實際專案中我們一般有一個專門存放使用者的表,會通過jdbc或從其他儲存系統讀取使用者資訊,這時就需要我們自定義讀取使用者資訊的邏輯,通過實現UserDetailsService介面即可告訴security從如何獲取使用者資訊

package top.zhenganwen.securitydemo.browser.config;

import org.hibernate.validator.constraints.NotBlank;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;

import java.util.Objects;

/**
 * @author zhenganwen
 * @date 2019/8/23
 * @desc CustomUserDetailsService
 */
@Component
public class CustomUserDetailsService implements UserDetailsService {

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

    @Override
    public UserDetails loadUserByUsername(@NotBlank String username) throws UsernameNotFoundException {
        logger.info("登入使用者名稱: " + username);
        // 實際專案中你可以呼叫Dao或Repository來查詢使用者是否存在
        if (Objects.equals(username,"admin") == false) {
            throw new UsernameNotFoundException("使用者名稱不存在");
        }
        
        // 在查詢到使用者後需要將相關資訊包裝成一個UserDetails例項返回給security,這裡的User是security提供的一個實現
        // 第三個引數需要傳一個許可權集合,這裡使用了一個security提供的工具類將用分號分隔的許可權字串轉成許可權集合,本來應該從使用者許可權表查詢的
        return new org.springframework.security.core.userdetails.User(
                "admin","123456",AuthorityUtils.commaSeparatedStringToAuthorityList("user,admin")
        );
    }
}
複製程式碼

重啟服務後只能通過admin,123456來登入了

處理使用者校驗邏輯——UserDetails

我們來看一下UserDetails介面原始碼

public interface UserDetails extends Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();

    // 用來和使用者登入時填寫的密碼進行比對
    String getPassword();

    String getUsername();

    // 賬戶是否是非過期的
    boolean isAccountNonExpired();

    // 賬戶是否是非凍結的
    boolean isAccountNonLocked();

    // 密碼是否是非過期的,有些安全性較高的系統需要賬戶每隔一段時間更換密碼
    boolean isCredentialsNonExpired();

    // 賬戶是否可用,可以對應邏輯刪除欄位
    boolean isEnabled();
}
複製程式碼

在重寫以is開頭的四個方法時,如果無需相應判斷,則返回true即可,例如對應使用者表的實體類如下

@Data
public class User{
    private Long id;
    private String username;
    private String password;
    private String phone;
    private int deleted;			//0-"正常的",1-"已刪除的"
    private int accountNonLocked;	 //0-"賬號未被凍結",1-"賬號已被凍結"
}
複製程式碼

為了方便,我們可以直接使用實體類實現UserDetails介面

@Data
public class User implements UserDetails{
    private Long id;
    private String uname;
    private String pwd;
    private String phone;
    private int deleted;			
    private int accountNonLocked;

    public String getPassword(){
        return pwd;
    }

    public String getUsername(){
        return uname;
    }

    public boolean isAccountNonExpired(){
        return true;
    }

    public boolean isAccountNonLocked(){
        return accountNonLocked == 0;
    }

    public boolean isCredentialsNonExpired(){
        return true;
    }

    public boolean isEnabled(){
        return deleted == 0;
    }
}
複製程式碼

處理密碼加密解密——PasswordEncoder

使用者表中的密碼欄位一般不會存放密碼的明文而是存放加密後的密文,這時我們就需要PasswordEncoder的支援了:

public interface PasswordEncoder {
	String encode(CharSequence rawPassword);
	boolean matches(CharSequence rawPassword,String encodedPassword);
}
複製程式碼

我們在插入使用者到資料庫時,需要呼叫encode對明文密碼加密後再插入;在使用者登入時,security會呼叫matches將我們從資料庫查出的密文面和使用者提交的明文密碼進行比對。

security為我們提供了一個該介面的非對稱加密(對同一明文密碼,每次呼叫encode得到的密文都是不一樣的,只有通過matches來比對明文和密文是否對應)實現類BCryptPasswordEncoder,我們只需配置一個該類的Beansecurity就會認為我們返回的UserDetailsgetPassword返回的密碼是通過該Bean加密過的(所以在插入使用者時要注意呼叫該Beanencode對密碼加密一下在插入資料庫)

@Configuration
public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
複製程式碼
@Component
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    BCryptPasswordEncoder passwordEncoder;

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

    @Override
    public UserDetails loadUserByUsername(@NotBlank String username) throws UsernameNotFoundException {
        logger.info("登入使用者名稱: " + username);
        // 實際專案中你可以呼叫Dao或Repository來查詢使用者是否存在
        if (Objects.equals(username,"admin") == false) {
            throw new UsernameNotFoundException("使用者名稱不存在");
        }
        // 假設查出來的密碼如下
        String pwd = passwordEncoder.encode("123456");
        
        return new org.springframework.security.core.userdetails.User(
                "admin",pwd,admin")
        );
    }
}
複製程式碼

BCryptPasswordEncoder不一定只能用於密碼的加密和校驗,日常開發中涉及到加密的功能我們都能使用它的encode方法,也能使用matches方法比對某密文是否是某明文加密後的結果

個性化使用者認證流程

自定義登入頁面

formLogin()後使用loginPage()就能指定登入的頁面,同時要記得將該URL的攔截放開;UsernamePasswordAuthenticationFilter預設攔截提交到/loginPOST請求並獲取登入資訊,如果你想表單填寫的action不為/post,那麼可以配置loginProcessingUrl使UsernamePasswordAuthenticationFilter與之對應

@Configuration
public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //設定認證方式為表單登入,若未登入而訪問受保護的URL則跳轉到表單登入頁(security幫我們寫了一個預設的登入頁)
                .formLogin()
                .loginPage("/sign-in.html").loginProcessingUrl("/auth/login")
                .and()
                // 驗證方式配置結束,開始配置驗證規則
                .authorizeRequests()
                    // 登入頁面不需要攔截
                    .antMatchers("/sign-in.html").permitAll()
                    // 設定任何請求都需要通過認證
                    .anyRequest().authenticated();
    }
}
複製程式碼

自定義登入頁:security-browser/src/main/resource/resources/sign-in.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>登入頁面</title>
</head>
<body>
<form action="/auth/login" method="post">
    使用者名稱: <input type="text" name="username">
    密碼: <input type="password" name="password">
    <button type="submit">提交</button>
</form>
</body>
</html>
複製程式碼

重啟後訪問GET /user,調整到了我們寫的登入頁sign-in.html,填寫admin,123456登入,發現還是報錯如下

There was an unexpected error (type=Forbidden,status=403).
Invalid CSRF Token 'null' was found on the request parameter '_csrf' or header 'X-CSRF-TOKEN'.
複製程式碼

這是因為security預設啟用了跨站偽造請求防護CSRF(例如使用HTTP客戶端Postman也可以發出這樣的登入請求),我們先禁用它

http
                .formLogin()
                .loginPage("/sign-in.html").loginProcessingUrl("/auth/login")
                .and()
                .authorizeRequests()
                    .antMatchers("/sign-in.html").permitAll()
                    .anyRequest().authenticated()
                .and()
                    .csrf().disable()
複製程式碼

再重啟訪問GET /user,跳轉登入後,自動跳轉回/user,自定義登入頁成功

REST登入邏輯

由於我們是基於REST的服務,所以如果是非瀏覽器請求,我們應該返回401狀態碼告訴客戶端需要認證,而不是重定向到登入頁

這時我們就不能將loginPage寫成登入頁路徑了,而應該重定向到一個Controller,由Controller判斷使用者是在瀏覽器訪問頁面時跳轉過來的還是非瀏覽器如安卓訪問REST服務時跳轉過來,如果是前者那麼就重定向到登入頁,如果是後者就響應401狀態碼和JSON訊息

package top.zhenganwen.securitydemo.browser;

import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.savedrequest.SavedRequest;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import top.zhenganwen.securitydemo.browser.support.SimpleResponseResult;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * @author zhenganwen
 * @date 2019/8/23
 * @desc AuthenticationController
 */
@RestController
public class BrowserSecurityController {

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

    // security會將跳轉前的請求儲存在session中
    private RequestCache requestCache = new HttpSessionRequestCache();

    private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    @RequestMapping("/auth/require")
    // 該註解可設定響應狀態碼
    @ResponseStatus(code = HttpStatus.UNAUTHORIZED)
    public SimpleResponseResult requireAuthentication(HttpServletRequest request,HttpServletResponse response) throws IOException {

        // 從session中取出跳轉前使用者訪問的URL
        SavedRequest savedRequest = requestCache.getRequest(request,response);
        if (savedRequest != null) {
            String redirectUrl = savedRequest.getRedirectUrl();
            logger.info("引發跳轉到/auth/login的請求是: {}",redirectUrl);
            if (StringUtils.endsWithIgnoreCase(redirectUrl,".html")) {
                // 如果使用者是訪問html頁面被FilterSecurityInterceptor攔截從而跳轉到了/auth/login,那麼就重定向到登入頁面
                redirectStrategy.sendRedirect(request,"/sign-in.html");
            }
        }

        // 如果不是訪問html而被攔截跳轉到了/auth/login,則返回JSON提示
        return new SimpleResponseResult("使用者未登入,請引導使用者至登入頁");
    }
}
複製程式碼
@Configuration
public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http  
                .formLogin()
                .loginPage("/auth/require").loginProcessingUrl("/auth/login")
                .and()
                .authorizeRequests()
                    .antMatchers("/auth/require").permitAll()
                    .antMatchers("/sign-in.html").permitAll()
                    .anyRequest().authenticated()
                .and()
                    .csrf().disable();
    }
}
複製程式碼

image.png

重構——配置代替hardcode

由於我們的security-browser模組是作為可複用模組來開發的,應該支援自定義配置,例如其他應用引入我們的security-browser模組之後,應該能配置他們自己的登入頁,如果他們沒有配置那就使用我們預設提供的sign-in.html,要想做到這點,我們需要提供一些配置項,例如別人引入我們的security-browser之後通過新增demo.security.browser.loginPage=/login.html就能將他們專案的login.html替換掉我們的sign-in.html

由於後續security-app也可能會需要支援類似的配置,因此我們在security-core中定義一個總的配置類來封裝各模組的不同配置項

security-core中的類:

package top.zhenganwen.security.core.properties;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;

/**
 * @author zhenganwen
 * @date 2019/8/23
 * @desc SecurityProperties 封裝整個專案各模組的配置項
 */
@Data
@ConfigurationProperties(prefix = "demo.security")
public class SecurityProperties {
    private BrowserProperties browser = new BrowserProperties();
}
複製程式碼
package top.zhenganwen.security.core.properties;

import lombok.Data;

/**
 * @author zhenganwen
 * @date 2019/8/23
 * @desc BrowserProperties  封裝security-browser模組的配置項
 */
@Data
public class BrowserProperties {
    private String loginPage = "/sign-in.html";	//提供一個預設的登入頁
}
複製程式碼
package top.zhenganwen.security.core;

import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import top.zhenganwen.security.core.properties.SecurityProperties;

/**
 * @author zhenganwen
 * @date 2019/8/23
 * @desc SecurityCoreConfig
 */
@Configuration
// 啟用在啟動時將application.properties中的demo.security字首的配置項注入到SecurityProperties中
@EnableConfigurationProperties(SecurityProperties.class)
public class SecurityCoreConfig {
}
複製程式碼

然後在security-browser中將SecurityProperties注入進來,將重定向到登入頁的邏輯依賴配置檔案中的demo.security.browser.loginPage

@RestController
public class BrowserSecurityController {

    private Logger logger = LoggerFactory.getLogger(getClass());
    private RequestCache requestCache = new HttpSessionRequestCache();
    private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    @Autowired
    private SecurityProperties securityProperties;

    @RequestMapping("/auth/require")
    @ResponseStatus(code = HttpStatus.UNAUTHORIZED)
    public SimpleResponseResult requireAuthentication(HttpServletRequest request,HttpServletResponse response) throws IOException {

        SavedRequest savedRequest = requestCache.getRequest(request,".html")) {
                redirectStrategy.sendRedirect(request,securityProperties.getBrowser().getLoginPage());
            }
        }

        return new SimpleResponseResult("使用者未登入,請引導使用者至登入頁");
    }
}
複製程式碼

將不攔截的登入頁URL設定為動態的

@Configuration
public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Autowired
    private SecurityProperties securityProperties;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .formLogin()
                .loginPage("/auth/require").loginProcessingUrl("/auth/login")
                .and()
                .authorizeRequests()
                    .antMatchers("/auth/require").permitAll()
            		// 將不攔截的登入頁URL設定為動態的
                    .antMatchers(securityProperties.getBrowser().getLoginPage()).permitAll()
                    .anyRequest().authenticated()
                .and()
                    .csrf().disable();
    }
}
複製程式碼

現在,我們將security-demo模組當做第三方應用,使用可複用的security-browser

首先,要將security-demo模組的啟動類SecurityDemoApplication移到top.zhenganwen.securitydemo包下,確保能夠掃描到security-core下的top.zhenganwen.securitydemo.core.SecurityCoreConfigsecurity-browser下的top.zhenganwen.securitydemo.browser.SecurityBrowserConfig

然後,在security-demoapplication.properties中新增配置項demo.security.browser.loginPage=/login.html並在resources下新建resources資料夾和其中的login.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<h1>Security Demo應用的登入頁面</h1>
<form action="/auth/login" method="post">
    使用者名稱: <input type="text" name="username">
    密碼: <input type="password" name="password">
    <button type="submit">提交</button>
</form>
</body>
</html>
複製程式碼

重啟服務,訪問/user.html發現跳轉到了login.html;註釋掉demo.security.browser.loginPage=/login.html,再重啟服務訪問/user.html發現跳轉到了sign-in.html,重構成功!

自定義登入成功處理——AuthenticationSuccessHandler

security處理登入成功的邏輯預設是重定向到之前被攔截的請求,但是對於REST服務來說,前端可能是AJAX請求登入,希望獲取的響應是使用者的相關資訊,這時你給他重定向顯然不合適。要想自定義登入成功後的處理,我們需要實現AuthenticationSuccessHandler介面

package top.zhenganwen.securitydemo.browser.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * @author zhenganwen
 * @date 2019/8/24
 * @desc CustomAuthenticationSuccessHandler
 */
@Component("customAuthenticationSuccessHandler")
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

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

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,HttpServletResponse response,Authentication authentication) throws IOException,ServletException {
        logger.info("使用者{}登入成功",authentication.getName());
        response.setContentType("application/json;charset=utf-8");
        response.getWriter().write(objectMapper.writeValueAsString(authentication));
        response.getWriter().flush();
    }
}
複製程式碼

在登入成功後,我們會拿到一個Authentication,這也是security的一個核心介面,作用是封裝使用者的相關資訊,這裡我們將其轉成JSON串響應給前端看一下它包含了哪些內容

我們還需要通過successHandler()將其配置到HttpSecurity中以使之生效(替代預設的登入成功處理邏輯):

@Configuration
public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private AuthenticationSuccessHandler customAuthenticationSuccessHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .formLogin()
                .loginPage("/auth/require").loginProcessingUrl("/auth/login")
                .successHandler(customAuthenticationSuccessHandler)
                .and()
                .authorizeRequests()
                    .antMatchers("/auth/require").permitAll()
                    .antMatchers(securityProperties.getBrowser().getLoginPage()).permitAll()
                    .anyRequest().authenticated()
                .and()
                .csrf().disable();
    }
}
複製程式碼

重啟服務,訪問/login.html並登入:

{
    authorities: [
        {
            authority: "admin"
        },{
            authority: "user"
        }
    ],details: {
        remoteAddress: "0:0:0:0:0:0:0:1",sessionId: "3BA37577BAC493D0FE1E07192B5524B1"
    },authenticated: true,principal: {
        password: null,username: "admin",authorities: [
            {
                authority: "admin"
            },{
                authority: "user"
            }
        ],accountNonExpired: true,accountNonLocked: true,credentialsNonExpired: true,enabled: true
    },credentials: null,name: "admin"
}
複製程式碼

可以發現Authentication包含了以下資訊

  • authorities,許可權,對應UserDetialsgetAuthorities()的返回結果
  • details,回話,客戶端的IP以及本次回話的SESSIONID
  • authenticated,是否通過認證
  • principle,對應UserDetailsServiceloadUserByUsername返回的UserDetails
  • credentials,密碼,security預設做了處理,不將密碼返回給前端
  • name,使用者名稱

這裡因為我們是表單登入,所以返回的是以上資訊,之後我們做第三方登入如微信、QQ,那麼Authentication包含的資訊就可能不一樣了,也就是說重寫的onAuthenticationSuccess方法的入參Authentication會根據登入方式的不同傳給我們不同的Authentication實現類物件

自定義登入失敗處理——AuthenticationFailureHandler

與登入成功處理對應,自然也可以自定義登入失敗處理

package top.zhenganwen.securitydemo.browser.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * @author zhenganwen
 * @date 2019/8/24
 * @desc CustomAuthenticationFailureHandler
 */
@Component("customAuthenticationFailureHandler")
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {

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

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request,AuthenticationException exception) throws IOException,ServletException {
        logger.info("登入失敗=>{}",exception.getMessage());
        response.setContentType("application/json;charset=utf-8");
        response.getWriter().write(objectMapper.writeValueAsString(exception));
        response.getWriter().flush();
    }
}
複製程式碼
@Configuration
public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private AuthenticationSuccessHandler customAuthenticationSuccessHandler;

    @Autowired
    private AuthenticationFailureHandler customAuthenticationFailureHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .formLogin()
                    .loginPage("/auth/require")
                    .loginProcessingUrl("/auth/login")
                    .successHandler(customAuthenticationSuccessHandler)
                    .failureHandler(customAuthenticationFailureHandler)
                .and()
                .authorizeRequests()
                    .antMatchers("/auth/require").permitAll()
                    .antMatchers(securityProperties.getBrowser().getLoginPage()).permitAll()
                    .anyRequest().authenticated()
                .and()
                .csrf().disable();
    }
}
複製程式碼

訪問/login.html輸入錯誤的密碼登入:

{
    cause: null,stackTrace: [...],localizedMessage: "壞的憑證",message: "壞的憑證",suppressed: [ ]
}
複製程式碼

重構

為了使security-browser成為可複用的模組,我們應該將登入成功/失敗處理策略抽離出去,讓第三方應用自由選擇,這時我們又可以新增一個配置項demo.security.browser.loginProcessType

切換到security-core:

package top.zhenganwen.security.core.properties;

/**
 * @author zhenganwen
 * @date 2019/8/24
 * @desc LoginProcessTypeEnum
 */
public enum LoginProcessTypeEnum {
	// 重定向到之前的請求頁或登入失敗頁
    REDIRECT("redirect"),// 登入成功返回使用者資訊,登入失敗返回錯誤資訊
    JSON("json");

    private String type;

    LoginProcessTypeEnum(String type) {
        this.type = type;
    }
}
複製程式碼
@Data
public class BrowserProperties {
    private String loginPage = "/sign-in.html";
    private LoginProcessTypeEnum loginProcessType = LoginProcessTypeEnum.JSON;    //預設返回JSON資訊
}
複製程式碼

重構登入成功/失敗處理器,其中SavedRequestAwareAuthenticationSuccessHandlerSimpleUrlAuthenticationFailureHandler就是security提供的預設的登入成功(跳轉到登入之前請求的頁面)和登入失敗(跳轉到異常頁)的處理器

package top.zhenganwen.securitydemo.browser.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import top.zhenganwen.security.core.properties.LoginProcessTypeEnum;
import top.zhenganwen.security.core.properties.SecurityProperties;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * @author zhenganwen
 * @date 2019/8/24
 * @desc CustomAuthenticationSuccessHandler
 */
@Component("customAuthenticationSuccessHandler")
public class CustomAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {

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

    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private SecurityProperties securityProperties;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,ServletException {
        if (securityProperties.getBrowser().getLoginProcessType() == LoginProcessTypeEnum.REDIRECT) {
            // 重定向到快取在session中的登入前請求的URL
            super.onAuthenticationSuccess(request,authentication);
            return;
        }
        logger.info("使用者{}登入成功",authentication.getName());
        response.setContentType("application/json;charset=utf-8");
        response.getWriter().write(objectMapper.writeValueAsString(authentication));
        response.getWriter().flush();
    }
}
複製程式碼
package top.zhenganwen.securitydemo.browser.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import top.zhenganwen.security.core.properties.LoginProcessTypeEnum;
import top.zhenganwen.security.core.properties.SecurityProperties;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * @author zhenganwen
 * @date 2019/8/24
 * @desc CustomAuthenticationFailureHandler
 */
@Component("customAuthenticationFailureHandler")
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

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

    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private SecurityProperties securityProperties;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request,ServletException {
        if (securityProperties.getBrowser().getLoginProcessType() == LoginProcessTypeEnum.REDIRECT) {
            super.onAuthenticationFailure(request,exception);
            return;
        }
        logger.info("登入失敗=>{}",exception.getMessage());
        response.setContentType("application/json;charset=utf-8");
        response.getWriter().write(objectMapper.writeValueAsString(exception));
        response.getWriter().flush();
    }
}
複製程式碼

訪問/login.html,分別進行登入成功和登入失敗測試,返回JSON響應

security-demo

  • application.properties中新增demo.security.browser.loginProcessType=redirect

  • 新建/resources/resources/index.html

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>Title</title>
    </head>
    <body>
    <h1>Spring Demo應用首頁</h1>
    </body>
    </html>
    複製程式碼
  • 新建/resources/resources/401.html

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>Title</title>
    </head>
    <body>
    <h1>login fail!</h1>
    </body>
    </html>
    複製程式碼

重啟服務,登入成功跳轉到index.html,登入失敗跳轉到401.html

認證流程原始碼級詳解

經過上述兩節,我們已經會使用security的一些基礎功能了,但都是碎片化的,對整體流程的把握還很模糊。知其然還要知其所以然,我們需要分析在登入時security都幫我們做了哪些事

認證處理流程

image.png

上圖是登入處理的大致流程,登入請求的過濾器XxxAutenticationFilter在攔截到登入請求後會見登入資訊封裝成一個authenticated=falseAuthentication傳給AuthenticationManager讓幫忙校驗,AuthenticationManager本身也不會做校驗邏輯,會委託AuthenticationProvider幫忙校驗,AuthenticationProvider會在校驗過程中丟擲校驗失敗異常或校驗通過返回一個新的帶有UserDetialsAuthentication返回,請求過濾器收到XxxAuthenticationFilter之後會呼叫登入成功處理器執行登入成功邏輯

我們以使用者名稱密碼錶單登入方式來斷點除錯逐步分析一下校驗流程,其他的登入方式也就大同小異了

image.png

image.png

securityloginProcess1.gif

認證結果如何在多個請求之間共享

要想在多個請求之間共享資料,需要藉助session,接下來我們看一下security將什麼東西放到了session中,又在什麼時候會從session讀取

上節說道在AbstractAuthenticationProcessingFilter的``doFilter方法中,校驗成功之後會呼叫successfulAuthentication(request,chain,authResult)`,我們來看一下這個方法幹了些什麼

protected void successfulAuthentication(HttpServletRequest request,FilterChain chain,Authentication authResult)
    throws IOException,ServletException {

    if (logger.isDebugEnabled()) {
        logger.debug("Authentication success. Updating SecurityContextHolder to contain: "
                     + authResult);
    }

    SecurityContextHolder.getContext().setAuthentication(authResult);
	...
    successHandler.onAuthenticationSuccess(request,authResult);
}
複製程式碼

可以發現,在呼叫登入成功處理器的處理邏輯之前,呼叫了一下SecurityContextHolder.getContext().setAuthentication(authResult),檢視可知SecurityContextHolder.getContext()就是獲取當前執行緒繫結的SecurityContext(可以看做是一個執行緒變數,作用域為執行緒的生命週期),而SecurityContext其實就是對Authentication的一層包裝

public class SecurityContextHolder {
	private static SecurityContextHolderStrategy strategy;
	public static SecurityContext getContext() {
		return strategy.getContext();
	}
}
複製程式碼
final class ThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {
    private static final ThreadLocal<SecurityContext> contextHolder = new ThreadLocal<SecurityContext>();
	public SecurityContext getContext() {
		SecurityContext ctx = contextHolder.get();
		if (ctx == null) {
			ctx = createEmptyContext();
			contextHolder.set(ctx);
		}
		return ctx;
	}
}
複製程式碼
public interface SecurityContext extends Serializable {
	Authentication getAuthentication();
	void setAuthentication(Authentication authentication);
}
複製程式碼
public class SecurityContextImpl implements SecurityContext {

	private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
    
	public Authentication getAuthentication() {
		return authentication;
	}

	public int hashCode() {
		if (this.authentication == null) {
			return -1;
		}
		else {
			return this.authentication.hashCode();
		}
	}

	public void setAuthentication(Authentication authentication) {
		this.authentication = authentication;
	}

	...
}
複製程式碼

那麼將Authentication儲存到當前執行緒的SecurityContext中的用意是什麼呢?

這就涉及到了另外一個特別的過濾器SecurityContextPersistenceFilter,它位於security的整個過濾器鏈的最前端:

private SecurityContextRepository repo;
// 請求到達的第一個過濾器
public void doFilter(ServletRequest req,ServletException {

    ...

    HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request,response);
    // 從Session中獲取SecurityContext,未登入時獲取的則是空
    SecurityContext contextBeforeChainExecution = repo.loadContext(holder);

    try {
        // 將SecurityContext儲存到當前執行緒的ThreadLocalMap中
        SecurityContextHolder.setContext(contextBeforeChainExecution);
	   // 執行後續過濾器和Controller方法
        chain.doFilter(holder.getRequest(),holder.getResponse());

    }
    // 在請求響應時經過的最後一個過濾器
    finally {
        // 從當前執行緒獲取SecurityContext
        SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext();
        SecurityContextHolder.clearContext();
        // 將SecurityContext持久化到Session
        repo.saveContext(contextAfterChainExecution,holder.getRequest(),holder.getResponse());
        ...
    }
}
複製程式碼
public class HttpSessionSecurityContextRepository implements SecurityContextRepository {
	...
	public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
		HttpServletRequest request = requestResponseHolder.getRequest();
		HttpServletResponse response = requestResponseHolder.getResponse();
		HttpSession httpSession = request.getSession(false);

		SecurityContext context = readSecurityContextFromSession(httpSession);
		...
		return context;
	}
    ...
}
複製程式碼

image.png

獲取認證使用者資訊

在我們的程式碼中可以通過靜態方法SecurityContextHolder.getContext().getAuthentication來獲取使用者資訊,或者可以直接在Controller入參宣告Authenticationsecurity會幫我們自動注入,如果只想獲取Authentication中的UserDetails對應的部分,則可使用@AuthenticationPrinciple UserDetails currentUser

@GetMapping("/info1")
public Object info1() {
    return SecurityContextHolder.getContext().getAuthentication();
}
@GetMapping("/info2")
public Object info2(Authentication authentication) {
    return authentication;
}
複製程式碼

GET /user/info1

{
    authorities: [
        {
            authority: "admin"
        },sessionId: "24AE70712BB99A969A5C56907C39C20E"
    },name: "admin"
}
複製程式碼
@GetMapping("/info3")
public Object info3(@AuthenticationPrincipal UserDetails currentUser) {
    return currentUser;
}
複製程式碼

GET /user/info3

{
    password: null,authorities: [
        {
            authority: "admin"
        },enabled: true
}
複製程式碼

參考資料