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

我的博客:www.zhenganwen.top,文末有驚喜!html

環境準備

本文中全部實例代碼已託管碼雲:gitee.com/zhenganwen/…前端

文末有驚喜!java

開發環境

  • JDK1.8
  • Maven

項目結構

image.png

  • spring-security-demomysql

    父工程,用於整個項目的依賴git

  • security-coregithub

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

  • security-browserredis

    PC端瀏覽器受權,主要經過Sessionspring

  • security-appsql

    移動端受權

  • security-demo

    應用security-browsersecurity-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(), 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(), 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(), 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(), 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(), 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(), new User());
    return users;
}
複製代碼
@Test
public void testPageable() throws Exception {
    mockMvc.perform(get("/user").
                    contentType(MediaType.APPLICATION_JSON_UTF8)
                    .param("username", "tom")
                    .param("password", "123456")
                    .param("phone", "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註解實現此類功能

使用方法

一、聲明視圖接口,每一個接口表明響應數據時對象字段可見策略

這裏視圖指的就是一種字段包含策略,後面添加@JsonView時會用到

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

    /** * 普通視圖,返回用戶基本信息 */
    public interface UserOrdinaryView {

    }

    /** * 詳情視圖,除了普通視圖包含的字段,還返回密碼等詳細信息 */
    public interface UserDetailsView extends UserOrdinaryView{
        
    }

    private String username;
    
    private String password;
}
複製代碼

視圖和視圖之間能夠存在繼承關係,繼承視圖後會繼承該視圖包含的字段

二、在響應對象的字段上添加視圖,表示該字段包含在該視圖中

@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;
}
複製代碼

三、在Controller方法上添加視圖,表示該方法返回的對象數據僅顯示該視圖包含的字段

@GetMapping("/user")
@JsonView(User.UserBasicView.class)
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("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, 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("tom","123"), new User("jack","456"), new User("alice","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\",\"password\":\"123\"}"))
        .andExpect(status().isOk());
}

User(id=null, 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, username=jack, 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\",\"password\":\"123\",\"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\",\"password\":\"123\",\"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, username=jack, 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, username=jack, password=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\",\"password\":null,\"birthday\":\"" + date.getTime() + "\"}"))
        .andExpect(status().isOk());
}

may not be empty User(id=null, username=jack, password=null, 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\",\"password\":null,\"birthday\":\"" + date.getTime() + "\"}"))
        .andExpect(status().isOk());
}

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

校驗

自定義消息

如今咱們能夠經過BindingResult獲得校驗失敗信息了

@PutMapping("/{id:\\d+}")
public void update(@PathVariable Long id, @Valid @RequestBody User user, 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\":\" \",\"password\":null,\"birthday\":\"" + date.getTime() + "\"}"))
        .andExpect(status().isOk());
}

password may not be empty username may not be empty User(id=null, username= , password=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\":\" \",\"password\":null,\"birthday\":\"" + date.getTime() + "\"}"))
        .andExpect(status().isOk());
}

password 密碼不能爲空
username 用戶名不能爲空
User(id=null, username= , password=null, birthday=Sun Aug 19 21:03:18 CST 2018)
複製代碼

自定義校驗註解

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

下面咱們就參考已有的約束註解照葫蘆畫瓢自定義一個「用戶名不可重複」的約束註解

一、新建約束註解類

咱們但願該註解標註在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 { };
}
複製代碼

二、編寫校驗邏輯類

依照已有註解,它們都還有一個註解@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;
    }
}
複製代碼

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

經過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 { };
}
複製代碼

四、測試

@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\",\"password\":\"123\",\"birthday\":\"" + date.getTime() + "\"}"))
        .andExpect(status().isOk());
}

the request username is alice User(id=null, username=alice, password=123, 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\",\"password\":\"123\",\"birthday\":\"" + date.getTime() + "\"}"))
        .andExpect(status().isOk());
}

the request username is tom
用戶名已被註冊
User(id=null, username=tom, password=123, 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,
                                                  isIncludeStackTrace(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",
                "NotBlank.java.lang.String",
                "NotBlank"
            ],
            "arguments": [
                {
                    "codes": [
                        "user.password",
                        "password"
                    ],
                    "arguments": null,
                    "defaultMessage": "password",
                    "code": "password"
                }
            ],
            "defaultMessage": "密碼不能爲空",
            "objectName": "user",
            "field": "password",
            "rejectedValue": null,
            "bindingFailure": false,
            "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中使用過濾器須要兩步

一、實現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");
    }
}
複製代碼

二、配置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;
    }
}
複製代碼

三、測試

訪問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提供的攔截器也須要兩步

一、實現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, HttpServletResponse httpServletResponse, 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, HttpServletResponse httpServletResponse, Object handler, 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());
        }
    }
}
複製代碼

二、配置類繼承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可添加多個攔截器

三、測試

  • 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, response, mappedHandler.getHandler());
複製代碼

mappedHandler.applyPreHandle其實就是調用HandlerInterceptorpreHandle方法,而在此以後才調用ha.handle(processedRequest, response, 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, UserQueryConditionDto userQueryConditionDto, 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、H五、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, response, 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, AuthorityUtils.commaSeparatedStringToAuthorityList("user,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, response, "/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, response);
        if (savedRequest != null) {
            String redirectUrl = savedRequest.getRedirectUrl();
            logger.info("引起跳轉到/auth/login的請求是: {}", redirectUrl);
            if (StringUtils.endsWithIgnoreCase(redirectUrl, ".html")) {
                redirectStrategy.sendRedirect(request, response, 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, HttpServletResponse response, 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, HttpServletResponse response, Authentication authentication) throws IOException , ServletException {
        if (securityProperties.getBrowser().getLoginProcessType() == LoginProcessTypeEnum.REDIRECT) {
            // 重定向到緩存在session中的登陸前請求的URL
            super.onAuthenticationSuccess(request, response, 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, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        if (securityProperties.getBrowser().getLoginProcessType() == LoginProcessTypeEnum.REDIRECT) {
            super.onAuthenticationFailure(request, response, 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, response, chain, authResult)`,咱們來看一下這個方法幹了些什麼

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

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

    SecurityContextHolder.getContext().setAuthentication(authResult);
	...
    successHandler.onAuthenticationSuccess(request, response, 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, ServletResponse res, FilterChain chain) throws IOException, 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"
        },
        {
            authority: "user"
        }
    ],
    details: {
        remoteAddress: "0:0:0:0:0:0:0:1",
        sessionId: "24AE70712BB99A969A5C56907C39C20E"
    },
    authenticated: true,
    principal: {
        password: null,
        username: "admin",
        authorities: [
            {
                authority: "admin"
            },
            {
                authority: "user"
            }
        ],
        accountNonExpired: true,
        accountNonLocked: true,
        credentialsNonExpired: true,
        enabled: true
    },
    credentials: null,
    name: "admin"
}
複製代碼
@GetMapping("/info3")
public Object info3(@AuthenticationPrincipal UserDetails currentUser) {
    return currentUser;
}
複製代碼

GET /user/info3

{
    password: null,
    username: "admin",
    authorities: [
        {
            authority: "admin"
        },
        {
            authority: "user"
        }
    ],
    accountNonExpired: true,
    accountNonLocked: true,
    credentialsNonExpired: true,
    enabled: true
}
複製代碼

參考資料

相關文章
相關標籤/搜索