Spring Security 技術棧開發企業級認證授權
個人部落格:www.zhenganwen.top,文末有驚喜!
環境準備
本文中所有例項程式碼已託管碼雲:gitee.com/zhenganwen/…
文末有驚喜!
開發環境
JDK1.8
Maven
專案結構
-
spring-security-demo
父工程,用於整個專案的依賴
-
security-core
安全認證核心模組,
security-browser
和security-app
都基於其來構建 -
security-browser
PC端瀏覽器授權,主要通過
Session
-
security-app
移動端授權
-
security-demo
應用
security-browser
security-app
依賴
spring-security-demo
新增spring
依賴自動相容依賴和編譯外掛
<packaging>pom</packaging>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.spring.platform</groupId>
<artifactId>platform-bom</artifactId>
<version>Brussels-SR4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId >
<version>Dalston.SR2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>2.3.2</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
</plugins>
</build>
複製程式碼
security-core
新增持久化、OAuth
認證、social
認證以及commons
工具類等依賴,一些依賴只是先加進來以備後用
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.social</groupId>
<artifactId>spring-social-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.social</groupId>
<artifactId>spring-social-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.social</groupId>
<artifactId>spring-social-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.social</groupId>
<artifactId>spring-social-web</artifactId>
</dependency>
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
</dependency>
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
</dependency>
<dependency>
<groupId>commons-beanutils</groupId>
<artifactId>commons-beanutils</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.22</version>
<scope>compile</scope>
</dependency>
</dependencies>
複製程式碼
security-browser
新增security-core
和叢集管理依賴
<dependencies>
<dependency>
<groupId>top.zhenganwen</groupId>
<artifactId>security-core</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session</artifactId>
</dependency>
</dependencies>
複製程式碼
security-app
新增security-core
<dependencies>
<dependency>
<groupId>top.zhenganwen</groupId>
<artifactId>security-core</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>
複製程式碼
security-demo
暫時引用security-browser
做PC端的驗證
<artifactId>security-demo</artifactId>
<dependencies>
<dependency>
<groupId>top.zhenganwen</groupId>
<artifactId>security-browser</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>
複製程式碼
配置
在security-demo
中新增啟動類如下
package top.zhenganwen.securitydemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author zhenganwen
* @date 2019/8/18
* @desc SecurityDemoApplication
*/
@SpringBootApplication
@RestController
public class SecurityDemoApplication {
public static void main(String[] args) {
SpringApplication.run(SecurityDemoApplication.class,args);
}
@RequestMapping("/hello")
public String hello() {
return "hello spring security";
}
}
複製程式碼
根據報錯資訊新增mysql
連線資訊
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/test?useUnicode=yes&characterEncoding=UTF-8&useSSL=false
spring.datasource.username=root
spring.datasource.password=123456
複製程式碼
暫時用不到session
叢集共享和redis
,先禁用掉
spring.session.store-type=none
複製程式碼
@SpringBootApplication(exclude = {RedisAutoConfiguration.class,RedisRepositoriesAutoConfiguration.class})
@RestController
public class SecurityDemoApplication {
複製程式碼
然後發現能夠啟動成功了,然而訪問/hello
去發現提示我們要登入,這是Spring Security
的預設認證策略在起作用,我們也先禁用它
security.basic.enabled = false
複製程式碼
重啟訪問/hello
,頁面顯示hello spring security
,環境搭建成功
Restful
Restful VS 傳統
Restful
是一種HTTP介面編寫風格,而不是一種標準或規定。使用Restful
風格和傳統方式的區別主要如下
- URL
- 傳統方式一般通過在
URL
中新增表明介面行為的字串和查詢引數,如/user/get?username=xxx
-
Restful
風格則推薦一個URL代表一個系統資源,/user/1
應表示訪問系統中id
為1的使用者
- 傳統方式一般通過在
- 請求方式
- 傳統方式一般通過
get
提交,弊端是get
提交會將請求引數附在URL上,而URL有長度限制,並且若不特殊處理,引數在URL上是明文顯示的,不安全。對上述兩點有要求的請求會使用post
提交 -
Restful
風格推崇使用提交方式描述請求行為,如POST
、DELETE
、PUT
、GET
應對應增、刪、改、查型別的請求
- 傳統方式一般通過
- 通訊媒介
- 傳統方式中,對請求的響應結果是一個頁面,如此針對不同的終端需要開發多個系統,且前後端邏輯耦合
-
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-demo
的pom
中新增一個打包外掛
<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.jar
和demo.jar.original
,其中demo.jar
是可執行的,而demo.jar.original
是保留了maven
預設打包方式
使用MockMVC編寫介面測試用例
秉著測試先行的原則(提倡先寫測試用例再寫介面,驗證程式按照我們的想法執行),我們需要藉助spring-boot-starter-test
測試框架和其中相關的MockMvc
API。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
提交方式請求/hello
(get("/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為MockMvc
、MockMvcRequestBuilders
、MockMvcRequestBuilders
-
MockMvc
,呼叫perform
指定介面地址 -
MockMvcRequestBuilders
,構建請求(包括請求路徑、提交方式、請求頭、請求體等) -
MockMvcRequestBuilders
,斷言響應結果,如響應狀態碼、響應體
MVC註解細節
@RestController
用於標識一個Controller
為Restful Controller
,其中方法的返回結果會被SpringMVC
自動轉換為JSON
並設定響應頭為Content-Type=application/json
@RequestMapping
用於將URL對映到方法上,並且SpringMVC
會自動將請求引數按照按照引數名對應關係繫結到方法入參上
package top.zhenganwen.securitydemo.dto;
import lombok.Data;
import java.io.Serializable;
/**
* @author zhenganwen
* @date 2019/8/18
* @desc User
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements Serializable {
private String username;
private String password;
}
複製程式碼
package top.zhenganwen.securitydemo.web.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import top.zhenganwen.securitydemo.dto.User;
import java.util.Arrays;
import java.util.List;
/**
* @author zhenganwen
* @date 2019/8/18
* @desc UserController
*/
@RestController
public class UserController {
@GetMapping("/user")
public List<User> query(String username) {
System.out.println(username);
List<User> users = Arrays.asList(new User(),new User(),new User());
return users;
}
}
複製程式碼
package top.zhenganwen.securitydemo.web.controller;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* @author zhenganwen
* @date 2019/8/18
* @desc UserControllerTest
*/
@RunWith(SpringRunner.class)
@SpringBootTest
public class UserControllerTest {
@Autowired
private WebApplicationContext webApplicationContext;
private MockMvc mockMvc;
@Before
public void setUp() throws Exception {
mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
}
@Test
public void query() throws Exception {
mockMvc.perform(get("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.param("username","tom"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(3));
}
}
複製程式碼
通過MockMvcRequestBuilders.param
可以為請求附帶URL形式引數。
指定提交方式
如果沒有通過method
屬性指定提交方式,那麼所有的提交方式都會被受理,但如果設定@RequestMapping(method = RequestMethod.GET)
,那麼只有GET
請求會被受理,其他提交方式都會導致405 unsupported request method
@RequestParam
必填引數
上例程式碼,如果請求不附帶引數username
,那麼Controller
的引數就會被賦予資料型別預設值。如果你想請求必須攜帶該引數,否則不予處理,那麼就可以使用@RequestParam
並指定required=true
(不指定也可以,預設就是)
Controller
@GetMapping("/user")
public List<User> query(@RequestParam String username) {
System.out.println(username);
List<User> users = Arrays.asList(new User(),new User());
return users;
}
複製程式碼
ControllerTest
@Test
public void testBadRequest() throws Exception {
mockMvc.perform(get("/user").
contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(status().is4xxClientError());
}
複製程式碼
因為請求沒有附帶引數username
,所以會報錯400 bad request
,我們可以使用is4xxClientError()
對響應狀態碼為400
的請求進行斷言
引數名對映
SpringMVC
預設是按引數名相同這一規則對映引數值得,如果你想將請求中引數username
的值繫結到方法引數userName
上,可以通過name
屬性或value
屬性
@GetMapping("/user")
public List<User> query(@RequestParam(name = "username") String userName) {
System.out.println(userName);
List<User> users = Arrays.asList(new User(),new User());
return users;
}
@GetMapping("/user")
public List<User> query(@RequestParam("username") String userName) {
System.out.println(userName);
List<User> users = Arrays.asList(new User(),new User());
return users;
}
複製程式碼
@Test
public void testParamBind() throws Exception {
mockMvc.perform(get("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.param("username","tom"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(3));
}
複製程式碼
預設引數值
如果希望不強制請求攜帶某引數,但又希望方法引數在沒有接收到引數值時能有個預設值(例如“”
比null
更不容易報錯),那麼可以通過defaultValue
屬性
@GetMapping("/user")
public List<User> query(@RequestParam(required = false,defaultValue = "") String userName) {
Objects.requireNonNull(userName);
List<User> users = Arrays.asList(new User(),new User());
return users;
}
複製程式碼
@Test
public void testDefaultValue() throws Exception {
mockMvc.perform(get("/user").
contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(3));
}
複製程式碼
Bean繫結
如果請求附帶的引數較多,並且各引數都隸屬於某個物件的屬性,那麼將它們一一寫在方法參列比較冗餘,我們可以將它們統一封裝到一個資料傳輸物件(Data Transportation Object DTO
)中,如
package top.zhenganwen.securitydemo.dto;
import lombok.Data;
/**
* @author zhenganwen
* @date 2019/8/19
* @desc UserCondition
*/
@Data
public class UserQueryConditionDto {
private String username;
private String password;
private String phone;
}
複製程式碼
然後在方法入參填寫該物件即可,SpringMVC
會幫我們實現請求引數到物件屬性的繫結(預設繫結規則是引數名一致)
@GetMapping("/user")
public List<User> query(@RequestParam("username") String userName,UserQueryConditionDto userQueryConditionDto) {
System.out.println(userName);
System.out.println(ReflectionToStringBuilder.toString(userQueryConditionDto,ToStringStyle.MULTI_LINE_STYLE));
List<User> users = Arrays.asList(new User(),new User());
return users;
}
複製程式碼
ReflectionToStringBuilder
反射工具類能夠在物件沒有重寫toString
方法時通過反射幫我們檢視物件的屬性。
@Test
public void testDtoBind() throws Exception {
mockMvc.perform(get("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.param("username","tom")
.param("password","123456")
.param("phone","12345678911"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(3));
}
複製程式碼
Bean繫結不影響@RequestParam繫結
並且不用擔心會和@RequestParam
衝突,輸出如下
tom
top.zhenganwen.securitydemo.dto.UserQueryConditionDto@440ef8d[
username=tom
password=123456
phone=12345678911
]
複製程式碼
Bean繫結優先於基本型別引數繫結
但是,如果不給userName
新增@RequestParam
註解,那麼它接收到的將是一個null
null
top.zhenganwen.securitydemo.dto.UserQueryConditionDto@440ef8d[
username=tom
password=123456
phone=12345678911
]
複製程式碼
分頁引數繫結
spring-data
家族(如spring-boot-data-redis
)幫我們封裝了一個分頁DTOPageable
,會將我們傳遞的分頁引數size
(每頁行數)、page
(當前頁碼)、sort
(排序欄位和排序策略)自動繫結到自動注入的Pageable
例項中
@GetMapping("/user")
public List<User> query(String userName,UserQueryConditionDto userQueryConditionDto,Pageable pageable) {
System.out.println(userName);
System.out.println(ReflectionToStringBuilder.toString(userQueryConditionDto,ToStringStyle.MULTI_LINE_STYLE));
System.out.println(pageable.getPageNumber());
System.out.println(pageable.getPageSize());
System.out.println(pageable.getSort());
List<User> users = Arrays.asList(new User(),new User());
return users;
}
複製程式碼
@Test
public void testPageable() throws Exception {
mockMvc.perform(get("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.param("username","12345678911")
.param("page","2")
.param("size","30")
.param("sort","age,desc"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(3));
}
複製程式碼
null
top.zhenganwen.securitydemo.dto.UserQueryConditionDto@24e5389c[
username=tom
password=123456
phone=12345678911
]
2
30
age: DESC
複製程式碼
@PathVariable
變數佔位
最常見的Restful URL
,像GET /user/1
獲取id
為1
的使用者的資訊,這時我們在編寫介面時需要將路徑中的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佔位符變數名一致時,可以省去@PathVariable
的value
屬性
正則匹配
有時我們需要對URL的匹配做細粒度的控制,例如/user/1
會匹配到/user/{id}
,而/user/xxx
則不會匹配到/user/{id}
@GetMapping("/user/{id:\\d+}")
public User getInfo(@PathVariable("id") Long id) {
System.out.println(id);
return new User("jack","123");
}
複製程式碼
@Test
public void testRegExSuccess() throws Exception {
mockMvc.perform(get("/user/1").
contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(status().isOk());
}
@Test
public void testRegExFail() throws Exception {
mockMvc.perform(get("/user/abc").
contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(status().is4xxClientError());
}
複製程式碼
@JsonView
應用場景
有時我們需要對響應物件的某些欄位進行過濾,例如查詢所有使用者時不顯示password
欄位,根據id
查詢使用者時則顯示password
欄位,這時可以通過@JsonView
註解實現此類功能
使用方法
1、宣告檢視介面,每個介面代表響應資料時物件欄位可見策略
這裡檢視指的就是一種欄位包含策略,後面新增@JsonView
時會用到
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements Serializable {
/**
* 普通檢視,返回使用者基本資訊
*/
public interface UserOrdinaryView {
}
/**
* 詳情檢視,除了普通檢視包含的欄位,還返回密碼等詳細資訊
*/
public interface UserDetailsView extends UserOrdinaryView{
}
private String username;
private String password;
}
複製程式碼
檢視和檢視之間可以存在繼承關係,繼承檢視後會繼承該檢視包含的欄位
2、在響應物件的欄位上新增檢視,表示該欄位包含在該檢視中
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements Serializable {
/**
* 普通檢視,返回使用者基本資訊
*/
public interface UserOrdinaryView {
}
/**
* 詳情檢視,除了普通檢視包含的欄位,還返回密碼等詳細資訊
*/
public interface UserDetailsView extends UserOrdinaryView{
}
@JsonView(UserOrdinaryView.class)
private String username;
@JsonView(UserDetailsView.class)
private String password;
}
複製程式碼
3、在Controller方法上新增檢視,表示該方法返回的物件資料僅顯示該檢視包含的欄位
@GetMapping("/user")
@JsonView(User.UserBasicView.class)
public List<User> query(String userName,ToStringStyle.MULTI_LINE_STYLE));
System.out.println(pageable.getPageNumber());
System.out.println(pageable.getPageSize());
System.out.println(pageable.getSort());
List<User> users = Arrays.asList(new User("tom","123"),new User("jack","456"),new User("alice","789"));
return users;
}
@GetMapping("/user/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
System.out.println(id);
return new User("jack","123");
}
複製程式碼
測試
@Test
public void testUserBasicViewSuccess() throws Exception {
MvcResult mvcResult = mockMvc.perform(get("/user").
contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(status().isOk())
.andReturn();
System.out.println(mvcResult.getResponse().getContentAsString());
}
[{"username":"tom"},{"username":"jack"},{"username":"alice"}]
@Test
public void testUserDetailsViewSuccess() throws Exception {
MvcResult mvcResult = mockMvc.perform(get("/user/1").
contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(status().isOk())
.andReturn();
System.out.println(mvcResult.getResponse().getContentAsString());
}
{"username":"jack","password":"123"}
複製程式碼
階段性重構
重構需要 小步快跑,即每寫完一部分功能都要回頭來看一下有哪些需要優化的地方
程式碼中兩個方法都的RequestMapping
都用了/user
,我們可以將其提至類上以供複用
@RestController
@RequestMapping("/user")
public class UserController {
@GetMapping
@JsonView(User.UserBasicView.class)
public List<User> query(String userName,Pageable pageable) {
System.out.println(userName);
System.out.println(ReflectionToStringBuilder.toString(userQueryConditionDto,ToStringStyle.MULTI_LINE_STYLE));
System.out.println(pageable.getPageNumber());
System.out.println(pageable.getPageSize());
System.out.println(pageable.getSort());
List<User> users = Arrays.asList(new User("tom","789"));
return users;
}
@GetMapping("/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
System.out.println(id);
return new User("jack","123");
}
}
複製程式碼
雖然是一個很細節的問題,但是一定要有這個思想和習慣
別忘了重構後重新執行一遍所有的測試用例,確保重構沒有更改程式行為
處理請求體
@RequestBody對映請求體到Java方法的引數
SpringMVC
預設不會解析請求體中的引數並繫結到方法引數
@PostMapping
public void createUser(User user) {
System.out.println(user);
}
複製程式碼
@Test
public void testCreateUser() throws Exception {
mockMvc.perform(post("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"jack\",\"password\":\"123\"}"))
.andExpect(status().isOk());
}
User(id=null,username=null,password=null)
複製程式碼
使用@RequestBody
可以將請求體中的JSON
資料解析成Java物件並繫結到方法入參
@PostMapping
public void createUser(@RequestBody User user) {
System.out.println(user);
}
複製程式碼
@Test
public void testCreateUser() throws Exception {
mockMvc.perform(post("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"jack\",username=jack,password=123)
複製程式碼
日期型別引數處理
如果需要將時間型別資料繫結到Bean
的Date
欄位上,網上常見的解決方案是加一個json
訊息轉換器進行格式化,這樣的話就將日期的顯示邏輯寫死在後端的。
比較好的做法應該是後端只儲存時間戳,傳給前端時也只傳時間戳,將格式化顯示的責任交給前端,前端愛怎麼顯示怎麼顯示
@PostMapping
public void createUser(@RequestBody User user) {
System.out.println(user);
}
複製程式碼
@Test
public void testDateBind() throws Exception {
Date date = new Date();
System.out.println(date.getTime());
mockMvc.perform(post("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"jack\",\"password\":\"123\",\"birthday\":\"" + date.getTime() + "\"}"))
.andExpect(status().isOk());
}
1566212381139
User(id=null,password=123,birthday=Mon Aug 19 18:59:41 CST 2019)
複製程式碼
@Valid註解驗證請求引數的合法性
抽離校驗邏輯
在Controller
方法中,我們經常需要對請求引數進行合法性校驗後再執行處理邏輯,傳統的寫法是使用if
判斷
@PostMapping
public void createUser(@RequestBody User user) {
if (StringUtils.isBlank(user.getUsername())) {
throw new IllegalArgumentException("使用者名稱不能為空");
}
if (StringUtils.isBlank(user.getPassword())) {
throw new IllegalArgumentException("密碼不能為空");
}
System.out.println(user);
}
複製程式碼
但是如果其他地方也需要校驗就需要編寫重複的程式碼,一旦校驗邏輯發生改變就需要改變多處,並且如果有所遺漏還會給程式埋下隱患。有點重構意識的可能會將每個校驗邏輯單獨封裝一個方法,但仍顯冗餘。
SpringMVC Restful
則推薦使用@Valid
來實現引數的校驗,並且未通過校驗的會響應400 bad request
給前端,以狀態碼錶示處理結果(及請求格式不對),而不是像上述程式碼一樣直接拋異常導致前端收到的狀態碼是500
首先我們要使用hibernate-validator
校驗框架提供的一些約束註解來約束Bean
欄位
@NotBlank
@JsonView(UserBasicView.class)
private String username;
@NotBlank
@JsonView(UserDetailsView.class)
private String password;
複製程式碼
僅新增這些註解,SpringMVC
是不會幫我們校驗的
@PostMapping
public void createUser(@RequestBody User user) {
System.out.println(user);
}
複製程式碼
@Test
public void testConstraintValidateFail() throws Exception {
mockMvc.perform(post("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"\"}"))
.andExpect(status().isOk());
}
User(id=null,username=,password=null,birthday=null)
複製程式碼
我們還要在需要校驗的Bean
前新增@Valid
註解,這樣SpringMVC
會根據我們在該Bean
中新增的約束註解進行校驗,在校驗不通過時響應400 bad request
@PostMapping
public void createUser(@Valid @RequestBody User user) {
System.out.println(user);
}
複製程式碼
@Test
public void testConstraintValidateSuccess() throws Exception {
mockMvc.perform(post("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"\"}"))
.andExpect(status().is4xxClientError());
}
複製程式碼
約束註解
hibernate-validator
提供的約束註解如下
例如,建立使用者時限制請求引數中的birthday
的值是一個過去時間
首先在Bean
的欄位新增約束註解
@Past
private Date birthday;
複製程式碼
然後在要驗證的Bean
前新增@Valid
註解
@PostMapping
public void createUser(@Valid @RequestBody User user) {
System.out.println(user);
}
複製程式碼
@Test
public void testValidatePastTimeSuccess() throws Exception {
// 獲取一年前的時間點
Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
mockMvc.perform(post("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"jack\",\"birthday\":\"" + date.getTime() + "\"}"))
.andExpect(status().isOk());
}
@Test
public void testValidatePastTimeFail() throws Exception {
// 獲取一年後的時間點
Date date = new Date(LocalDateTime.now().plusYears(1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
mockMvc.perform(post("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"jack\",\"birthday\":\"" + date.getTime() + "\"}"))
.andExpect(status().is4xxClientError());
}
複製程式碼
複用校驗邏輯
這樣,如果我們需要對修改使用者的方法新增校驗,只需新增@Valid
即可
@PutMapping("/{id}")
public void update(@Valid @RequestBody User user,@PathVariable Long id) {
System.out.println(user);
System.out.println(id);
}
複製程式碼
@Test
public void testUpdateSuccess() throws Exception {
mockMvc.perform(put("/user/1").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"jack\",\"password\":\"789\"}"))
.andExpect(status().isOk());
}
User(id=null,password=789,birthday=null)
1
@Test
public void testUpdateFail() throws Exception {
mockMvc.perform(put("/user/1").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"jack\",\"password\":\" \"}"))
.andExpect(status().is4xxClientError());
}
複製程式碼
約束邏輯只需在Bean
中通過約束註解宣告一次,其他任何需要使用到該約束校驗的地方只需新增@Valid
即可
BindingResult處理校驗結果
上述處理方式還是不夠完美,我們只是通過響應狀態碼告訴前端請求資料格式不對,但是沒有明確指明哪裡不對,我們需要給前端一些更明確的資訊
上例中,如果沒有通過校驗,那麼方法就不會被執行而直接返回了,我們想要插入一些提示資訊都沒有辦法編寫。這時可以使用BindingResult
,它能夠幫助我們獲取校驗失敗資訊並返回給前端,同時響應狀態碼會變為200
@PostMapping
public void createUser(@Valid @RequestBody User user,BindingResult errors) {
if (errors.hasErrors()) {
errors.getAllErrors().stream().forEach(error -> System.out.println(error.getDefaultMessage()));
}
System.out.println(user);
}
@PutMapping("/{id}")
public void update(@PathVariable Long id,@Valid @RequestBody User user,BindingResult errors) {
if (errors.hasErrors()) {
errors.getAllErrors().stream().forEach(error -> System.out.println(error.getDefaultMessage()));
}
System.out.println(user);
System.out.println(id);
}
複製程式碼
@Test
public void testBindingResult() throws Exception {
Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
mockMvc.perform(post("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"jack\",\"password\":null,\"birthday\":\"" + date.getTime() + "\"}"))
.andExpect(status().isOk());
}
may not be empty
User(id=null,birthday=Sun Aug 19 20:44:02 CST 2018)
@Test
public void testBindingResult2() throws Exception {
Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
mockMvc.perform(put("/user/1").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"jack\",birthday=Sun Aug 19 20:42:56 CST 2018)
1
複製程式碼
值得注意的是,
BindingResult
必須和@Valid
一起使用,並且在參列中的位置必須緊跟在@Valid
修飾的引數後面,否則會出現如下令人困惑的結果
@PutMapping("/{id}")
public void update(@Valid @RequestBody User user,@PathVariable Long id,BindingResult errors) {
if (errors.hasErrors()) {
errors.getAllErrors().stream().forEach(error -> System.out.println(error.getDefaultMessage()));
}
System.out.println(user);
System.out.println(id);
}
複製程式碼
上述程式碼中,在校驗的Bean
和BindingResult
之間插入了一個id
,你會發現BindingResult
不起作用了
@Test
public void testBindingResult2() throws Exception {
Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
mockMvc.perform(put("/user/1").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"jack\",\"birthday\":\"" + date.getTime() + "\"}"))
.andExpect(status().isOk());
}
java.lang.AssertionError: Status
Expected :200
Actual :400
複製程式碼
校驗
自定義訊息
現在我們可以通過BindingResult
得到校驗失敗資訊了
@PutMapping("/{id:\\d+}")
public void update(@PathVariable Long id,BindingResult errors) {
if (errors.hasErrors()) {
errors.getAllErrors().stream().forEach(error -> {
FieldError fieldError = (FieldError) error;
System.out.println(fieldError.getField() + " " + fieldError.getDefaultMessage());
});
}
System.out.println(user);
}
複製程式碼
@Test
public void testBindingResult3() throws Exception {
Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
mockMvc.perform(put("/user/1").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\" \",\"birthday\":\"" + date.getTime() + "\"}"))
.andExpect(status().isOk());
}
password may not be empty
username may not be empty
User(id=null,birthday=Sun Aug 19 20:56:35 CST 2018)
複製程式碼
但是預設的訊息提示不太友好並且還需要我們自己拼接,這時我們需要自定義訊息提示,只需要使用約束註解的message
屬性指定驗證未通過的提示訊息即可
@NotBlank(message = "使用者名稱不能為空")
@JsonView(UserBasicView.class)
private String username;
@NotBlank(message = "密碼不能為空")
@JsonView(UserDetailsView.class)
private String password;
複製程式碼
@Test
public void testBindingResult3() throws Exception {
Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
mockMvc.perform(put("/user/1").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\" \",\"birthday\":\"" + date.getTime() + "\"}"))
.andExpect(status().isOk());
}
password 密碼不能為空
username 使用者名稱不能為空
User(id=null,birthday=Sun Aug 19 21:03:18 CST 2018)
複製程式碼
自定義校驗註解
雖然hibernate-validator
提供了一些常用的約束註解,但是對於複雜的業務場景還是需要我們自定義一個約束註解,畢竟有時僅僅是非空或格式合法的校驗是不夠的,可能我們需要去資料庫查詢進行校驗
下面我們就參考已有的約束註解照葫蘆畫瓢自定義一個“使用者名稱不可重複”的約束註解
1、新建約束註解類
我們希望該註解標註在Bean
的某些欄位上,使用@Target({FIELD})
;此外,要想該註解在執行期起作用,還要新增@Retention(RUNTIME)
package top.zhenganwen.securitydemo.annotation.valid;
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
/**
* @author zhenganwen
* @date 2019/8/20
* @desc Unrepeatable
*/
@Target({FIELD})
@Retention(RUNTIME)
public @interface Unrepeatable {
}
複製程式碼
參考已有的約束註解如NotNull
、NotBlank
,它們都有三個方法
String message() default "{org.hibernate.validator.constraints.NotBlank.message}";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
複製程式碼
於是我們也宣告這三個方法
@Target({FIELD})
@Retention(RUNTIME)
public @interface Unrepeatable {
String message() default "使用者名稱已被註冊";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
}
複製程式碼
2、編寫校驗邏輯類
依照已有註解,它們都還有一個註解@Constraint
@Documented
@Constraint(validatedBy = { })
@Target({ METHOD,FIELD,ANNOTATION_TYPE,CONSTRUCTOR,PARAMETER })
@Retention(RUNTIME)
@ReportAsSingleViolation
@NotNull
public @interface NotBlank {
複製程式碼
按住Ctrl
點選validateBy
屬性進行檢視,發現它需要一個ConstraintValidator
的實現類,現在我們需要編寫一個ConstraintValidator
自定義校驗邏輯並通過validatedBy
屬性將其繫結到我們的Unrepeatable
註解上
package top.zhenganwen.securitydemo.annotation.valid;
import org.springframework.beans.factory.annotation.Autowired;
import top.zhenganwen.securitydemo.service.UserService;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
/**
* @author zhenganwen
* @date 2019/8/20
* @desc UsernameUnrepeatableValidator
*/
public class UsernameUnrepeatableValidator implements ConstraintValidator<Unrepeatable,String> {
@Autowired
private UserService userService;
@Override
public void initialize(Unrepeatable unrepeatableAnnotation) {
System.out.println(unrepeatableAnnotation);
System.out.println("UsernameUnrepeatableValidator initialized===================");
}
@Override
public boolean isValid(String value,ConstraintValidatorContext context) {
System.out.println("the request username is " + value);
boolean ifExists = userService.checkUsernameIfExists( value);
// 如果使用者名稱存在,則拒絕請求並提示使用者名稱已被註冊,否則處理請求
return ifExists == true ? false : true;
}
}
複製程式碼
其中,ConstraintValidator<A,T>
泛型A
指定為要繫結到的註解,T
指定要校驗欄位的型別;isValid
用來編寫自定義校驗邏輯,如查詢資料庫是否存在該使用者名稱的記錄,返回true
表示校驗通過,false
校驗失敗
@ComponentScan
掃描範圍內的ConstraintValidator
實現類會被Spring
注入到容器中,因此你無須在該類上標註Component
即可在類中注入其他Bean
,例如本例中注入了一個UserService
package top.zhenganwen.securitydemo.service;
import org.springframework.stereotype.Service;
import java.util.Objects;
/**
* @author zhenganwen
* @date 2019/8/20
* @desc UserService
*/
@Service
public class UserService {
public boolean checkUsernameIfExists(String username) {
// select count(username) from user where username=?
// as if username "tom" has been registered
if (Objects.equals(username,"tom")) {
return true;
}
return false;
}
}
複製程式碼
3、在約束註解上指定校驗類
通過validatedBy
屬性指定該註解繫結的一系列校驗類(這些校驗類必須是ConstraintValidator<A,T>
的實現類
@Target({FIELD})
@Retention(RUNTIME)
@Constraint(validatedBy = { UsernameUnrepeatableValidator.class})
public @interface Unrepeatable {
String message() default "使用者名稱已被註冊";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
}
複製程式碼
4、測試
@PostMapping
public void createUser(@Valid @RequestBody User user,BindingResult errors) {
if (errors.hasErrors()) {
errors.getAllErrors().stream().forEach(error -> System.out.println(error.getDefaultMessage()));
}
System.out.println(user);
}
複製程式碼
@Test
public void testCreateUserWithNewUsername() throws Exception {
Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
mockMvc.perform(post("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"alice\",\"birthday\":\"" + date.getTime() + "\"}"))
.andExpect(status().isOk());
}
the request username is alice
User(id=null,username=alice,birthday=Mon Aug 20 08:25:11 CST 2018)
@Test
public void testCreateUserWithExistedUsername() throws Exception {
Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
mockMvc.perform(post("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"tom\",\"birthday\":\"" + date.getTime() + "\"}"))
.andExpect(status().isOk());
}
the request username is tom
使用者名稱已被註冊
User(id=null,username=tom,birthday=Mon Aug 20 08:25:11 CST 2018)
複製程式碼
刪除使用者
@Test
public void testDeleteUser() throws Exception {
mockMvc.perform(delete("/user/1").
contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(status().isOk());
}
java.lang.AssertionError: Status
Expected :200
Actual :405
複製程式碼
測試先行,即先寫測試用例後寫功能程式碼,即使我們知道沒有編寫該功能測試肯定不會通過,但測試程式碼也是需要檢驗的,確保測試邏輯的正確性
Restful
提倡以響應狀態碼來表示請求處理結果,例如200表示刪除成功,若沒有特別要求需要返回某些資訊,那麼無需新增響應體
@DeleteMapping("/{id:\\d+}")
public void delete(@PathVariable Long id) {
System.out.println(id);
// delete user
}
複製程式碼
@Test
public void testDeleteUser() throws Exception {
mockMvc.perform(delete("/user/1").
contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(status().isOk());
}
1
複製程式碼
錯誤處理
SpringBoot預設的錯誤處理機制
區分客戶端進行響應
當請求處理髮生錯誤時,SpringMVC
根據客戶端的型別會有不同的響應結果,例如瀏覽器訪問localhost:8080/xxx
會返回如下錯誤頁面
而使用Postman
請求則會得到如下響應
{
"timestamp": 1566268880358,"status": 404,"error": "Not Found","message": "No message available","path": "/xxx"
}
複製程式碼
該機制對應的原始碼在BasicErrorController
中(發生4xx
或500
異常時,會將請求轉發到/error
,由BasicErrorController
決定異常響應邏輯)
@RequestMapping(produces = "text/html")
public ModelAndView errorHtml(HttpServletRequest request,HttpServletResponse response) {
HttpStatus status = getStatus(request);
Map<String,Object> model = Collections.unmodifiableMap(getErrorAttributes(
request,isIncludeStackTrace(request,MediaType.TEXT_HTML)));
response.setStatus(status.value());
ModelAndView modelAndView = resolveErrorView(request,response,status,model);
return (modelAndView == null ? new ModelAndView("error",model) : modelAndView);
}
@RequestMapping
@ResponseBody
public ResponseEntity<Map<String,Object>> error(HttpServletRequest request) {
Map<String,Object> body = getErrorAttributes(request,MediaType.ALL));
HttpStatus status = getStatus(request);
return new ResponseEntity<Map<String,Object>>(body,status);
}
複製程式碼
如果是瀏覽器發出的請求,它的請求頭會附帶Accept: text/html...
,而Postman
發出的請求則是Accept: */*
,因此前者會執行errorHtml
響應錯誤頁面,而error
會收集異常資訊以map
的形式返回
自定義錯誤頁面
對於客戶端是瀏覽器的錯誤響應,例如404/500,我們可以在src/main/resources/resources/error
資料夾下編寫自定義錯誤頁面,SpringMVC
會在發生相應異常時返回該資料夾下的404.html
或500.html
建立src/main/resources/resources/error
資料夾並新增404.html
和500.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>頁面找不到了</title>
</head>
<body>
抱歉,頁面找不到了!
</body>
</html>
複製程式碼
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>服務異常</title>
</head>
<body>
服務端內部錯誤
</body>
</html>
複製程式碼
模擬處理請求時發生異常
@GetMapping("/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
throw new RuntimeException("id不存在");
// System.out.println(id);
// return new User(1L,"jack","123");
// return null;
}
複製程式碼
訪問localhost:8080/xxx
顯示404.html
頁面,訪問localhost:8080/user/1
顯示500.html
頁面
值得注意的是,自定義異常頁面並不會導致非瀏覽器請求也會響應該頁面
自定義異常處理
對於4XX
的客戶端錯誤,SpringMVC
會直接返回錯誤響應和不會執行Controller
方法;對於500
的服務端丟擲異常,則會收集異常類的message
欄位值返回
預設異常響應結果
例如客戶端錯誤,GET /user/1
{
"timestamp": 1566270327128,"status": 500,"error": "Internal Server Error","exception": "java.lang.RuntimeException","message": "id不存在","path": "/user/1"
}
複製程式碼
例如服務端錯誤
@PostMapping
public void createUser(@Valid @RequestBody User user) {
System.out.println(user);
}
複製程式碼
POST localhost:8080/user
Body {}
複製程式碼
{
"timestamp": 1566272056042,"status": 400,"error": "Bad Request","exception": "org.springframework.web.bind.MethodArgumentNotValidException","errors": [
{
"codes": [
"NotBlank.user.username","NotBlank.username","NotBlank.java.lang.String","NotBlank"
],"arguments": [
{
"codes": [
"user.username","username"
],"arguments": null,"defaultMessage": "username","code": "username"
}
],"defaultMessage": "使用者名稱不能為空","objectName": "user","field": "username","rejectedValue": null,"bindingFailure": false,"code": "NotBlank"
},{
"codes": [
"NotBlank.user.password","NotBlank.password","arguments": [
{
"codes": [
"user.password","password"
],"defaultMessage": "password","code": "password"
}
],"defaultMessage": "密碼不能為空","field": "password","code": "NotBlank"
}
],"message": "Validation failed for object='user'. Error count: 2","path": "/user"
}
複製程式碼
自定義異常響應結果
有時我們需要經常在處理請求時丟擲異常以終止對該請求的處理,例如
package top.zhenganwen.securitydemo.web.exception.response;
import lombok.Data;
import java.io.Serializable;
/**
* @author zhenganwen
* @date 2019/8/20
* @desc IdNotExistException
*/
@Data
public class IdNotExistException extends RuntimeException {
private Serializable id;
public IdNotExistException(Serializable id) {
super("id不存在");
this.id = id;
}
}
複製程式碼
@GetMapping("/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
throw new IdNotExistException(id);
}
複製程式碼
GET /user/1
{
"timestamp": 1566270990177,"status": 500,"error": "Internal Server Error","exception": "top.zhenganwen.securitydemo.exception.response.IdNotExistException","message": "id不存在","path": "/user/1"
}
複製程式碼
SpringMVC
預設只會將異常的message
返回,如果我們需要將IdNotExistException
的id
也返回以給前端更明確的提示,就需要我們自定義異常處理
- 自定義的異常處理類需要新增
@ControllerAdvice
- 在處理異常的方法上使用
@ExceptionHandler
宣告該方法要截獲哪些異常,所有的Controller
若丟擲這些異常中的一個則會轉為執行該方法 - 捕獲到的異常會作為方法的入參
- 方法返回的結果與
Controller
方法返回的結果意義相同,如果需要返回json
則需在方法上新增@ResponseBody
註解,如果在類上新增該註解則表示每個方法都有該註解
package top.zhenganwen.securitydemo.web.exception.handler;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import top.zhenganwen.securitydemo.web.exception.response.IdNotExistException;
import java.util.HashMap;
import java.util.Map;
/**
* @author zhenganwen
* @date 2019/8/20
* @desc UserControllerExceptionHandler
*/
@ControllerAdvice
@ResponseBody
public class UserControllerExceptionHandler {
@ExceptionHandler(IdNotExistException.class)
public Map<String,Object> handleIdNotExistException(IdNotExistException e) {
Map<String,Object> jsonResult = new HashMap<>();
jsonResult.put("message",e.getMessage());
jsonResult.put("id",e.getId());
return jsonResult;
}
}
複製程式碼
重啟後使用Postman GET /user/1
得到響應如下
{
"id": 1,"message": "id不存在"
}
複製程式碼
攔截
需求:記錄所有請求 的處理時間
過濾器Filter
過濾器是JavaEE
中的標準,是不依賴SpringMVC
的,要想在SpringMVC
中使用過濾器需要兩步
1、實現Filter
介面並注入到Spring容器
package top.zhenganwen.securitydemo.web.filter;
import org.springframework.stereotype.Component;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
/**
* @author zhenganwen
* @date 2019/8/20
* @desc TimeFilter
*/
@Component
public class TimeFilter implements Filter {
// 在web容器啟動時執行
@Override
public void init(FilterConfig filterConfig) throws ServletException {
System.out.println("TimeFilter init");
}
// 在收到請求時執行,這時請求還未到達SpringMVC的入口DispatcherServlet
// 單次請求只會執行一次(不論期間發生了幾次請求轉發)
@Override
public void doFilter(ServletRequest servletRequest,ServletResponse servletResponse,FilterChain filterChain) throws IOException,ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
String service = "【" + request.getMethod() + " " + request.getRequestURI() + "】";
System.out.println("[TimeFilter] 收到服務呼叫:" + service);
Date start = new Date();
System.out.println("[TimeFilter] 開始執行服務" + service + simpleDateFormat.format(start));
filterChain.doFilter(servletRequest,servletResponse);
Date end = new Date();
System.out.println("[TimeFilter] 服務" + service + "執行完畢 " + simpleDateFormat.format(end) +
",共耗時:" + (end.getTime() - start.getTime()) + "ms");
}
// 在容器銷燬時執行
@Override
public void destroy() {
System.out.println("TimeFilter destroyed");
}
}
複製程式碼
2、配置FilterRegistrationBean
,這一步相當於傳統方式在web.xml
中新增一個<Filter>
節點
package top.zhenganwen.securitydemo.web.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import top.zhenganwen.securitydemo.web.filter.TimeFilter;
/**
* @author zhenganwen
* @date 2019/8/20
* @desc WebConfig
*/
@Configuration
public class WebConfig {
@Autowired
TimeFilter timeFilter;
// 新增這個bean相當於在web.xml中新增一個Fitler節點
@Bean
public FilterRegistrationBean registerTimeFilter() {
FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
filterRegistrationBean.setFilter(timeFilter);
return filterRegistrationBean;
}
}
複製程式碼
3、測試
訪問GET /user/1
,控制檯日誌如下
@GetMapping("/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
// throw new IdNotExistException(id);
User user = new User();
return user;
}
複製程式碼
[TimeFilter] 收到服務呼叫:【GET /user/1】
[TimeFilter] 開始執行服務【GET /user/1】2019-08-20 02:13:44
[TimeFilter] 服務【GET /user/1】執行完畢 2019-08-20 02:13:44,共耗時:4ms
複製程式碼
由於Filter
是JavaEE
中的標準,所以它僅依賴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
-
Interceptor
是SpringMVC
中的元件,因此它知道Controller
的存在,能夠獲取相關資訊(如該請求對映的方法,方法所在的bean
等)
使用SpringMVC
提供的攔截器也需要兩步
1、實現HandlerInterceptor
介面
package top.zhenganwen.securitydemo.web.interceptor;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.text.SimpleDateFormat;
import java.util.Date;
/**
* @author zhenganwen
* @date 2019/8/20
* @desc TimeInterceptor
*/
@Component
public class TimeInterceptor implements HandlerInterceptor {
/**
* 在Controller方法執行前被執行
* @param httpServletRequest
* @param httpServletResponse
* @param handler 處理器(Controller方法的封裝)
* @return true 會接著執行Controller方法
* false 不會執行Controller方法,直接響應200
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest httpServletRequest,HttpServletResponse httpServletResponse,Object handler) throws Exception {
HandlerMethod handlerMethod = (HandlerMethod) handler;
String service = "【" + handlerMethod.getBean() + "#" + handlerMethod.getMethod().getName() + "】";
Date start = new Date();
System.out.println("[TimeInterceptor # preHandle] 服務" + service + "被呼叫 " + new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(start));
httpServletRequest.setAttribute("start",start.getTime());
return true;
}
/**
* 在Controller方法正常執行完畢後執行,如果Controller方法丟擲異常則不會執行此方法
* @param httpServletRequest
* @param httpServletResponse
* @param handler
* @param modelAndView Controller方法返回的檢視
* @throws Exception
*/
@Override
public void postHandle(HttpServletRequest httpServletRequest,Object handler,ModelAndView modelAndView) throws Exception {
HandlerMethod handlerMethod = (HandlerMethod) handler;
String service = "【" + handlerMethod.getBean() + "#" + handlerMethod.getMethod().getName() + "】";
Date end = new Date();
System.out.println("[TimeInterceptor # postHandle] 服務" + service + "呼叫結束 " + new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(end)
+ " 共耗時:" + (end.getTime() - (Long) httpServletRequest.getAttribute("start")) + "ms");
}
/**
* 無論Controller方法是否丟擲異常,都會被執行
* @param httpServletRequest
* @param httpServletResponse
* @param handler
* @param e 如果Controller方法丟擲異常則為對應丟擲的異常,否則為null
* @throws Exception
*/
@Override
public void afterCompletion(HttpServletRequest httpServletRequest,Exception e) throws Exception {
HandlerMethod handlerMethod = (HandlerMethod) handler;
String service = "【" + handlerMethod.getBean() + "#" + handlerMethod.getMethod().getName() + "】";
Date end = new Date();
System.out.println("[TimeInterceptor # afterCompletion] 服務" + service + "呼叫結束 " + new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(end)
+ " 共耗時:" + (end.getTime() - (Long) httpServletRequest.getAttribute("start")) + "ms");
if (e != null) {
System.out.println("[TimeInterceptor#afterCompletion] 服務" + service + "呼叫異常:" + e.getMessage());
}
}
}
複製程式碼
2、配置類繼承WebMvcConfigureAdapter並重寫addInterceptor方法新增自定義攔截器
@Configuration
public class WebConfig extends WebMvcConfigurerAdapter {
@Autowired
TimeFilter timeFilter;
@Autowired
TimeInterceptor timeInterceptor;
@Bean
public FilterRegistrationBean registerTimeFilter() {
FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
filterRegistrationBean.setFilter(timeFilter);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(timeInterceptor);
}
}
複製程式碼
多次呼叫addInterceptor
可新增多個攔截器
3、測試
GET /user/1
[TimeFilter] 收到服務呼叫:【GET /user/1】
[TimeFilter] 開始執行服務【GET /user/1】2019-08-20 02:59:00
[TimeInterceptor # preHandle] 服務【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】被呼叫 2019-08-20 02:59:00
[TimeFilter] 服務【GET /user/1】執行完畢 2019-08-20 02:59:00,共耗時:2ms
複製程式碼
- 將
preHandle
返回值改為true
[TimeFilter] 收到服務呼叫:【GET /user/1】
[TimeFilter] 開始執行服務【GET /user/1】2019-08-20 02:59:20
[TimeInterceptor # preHandle] 服務【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】被呼叫 2019-08-20 02:59:20
[TimeInterceptor # postHandle] 服務【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】呼叫結束 2019-08-20 02:59:20 共耗時:39ms
[TimeInterceptor # afterCompletion] 服務【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】呼叫結束 2019-08-20 02:59:20 共耗時:39ms
[TimeFilter] 服務【GET /user/1】執行完畢 2019-08-20 02:59:20,共耗時:42ms
複製程式碼
- 在Controller方法中丟擲異常
@GetMapping("/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
throw new IdNotExistException(id);
// User user = new User();
// return user;
}
複製程式碼
[TimeFilter] 收到服務呼叫:【GET /user/1】
[TimeFilter] 開始執行服務【GET /user/1】2019-08-20 03:05:56
[TimeInterceptor # preHandle] 服務【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】被呼叫 2019-08-20 03:05:56
[TimeInterceptor # afterCompletion] 服務【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】呼叫結束 2019-08-20 03:05:56 共耗時:11ms
[TimeFilter] 服務【GET /user/1】執行完畢 2019-08-20 03:05:56,共耗時:14ms
複製程式碼
發現afterCompletion
中的異常列印邏輯並未被執行,這是因為IdNotExistException
被我們之前自定義的異常處理器處理掉了,沒有丟擲來。我們改為丟擲RuntimeException
再試一下
@GetMapping("/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
throw new RuntimeException("id not exist");
}
複製程式碼
[TimeFilter] 收到服務呼叫:【GET /user/1】
[TimeFilter] 開始執行服務【GET /user/1】2019-08-20 03:09:38
[TimeInterceptor # preHandle] 服務【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】被呼叫 2019-08-20 03:09:38
[TimeInterceptor # afterCompletion] 服務【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】呼叫結束 2019-08-20 03:09:38 共耗時:7ms
[TimeInterceptor#afterCompletion] 服務【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】呼叫異常:id not exist
java.lang.RuntimeException: id not exist
at top.zhenganwen.securitydemo.web.controller.UserController.getInfo(UserController.java:42)
...
[TimeInterceptor # preHandle] 服務【org.springframework.boot.autoconfigure.web.BasicErrorController@33f17289#error】被呼叫 2019-08-20 03:09:38
[TimeInterceptor # postHandle] 服務【org.springframework.boot.autoconfigure.web.BasicErrorController@33f17289#error】呼叫結束 2019-08-20 03:09:38 共耗時:7ms
[TimeInterceptor # afterCompletion] 服務【org.springframework.boot.autoconfigure.web.BasicErrorController@33f17289#error】呼叫結束 2019-08-20 03:09:38 共耗時:7ms
複製程式碼
方法呼叫時序圖大致如下
切片Aspect
應用場景
Interceptor
仍然有它的侷限性,即無法獲取呼叫Controller方法的入參資訊,例如我們需要對使用者下單請求的訂單物品資訊記錄日誌以便為推薦系統提供資料,那麼這時Interceptor
就無能為力了
追蹤原始碼DispatcherServlet -> doService -> doDispatch
可發現Interceptor
無法獲取入參的原因:
if (!mappedHandler.applyPreHandle(processedRequest,response)) {
return;
}
// Actually invoke the handler.
mv = ha.handle(processedRequest,mappedHandler.getHandler());
複製程式碼
mappedHandler.applyPreHandle
其實就是呼叫HandlerInterceptor
的preHandle
方法,而在此之後才呼叫ha.handle(processedRequest,mappedHandler.getHandler())
將請求引數processedRequest
注入到handler
入參上
使用方法
面向切面程式設計(Aspect-Oriented Program AOP
)是基於動態代理的一種物件增強設計模式,能夠實現在不修改現有程式碼的前提下新增可插拔的功能。
在SpringMVC
中使用AOP我們需要三步
- 編寫切片/切面類,將切入點和增強結合在一起
- 新增
@Component
,注入Spring容器 - 新增
@Aspect
,啟動切面程式設計開關
- 新增
- 編寫切入點,使用註解可以完成,切入點包含兩部分:哪些方法需要增強以及增強的時機
- 切入時機
-
@Before
,方法執行前 -
@AfterReturning
,方法正常執行結束後 -
@AfterThrowing
,方法丟擲異常後 -
@After
,方法正常執行結束return
前,相當於在return
前插入了一段finally
-
@Around
,可利用注入的入參ProceedingJoinPoint
靈活的實現上述4種時機,它的作用與攔截器方法中的handler
類似,只不過提供了更多有用的執行時資訊
-
- 切入點,可以使用
execution
表示式,具體詳見:docs.spring.io/spring/docs…
- 切入時機
- 編寫增強方法,
- 其中只有
@Around
可以有入參,能拿到ProceedingJoinPoint
例項 - 通過呼叫
ProceedingJoinPoint
的point.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
複製程式碼
總結
請求過程
響應過程
檔案上傳下載及Mock測試
檔案上傳
老規矩,測試先行,不過使用MockMvc
模擬檔案上傳請求還是有些不一樣的,請求需要使用靜態方法fileUpload
且要設定contentType
為multipart/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
測試結果如下
控制檯日誌:
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中拉取訊息(如單號)進行下單處理並將處理結果返回給秒殺系統;秒殺系統獨立設一個監聽訂單處理結果訊息的執行緒,將處理結果返回給客戶端。如圖所示
要實現類似上述的效果,需要使用Future
模式(可參考《Java多執行緒程式設計實戰(設計模式篇)》),即我們可以設定一個處理結果憑證DeferredResult
,如果我們直接呼叫它的getResult
是獲取不到處理結果的(會被阻塞,表現為雖然請求執行緒繼續處理請求了,但是客戶端仍在pending
,只有當某個執行緒呼叫它的setResult(result)
,才會將對應的result
響應給客戶端
本例中,為降低複雜性,使用本地記憶體中的LinkedList
代替分散式訊息中介軟體,使用本地新建執行緒代替訂單系統執行緒,各類之間的關係如下
秒殺系統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();
}
}
複製程式碼
測試
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
方法對非同步處理進行一些配置
registerCallableInterceptors & registerDeferredResultInterceptors
我們之前通過重寫addInterceptors
方法註冊的攔截器對Callable
和DeferredResult
兩種非同步處理是無效的,如果想為這兩者配置攔截器需重寫這兩個方法
setDefaultTimeout
設定非同步處理的超時時間,超過該時間就直接響應而不會等非同步任務結束了
setTaskExecutor
SpringBoot
預設是通過新建執行緒的方式執行非同步任務的,執行完後執行緒就被銷燬了,要想通過複用執行緒(執行緒池)的方式執行非同步任務,你可以通過此方法傳入一個自定義的執行緒池
前後端分離
Swagger介面檔案
swagger
專案能夠根據我們所寫的介面自動生成介面檔案,方便我們前後端分離開發
依賴
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.7.0</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.7.0</version>
</dependency>
複製程式碼
在啟動類SecurityDemoApplication
上新增@@EnableSwagger2
註解開啟介面檔案自動生成開關,啟動後訪問localhost:8080/swagger-ui.html
常用註解
-
@ApiOperation
,註解在Controller方法上,用來描述方法的行為@GetMapping @JsonView(User.UserBasicView.class) @ApiOperation("使用者查詢服務") public List<User> query(String userName,Pageable pageable) { 複製程式碼
-
@ApiModelProperty
,註解在Bean
的欄位上,用來描述欄位的含義@Data public class UserQueryConditionDto { @ApiModelProperty("使用者名稱") private String username; @ApiModelProperty("密碼") private String password; @ApiModelProperty("電話號碼") private String phone; } 複製程式碼
-
@ApiParam
,註解在Controller方法引數上,用來描述引數含義@DeleteMapping("/{id:\\d+}") public void delete(@ApiParam("使用者id") @PathVariable Long id) { System.out.println(id); } 複製程式碼
重啟後介面檔案會重新生成
WireMock
為了方便前後端並行開發,我們可以使用WireMock
作為虛擬介面伺服器
在後端介面沒開發完成時,前端可能會通過本地檔案的方式偽造一些靜態資料(例如JSON檔案)作為請求的響應結果,這種方式在前端只有一種終端時是沒問題的。但是當前端有多種,如PC、H5、APP、小程式等時,每種都去在自己的本地偽造資料,那麼就顯得有些重複,而且每個人按照自己的想法偽造資料可能會導致最終和真實介面無法無縫對接
這時wiremock
的出現就解決了這一痛點,wiremock
是用Java
開發的一個獨立伺服器,能夠對外提供HTTP服務,我們可以通過wiremock
客戶端去編輯/配置wiremock
伺服器使它能像web
服務一樣提供各種各樣的介面,而且無需重新部署
下載 & 啟動wiremock服務
wiremock可以以jar
方式執行,下載地址,下載完成後切換到其所在目錄cmd
執行以下命令啟動wiremock
伺服器,--port=
指定執行埠
java -jar wiremock-standalone-2.24.1.jar --port=8062
複製程式碼
依賴
引入wiremock
客戶端依賴及其依賴的httpclient
<dependency>
<groupId>com.github.tomakehurst</groupId>
<artifactId>wiremock</artifactId>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
複製程式碼
由於在父工程中已經使用了依賴自動相容,所以無需指定版本號。接著通過客戶端API去編輯wiremock
伺服器,為其新增介面
package top.zhenganwen.securitydemo.wiremock;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
/**
* @author zhenganwen
* @date 2019/8/22
* @desc MockServer
*/
public class MockServer {
public static void main(String[] args) {
configureFor("127.0.0.1",8062);
removeAllMappings(); // 移除所有舊的配置
// 新增配置,一個stub代表一個介面
stubFor(
get(urlEqualTo("/order/1")).
// 設定響應結果
willReturn(
aResponse()
.withBody("{\"id\":1,\"orderNumber\":\"545616156\"}")
.withStatus(200)
)
);
}
}
複製程式碼
你可以先將JSON資料存在
resources
中,然後通過ClassPathResource#getFile
和FileUtils#readLines
將資料讀成字串
訪問localhost:8062/order/1
:
{
id: 1,orderNumber: "545616156"
}
複製程式碼
通過WireMock
API,你可以為虛擬伺服器配置各種各樣的介面服務
使用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基本原理
三種過濾器
如圖所示,Spring Security
的核心其實就是一串過濾器鏈,所以它是非侵入式可插拔的。過濾器鏈中的過濾器分3種:
-
認證過濾器
XxxAuthenticationFilter
,如上圖中標註為綠色的,它們的類名以AuthenticationFilter
結尾,作用是將登入的資訊儲存起來。這些過濾器是根據我們的配置動態生效的,如我們之前呼叫formLogin()
其實就是啟用了UsernamePasswordAuthenticationFilter
,呼叫httpBaisc()
就是啟用了BasicAuthenticationFilter
後面最貼近
Controller
的兩個過濾器ExceptionTranslationFilter
和FilterSecurityInterceptor
包含了最核心的認證邏輯,預設是啟用的,而且我們也無法禁用它們 -
FilterSecurityInterceptor
,雖然命名以Interceptor
結尾,但其實還是一個Filter
,它是最貼近Controller
的一個過濾器,它會根據我們配置的攔截規則(哪些URL需要登入後才能訪問,哪些URL需要某些特定的許可權才能訪問等)對訪問相應URL的請求進行攔截,以下是它的部分原始碼public void doFilter(ServletRequest request,ServletResponse response,FilterChain chain) throws IOException,ServletException { FilterInvocation fi = new FilterInvocation(request,chain); invoke(fi); } public void invoke(FilterInvocation fi) throws IOException,ServletException { ... InterceptorStatusToken token = super.beforeInvocation(fi); ... fi.getChain().doFilter(fi.getRequest(),fi.getResponse()); ... } 複製程式碼
doFilter
就是真正呼叫我們的Controller
了(因為它是過濾器鏈的末尾),但在此之前它會呼叫beforeInvocation
對請求進行攔截校驗是否有相關的身份和許可權,校驗失敗對應會丟擲未經認證異常(Unauthenticated
)和未經授權異常(Unauthorized
),這些異常會被ExceptionTranslationFilter
捕獲到 -
ExceptionTranslationFilter
,顧名思義就是解析異常的,其部分原始碼如下public void doFilter(ServletRequest req,ServletResponse res,FilterChain chain) throws IOException,ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) res; try { chain.doFilter(request,response); } catch (Exception ex) { // Try to extract a SpringSecurityException from the stacktrace ... } } 複製程式碼
它呼叫
chain.doFilter
其實就是去到了FilterSecurityInterceptor
,它會對FilterSecurityInterceptor.doFilter
中丟擲的SpringSecurityException
異常進行捕獲並解析處理,例如FilterSecurityInterceptor
丟擲了Unauthenticated
異常,那麼ExceptionTranslationFilter
就會重定向到登入頁或是彈出登入框(取決於我們配置了什麼認證過濾器),當我們成功登入後,認證過濾又會重定向到我們最初要訪問的URL
斷點除錯
我們可以通過斷點除錯的方式來驗證上述所說,將驗證方式設為formLogin
,然後在3個過濾器和Controller
中分別打斷點,重啟服務訪問/user
自定義使用者認證邏輯
處理使用者資訊獲取邏輯——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
,我們只需配置一個該類的Bean
,security
就會認為我們返回的UserDetails
的getPassword
返回的密碼是通過該Bean
加密過的(所以在插入使用者時要注意呼叫該Bean
的encode
對密碼加密一下在插入資料庫)
@Configuration
public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter {
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
複製程式碼
@Component
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
BCryptPasswordEncoder passwordEncoder;
private Logger logger = LoggerFactory.getLogger(getClass());
@Override
public UserDetails loadUserByUsername(@NotBlank String username) throws UsernameNotFoundException {
logger.info("登入使用者名稱: " + username);
// 實際專案中你可以呼叫Dao或Repository來查詢使用者是否存在
if (Objects.equals(username,"admin") == false) {
throw new UsernameNotFoundException("使用者名稱不存在");
}
// 假設查出來的密碼如下
String pwd = passwordEncoder.encode("123456");
return new org.springframework.security.core.userdetails.User(
"admin",pwd,admin")
);
}
}
複製程式碼
BCryptPasswordEncoder
不一定只能用於密碼的加密和校驗,日常開發中涉及到加密的功能我們都能使用它的encode
方法,也能使用matches
方法比對某密文是否是某明文加密後的結果
個性化使用者認證流程
自定義登入頁面
在formLogin()
後使用loginPage()
就能指定登入的頁面,同時要記得將該URL的攔截放開;UsernamePasswordAuthenticationFilter
預設攔截提交到/login
的POST
請求並獲取登入資訊,如果你想表單填寫的action
不為/post
,那麼可以配置loginProcessingUrl
使UsernamePasswordAuthenticationFilter
與之對應
@Configuration
public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter {
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//設定認證方式為表單登入,若未登入而訪問受保護的URL則跳轉到表單登入頁(security幫我們寫了一個預設的登入頁)
.formLogin()
.loginPage("/sign-in.html").loginProcessingUrl("/auth/login")
.and()
// 驗證方式配置結束,開始配置驗證規則
.authorizeRequests()
// 登入頁面不需要攔截
.antMatchers("/sign-in.html").permitAll()
// 設定任何請求都需要通過認證
.anyRequest().authenticated();
}
}
複製程式碼
自定義登入頁:security-browser/src/main/resource/resources/sign-in.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>登入頁面</title>
</head>
<body>
<form action="/auth/login" method="post">
使用者名稱: <input type="text" name="username">
密碼: <input type="password" name="password">
<button type="submit">提交</button>
</form>
</body>
</html>
複製程式碼
重啟後訪問GET /user
,調整到了我們寫的登入頁sign-in.html
,填寫admin,123456
登入,發現還是報錯如下
There was an unexpected error (type=Forbidden,status=403).
Invalid CSRF Token 'null' was found on the request parameter '_csrf' or header 'X-CSRF-TOKEN'.
複製程式碼
這是因為security
預設啟用了跨站偽造請求防護CSRF(例如使用HTTP客戶端Postman
也可以發出這樣的登入請求),我們先禁用它
http
.formLogin()
.loginPage("/sign-in.html").loginProcessingUrl("/auth/login")
.and()
.authorizeRequests()
.antMatchers("/sign-in.html").permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable()
複製程式碼
再重啟訪問GET /user
,跳轉登入後,自動跳轉回/user
,自定義登入頁成功
REST登入邏輯
由於我們是基於REST
的服務,所以如果是非瀏覽器請求,我們應該返回401狀態碼告訴客戶端需要認證,而不是重定向到登入頁
這時我們就不能將loginPage
寫成登入頁路徑了,而應該重定向到一個Controller
,由Controller
判斷使用者是在瀏覽器訪問頁面時跳轉過來的還是非瀏覽器如安卓訪問REST服務時跳轉過來,如果是前者那麼就重定向到登入頁,如果是後者就響應401狀態碼和JSON訊息
package top.zhenganwen.securitydemo.browser;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.savedrequest.SavedRequest;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import top.zhenganwen.securitydemo.browser.support.SimpleResponseResult;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* @author zhenganwen
* @date 2019/8/23
* @desc AuthenticationController
*/
@RestController
public class BrowserSecurityController {
private Logger logger = LoggerFactory.getLogger(getClass());
// security會將跳轉前的請求儲存在session中
private RequestCache requestCache = new HttpSessionRequestCache();
private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
@RequestMapping("/auth/require")
// 該註解可設定響應狀態碼
@ResponseStatus(code = HttpStatus.UNAUTHORIZED)
public SimpleResponseResult requireAuthentication(HttpServletRequest request,HttpServletResponse response) throws IOException {
// 從session中取出跳轉前使用者訪問的URL
SavedRequest savedRequest = requestCache.getRequest(request,response);
if (savedRequest != null) {
String redirectUrl = savedRequest.getRedirectUrl();
logger.info("引發跳轉到/auth/login的請求是: {}",redirectUrl);
if (StringUtils.endsWithIgnoreCase(redirectUrl,".html")) {
// 如果使用者是訪問html頁面被FilterSecurityInterceptor攔截從而跳轉到了/auth/login,那麼就重定向到登入頁面
redirectStrategy.sendRedirect(request,"/sign-in.html");
}
}
// 如果不是訪問html而被攔截跳轉到了/auth/login,則返回JSON提示
return new SimpleResponseResult("使用者未登入,請引導使用者至登入頁");
}
}
複製程式碼
@Configuration
public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter {
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin()
.loginPage("/auth/require").loginProcessingUrl("/auth/login")
.and()
.authorizeRequests()
.antMatchers("/auth/require").permitAll()
.antMatchers("/sign-in.html").permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable();
}
}
複製程式碼
重構——配置代替hardcode
由於我們的security-browser
模組是作為可複用模組來開發的,應該支援自定義配置,例如其他應用引入我們的security-browser
模組之後,應該能配置他們自己的登入頁,如果他們沒有配置那就使用我們預設提供的sign-in.html
,要想做到這點,我們需要提供一些配置項,例如別人引入我們的security-browser
之後通過新增demo.security.browser.loginPage=/login.html
就能將他們專案的login.html
替換掉我們的sign-in.html
由於後續security-app
也可能會需要支援類似的配置,因此我們在security-core
中定義一個總的配置類來封裝各模組的不同配置項
security-core
中的類:
package top.zhenganwen.security.core.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* @author zhenganwen
* @date 2019/8/23
* @desc SecurityProperties 封裝整個專案各模組的配置項
*/
@Data
@ConfigurationProperties(prefix = "demo.security")
public class SecurityProperties {
private BrowserProperties browser = new BrowserProperties();
}
複製程式碼
package top.zhenganwen.security.core.properties;
import lombok.Data;
/**
* @author zhenganwen
* @date 2019/8/23
* @desc BrowserProperties 封裝security-browser模組的配置項
*/
@Data
public class BrowserProperties {
private String loginPage = "/sign-in.html"; //提供一個預設的登入頁
}
複製程式碼
package top.zhenganwen.security.core;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import top.zhenganwen.security.core.properties.SecurityProperties;
/**
* @author zhenganwen
* @date 2019/8/23
* @desc SecurityCoreConfig
*/
@Configuration
// 啟用在啟動時將application.properties中的demo.security字首的配置項注入到SecurityProperties中
@EnableConfigurationProperties(SecurityProperties.class)
public class SecurityCoreConfig {
}
複製程式碼
然後在security-browser
中將SecurityProperties
注入進來,將重定向到登入頁的邏輯依賴配置檔案中的demo.security.browser.loginPage
@RestController
public class BrowserSecurityController {
private Logger logger = LoggerFactory.getLogger(getClass());
private RequestCache requestCache = new HttpSessionRequestCache();
private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
@Autowired
private SecurityProperties securityProperties;
@RequestMapping("/auth/require")
@ResponseStatus(code = HttpStatus.UNAUTHORIZED)
public SimpleResponseResult requireAuthentication(HttpServletRequest request,HttpServletResponse response) throws IOException {
SavedRequest savedRequest = requestCache.getRequest(request,".html")) {
redirectStrategy.sendRedirect(request,securityProperties.getBrowser().getLoginPage());
}
}
return new SimpleResponseResult("使用者未登入,請引導使用者至登入頁");
}
}
複製程式碼
將不攔截的登入頁URL設定為動態的
@Configuration
public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter {
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Autowired
private SecurityProperties securityProperties;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin()
.loginPage("/auth/require").loginProcessingUrl("/auth/login")
.and()
.authorizeRequests()
.antMatchers("/auth/require").permitAll()
// 將不攔截的登入頁URL設定為動態的
.antMatchers(securityProperties.getBrowser().getLoginPage()).permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable();
}
}
複製程式碼
現在,我們將security-demo
模組當做第三方應用,使用可複用的security-browser
首先,要將security-demo
模組的啟動類SecurityDemoApplication
移到top.zhenganwen.securitydemo
包下,確保能夠掃描到security-core
下的top.zhenganwen.securitydemo.core.SecurityCoreConfig
和security-browser
下的top.zhenganwen.securitydemo.browser.SecurityBrowserConfig
然後,在security-demo
的application.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
,許可權,對應UserDetials
中getAuthorities()
的返回結果 -
details
,回話,客戶端的IP以及本次回話的SESSIONID -
authenticated
,是否通過認證 -
principle
,對應UserDetailsService
中loadUserByUsername
返回的UserDetails
-
credentials
,密碼,security
預設做了處理,不將密碼返回給前端 -
name
,使用者名稱
這裡因為我們是表單登入,所以返回的是以上資訊,之後我們做第三方登入如微信、QQ,那麼Authentication
包含的資訊就可能不一樣了,也就是說重寫的onAuthenticationSuccess
方法的入參Authentication
會根據登入方式的不同傳給我們不同的Authentication
實現類物件
自定義登入失敗處理——AuthenticationFailureHandler
與登入成功處理對應,自然也可以自定義登入失敗處理
package top.zhenganwen.securitydemo.browser.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* @author zhenganwen
* @date 2019/8/24
* @desc CustomAuthenticationFailureHandler
*/
@Component("customAuthenticationFailureHandler")
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private ObjectMapper objectMapper;
@Override
public void onAuthenticationFailure(HttpServletRequest request,AuthenticationException exception) throws IOException,ServletException {
logger.info("登入失敗=>{}",exception.getMessage());
response.setContentType("application/json;charset=utf-8");
response.getWriter().write(objectMapper.writeValueAsString(exception));
response.getWriter().flush();
}
}
複製程式碼
@Configuration
public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter {
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Autowired
private SecurityProperties securityProperties;
@Autowired
private AuthenticationSuccessHandler customAuthenticationSuccessHandler;
@Autowired
private AuthenticationFailureHandler customAuthenticationFailureHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin()
.loginPage("/auth/require")
.loginProcessingUrl("/auth/login")
.successHandler(customAuthenticationSuccessHandler)
.failureHandler(customAuthenticationFailureHandler)
.and()
.authorizeRequests()
.antMatchers("/auth/require").permitAll()
.antMatchers(securityProperties.getBrowser().getLoginPage()).permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable();
}
}
複製程式碼
訪問/login.html
輸入錯誤的密碼登入:
{
cause: null,stackTrace: [...],localizedMessage: "壞的憑證",message: "壞的憑證",suppressed: [ ]
}
複製程式碼
重構
為了使security-browser
成為可複用的模組,我們應該將登入成功/失敗處理策略抽離出去,讓第三方應用自由選擇,這時我們又可以新增一個配置項demo.security.browser.loginProcessType
切換到security-core
:
package top.zhenganwen.security.core.properties;
/**
* @author zhenganwen
* @date 2019/8/24
* @desc LoginProcessTypeEnum
*/
public enum LoginProcessTypeEnum {
// 重定向到之前的請求頁或登入失敗頁
REDIRECT("redirect"),// 登入成功返回使用者資訊,登入失敗返回錯誤資訊
JSON("json");
private String type;
LoginProcessTypeEnum(String type) {
this.type = type;
}
}
複製程式碼
@Data
public class BrowserProperties {
private String loginPage = "/sign-in.html";
private LoginProcessTypeEnum loginProcessType = LoginProcessTypeEnum.JSON; //預設返回JSON資訊
}
複製程式碼
重構登入成功/失敗處理器,其中SavedRequestAwareAuthenticationSuccessHandler
和SimpleUrlAuthenticationFailureHandler
就是security
提供的預設的登入成功(跳轉到登入之前請求的頁面)和登入失敗(跳轉到異常頁)的處理器
package top.zhenganwen.securitydemo.browser.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import top.zhenganwen.security.core.properties.LoginProcessTypeEnum;
import top.zhenganwen.security.core.properties.SecurityProperties;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* @author zhenganwen
* @date 2019/8/24
* @desc CustomAuthenticationSuccessHandler
*/
@Component("customAuthenticationSuccessHandler")
public class CustomAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private ObjectMapper objectMapper;
@Autowired
private SecurityProperties securityProperties;
@Override
public void onAuthenticationSuccess(HttpServletRequest request,ServletException {
if (securityProperties.getBrowser().getLoginProcessType() == LoginProcessTypeEnum.REDIRECT) {
// 重定向到快取在session中的登入前請求的URL
super.onAuthenticationSuccess(request,authentication);
return;
}
logger.info("使用者{}登入成功",authentication.getName());
response.setContentType("application/json;charset=utf-8");
response.getWriter().write(objectMapper.writeValueAsString(authentication));
response.getWriter().flush();
}
}
複製程式碼
package top.zhenganwen.securitydemo.browser.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import top.zhenganwen.security.core.properties.LoginProcessTypeEnum;
import top.zhenganwen.security.core.properties.SecurityProperties;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* @author zhenganwen
* @date 2019/8/24
* @desc CustomAuthenticationFailureHandler
*/
@Component("customAuthenticationFailureHandler")
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private ObjectMapper objectMapper;
@Autowired
private SecurityProperties securityProperties;
@Override
public void onAuthenticationFailure(HttpServletRequest request,ServletException {
if (securityProperties.getBrowser().getLoginProcessType() == LoginProcessTypeEnum.REDIRECT) {
super.onAuthenticationFailure(request,exception);
return;
}
logger.info("登入失敗=>{}",exception.getMessage());
response.setContentType("application/json;charset=utf-8");
response.getWriter().write(objectMapper.writeValueAsString(exception));
response.getWriter().flush();
}
}
複製程式碼
訪問/login.html
,分別進行登入成功和登入失敗測試,返回JSON響應
在security-demo
中
-
application.properties
中新增demo.security.browser.loginProcessType=redirect
-
新建
/resources/resources/index.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Title</title> </head> <body> <h1>Spring Demo應用首頁</h1> </body> </html> 複製程式碼
-
新建
/resources/resources/401.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Title</title> </head> <body> <h1>login fail!</h1> </body> </html> 複製程式碼
重啟服務,登入成功跳轉到index.html
,登入失敗跳轉到401.html
認證流程原始碼級詳解
經過上述兩節,我們已經會使用security
的一些基礎功能了,但都是碎片化的,對整體流程的把握還很模糊。知其然還要知其所以然,我們需要分析在登入時security
都幫我們做了哪些事
認證處理流程
上圖是登入處理的大致流程,登入請求的過濾器XxxAutenticationFilter
在攔截到登入請求後會見登入資訊封裝成一個authenticated=false
的Authentication
傳給AuthenticationManager
讓幫忙校驗,AuthenticationManager
本身也不會做校驗邏輯,會委託AuthenticationProvider
幫忙校驗,AuthenticationProvider
會在校驗過程中丟擲校驗失敗異常或校驗通過返回一個新的帶有UserDetials
的Authentication
返回,請求過濾器收到XxxAuthenticationFilter
之後會呼叫登入成功處理器執行登入成功邏輯
我們以使用者名稱密碼錶單登入方式來斷點除錯逐步分析一下校驗流程,其他的登入方式也就大同小異了
認證結果如何在多個請求之間共享
要想在多個請求之間共享資料,需要藉助session
,接下來我們看一下security
將什麼東西放到了session
中,又在什麼時候會從session
讀取
上節說道在AbstractAuthenticationProcessingFilter
的``doFilter方法中,校驗成功之後會呼叫
successfulAuthentication(request,chain,authResult)`,我們來看一下這個方法幹了些什麼
protected void successfulAuthentication(HttpServletRequest request,FilterChain chain,Authentication authResult)
throws IOException,ServletException {
if (logger.isDebugEnabled()) {
logger.debug("Authentication success. Updating SecurityContextHolder to contain: "
+ authResult);
}
SecurityContextHolder.getContext().setAuthentication(authResult);
...
successHandler.onAuthenticationSuccess(request,authResult);
}
複製程式碼
可以發現,在呼叫登入成功處理器的處理邏輯之前,呼叫了一下SecurityContextHolder.getContext().setAuthentication(authResult)
,檢視可知SecurityContextHolder.getContext()
就是獲取當前執行緒繫結的SecurityContext
(可以看做是一個執行緒變數,作用域為執行緒的生命週期),而SecurityContext
其實就是對Authentication
的一層包裝
public class SecurityContextHolder {
private static SecurityContextHolderStrategy strategy;
public static SecurityContext getContext() {
return strategy.getContext();
}
}
複製程式碼
final class ThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {
private static final ThreadLocal<SecurityContext> contextHolder = new ThreadLocal<SecurityContext>();
public SecurityContext getContext() {
SecurityContext ctx = contextHolder.get();
if (ctx == null) {
ctx = createEmptyContext();
contextHolder.set(ctx);
}
return ctx;
}
}
複製程式碼
public interface SecurityContext extends Serializable {
Authentication getAuthentication();
void setAuthentication(Authentication authentication);
}
複製程式碼
public class SecurityContextImpl implements SecurityContext {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
public Authentication getAuthentication() {
return authentication;
}
public int hashCode() {
if (this.authentication == null) {
return -1;
}
else {
return this.authentication.hashCode();
}
}
public void setAuthentication(Authentication authentication) {
this.authentication = authentication;
}
...
}
複製程式碼
那麼將Authentication
儲存到當前執行緒的SecurityContext
中的用意是什麼呢?
這就涉及到了另外一個特別的過濾器SecurityContextPersistenceFilter
,它位於security
的整個過濾器鏈的最前端:
private SecurityContextRepository repo;
// 請求到達的第一個過濾器
public void doFilter(ServletRequest req,ServletException {
...
HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request,response);
// 從Session中獲取SecurityContext,未登入時獲取的則是空
SecurityContext contextBeforeChainExecution = repo.loadContext(holder);
try {
// 將SecurityContext儲存到當前執行緒的ThreadLocalMap中
SecurityContextHolder.setContext(contextBeforeChainExecution);
// 執行後續過濾器和Controller方法
chain.doFilter(holder.getRequest(),holder.getResponse());
}
// 在請求響應時經過的最後一個過濾器
finally {
// 從當前執行緒獲取SecurityContext
SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext();
SecurityContextHolder.clearContext();
// 將SecurityContext持久化到Session
repo.saveContext(contextAfterChainExecution,holder.getRequest(),holder.getResponse());
...
}
}
複製程式碼
public class HttpSessionSecurityContextRepository implements SecurityContextRepository {
...
public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
HttpServletRequest request = requestResponseHolder.getRequest();
HttpServletResponse response = requestResponseHolder.getResponse();
HttpSession httpSession = request.getSession(false);
SecurityContext context = readSecurityContextFromSession(httpSession);
...
return context;
}
...
}
複製程式碼
獲取認證使用者資訊
在我們的程式碼中可以通過靜態方法SecurityContextHolder.getContext().getAuthentication
來獲取使用者資訊,或者可以直接在Controller
入參宣告Authentication
,security
會幫我們自動注入,如果只想獲取Authentication
中的UserDetails
對應的部分,則可使用@AuthenticationPrinciple UserDetails currentUser
@GetMapping("/info1")
public Object info1() {
return SecurityContextHolder.getContext().getAuthentication();
}
@GetMapping("/info2")
public Object info2(Authentication authentication) {
return authentication;
}
複製程式碼
GET /user/info1
{
authorities: [
{
authority: "admin"
},sessionId: "24AE70712BB99A969A5C56907C39C20E"
},name: "admin"
}
複製程式碼
@GetMapping("/info3")
public Object info3(@AuthenticationPrincipal UserDetails currentUser) {
return currentUser;
}
複製程式碼
GET /user/info3
{
password: null,authorities: [
{
authority: "admin"
},enabled: true
}
複製程式碼
參考資料
-
視訊教程
連結: pan.baidu.com/s/1wQWD4wE0… 提取碼: z6zi