0%

Spring Security 技术栈开发企业级认证授权

环境准备

本文中所有实例代码已托管码云:https://gitee.com/zhenganwen/code-demo/tree/master/spring-security-demo

开发环境

  • JDK1.8
  • Maven

项目结构

image.png

  • spring-security-demo

    父工程,用于整个项目的依赖

  • security-core

    安全认证核心模块,security-browsersecurity-app都基于其来构建

  • security-browser

    PC端浏览器授权,主要通过Session

  • security-app

    移动端授权

  • security-demo

    应用security-browsersecurity-app

依赖

spring-security-demo

添加spring依赖自动兼容依赖和编译插件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<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工具类等依赖,一些依赖只是先加进来以备后用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<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和集群管理依赖

1
2
3
4
5
6
7
8
9
10
11
<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

1
2
3
4
5
6
7
<dependencies>
<dependency>
<groupId>top.zhenganwen</groupId>
<artifactId>security-core</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>

security-demo

暂时引用security-browser做PC端的验证

1
2
3
4
5
6
7
8
<artifactId>security-demo</artifactId>
<dependencies>
<dependency>
<groupId>top.zhenganwen</groupId>
<artifactId>security-browser</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>

配置

security-demo中添加启动类如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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连接信息

1
2
3
4
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,先禁用掉

1
spring.session.store-type=none
1
2
3
@SpringBootApplication(exclude = {RedisAutoConfiguration.class,RedisRepositoriesAutoConfiguration.class})
@RestController
public class SecurityDemoApplication {

然后发现能够启动成功了,然而访问/hello去发现提示我们要登录,这是Spring Security的默认认证策略在起作用,我们也先禁用它

1
security.basic.enabled = false

重启访问/hello,页面显示hello spring security,环境搭建成功

Restful

Restful VS 传统

Restful是一种HTTP接口编写风格,而不是一种标准或规定。使用Restful风格和传统方式的区别主要如下

  • URL
    • 传统方式一般通过在URL中添加表明接口行为的字符串和查询参数,如/user/get?username=xxx
    • Restful风格则推荐一个URL代表一个系统资源,/user/1应表示访问系统中id为1的用户
  • 请求方式
    • 传统方式一般通过get提交,弊端是get提交会将请求参数附在URL上,而URL有长度限制,并且若不特殊处理,参数在URL上是明文显示的,不安全。对上述两点有要求的请求会使用post提交
    • Restful风格推崇使用提交方式描述请求行为,如POSTDELETEPUTGET应对应增、删、改、查类型的请求
  • 通讯媒介
    • 传统方式中,对请求的响应结果是一个页面,如此针对不同的终端需要开发多个系统,且前后端逻辑耦合
    • Restful风格提倡使用JSON作为前后端通讯媒介,前后端分离;通过响应状态码来标识响应结果类型,如200表示请求被成功处理,404表示没有找到相应资源,500表示服务端处理异常。

Restful详解参考:https://www.runoob.com/w3cnote/restful-architecture.html

SpringMVC高级特性与REST服务

Jar包方式运行

上述搭建的环境已经能通过IDE运行并访问/hello,但是生产环境一般是将项目打成一个可执行的jar包,能够通过java -jar直接运行。

此时如果我们右键父工程运行maven命令clean package你会发现security-demo/target中生成的jar只有7KB,这是因为maven默认的打包方式是不会将其依赖的jar进来并且设置springboot启动类的。这时我们需要在security-demopom中添加一个打包插件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>1.3.3.RELEASE</version>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
<!-- 生成的jar文件名 -->
<finalName>demo</finalName>
</build>

这样再执行clean package就会发现target下生产了一个demo.jardemo.jar.original,其中demo.jar是可执行的,而demo.jar.original是保留了maven默认打包方式

使用MockMVC编写接口测试用例

秉着测试先行的原则(提倡先写测试用例再写接口,验证程序按照我们的想法运行),我们需要借助spring-boot-starter-test测试框架和其中相关的MockMvcAPI。mock为打桩的意思,意为使用测试用例将程序打造牢固。

首先在security-demo中添加测试依赖

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>

然后在src/test/java中新建测试类如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package top.zhenganwen.securitydemo;

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

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

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

@Autowired
WebApplicationContext webApplicationContext;

private MockMvc mockMvc;

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

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

因为是测试HTTP接口,因此需要注入web容器WebApplicationContext。其中get()status()jsonPath()都是静态导入的方法,测试代码的意思是通过GET提交方式请求/helloget("/hello"))并附加请求头为Content-Type: application/json(这样参数就会以json的方式附在请求体中,是的没错,GET请求也是可以附带请求体的!)

andExpect(status().isOk())期望响应状态码为200(参见HTTP状态码),andExpect((jsonPath("$").value("hello spring security"))期望响应的JSON数据是一个字符串且内容为hello spring security(该方法依赖JSON解析框架jsonpath$表示JSON本体在Java中对应的数据类型对象,更多API详见:https://github.com/search?q=jsonpath)

其中比较重要的API为MockMvcMockMvcRequestBuildersMockMvcRequestBuilders

  • MockMvc,调用perform指定接口地址
  • MockMvcRequestBuilders,构建请求(包括请求路径、提交方式、请求头、请求体等)
  • MockMvcRequestBuilders,断言响应结果,如响应状态码、响应体

MVC注解细节

@RestController

用于标识一个ControllerRestful Controller,其中方法的返回结果会被SpringMVC自动转换为JSON并设置响应头为Content-Type=application/json

@RequestMapping

用于将URL映射到方法上,并且SpringMVC会自动将请求参数按照按照参数名对应关系绑定到方法入参上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
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

1
2
3
4
5
6
@GetMapping("/user")
public List<User> query(@RequestParam String username) {
System.out.println(username);
List<User> users = Arrays.asList(new User(), new User(), new User());
return users;
}

ControllerTest

1
2
3
4
5
6
@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属性

1
2
3
4
5
6
7
8
9
10
11
12
13
@GetMapping("/user")
public List<User> query(@RequestParam(name = "username") String userName) {
System.out.println(userName);
List<User> users = Arrays.asList(new User(), new User(), new User());
return users;
}

@GetMapping("/user")
public List<User> query(@RequestParam("username") String userName) {
System.out.println(userName);
List<User> users = Arrays.asList(new User(), new User(), new User());
return users;
}
1
2
3
4
5
6
7
8
@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属性

1
2
3
4
5
6
@GetMapping("/user")
public List<User> query(@RequestParam(required = false,defaultValue = "") String userName) {
Objects.requireNonNull(userName);
List<User> users = Arrays.asList(new User(), new User(), new User());
return users;
}
1
2
3
4
5
6
7
@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)中,如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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会帮我们实现请求参数到对象属性的绑定(默认绑定规则是参数名一致)

1
2
3
4
5
6
7
@GetMapping("/user")
public List<User> query(@RequestParam("username") String userName, UserQueryConditionDto userQueryConditionDto) {
System.out.println(userName);
System.out.println(ReflectionToStringBuilder.toString(userQueryConditionDto, ToStringStyle.MULTI_LINE_STYLE));
List<User> users = Arrays.asList(new User(), new User(), new User());
return users;
}

ReflectionToStringBuilder反射工具类能够在对象没有重写toString方法时通过反射帮我们查看对象的属性。

1
2
3
4
5
6
7
8
9
10
@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冲突,输出如下

1
2
3
4
5
6
tom
top.zhenganwen.securitydemo.dto.UserQueryConditionDto@440ef8d[
username=tom
password=123456
phone=12345678911
]

Bean绑定优先于基本类型参数绑定

但是,如果不给userName添加@RequestParam注解,那么它接收到的将是一个null

1
2
3
4
5
6
null
top.zhenganwen.securitydemo.dto.UserQueryConditionDto@440ef8d[
username=tom
password=123456
phone=12345678911
]

分页参数绑定

spring-data家族(如spring-boot-data-redis)帮我们封装了一个分页DTOPageable,会将我们传递的分页参数size(每页行数)、page(当前页码)、sort(排序字段和排序策略)自动绑定到自动注入的Pageable实例中

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

@PathVariable

变量占位

最常见的Restful URL,像GET /user/1获取id1的用户的信息,这时我们在编写接口时需要将路径中的1替换成一个占位符如{id},根据实际的URL请求动态的绑定到方法参数id

1
2
3
4
5
@GetMapping("/user/{id}")
public User info(@PathVariable("id") Long id) {
System.out.println(id);
return new User("jack","123");
}
1
2
3
4
5
6
7
8
9
@Test
public void testPathVariable() throws Exception {
mockMvc.perform(get("/user/1").
contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(status().isOk())
.andExpect(jsonPath("$.username").value("jack"));
}

1

当方法参数名和URL占位符变量名一致时,可以省去@PathVariablevalue属性

正则匹配

有时我们需要对URL的匹配做细粒度的控制,例如/user/1会匹配到/user/{id},而/user/xxx则不会匹配到/user/{id}

1
2
3
4
5
@GetMapping("/user/{id:\\d+}")
public User getInfo(@PathVariable("id") Long id) {
System.out.println(id);
return new User("jack","123");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
@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时会用到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements Serializable {

/**
* 普通视图,返回用户基本信息
*/
public interface UserOrdinaryView {

}

/**
* 详情视图,除了普通视图包含的字段,还返回密码等详细信息
*/
public interface UserDetailsView extends UserOrdinaryView{

}

private String username;

private String password;
}

视图和视图之间可以存在继承关系,继承视图后会继承该视图包含的字段

2、在响应对象的字段上添加视图,表示该字段包含在该视图中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@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方法上添加视图,表示该方法返回的对象数据仅显示该视图包含的字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@GetMapping("/user")
@JsonView(User.UserBasicView.class)
public List<User> query(String userName, UserQueryConditionDto userQueryConditionDto, Pageable pageable) {
System.out.println(userName);
System.out.println(ReflectionToStringBuilder.toString(userQueryConditionDto, ToStringStyle.MULTI_LINE_STYLE));
System.out.println(pageable.getPageNumber());
System.out.println(pageable.getPageSize());
System.out.println(pageable.getSort());
List<User> users = Arrays.asList(new User("tom","123"), new User("jack","456"), new User("alice","789"));
return users;
}

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

测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@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,我们可以将其提至类上以供复用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RestController
@RequestMapping("/user")
public class UserController {

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

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

虽然是一个很细节的问题,但是一定要有这个思想和习惯

别忘了重构后重新运行一遍所有的测试用例,确保重构没有更改程序行为

处理请求体

@RequestBody映射请求体到Java方法的参数

SpringMVC默认不会解析请求体中的参数并绑定到方法参数

1
2
3
4
@PostMapping
public void createUser(User user) {
System.out.println(user);
}
1
2
3
4
5
6
7
8
9
@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对象并绑定到方法入参

1
2
3
4
@PostMapping
public void createUser(@RequestBody User user) {
System.out.println(user);
}
1
2
3
4
5
6
7
8
9
@Test
public void testCreateUser() throws Exception {
mockMvc.perform(post("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"jack\",\"password\":\"123\"}"))
.andExpect(status().isOk());
}

User(id=null, username=jack, password=123)

日期类型参数处理

如果需要将时间类型数据绑定到BeanDate字段上,网上常见的解决方案是加一个json消息转换器进行格式化,这样的话就将日期的显示逻辑写死在后端的。

比较好的做法应该是后端只保存时间戳,传给前端时也只传时间戳,将格式化显示的责任交给前端,前端爱怎么显示怎么显示

1
2
3
4
@PostMapping
public void createUser(@RequestBody User user) {
System.out.println(user);
}
1
2
3
4
5
6
7
8
9
10
11
12
@Test
public void testDateBind() throws Exception {
Date date = new Date();
System.out.println(date.getTime());
mockMvc.perform(post("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"jack\",\"password\":\"123\",\"birthday\":\"" + date.getTime() + "\"}"))
.andExpect(status().isOk());
}

1566212381139
User(id=null, username=jack, password=123, birthday=Mon Aug 19 18:59:41 CST 2019)

@Valid注解验证请求参数的合法性

抽离校验逻辑

Controller方法中,我们经常需要对请求参数进行合法性校验后再执行处理逻辑,传统的写法是使用if判断

1
2
3
4
5
6
7
8
9
10
@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字段

1
2
3
4
5
6
7
@NotBlank
@JsonView(UserBasicView.class)
private String username;

@NotBlank
@JsonView(UserDetailsView.class)
private String password;

仅添加这些注解,SpringMVC是不会帮我们校验的

1
2
3
4
@PostMapping
public void createUser(@RequestBody User user) {
System.out.println(user);
}
1
2
3
4
5
6
7
8
9
@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

1
2
3
4
@PostMapping
public void createUser(@Valid @RequestBody User user) {
System.out.println(user);
}
1
2
3
4
5
6
7
@Test
public void testConstraintValidateSuccess() throws Exception {
mockMvc.perform(post("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"\"}"))
.andExpect(status().is4xxClientError());
}

约束注解

hibernate-validator提供的约束注解如下

image.png

image.png

例如,创建用户时限制请求参数中的birthday的值是一个过去时间

首先在Bean的字段添加约束注解

1
2
@Past
private Date birthday;

然后在要验证的Bean前添加@Valid注解

1
2
3
4
@PostMapping
public void createUser(@Valid @RequestBody User user) {
System.out.println(user);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Test
public void testValidatePastTimeSuccess() throws Exception {
// 获取一年前的时间点
Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
mockMvc.perform(post("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"jack\",\"password\":\"123\",\"birthday\":\"" + date.getTime() + "\"}"))
.andExpect(status().isOk());
}

@Test
public void testValidatePastTimeFail() throws Exception {
// 获取一年后的时间点
Date date = new Date(LocalDateTime.now().plusYears(1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
mockMvc.perform(post("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"jack\",\"password\":\"123\",\"birthday\":\"" + date.getTime() + "\"}"))
.andExpect(status().is4xxClientError());
}

复用校验逻辑

这样,如果我们需要对修改用户的方法添加校验,只需添加@Valid即可

1
2
3
4
5
@PutMapping("/{id}")
public void update(@Valid @RequestBody User user, @PathVariable Long id) {
System.out.println(user);
System.out.println(id);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Test
public void testUpdateSuccess() throws Exception {
mockMvc.perform(put("/user/1").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"jack\",\"password\":\"789\"}"))
.andExpect(status().isOk());
}

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

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

约束逻辑只需在Bean中通过约束注解声明一次,其他任何需要使用到该约束校验的地方只需添加@Valid即可

BindingResult处理校验结果

上述处理方式还是不够完美,我们只是通过响应状态码告诉前端请求数据格式不对,但是没有明确指明哪里不对,我们需要给前端一些更明确的信息

上例中,如果没有通过校验,那么方法就不会被执行而直接返回了,我们想要插入一些提示信息都没有办法编写。这时可以使用BindingResult,它能够帮助我们获取校验失败信息并返回给前端,同时响应状态码会变为200

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@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);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Test
public void testBindingResult() throws Exception {
Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
mockMvc.perform(post("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"jack\",\"password\":null,\"birthday\":\"" + date.getTime() + "\"}"))
.andExpect(status().isOk());
}

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

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

may not be empty
User(id=null, username=jack, password=null, birthday=Sun Aug 19 20:42:56 CST 2018)
1

值得注意的是,BindingResult必须和@Valid一起使用,并且在参列中的位置必须紧跟在@Valid修饰的参数后面,否则会出现如下令人困惑的结果

1
2
3
4
5
6
7
8
@PutMapping("/{id}")
public void update(@Valid @RequestBody User user, @PathVariable Long id, BindingResult errors) {
if (errors.hasErrors()) {
errors.getAllErrors().stream().forEach(error -> System.out.println(error.getDefaultMessage()));
}
System.out.println(user);
System.out.println(id);
}

上述代码中,在校验的BeanBindingResult之间插入了一个id,你会发现BindingResult不起作用了

1
2
3
4
5
6
7
8
9
10
11
12
@Test
public void testBindingResult2() throws Exception {
Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
mockMvc.perform(put("/user/1").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"jack\",\"password\":null,\"birthday\":\"" + date.getTime() + "\"}"))
.andExpect(status().isOk());
}

java.lang.AssertionError: Status
Expected :200
Actual :400

校验

自定义消息

现在我们可以通过BindingResult得到校验失败信息了

1
2
3
4
5
6
7
8
9
10
@PutMapping("/{id:\\d+}")
public void update(@PathVariable Long id, @Valid @RequestBody User user, BindingResult errors) {
if (errors.hasErrors()) {
errors.getAllErrors().stream().forEach(error -> {
FieldError fieldError = (FieldError) error;
System.out.println(fieldError.getField() + " " + fieldError.getDefaultMessage());
});
}
System.out.println(user);
}
1
2
3
4
5
6
7
8
9
10
11
12
@Test
public void testBindingResult3() throws Exception {
Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
mockMvc.perform(put("/user/1").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\" \",\"password\":null,\"birthday\":\"" + date.getTime() + "\"}"))
.andExpect(status().isOk());
}

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

但是默认的消息提示不太友好并且还需要我们自己拼接,这时我们需要自定义消息提示,只需要使用约束注解的message属性指定验证未通过的提示消息即可

1
2
3
4
5
6
7
@NotBlank(message = "用户名不能为空")
@JsonView(UserBasicView.class)
private String username;

@NotBlank(message = "密码不能为空")
@JsonView(UserDetailsView.class)
private String password;
1
2
3
4
5
6
7
8
9
10
11
12
@Test
public void testBindingResult3() throws Exception {
Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
mockMvc.perform(put("/user/1").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\" \",\"password\":null,\"birthday\":\"" + date.getTime() + "\"}"))
.andExpect(status().isOk());
}

password 密码不能为空
username 用户名不能为空
User(id=null, username= , password=null, birthday=Sun Aug 19 21:03:18 CST 2018)

自定义校验注解

虽然hibernate-validator提供了一些常用的约束注解,但是对于复杂的业务场景还是需要我们自定义一个约束注解,毕竟有时仅仅是非空或格式合法的校验是不够的,可能我们需要去数据库查询进行校验

下面我们就参考已有的约束注解照葫芦画瓢自定义一个“用户名不可重复”的约束注解

1、新建约束注解类

我们希望该注解标注在Bean的某些字段上,使用@Target({FIELD});此外,要想该注解在运行期起作用,还要添加@Retention(RUNTIME)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package top.zhenganwen.securitydemo.annotation.valid;

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

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

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

}

参考已有的约束注解如NotNullNotBlank,它们都有三个方法

1
2
3
4
5
String message() default "{org.hibernate.validator.constraints.NotBlank.message}";

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

Class<? extends Payload>[] payload() default { };

于是我们也声明这三个方法

1
2
3
4
5
6
7
8
9
@Target({FIELD})
@Retention(RUNTIME)
public @interface Unrepeatable {
String message() default "用户名已被注册";

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

Class<? extends Payload>[] payload() default { };
}

2、编写校验逻辑类

依照已有注解,它们都还有一个注解@Constraint

1
2
3
4
5
6
7
@Documented
@Constraint(validatedBy = { })
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
@Retention(RUNTIME)
@ReportAsSingleViolation
@NotNull
public @interface NotBlank {

按住Ctrl点击validateBy属性进行查看,发现它需要一个ConstraintValidator的实现类,现在我们需要编写一个ConstraintValidator自定义校验逻辑并通过validatedBy属性将其绑定到我们的Unrepeatable注解上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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>的实现类

1
2
3
4
5
6
7
8
9
10
@Target({FIELD})
@Retention(RUNTIME)
@Constraint(validatedBy = { UsernameUnrepeatableValidator.class})
public @interface Unrepeatable {
String message() default "用户名已被注册";

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

Class<? extends Payload>[] payload() default { };
}

4、测试

1
2
3
4
5
6
7
@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);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Test
public void testCreateUserWithNewUsername() throws Exception {
Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
mockMvc.perform(post("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"alice\",\"password\":\"123\",\"birthday\":\"" + date.getTime() + "\"}"))
.andExpect(status().isOk());
}

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


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

the request username is tom
用户名已被注册
User(id=null, username=tom, password=123, birthday=Mon Aug 20 08:25:11 CST 2018)

删除用户

1
2
3
4
5
6
7
8
9
10
@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表示删除成功,若没有特别要求需要返回某些信息,那么无需添加响应体

1
2
3
4
5
@DeleteMapping("/{id:\\d+}")
public void delete(@PathVariable Long id) {
System.out.println(id);
// delete user
}
1
2
3
4
5
6
7
8
@Test
public void testDeleteUser() throws Exception {
mockMvc.perform(delete("/user/1").
contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(status().isOk());
}

1

错误处理

SpringBoot默认的错误处理机制

区分客户端进行响应

当请求处理发生错误时,SpringMVC根据客户端的类型会有不同的响应结果,例如浏览器访问localhost:8080/xxx会返回如下错误页面

image.png

而使用Postman请求则会得到如下响应

1
2
3
4
5
6
7
{
"timestamp": 1566268880358,
"status": 404,
"error": "Not Found",
"message": "No message available",
"path": "/xxx"
}

该机制对应的源码在BasicErrorController中(发生4xx500异常时,会将请求转发到/error,由BasicErrorController决定异常响应逻辑)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@RequestMapping(produces = "text/html")
public ModelAndView errorHtml(HttpServletRequest request,
HttpServletResponse response) {
HttpStatus status = getStatus(request);
Map<String, Object> model = Collections.unmodifiableMap(getErrorAttributes(
request, isIncludeStackTrace(request, MediaType.TEXT_HTML)));
response.setStatus(status.value());
ModelAndView modelAndView = resolveErrorView(request, response, status, model);
return (modelAndView == null ? new ModelAndView("error", model) : modelAndView);
}

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

如果是浏览器发出的请求,它的请求头会附带Accept: text/html...,而Postman发出的请求则是Accept: */*,因此前者会执行errorHtml响应错误页面,而error会收集异常信息以map的形式返回

自定义错误页面

对于客户端是浏览器的错误响应,例如404/500,我们可以在src/main/resources/resources/error文件夹下编写自定义错误页面,SpringMVC会在发生相应异常时返回该文件夹下的404.html500.html

创建src/main/resources/resources/error文件夹并添加404.html500.html

1
2
3
4
5
6
7
8
9
10
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>页面找不到了</title>
</head>
<body>
抱歉,页面找不到了!
</body>
</html>
1
2
3
4
5
6
7
8
9
10
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>服务异常</title>
</head>
<body>
服务端内部错误
</body>
</html>

模拟处理请求时发生异常

1
2
3
4
5
6
7
8
@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

1
2
3
4
5
6
7
8
{
"timestamp": 1566270327128,
"status": 500,
"error": "Internal Server Error",
"exception": "java.lang.RuntimeException",
"message": "id不存在",
"path": "/user/1"
}

例如服务端错误

1
2
3
4
@PostMapping
public void createUser(@Valid @RequestBody User user) {
System.out.println(user);
}
1
2
POST	localhost:8080/user
Body {}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
{
"timestamp": 1566272056042,
"status": 400,
"error": "Bad Request",
"exception": "org.springframework.web.bind.MethodArgumentNotValidException",
"errors": [
{
"codes": [
"NotBlank.user.username",
"NotBlank.username",
"NotBlank.java.lang.String",
"NotBlank"
],
"arguments": [
{
"codes": [
"user.username",
"username"
],
"arguments": null,
"defaultMessage": "username",
"code": "username"
}
],
"defaultMessage": "用户名不能为空",
"objectName": "user",
"field": "username",
"rejectedValue": null,
"bindingFailure": false,
"code": "NotBlank"
},
{
"codes": [
"NotBlank.user.password",
"NotBlank.password",
"NotBlank.java.lang.String",
"NotBlank"
],
"arguments": [
{
"codes": [
"user.password",
"password"
],
"arguments": null,
"defaultMessage": "password",
"code": "password"
}
],
"defaultMessage": "密码不能为空",
"objectName": "user",
"field": "password",
"rejectedValue": null,
"bindingFailure": false,
"code": "NotBlank"
}
],
"message": "Validation failed for object='user'. Error count: 2",
"path": "/user"
}

自定义异常响应结果

有时我们需要经常在处理请求时抛出异常以终止对该请求的处理,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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;
}
}
1
2
3
4
5
@GetMapping("/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
throw new IdNotExistException(id);
}

GET /user/1

1
2
3
4
5
6
7
8
{
"timestamp": 1566270990177,
"status": 500,
"error": "Internal Server Error",
"exception": "top.zhenganwen.securitydemo.exception.response.IdNotExistException",
"message": "id不存在",
"path": "/user/1"
}

SpringMVC默认只会将异常的message返回,如果我们需要将IdNotExistExceptionid也返回以给前端更明确的提示,就需要我们自定义异常处理

  1. 自定义的异常处理类需要添加@ControllerAdvice
  2. 在处理异常的方法上使用@ExceptionHandler声明该方法要截获哪些异常,所有的Controller若抛出这些异常中的一个则会转为执行该方法
  3. 捕获到的异常会作为方法的入参
  4. 方法返回的结果与Controller方法返回的结果意义相同,如果需要返回json则需在方法上添加@ResponseBody注解,如果在类上添加该注解则表示每个方法都有该注解
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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得到响应如下

1
2
3
4
{
"id": 1,
"message": "id不存在"
}

拦截

需求:记录所有请求 的处理时间

过滤器Filter

过滤器是JavaEE中的标准,是不依赖SpringMVC的,要想在SpringMVC中使用过滤器需要两步

1、实现Filter接口并注入到Spring容器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
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>节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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,控制台日志如下

1
2
3
4
5
6
7
@GetMapping("/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
// throw new IdNotExistException(id);
User user = new User();
return user;
}
1
2
3
[TimeFilter] 收到服务调用:【GET /user/1
[TimeFilter] 开始执行服务【GET /user/12019-08-20 02:13:44
[TimeFilter] 服务【GET /user/1】执行完毕 2019-08-20 02:13:44,共耗时:4ms

由于FilterJavaEE中的标准,所以它仅依赖servlet-api而不依赖任何第三方类库,因此它自然也不知道Controller的存在,自然也就无法知道本次请求将被映射到哪个方法上,SpringMVC通过引入拦截器弥补了这一缺点

通过filterRegistrationBean.addUrlPattern可以为过滤器添加拦截规则,默认的拦截规则是所有URL

1
2
3
4
5
6
7
@Bean
public FilterRegistrationBean registerTimeFilter() {
FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
filterRegistrationBean.setFilter(timeFilter);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}

拦截器Interceptor

拦截器与Filter的有如下不同之处

  • Filter是基于请求的,Interceptor是基于Controller的,一次请求可能会执行多个Controller(通过转发),因此一次请求只会执行一次Filter但可能执行多次Interceptor
  • InterceptorSpringMVC中的组件,因此它知道Controller的存在,能够获取相关信息(如该请求映射的方法,方法所在的bean等)

使用SpringMVC提供的拦截器也需要两步

1、实现HandlerInterceptor接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
package top.zhenganwen.securitydemo.web.interceptor;

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

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

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

/**
* 在Controller方法执行前被执行
* @param httpServletRequest
* @param httpServletResponse
* @param handler 处理器(Controller方法的封装)
* @return true 会接着执行Controller方法
* false 不会执行Controller方法,直接响应200
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object handler) throws Exception {
HandlerMethod handlerMethod = (HandlerMethod) handler;
String service = "【" + handlerMethod.getBean() + "#" + handlerMethod.getMethod().getName() + "】";
Date start = new Date();
System.out.println("[TimeInterceptor # preHandle] 服务" + service + "被调用 " + new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(start));
httpServletRequest.setAttribute("start", start.getTime());
return true;
}

/**
* 在Controller方法正常执行完毕后执行,如果Controller方法抛出异常则不会执行此方法
* @param httpServletRequest
* @param httpServletResponse
* @param handler
* @param modelAndView Controller方法返回的视图
* @throws Exception
*/
@Override
public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object handler, ModelAndView modelAndView) throws Exception {
HandlerMethod handlerMethod = (HandlerMethod) handler;
String service = "【" + handlerMethod.getBean() + "#" + handlerMethod.getMethod().getName() + "】";
Date end = new Date();
System.out.println("[TimeInterceptor # postHandle] 服务" + service + "调用结束 " + new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(end)
+ " 共耗时:" + (end.getTime() - (Long) httpServletRequest.getAttribute("start")) + "ms");
}

/**
* 无论Controller方法是否抛出异常,都会被执行
* @param httpServletRequest
* @param httpServletResponse
* @param handler
* @param e 如果Controller方法抛出异常则为对应抛出的异常,否则为null
* @throws Exception
*/
@Override
public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object handler, Exception e) throws Exception {
HandlerMethod handlerMethod = (HandlerMethod) handler;
String service = "【" + handlerMethod.getBean() + "#" + handlerMethod.getMethod().getName() + "】";
Date end = new Date();
System.out.println("[TimeInterceptor # afterCompletion] 服务" + service + "调用结束 " + new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(end)
+ " 共耗时:" + (end.getTime() - (Long) httpServletRequest.getAttribute("start")) + "ms");
if (e != null) {
System.out.println("[TimeInterceptor#afterCompletion] 服务" + service + "调用异常:" + e.getMessage());
}
}
}

2、配置类继承WebMvcConfigureAdapter并重写addInterceptor方法添加自定义拦截器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@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
1
2
3
4
[TimeFilter] 收到服务调用:【GET /user/1
[TimeFilter] 开始执行服务【GET /user/12019-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
1
2
3
4
5
6
[TimeFilter] 收到服务调用:【GET /user/1
[TimeFilter] 开始执行服务【GET /user/12019-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方法中抛出异常
1
2
3
4
5
6
7
@GetMapping("/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
throw new IdNotExistException(id);
// User user = new User();
// return user;
}
1
2
3
4
5
[TimeFilter] 收到服务调用:【GET /user/1
[TimeFilter] 开始执行服务【GET /user/12019-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再试一下

1
2
3
4
5
@GetMapping("/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
throw new RuntimeException("id not exist");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
[TimeFilter] 收到服务调用:【GET /user/1
[TimeFilter] 开始执行服务【GET /user/12019-08-20 03:09:38
[TimeInterceptor # preHandle] 服务【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】被调用 2019-08-20 03:09:38
[TimeInterceptor # afterCompletion] 服务【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】调用结束 2019-08-20 03:09:38 共耗时:7ms
[TimeInterceptor#afterCompletion] 服务【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】调用异常:id not exist

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

[TimeInterceptor # preHandle] 服务【org.springframework.boot.autoconfigure.web.BasicErrorController@33f17289#error】被调用 2019-08-20 03:09:38
[TimeInterceptor # postHandle] 服务【org.springframework.boot.autoconfigure.web.BasicErrorController@33f17289#error】调用结束 2019-08-20 03:09:38 共耗时:7ms
[TimeInterceptor # afterCompletion] 服务【org.springframework.boot.autoconfigure.web.BasicErrorController@33f17289#error】调用结束 2019-08-20 03:09:38 共耗时:7ms

方法调用时序图大致如下

image.png

切片Aspect

应用场景

Interceptor仍然有它的局限性,即无法获取调用Controller方法的入参信息,例如我们需要对用户下单请求的订单物品信息记录日志以便为推荐系统提供数据,那么这时Interceptor就无能为力了

追踪源码DispatcherServlet -> doService -> doDispatch可发现Interceptor无法获取入参的原因:

1
2
3
4
5
6
if (!mappedHandler.applyPreHandle(processedRequest, response)) {
return;
}

// Actually invoke the handler.
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());

mappedHandler.applyPreHandle其实就是调用HandlerInterceptorpreHandle方法,而在此之后才调用ha.handle(processedRequest, response, mappedHandler.getHandler())将请求参数processedRequest注入到handler入参上

使用方法

面向切面编程(Aspect-Oriented Program AOP)是基于动态代理的一种对象增强设计模式,能够实现在不修改现有代码的前提下添加可插拔的功能。

SpringMVC中使用AOP我们需要三步

  • 编写切片/切面类,将切入点和增强结合在一起
    • 添加@Component,注入Spring容器
    • 添加@Aspect,启动切面编程开关
  • 编写切入点,使用注解可以完成,切入点包含两部分:哪些方法需要增强以及增强的时机
    • 切入时机
      • @Before,方法执行前
      • @AfterReturning,方法正常执行结束后
      • @AfterThrowing,方法抛出异常后
      • @After,方法正常执行结束return前,相当于在return前插入了一段finally
      • @Around,可利用注入的入参ProceedingJoinPoint灵活的实现上述4种时机,它的作用与拦截器方法中的handler类似,只不过提供了更多有用的运行时信息
    • 切入点,可以使用execution表达式,具体详见:https://docs.spring.io/spring/docs/4.3.25.RELEASE/spring-framework-reference/htmlsingle/#aop-pointcuts-examples
  • 编写增强方法,
    • 其中只有@Around可以有入参,能拿到ProceedingJoinPoint实例
    • 通过调用ProceedingJoinPointpoint.proceed()能够调用对应的Controller方法并拿到返回值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
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;
}
}

测试

1
2
3
4
5
6
@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

1
2
3
4
5
6
7
8
9
[TimeFilter] 收到服务调用:【GET /user/1
[TimeFilter] 开始执行服务【GET /user/12019-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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[TimeFilter] 收到服务调用:【GET /user/1
[TimeFilter] 开始执行服务【GET /user/12019-08-20 05:24:40
[TimeInterceptor # preHandle] 服务【top.zhenganwen.securitydemo.web.controller.UserController@49433c98#getInfo】被调用 2019-08-20 05:24:40
[GlobalControllerAspect]开始调用服务【public top.zhenganwen.securitydemo.dto.User top.zhenganwen.securitydemo.web.controller.UserController.getInfo(java.lang.Long)】 请求参数: [1], 2019-08-20 05:24:40
[UserController # getInfo]query user by id
[GlobalControllerAspect]调用服务【public top.zhenganwen.securitydemo.dto.User top.zhenganwen.securitydemo.web.controller.UserController.getInfo(java.lang.Long)】发生异常, message=id not exist
[TimeInterceptor # afterCompletion] 服务【top.zhenganwen.securitydemo.web.controller.UserController@49433c98#getInfo】调用结束 2019-08-20 05:24:40 共耗时:2ms
[TimeInterceptor#afterCompletion] 服务【top.zhenganwen.securitydemo.web.controller.UserController@49433c98#getInfo】调用异常:id not exist

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

[TimeInterceptor # preHandle] 服务【org.springframework.boot.autoconfigure.web.BasicErrorController@445821a6#error】被调用 2019-08-20 05:24:40
[TimeInterceptor # postHandle] 服务【org.springframework.boot.autoconfigure.web.BasicErrorController@445821a6#error】调用结束 2019-08-20 05:24:40 共耗时:2ms
[TimeInterceptor # afterCompletion] 服务【org.springframework.boot.autoconfigure.web.BasicErrorController@445821a6#error】调用结束 2019-08-20 05:24:40 共耗时:3ms

总结

请求过程

image.png

响应过程

image.png

文件上传下载及Mock测试

文件上传

老规矩,测试先行,不过使用MockMvc模拟文件上传请求还是有些不一样的,请求需要使用静态方法fileUpload且要设置contentTypemultipart/form-data

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
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;
}
}

测试结果

1
2
3
4
[FileController]文件请求参数: file
[FileController]文件名称: file
[FileController]文件大小: 12字节
1566349460611_hello.txt

查看桌面发现多了一个1566349460611_hello.txt并且其中的内容为hello upload

文件下载

引入apache io工具包

1
2
3
4
5
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.5</version>
</dependency>

文件下载接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@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线程池会显得分身乏力,这时我们可以采取异步处理的方式。

为避免前文添加的过滤器、拦截器、切片日志的干扰,我们暂时先注释掉

1
2
//@Component
public class TimeFilter implements Filter {

突然发现实现过滤器好像继承了Filter接口并添加@Component就能生效,因为仅注释掉WebConfig中的registerTimeFilter方法,发现TimeFilter还是打印了日志

1
2
//@Configuration
public class WebConfig extends WebMvcConfigurerAdapter {
1
2
3
//@Aspect
//@Component
public class GlobalControllerAspect {

Callable异步处理

Controller中,如果将一个Callable作为方法的返回值,那么tomcat线程池中的线程在响应结果时会新建一个线程执行该Callable并将其返回结果返回给客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package top.zhenganwen.securitydemo.web.controller;

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

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


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

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

// 创建订单
@PostMapping
public Callable<String> createOrder() {
// 生成12位单号
String orderNumber = RandomStringUtils.randomNumeric(12);
logger.info("[主线程]收到创建订单请求,订单号=>" + orderNumber);
Callable<String> result = () -> {
logger.info("[副线程]创建订单开始,订单号=>"+orderNumber);
// 模拟创建订单逻辑
TimeUnit.SECONDS.sleep(3);
logger.info("[副线程]创建订单完成,订单号=>" + orderNumber+",返回结果给客户端");
return orderNumber;
};
logger.info("[主线程]已将请求委托副线程处理(订单号=>" + orderNumber + "),继续处理其它请求");
return result;
}
}

使用Postman测试结果如下

image.png

控制台日志:

1
2
3
4
2019-08-21 21:10:39.059  INFO 17044 --- [nio-8080-exec-2] t.z.s.w.controller.AsyncOrderController  : [主线程]收到创建订单请求,订单号=>719547514079
2019-08-21 21:10:39.059 INFO 17044 --- [nio-8080-exec-2] t.z.s.w.controller.AsyncOrderController : [主线程]已将请求委托副线程处理(订单号=>719547514079),继续处理其它请求
2019-08-21 21:10:39.063 INFO 17044 --- [ MvcAsync1] t.z.s.w.controller.AsyncOrderController : [副线程]创建订单开始,订单号=>719547514079
2019-08-21 21:10:42.064 INFO 17044 --- [ MvcAsync1] t.z.s.w.controller.AsyncOrderController : [副线程]创建订单完成,订单号=>719547514079,返回结果给客户端

观察可知主线程并没有执行Callable下单任务而直接跑去继续监听其他请求了,下单任务由SpringMVC新启了一个线程MvcAsync1执行,Postman的响应时间也是在Callable执行完毕后得到了它的返回值。对于客户端来说,后端的异步处理是透明的,与同步时没有什么区别;但是对于后端来说,tomcat监听请求的线程被占用的时间很短,大大提高了自身的并发能力

DeferredResult异步处理

Callable异步处理的缺陷是,只能通过在本地新建副线程的方式进行异步处理,但现在随着微服务架构的盛行,我们经常需要跨系统的异步处理。例如在秒杀系统中,并发下单请求量较大,如果后端对每个下单请求做同步处理(即在请求线程中处理订单)后再返回响应结果,会导致服务假死(发送下单请求没有任何响应);这时我们可能会利用消息中间件,请求线程只负责监听下单请求,然后发消息给MQ,让订单系统从MQ中拉取消息(如单号)进行下单处理并将处理结果返回给秒杀系统;秒杀系统独立设一个监听订单处理结果消息的线程,将处理结果返回给客户端。如图所示

image.png

要实现类似上述的效果,需要使用Future模式(可参考《Java多线程编程实战(设计模式篇)》),即我们可以设置一个处理结果凭证DeferredResult,如果我们直接调用它的getResult是获取不到处理结果的(会被阻塞,表现为虽然请求线程继续处理请求了,但是客户端仍在pending,只有当某个线程调用它的setResult(result),才会将对应的result响应给客户端

本例中,为降低复杂性,使用本地内存中的LinkedList代替分布式消息中间件,使用本地新建线程代替订单系统线程,各类之间的关系如下

image.png

秒杀系统AsyncOrderController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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> {
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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> {
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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;
}

凭证缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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);
}
}

两个队列对应的两个监听

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
package top.zhenganwen.securitydemo.web.async;

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

import java.util.concurrent.TimeUnit;

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

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

@Autowired
OrderProcessingQueue orderProcessingQueue;

@Autowired
OrderCompletionQueue orderCompletionQueue;

@Autowired
DeferredResultHolder deferredResultHolder;

// spring容器启动或刷新时执行此方法
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {

// 本系统(秒杀系统)启动时,启动一个监听MQ下单完成消息的线程
new Thread(() -> {

while (true) {
String finishedOrderNumber;
OrderCompletionResult orderCompletionResult;
synchronized (orderCompletionQueue) {
while (orderCompletionQueue.isEmpty()) {
try {
orderCompletionQueue.wait();
} catch (InterruptedException e) { }
}
orderCompletionResult = orderCompletionQueue.pollFirst();
orderCompletionQueue.notifyAll();
}
finishedOrderNumber = orderCompletionResult.getOrderNumber();
logger.info("收到订单处理完成消息,单号为: {}", finishedOrderNumber);
deferredResultHolder.completeOrder(finishedOrderNumber, orderCompletionResult.getResult());
}

},"本地监听线程-监听订单处理完成")
.start();


// 假设是订单系统监听MQ下单消息的线程
new Thread(() -> {

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

logger.info("收到下单请求,开始执行下单逻辑,单号为: {}", orderNumber);
boolean status;
// 模拟执行下单逻辑
try {
TimeUnit.SECONDS.sleep(2);
status = true;
} catch (Exception e) {
logger.info("下单失败=>{}", e.getMessage());
status = false;
}
// 向 订单处理完成MQ 发送消息
synchronized (orderCompletionQueue) {
orderCompletionQueue.addLast(new OrderCompletionResult(orderNumber, status == true ? "success" : "error"));
logger.info("发送订单完成消息, 单号: {}",orderNumber);
orderCompletionQueue.notifyAll();
}
}

},"订单系统线程-监听下单消息")
.start();
}
}

测试

image.png

1
2
3
4
5
6
2019-08-22 13:22:05.520  INFO 21208 --- [nio-8080-exec-2] t.z.s.web.async.AsyncOrderController     : 【请求线程】收到下单请求
2019-08-22 13:22:05.521 INFO 21208 --- [nio-8080-exec-2] t.z.s.web.async.AsyncOrderController : 【请求线程】继续处理其它请求
2019-08-22 13:22:06.022 INFO 21208 --- [ 订单系统线程-监听下单消息] t.z.s.web.async.OrderProcessingListener : 收到下单请求,开始执行下单逻辑,单号为: 104691998710
2019-08-22 13:22:06.022 INFO 21208 --- [地临时线程-向MQ发送下单消息] t.z.s.web.async.AsyncOrderController : 向MQ发送下单消息, 单号: 104691998710
2019-08-22 13:22:08.023 INFO 21208 --- [ 订单系统线程-监听下单消息] t.z.s.web.async.OrderProcessingListener : 发送订单完成消息, 单号: 104691998710
2019-08-22 13:22:08.023 INFO 21208 --- [本地监听线程-监听订单处理完成] t.z.s.web.async.OrderProcessingListener : 收到订单处理完成消息,单号为: 104691998710

configu reSync异步处理拦截、超时、线程池配置

在我们之前扩展WebMvcConfigureAdapter的子类WebConfig中可以通过重写configureAsyncSupport方法对异步处理进行一些配置

image.png

registerCallableInterceptors & registerDeferredResultInterceptors

我们之前通过重写addInterceptors方法注册的拦截器对CallableDeferredResult两种异步处理是无效的,如果想为这两者配置拦截器需重写这两个方法

setDefaultTimeout

设置异步处理的超时时间,超过该时间就直接响应而不会等异步任务结束了

setTaskExecutor

SpringBoot默认是通过新建线程的方式执行异步任务的,执行完后线程就被销毁了,要想通过复用线程(线程池)的方式执行异步任务,你可以通过此方法传入一个自定义的线程池

前后端分离

Swagger接口文档

swagger项目能够根据我们所写的接口自动生成接口文档,方便我们前后端分离开发

依赖

1
2
3
4
5
6
7
8
9
10
<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方法上,用来描述方法的行为

    1
    2
    3
    4
    @GetMapping
    @JsonView(User.UserBasicView.class)
    @ApiOperation("用户查询服务")
    public List<User> query(String userName, UserQueryConditionDto userQueryConditionDto, Pageable pageable) {
  • @ApiModelProperty,注解在Bean的字段上,用来描述字段的含义

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @Data
    public class UserQueryConditionDto {

    @ApiModelProperty("用户名")
    private String username;
    @ApiModelProperty("密码")
    private String password;
    @ApiModelProperty("电话号码")
    private String phone;
    }
  • @ApiParam,注解在Controller方法参数上,用来描述参数含义

    1
    2
    3
    4
    @DeleteMapping("/{id:\\d+}")
    public void delete(@ApiParam("用户id") @PathVariable Long id) {
    System.out.println(id);
    }

重启后接口文档会重新生成

image.png

image.png

WireMock

为了方便前后端并行开发,我们可以使用WireMock作为虚拟接口服务器

在后端接口没开发完成时,前端可能会通过本地文件的方式伪造一些静态数据(例如JSON文件)作为请求的响应结果,这种方式在前端只有一种终端时是没问题的。但是当前端有多种,如PC、H5、APP、小程序等时,每种都去在自己的本地伪造数据,那么就显得有些重复,而且每个人按照自己的想法伪造数据可能会导致最终和真实接口无法无缝对接

这时wiremock的出现就解决了这一痛点,wiremock是用Java开发的一个独立服务器,能够对外提供HTTP服务,我们可以通过wiremock客户端去编辑/配置wiremock服务器使它能像web服务一样提供各种各样的接口,而且无需重新部署

下载 & 启动wiremock服务

wiremock可以以jar方式运行,下载地址,下载完成后切换到其所在目录cmd执行以下命令启动wiremock服务器,--port=指定运行端口

1
java -jar wiremock-standalone-2.24.1.jar --port=8062

依赖

引入wiremock客户端依赖及其依赖的httpclient

1
2
3
4
5
6
7
8
<dependency>
<groupId>com.github.tomakehurst</groupId>
<artifactId>wiremock</artifactId>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>

由于在父工程中已经使用了依赖自动兼容,所以无需指定版本号。接着通过客户端API去编辑wiremock服务器,为其添加接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package top.zhenganwen.securitydemo.wiremock;

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

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

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

// 添加配置,一个stub代表一个接口
stubFor(
get(urlEqualTo("/order/1")).
// 设置响应结果
willReturn(
aResponse()
.withBody("{\"id\":1,\"orderNumber\":\"545616156\"}")
.withStatus(200)
)
);
}
}

你可以先将JSON数据存在resources中,然后通过ClassPathResource#getFileFileUtils#readLines将数据读成字符串

访问localhost:8062/order/1

1
2
3
4
{
id: 1,
orderNumber: "545616156"
}

通过WireMockAPI,你可以为虚拟服务器配置各种各样的接口服务

使用Spring Security开发基于表单的认证

Summary

Spring Security核心功能

  • 认证(你是谁)
  • 授权(你能干什么)
  • 攻击防护(防止伪造身份,如果黑客能 伪造身份登录系统,上述两个功能就不起作用了)

本章内容

  • Spring Security基本原理
  • 实现用户名 + 密码认证
  • 使用手机号 + 短信认证

Spring Security第一印象

Security有一个默认的基础认证机制,我们注释掉配置项security.basic.enabled=false(默认值为true),重启查看日志会发现一条信息

1
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)来配置验证方式、验证逻辑等,如设置验证方式为表单验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package top.zhenganwen.securitydemo.browser.config;

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

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

@Override
protected void configure(HttpSecurity http) throws Exception {
http
//设置认证方式为表单登录,若未登录而访问受保护的URL则跳转到表单登录页(security帮我们写了一个默认的登录页)
.formLogin()
// 添加其他配置
.and()
// 验证方式配置结束,开始配置验证规则
.authorizeRequests()
// 设置任何请求都需要通过认证
.anyRequest()
.authenticated();
}
}

访问/user,跳转到默认的登录页/login(该登录页和登录URL我们可以自定义),用户名user,密码还是日志中的,登录成功跳转到/user

httpBasic

如果将认证方式由formLogin改为httpBasic就是security最默认的配置(相当于引入security依赖后什么都不配的效果),即弹出登录框

Spring Security基本原理

三种过滤器

image.png

如图所示,Spring Security的核心其实就是一串过滤器链,所以它是非侵入式可插拔的。过滤器链中的过滤器分3种:

  • 认证过滤器XxxAuthenticationFilter,如上图中标注为绿色的,它们的类名以AuthenticationFilter结尾,作用是将登录的信息保存起来。这些过滤器是根据我们的配置动态生效的,如我们之前调用formLogin()其实就是启用了UsernamePasswordAuthenticationFilter,调用httpBaisc()就是启用了BasicAuthenticationFilter

    后面最贴近Controller的两个过滤器ExceptionTranslationFilterFilterSecurityInterceptor包含了最核心的认证逻辑,默认是启用的,而且我们也无法禁用它们

  • FilterSecurityInterceptor,虽然命名以Interceptor结尾,但其实还是一个Filter,它是最贴近Controller的一个过滤器,它会根据我们配置的拦截规则(哪些URL需要登录后才能访问,哪些URL需要某些特定的权限才能访问等)对访问相应URL的请求进行拦截,以下是它的部分源码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public void doFilter(ServletRequest request, ServletResponse response,
    FilterChain chain) throws IOException, ServletException {
    FilterInvocation fi = new FilterInvocation(request, response, chain);
    invoke(fi);
    }

    public void invoke(FilterInvocation fi) throws IOException, ServletException {
    ...
    InterceptorStatusToken token = super.beforeInvocation(fi);
    ...
    fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
    ...
    }

    doFilter就是真正调用我们的Controller了(因为它是过滤器链的末尾),但在此之前它会调用beforeInvocation对请求进行拦截校验是否有相关的身份和权限,校验失败对应会抛出未经认证异常(Unauthenticated)和未经授权异常(Unauthorized),这些异常会被ExceptionTranslationFilter捕获到

  • ExceptionTranslationFilter,顾名思义就是解析异常的,其部分源码如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
    throws IOException, ServletException {
    HttpServletRequest request = (HttpServletRequest) req;
    HttpServletResponse response = (HttpServletResponse) res;

    try {
    chain.doFilter(request, response);
    }
    catch (Exception ex) {
    // Try to extract a SpringSecurityException from the stacktrace
    ...
    }
    }

    它调用chain.doFilter其实就是去到了FilterSecurityInterceptor,它会对FilterSecurityInterceptor.doFilter中抛出的SpringSecurityException异常进行捕获并解析处理,例如FilterSecurityInterceptor抛出了Unauthenticated异常,那么ExceptionTranslationFilter就会重定向到登录页或是弹出登录框(取决于我们配置了什么认证过滤器),当我们成功登录后,认证过滤又会重定向到我们最初要访问的URL

断点调试

我们可以通过断点调试的方式来验证上述所说,将验证方式设为formLogin,然后在3个过滤器和Controller中分别打断点,重启服务访问/user

image.png

自定义用户认证逻辑

处理用户信息获取逻辑——UserDetailsService

到此为止我们登录都是通过user和启动日志生成的密码,这是security内置了一个user用户。实际项目中我们一般有一个专门存放用户的表,会通过jdbc或从其他存储系统读取用户信息,这时就需要我们自定义读取用户信息的逻辑,通过实现UserDetailsService接口即可告诉security从如何获取用户信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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接口源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();

// 用来和用户登录时填写的密码进行比对
String getPassword();

String getUsername();

// 账户是否是非过期的
boolean isAccountNonExpired();

// 账户是否是非冻结的
boolean isAccountNonLocked();

// 密码是否是非过期的,有些安全性较高的系统需要账户每隔一段时间更换密码
boolean isCredentialsNonExpired();

// 账户是否可用,可以对应逻辑删除字段
boolean isEnabled();
}

在重写以is开头的四个方法时,如果无需相应判断,则返回true即可,例如对应用户表的实体类如下

1
2
3
4
5
6
7
8
9
@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接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@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的支持了:

1
2
3
4
public interface PasswordEncoder {
String encode(CharSequence rawPassword);
boolean matches(CharSequence rawPassword, String encodedPassword);
}

我们在插入用户到数据库时,需要调用encode对明文密码加密后再插入;在用户登录时,security会调用matches将我们从数据库查出的密文面和用户提交的明文密码进行比对。

security为我们提供了一个该接口的非对称加密(对同一明文密码,每次调用encode得到的密文都是不一样的,只有通过matches来比对明文和密文是否对应)实现类BCryptPasswordEncoder,我们只需配置一个该类的Beansecurity就会认为我们返回的UserDetailsgetPassword返回的密码是通过该Bean加密过的(所以在插入用户时要注意调用该Beanencode对密码加密一下在插入数据库)

1
2
3
4
5
6
7
@Configuration
public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter {

@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Component
public class CustomUserDetailsService implements UserDetailsService {

@Autowired
BCryptPasswordEncoder passwordEncoder;

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

@Override
public UserDetails loadUserByUsername(@NotBlank String username) throws UsernameNotFoundException {
logger.info("登录用户名: " + username);
// 实际项目中你可以调用Dao或Repository来查询用户是否存在
if (Objects.equals(username, "admin") == false) {
throw new UsernameNotFoundException("用户名不存在");
}
// 假设查出来的密码如下
String pwd = passwordEncoder.encode("123456");

return new org.springframework.security.core.userdetails.User(
"admin", pwd, AuthorityUtils.commaSeparatedStringToAuthorityList("user,admin")
);
}
}

BCryptPasswordEncoder不一定只能用于密码的加密和校验,日常开发中涉及到加密的功能我们都能使用它的encode方法,也能使用matches方法比对某密文是否是某明文加密后的结果

个性化用户认证流程

自定义登录页面

formLogin()后使用loginPage()就能指定登录的页面,同时要记得将该URL的拦截放开;UsernamePasswordAuthenticationFilter默认拦截提交到/loginPOST请求并获取登录信息,如果你想表单填写的action不为/post,那么可以配置loginProcessingUrl使UsernamePasswordAuthenticationFilter与之对应

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!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登录,发现还是报错如下

1
2
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也可以发出这样的登录请求),我们先禁用它

1
2
3
4
5
6
7
8
9
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消息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
package top.zhenganwen.securitydemo.browser;

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

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

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

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

// security会将跳转前的请求存储在session中
private RequestCache requestCache = new HttpSessionRequestCache();

private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

@RequestMapping("/auth/require")
// 该注解可设置响应状态码
@ResponseStatus(code = HttpStatus.UNAUTHORIZED)
public SimpleResponseResult requireAuthentication(HttpServletRequest request, HttpServletResponse response) throws IOException {

// 从session中取出跳转前用户访问的URL
SavedRequest savedRequest = requestCache.getRequest(request, response);
if (savedRequest != null) {
String redirectUrl = savedRequest.getRedirectUrl();
logger.info("引发跳转到/auth/login的请求是: {}", redirectUrl);
if (StringUtils.endsWithIgnoreCase(redirectUrl, ".html")) {
// 如果用户是访问html页面被FilterSecurityInterceptor拦截从而跳转到了/auth/login,那么就重定向到登录页面
redirectStrategy.sendRedirect(request, response, "/sign-in.html");
}
}

// 如果不是访问html而被拦截跳转到了/auth/login,则返回JSON提示
return new SimpleResponseResult("用户未登录,请引导用户至登录页");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Configuration
public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter {

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

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

image.png

重构——配置代替hardcode

由于我们的security-browser模块是作为可复用模块来开发的,应该支持自定义配置,例如其他应用引入我们的security-browser模块之后,应该能配置他们自己的登录页,如果他们没有配置那就使用我们默认提供的sign-in.html,要想做到这点,我们需要提供一些配置项,例如别人引入我们的security-browser之后通过添加demo.security.browser.loginPage=/login.html就能将他们项目的login.html替换掉我们的sign-in.html

由于后续security-app也可能会需要支持类似的配置,因此我们在security-core中定义一个总的配置类来封装各模块的不同配置项

security-core中的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
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"; //提供一个默认的登录页
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@RestController
public class BrowserSecurityController {

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

@Autowired
private SecurityProperties securityProperties;

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

SavedRequest savedRequest = requestCache.getRequest(request, response);
if (savedRequest != null) {
String redirectUrl = savedRequest.getRedirectUrl();
logger.info("引发跳转到/auth/login的请求是: {}", redirectUrl);
if (StringUtils.endsWithIgnoreCase(redirectUrl, ".html")) {
redirectStrategy.sendRedirect(request, response, securityProperties.getBrowser().getLoginPage());
}
}

return new SimpleResponseResult("用户未登录,请引导用户至登录页");
}
}

将不拦截的登录页URL设置为动态的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Configuration
public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter {

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

@Autowired
private SecurityProperties securityProperties;

@Override
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin()
.loginPage("/auth/require").loginProcessingUrl("/auth/login")
.and()
.authorizeRequests()
.antMatchers("/auth/require").permitAll()
// 将不拦截的登录页URL设置为动态的
.antMatchers(securityProperties.getBrowser().getLoginPage()).permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable();
}
}

现在,我们将security-demo模块当做第三方应用,使用可复用的security-browser

首先,要将security-demo模块的启动类SecurityDemoApplication移到top.zhenganwen.securitydemo包下,确保能够扫描到security-core下的top.zhenganwen.securitydemo.core.SecurityCoreConfigsecurity-browser下的top.zhenganwen.securitydemo.browser.SecurityBrowserConfig

然后,在security-demoapplication.properties中添加配置项demo.security.browser.loginPage=/login.html并在resources下新建resources文件夹和其中的login.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!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接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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中以使之生效(替代默认的登录成功处理逻辑):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@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并登录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
{
authorities: [
{
authority: "admin"
},
{
authority: "user"
}
],
details: {
remoteAddress: "0:0:0:0:0:0:0:1",
sessionId: "3BA37577BAC493D0FE1E07192B5524B1"
},
authenticated: true,
principal: {
password: null,
username: "admin",
authorities: [
{
authority: "admin"
},
{
authority: "user"
}
],
accountNonExpired: true,
accountNonLocked: true,
credentialsNonExpired: true,
enabled: true
},
credentials: null,
name: "admin"
}

可以发现Authentication包含了以下信息

  • authorities,权限,对应UserDetialsgetAuthorities()的返回结果
  • details,回话,客户端的IP以及本次回话的SESSIONID
  • authenticated,是否通过认证
  • principle,对应UserDetailsServiceloadUserByUsername返回的UserDetails
  • credentials,密码,security默认做了处理,不将密码返回给前端
  • name,用户名

这里因为我们是表单登录,所以返回的是以上信息,之后我们做第三方登录如微信、QQ,那么Authentication包含的信息就可能不一样了,也就是说重写的onAuthenticationSuccess方法的入参Authentication会根据登录方式的不同传给我们不同的Authentication实现类对象

自定义登录失败处理——AuthenticationFailureHandler

与登录成功处理对应,自然也可以自定义登录失败处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package top.zhenganwen.securitydemo.browser.config;

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

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

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

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

@Autowired
private ObjectMapper objectMapper;

@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
logger.info("登录失败=>{}", exception.getMessage());
response.setContentType("application/json;charset=utf-8");
response.getWriter().write(objectMapper.writeValueAsString(exception));
response.getWriter().flush();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@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输入错误的密码登录:

1
2
3
4
5
6
7
{
cause: null,
stackTrace: [...],
localizedMessage: "坏的凭证",
message: "坏的凭证",
suppressed: [ ]
}

重构

为了使security-browser成为可复用的模块,我们应该将登录成功/失败处理策略抽离出去,让第三方应用自由选择,这时我们又可以新增一个配置项demo.security.browser.loginProcessType

切换到security-core:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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;
}
}
1
2
3
4
5
@Data
public class BrowserProperties {
private String loginPage = "/sign-in.html";
private LoginProcessTypeEnum loginProcessType = LoginProcessTypeEnum.JSON; //默认返回JSON信息
}

重构登录成功/失败处理器,其中SavedRequestAwareAuthenticationSuccessHandlerSimpleUrlAuthenticationFailureHandler就是security提供的默认的登录成功(跳转到登录之前请求的页面)和登录失败(跳转到异常页)的处理器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package top.zhenganwen.securitydemo.browser.config;

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

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

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

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

@Autowired
private ObjectMapper objectMapper;

@Autowired
private SecurityProperties securityProperties;

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException
, ServletException {
if (securityProperties.getBrowser().getLoginProcessType() == LoginProcessTypeEnum.REDIRECT) {
// 重定向到缓存在session中的登录前请求的URL
super.onAuthenticationSuccess(request, response, authentication);
return;
}
logger.info("用户{}登录成功", authentication.getName());
response.setContentType("application/json;charset=utf-8");
response.getWriter().write(objectMapper.writeValueAsString(authentication));
response.getWriter().flush();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package top.zhenganwen.securitydemo.browser.config;

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

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

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

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

@Autowired
private ObjectMapper objectMapper;

@Autowired
private SecurityProperties securityProperties;

@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
if (securityProperties.getBrowser().getLoginProcessType() == LoginProcessTypeEnum.REDIRECT) {
super.onAuthenticationFailure(request, response, exception);
return;
}
logger.info("登录失败=>{}", exception.getMessage());
response.setContentType("application/json;charset=utf-8");
response.getWriter().write(objectMapper.writeValueAsString(exception));
response.getWriter().flush();
}
}

访问/login.html,分别进行登录成功和登录失败测试,返回JSON响应

security-demo

  • application.properties中添加demo.security.browser.loginProcessType=redirect

  • 新建/resources/resources/index.html

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <!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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8">
    <title>Title</title>
    </head>
    <body>
    <h1>login fail!</h1>
    </body>
    </html>

重启服务,登录成功跳转到index.html,登录失败跳转到401.html

认证流程源码级详解

经过上述两节,我们已经会使用security的一些基础功能了,但都是碎片化的,对整体流程的把握还很模糊。知其然还要知其所以然,我们需要分析在登录时security都帮我们做了哪些事

认证处理流程

image.png

上图是登录处理的大致流程,登录请求的过滤器XxxAutenticationFilter在拦截到登录请求后会见登录信息封装成一个authenticated=falseAuthentication传给AuthenticationManager让帮忙校验,AuthenticationManager本身也不会做校验逻辑,会委托AuthenticationProvider帮忙校验,AuthenticationProvider会在校验过程中抛出校验失败异常或校验通过返回一个新的带有UserDetialsAuthentication返回,请求过滤器收到XxxAuthenticationFilter之后会调用登录成功处理器执行登录成功逻辑

我们以用户名密码表单登录方式来断点调试逐步分析一下校验流程,其他的登录方式也就大同小异了

image.png

image.png

securityloginProcess1.gif

认证结果如何在多个请求之间共享

要想在多个请求之间共享数据,需要借助session,接下来我们看一下security将什么东西放到了session中,又在什么时候会从session读取

上节说道在AbstractAuthenticationProcessingFilter的``doFilter方法中,校验成功之后会调用successfulAuthentication(request, response, chain, authResult)`,我们来看一下这个方法干了些什么

1
2
3
4
5
6
7
8
9
10
11
12
13
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, Authentication authResult)
throws IOException, ServletException {

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

SecurityContextHolder.getContext().setAuthentication(authResult);
...
successHandler.onAuthenticationSuccess(request, response, authResult);
}

可以发现,在调用登录成功处理器的处理逻辑之前,调用了一下SecurityContextHolder.getContext().setAuthentication(authResult),查看可知SecurityContextHolder.getContext()就是获取当前线程绑定的SecurityContext(可以看做是一个线程变量,作用域为线程的生命周期),而SecurityContext其实就是对Authentication的一层包装

1
2
3
4
5
6
public class SecurityContextHolder {
private static SecurityContextHolderStrategy strategy;
public static SecurityContext getContext() {
return strategy.getContext();
}
}
1
2
3
4
5
6
7
8
9
10
11
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;
}
}
1
2
3
4
public interface SecurityContext extends Serializable {
Authentication getAuthentication();
void setAuthentication(Authentication authentication);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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的整个过滤器链的最前端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private SecurityContextRepository repo;
// 请求到达的第一个过滤器
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {

...

HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request,response);
// 从Session中获取SecurityContext,未登录时获取的则是空
SecurityContext contextBeforeChainExecution = repo.loadContext(holder);

try {
// 将SecurityContext保存到当前线程的ThreadLocalMap中
SecurityContextHolder.setContext(contextBeforeChainExecution);
// 执行后续过滤器和Controller方法
chain.doFilter(holder.getRequest(), holder.getResponse());

}
// 在请求响应时经过的最后一个过滤器
finally {
// 从当前线程获取SecurityContext
SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext();
SecurityContextHolder.clearContext();
// 将SecurityContext持久化到Session
repo.saveContext(contextAfterChainExecution, holder.getRequest(),holder.getResponse());
...
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
public class HttpSessionSecurityContextRepository implements SecurityContextRepository {
...
public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
HttpServletRequest request = requestResponseHolder.getRequest();
HttpServletResponse response = requestResponseHolder.getResponse();
HttpSession httpSession = request.getSession(false);

SecurityContext context = readSecurityContextFromSession(httpSession);
...
return context;
}
...
}

image.png

获取认证用户信息

在我们的代码中可以通过静态方法SecurityContextHolder.getContext().getAuthentication来获取用户信息,或者可以直接在Controller入参声明Authenticationsecurity会帮我们自动注入,如果只想获取Authentication中的UserDetails对应的部分,则可使用@AuthenticationPrinciple UserDetails currentUser

1
2
3
4
5
6
7
8
@GetMapping("/info1")
public Object info1() {
return SecurityContextHolder.getContext().getAuthentication();
}
@GetMapping("/info2")
public Object info2(Authentication authentication) {
return authentication;
}

GET /user/info1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
{
authorities: [
{
authority: "admin"
},
{
authority: "user"
}
],
details: {
remoteAddress: "0:0:0:0:0:0:0:1",
sessionId: "24AE70712BB99A969A5C56907C39C20E"
},
authenticated: true,
principal: {
password: null,
username: "admin",
authorities: [
{
authority: "admin"
},
{
authority: "user"
}
],
accountNonExpired: true,
accountNonLocked: true,
credentialsNonExpired: true,
enabled: true
},
credentials: null,
name: "admin"
}
1
2
3
4
@GetMapping("/info3")
public Object info3(@AuthenticationPrincipal UserDetails currentUser) {
return currentUser;
}

GET /user/info3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
password: null,
username: "admin",
authorities: [
{
authority: "admin"
},
{
authority: "user"
}
],
accountNonExpired: true,
accountNonLocked: true,
credentialsNonExpired: true,
enabled: true
}

使用Spring Security开发基于表单的认证

实现图形验证码功能

功能实现

由于图形验证码是通用功能,所以我们将相关逻辑写在security-code

首先,将图形、图形中的验证码、验证码过期时间封装在一起

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package top.zhenganwen.security.core.verifycode.dto;

import lombok.Data;
import java.awt.image.BufferedImage;
import java.time.LocalDateTime;

/**
* @author zhenganwen
* @date 2019/8/24
* @desc ImageCode
*/
@Data
public class ImageCode {
private String code;
private BufferedImage image;
// 验证码过期时间
private LocalDateTime expireTime;

public ImageCode(String code, BufferedImage image, LocalDateTime expireTime) {
this.code = code;
this.image = image;
this.expireTime = expireTime;
}

public ImageCode(String code, BufferedImage image, int durationSeconds) {
this(code, image, LocalDateTime.now().plusSeconds(durationSeconds));
}

public boolean isExpired() {
return LocalDateTime.now().isAfter(expireTime);
}
}

然后提供一个生成验证码的接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
package top.zhenganwen.security.core.verifycode;

import org.springframework.social.connect.web.HttpSessionSessionStrategy;
import org.springframework.social.connect.web.SessionStrategy;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.ServletWebRequest;
import top.zhenganwen.security.core.verifycode.dto.ImageCode;

import javax.imageio.ImageIO;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.Random;

/**
* @author zhenganwen
* @date 2019/8/24
* @desc VerifyCodeController
*/
@RestController
@RequestMapping("/verifyCode")
public class VerifyCodeController {

private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

public static final String SESSION_KEY = "SESSION_KEY_IMAGE_CODE";

/**
* 1.生成图形验证码
* 2.将验证码存到Session中
* 3.将图形响应给前端
*/
@GetMapping("/image")
public void imageCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
ImageCode imageCode = generateImageCode(67, 23, 4);
// Session读写工具类, 第一个参数写法固定
sessionStrategy.setAttribute(new ServletWebRequest(request), SESSION_KEY, imageCode.getCode());
ImageIO.write(imageCode.getImage(), "JPEG", response.getOutputStream());
}

/**
* @param width 图形宽度
* @param height 图形高度
* @param strLength 验证码字符数
* @return
*/
private ImageCode generateImageCode(int width, int height, int strLength) {

BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics g = image.getGraphics();
Random random = new Random();

g.setColor(getRandColor(200, 250));
g.fillRect(0, 0, width, height);
g.setFont(new Font("Times New Roman", Font.ITALIC, 20));
g.setColor(getRandColor(160, 200));
for (int i = 0; i < 155; i++) {
int x = random.nextInt(width);
int y = random.nextInt(height);
int xl = random.nextInt(12);
int yl = random.nextInt(12);
g.drawLine(x, y, x + xl, y + yl);
}

String sRand = "";
for (int i = 0; i < strLength; i++) {
String rand = String.valueOf(random.nextInt(10));
sRand += rand;
g.setColor(new Color(20 + random.nextInt(110), 20 + random.nextInt(110), 20 + random.nextInt(110)));
g.drawString(rand, 13 * i + 6, 16);
}

g.dispose();

return new ImageCode(sRand, image, 60);
}

/**
* 生成随机背景条纹
*
* @param fc
* @param bc
* @return
*/
private Color getRandColor(int fc, int bc) {
Random random = new Random();
if (fc > 255) {
fc = 255;
}
if (bc > 255) {
bc = 255;
}
int r = fc + random.nextInt(bc - fc);
int g = fc + random.nextInt(bc - fc);
int b = fc + random.nextInt(bc - fc);
return new Color(r, g, b);
}
}

security-browser的配置类中将生成验证码的接口权限放开:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin()
.loginPage("/auth/require")
.loginProcessingUrl("/auth/login")
.successHandler(customAuthenticationSuccessHandler)
.failureHandler(customAuthenticationFailureHandler)
.and()
.authorizeRequests()
.antMatchers(
"/auth/require",
securityProperties.getBrowser().getLoginPage(),
"/verifyCode/image").permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable();
}

security-demo中测试验证码的生成,在login.html中添加验证码输入框:

1
2
3
4
5
6
<form action="/auth/login" method="post">
用户名: <input type="text" name="username">
密码: <input type="password" name="password">
验证码:<input type="text" name="verifyCode"><img src="/verifyCode/image" alt="">
<button type="submit">提交</button>
</form>

访问/login.html,验证码生成如下:

image.png

接下来我们编写验证码校验逻辑,由于security并未提供验证码校验对应的过滤器,因此我们需要自定义一个并将其插入到UsernamePasswordFilter之前:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package top.zhenganwen.security.core.verifycode;


import org.springframework.security.core.AuthenticationException;

/**
* @author zhenganwen
* @date 2019/8/24
* @desc VerifyCodeException
*/
public class VerifyCodeException extends AuthenticationException {
public VerifyCodeException(String explanation) {
super(explanation);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
package top.zhenganwen.security.core.verifycode;

import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.social.connect.web.HttpSessionSessionStrategy;
import org.springframework.social.connect.web.SessionStrategy;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.filter.OncePerRequestFilter;
import top.zhenganwen.security.core.verifycode.dto.ImageCode;

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

/**
* @author zhenganwen
* @date 2019/8/24
* @desc VerifyCodeAuthenticationFilter
*/
@Component
// 继承OncePerRequestFilter的过滤器在一次请求中只会被执行一次
public class VerifyCodeAuthenticationFilter extends OncePerRequestFilter {

@Autowired
private AuthenticationFailureHandler customAuthenticationFailureHandler;

private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException,
IOException {
// 如果是登录请求
if (Objects.equals(request.getRequestURI(), "/auth/login") && StringUtils.endsWithIgnoreCase(request.getMethod(), "POST")) {
try {
this.validateVerifyCode(new ServletWebRequest(request));
} catch (VerifyCodeException e) {
// 若抛出异常则使用自定义认证失败处理器处理一下,否则没人捕获(因为该过滤器配在了UsernamePasswordAuthenticationFilter的前面)
customAuthenticationFailureHandler.onAuthenticationFailure(request, response, e);
}
}
filterChain.doFilter(request, response);
}

// 从Session中读取验证码和用户提交的验证码进行比对
private void validateVerifyCode(ServletWebRequest request) {
String verifyCode = (String) request.getParameter("verifyCode");
if (StringUtils.isBlank(verifyCode)) {
throw new VerifyCodeException("验证码不能为空");
}
ImageCode imageCode = (ImageCode) sessionStrategy.getAttribute(request, VerifyCodeController.SESSION_KEY);
if (imageCode == null) {
throw new VerifyCodeException("验证码不存在");
}
if (imageCode.isExpired()) {
throw new VerifyCodeException("验证码已过期,请刷新页面");
}
if (StringUtils.equals(verifyCode,imageCode.getCode()) == false) {
throw new VerifyCodeException("验证码错误");
}
// 登录成功,移除Session中保存的验证码
sessionStrategy.removeAttribute(request, VerifyCodeController.SESSION_KEY);
}
}

security-browser

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.addFilterBefore(verifyCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.formLogin()
.loginPage("/auth/require")
.loginProcessingUrl("/auth/login")
.successHandler(customAuthenticationSuccessHandler)
.failureHandler(customAuthenticationFailureHandler)
.and()
.authorizeRequests()
.antMatchers(
"/auth/require",
securityProperties.getBrowser().getLoginPage(),
"/verifyCode/image").permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable();
}

访问/login.html什么都不填直接登录,返回的JSON如下

1
{"cause":null,"stackTrace":[...],"localizedMessage":"验证码不能为空","message":"验证码不能为空","suppressed":[]}{"cause":null,"stackTrace":[...],"localizedMessage":"坏的凭证","message":"坏的凭证","suppressed":[]}

发现连着返回了两个exception的JSON串,且是一前以后返回的(两个JSON串是连着的,中间没有任何符号),这是因为我们在VerifyCodeAuthenticationFilter中调用customAuthenticationFailureHandler进行认证失败处理之后,接着执行了doFilter,而后的UsernamePasswordAuthenticationFilter也会拦截登录请求/auth/login,在校验的过程中捕获到BadCredentialsException,又调用customAuthenticationFailureHandler返回了一个exceptionJSON串

这里有两点需要优化

  • 返回的异常信息不应该包含堆栈

    CustomAuthenticationFailureHandler中返回从exception中提取的异常信息,而不要直接返回exception

    1
    2
    //        response.getWriter().write(objectMapper.writeValueAsString(exception));
    response.getWriter().write(objectMapper.writeValueAsString(new SimpleResponseResult(exception.getMessage())));
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14

    - 在`VerifyCodeAuthenticationFilter`发现认证失败异常并调用认证失败处理器处理后,应该`return`一下,没有必要再走后续的过滤器了

    ```java
    if (Objects.equals(request.getRequestURI(), "/auth/login") && StringUtils.endsWithIgnoreCase(request.getMethod(), "POST")) {
    try {
    this.validateVerifyCode(new ServletWebRequest(request));
    } catch (VerifyCodeException e) {
    // 若抛出异常则使用自定义认证失败处理器处理一下,否则没人捕获(因为该过滤器配在了UsernamePasswordAuthenticationFilter的前面)
    customAuthenticationFailureHandler.onAuthenticationFailure(request, response, e);
    return;
    }
    }
    filterChain.doFilter(request, response);

重新测试

1
2
3
{
content: "验证码不能为空"
}

接着测试验证码,填入admin,123456和图形验证码后登陆,登陆成功,认证成功处理器返回Authentication

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
{
authorities: [
{
authority: "admin"
},
{
authority: "user"
}
],
details: {
remoteAddress: "0:0:0:0:0:0:0:1",
sessionId: "452F44596C9D9FF55DBA91A1F24E05B0"
},
authenticated: true,
principal: {
password: null,
username: "admin",
authorities: [
{
authority: "admin"
},
{
authority: "user"
}
],
accountNonExpired: true,
accountNonLocked: true,
credentialsNonExpired: true,
enabled: true
},
credentials: null,
name: "admin"
}

重构图形验证码功能

至此,图形验证码的功能我们已经基本实现完了,但是作为高级工程师我们不应该满足于此,在实现功能之余还应该想想如何重构代码使该功能可重用,当别人需要不同尺寸、不同数量验证字符、不同验证逻辑时,也能够复用我们的代码

图形验证码基本参数可配置

如图形的长宽像素、验证码字符数、验证码有效期持续时间

一般系统的配置生效机制如下,我们作为被依赖的模块需要提供一个常用的默认配置,依赖我们的应用可以自己添加配置项来覆盖这个默认配置,最后在应用运行时还可以通过在请求中附带参数来动态切换配置

image.png

security-core添加配置类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package top.zhenganwen.security.core.properties;

import lombok.Data;

/**
* @author zhenganwen
* @date 2019/8/25
* @desc ImageCodeProperties
*/
@Data
public class ImageCodeProperties {
private int width=67;
private int height=23;
private int strLength=4;
private int durationSeconds = 60;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
package top.zhenganwen.security.core.properties;

import lombok.Data;

/**
* @author zhenganwen
* @date 2019/8/25
* @desc VerifyCodeProperties 封装图形验证码和短信验证码
*/
@Data
public class VerifyCodeProperties {
private ImageCodeProperties image = new ImageCodeProperties();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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();
private VerifyCodeProperties code = new VerifyCodeProperties();
}

在生成验证接口中,将对应参数改为动态读取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
package top.zhenganwen.security.core.verifycode;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.social.connect.web.HttpSessionSessionStrategy;
import org.springframework.social.connect.web.SessionStrategy;
import org.springframework.web.bind.ServletRequestUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.ServletWebRequest;
import top.zhenganwen.security.core.properties.SecurityProperties;
import top.zhenganwen.security.core.verifycode.dto.ImageCode;

import javax.imageio.ImageIO;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.Random;

/**
* @author zhenganwen
* @date 2019/8/24
* @desc VerifyCodeController
*/
@RestController
@RequestMapping("/verifyCode")
public class VerifyCodeController {

private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

public static final String SESSION_KEY = "SESSION_KEY_IMAGE_CODE";

@Autowired
private SecurityProperties securityProperties;

/**
* 1.生成图形验证码
* 2.将验证码存到session中
* 3.将图形响应给前端
*/
@GetMapping("/image")
public void imageCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
// 首先读取URL参数中的width/height,如果没有则使用配置文件中的
int width = ServletRequestUtils.getIntParameter(request, "width", securityProperties.getCode().getImage().getWidth());
int height = ServletRequestUtils.getIntParameter(request, "height", securityProperties.getCode().getImage().getHeight());

ImageCode imageCode = generateImageCode(width, height, securityProperties.getCode().getImage().getStrLength());
// Session读写工具类, 第一个参数写法固定
sessionStrategy.setAttribute(new ServletWebRequest(request), SESSION_KEY, imageCode);
ImageIO.write(imageCode.getImage(), "JPEG", response.getOutputStream());
}

/**
* @param width 图形宽度
* @param height 图形高度
* @param strLength 验证码字符数
* @return
*/
private ImageCode generateImageCode(int width, int height, int strLength) {

BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics g = image.getGraphics();
Random random = new Random();

g.setColor(getRandColor(200, 250));
g.fillRect(0, 0, width, height);
g.setFont(new Font("Times New Roman", Font.ITALIC, 20));
g.setColor(getRandColor(160, 200));
for (int i = 0; i < 155; i++) {
int x = random.nextInt(width);
int y = random.nextInt(height);
int xl = random.nextInt(12);
int yl = random.nextInt(12);
g.drawLine(x, y, x + xl, y + yl);
}

String sRand = "";
for (int i = 0; i < strLength; i++) {
String rand = String.valueOf(random.nextInt(10));
sRand += rand;
g.setColor(new Color(20 + random.nextInt(110), 20 + random.nextInt(110), 20 + random.nextInt(110)));
g.drawString(rand, 13 * i + 6, 16);
}

g.dispose();

return new ImageCode(sRand, image, securityProperties.getCode().getImage().getDurationSeconds());
}

}

测试应用级配置验证码字符数覆盖默认的,在security-demoapplication.properties中添加配置项

1
demo.security.code.image.strLength=6

测试请求参数级配置覆盖应用级配置

1
demo.security.code.image.width=100
1
验证码:<input type="text" name="verifyCode"><img src="/verifyCode/image?width=200" alt="">

访问/login.html,发现图形宽度200,验证码字符数为6,测试成功

验证码认证过滤器拦截的接口可配

现在我们的VerifyCodeFilter仅拦截登录请求并进行验证码校验,可能别的接口也需要验证码才能调用(也许是为了非法重复请求),那么这时我们需要支持应用能够动态地配置需要进行验证码校验的接口,例如

1
demo.security.code.image.url=/user,/user/*

表示请求/user/user/*之前都需要进行验证码校验

于是我们新增一个可配置拦截URI的属性

1
2
3
4
5
6
7
8
9
@Data
public class ImageCodeProperties {
private int width=67;
private int height=23;
private int strLength=4;
private int durationSeconds = 60;
// 需要拦截的URI列表,多个URI以逗号分隔
private String uriPatterns;
}

然后在VerifyCodeAuthenticationFilter读取配置文件中的demo.security.code.image.uriPatterns并初始化一个uriPatternSet集合,在拦截逻辑里遍历集合并将拦截的URI与集合元素进行模式匹配,如果有一个匹配上则说明该URI需要检验验证码,验证失败则抛出异常留给认证失败处理器处理,校验成功则跳出遍历循环直接放行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@Component
public class VerifyCodeAuthenticationFilter extends OncePerRequestFilter implements InitializingBean {

@Autowired
private AuthenticationFailureHandler customAuthenticationFailureHandler;

private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

@Autowired
private SecurityProperties securityProperties;

private Set<String> uriPatternSet = new HashSet<>();

// uri匹配工具类,帮我们做类似/user/1到/user/*的匹配
private AntPathMatcher antPathMatcher = new AntPathMatcher();

@Override
public void afterPropertiesSet() throws ServletException {
super.afterPropertiesSet();
String uriPatterns = securityProperties.getCode().getImage().getUriPatterns();
if (StringUtils.isNotBlank(uriPatterns)) {
String[] strings = StringUtils.splitByWholeSeparatorPreserveAllTokens(uriPatterns, ",");
uriPatternSet.addAll(Arrays.asList(strings));
}
uriPatternSet.add("/auth/login");
}

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException,
IOException {
for (String uriPattern : uriPatternSet) {
if (antPathMatcher.match(uriPattern, request.getRequestURI())) {
try {
this.validateVerifyCode(new ServletWebRequest(request));
} catch (VerifyCodeException e) {
// 若抛出异常则使用自定义认证失败处理器处理一下,否则没人捕获(因为该过滤器配在了UsernamePasswordAuthenticationFilter的前面)就抛给前端了
customAuthenticationFailureHandler.onAuthenticationFailure(request, response, e);
return;
}
break;
}
}
filterChain.doFilter(request, response);
}

private void validateVerifyCode(ServletWebRequest request) {...}
}

我们将uriPatternSet的初始化逻辑写在了InitializingBean接口的afterPropertiesSet方法中,这相当于在传统的spring.xml中配置了一个init-method标签,该方法会在VerifyCodeAuthenticationFilter的所有autowire属性被赋值后由spring执行

访问/user/user/1均被提示验证码不能为空,修改配置项为uriPattern=/user/*重启后登录/login.html再访问/user没被拦截,而访问/user/1提示验证码不能为空,测试成功

图形验证码生成逻辑可配——以增量的方式适应变化

现在我们的图形验证码的样式是固定的,只能生成数字验证码,别人要想换一个样式或生成字母、汉子验证码似乎无能为力。他在想,如果他能够像使用Spring一样实现一个接口返回自定义的ImageCode来使用自己的验证码生成逻辑那该多好

Spring提供的这种你实现一个接口就能替代Spring原有实现的思想一种很常用设计模式,在需要扩展功能的时候无需更改原有代码,而只需添加一个实现类,以增量的方式适应变化

首先我们将生成图形验证码的逻辑抽象成接口

1
2
3
4
5
6
7
8
9
10
11
12
13
package top.zhenganwen.security.core.verifycode;

import top.zhenganwen.security.core.verifycode.dto.ImageCode;

/**
* @author zhenganwen
* @date 2019/8/25
* @desc ImageCodeGenerator 图形验证码生成器接口
*/
public interface ImageCodeGenerator {

ImageCode generateImageCode(int width, int height, int strLength, int durationSeconds);
}

然后将之前写在Controller中的生成图形验证码的方法作为该接口的默认实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
package top.zhenganwen.security.core.verifycode;

import top.zhenganwen.security.core.verifycode.dto.ImageCode;

import java.awt.*;
import java.awt.image.BufferedImage;
import java.util.Random;

/**
* @author zhenganwen
* @date 2019/8/25
* @desc DefaultImageCodeGenerator
*/
public class DefaultImageCodeGenerator implements ImageCodeGenerator {

@Override
public ImageCode generateImageCode(int width, int height, int strLength, int durationSeconds) {
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics g = image.getGraphics();
Random random = new Random();

g.setColor(getRandColor(200, 250));
g.fillRect(0, 0, width, height);
g.setFont(new Font("Times New Roman", Font.ITALIC, 20));
g.setColor(getRandColor(160, 200));
for (int i = 0; i < 155; i++) {
int x = random.nextInt(width);
int y = random.nextInt(height);
int xl = random.nextInt(12);
int yl = random.nextInt(12);
g.drawLine(x, y, x + xl, y + yl);
}

String sRand = "";
for (int i = 0; i < strLength; i++) {
String rand = String.valueOf(random.nextInt(10));
sRand += rand;
g.setColor(new Color(20 + random.nextInt(110), 20 + random.nextInt(110), 20 + random.nextInt(110)));
g.drawString(rand, 13 * i + 6, 16);
}

g.dispose();

return new ImageCode(sRand, image, durationSeconds);
}

/**
* 生成随机背景条纹
*
* @param fc
* @param bc
* @return
*/
private Color getRandColor(int fc, int bc) {
Random random = new Random();
if (fc > 255) {
fc = 255;
}
if (bc > 255) {
bc = 255;
}
int r = fc + random.nextInt(bc - fc);
int g = fc + random.nextInt(bc - fc);
int b = fc + random.nextInt(bc - fc);
return new Color(r, g, b);
}
}

然后将该默认实现注入到容器中,注意@ConditionOnMissingBean是实现该模式的重点注解,标注了该注解的bean会在所有未标注@ConditionOnMissingBeanbean都被实例化注入到容器中后,判断容器中是否存在id为imageCodeGeneratorbean,如果不存在才会进行实例化并作为id为imageCodeGeneratorbean被使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package top.zhenganwen.security.core;

import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import top.zhenganwen.security.core.properties.SecurityProperties;
import top.zhenganwen.security.core.verifycode.DefaultImageCodeGenerator;
import top.zhenganwen.security.core.verifycode.ImageCodeGenerator;

/**
* @author zhenganwen
* @date 2019/8/23
* @desc SecurityCoreConfig
*/
@Configuration
@EnableConfigurationProperties(SecurityProperties.class)
public class SecurityCoreConfig {

@Bean
@ConditionalOnMissingBean(name = "imageCodeGenerator")
public ImageCodeGenerator imageCodeGenerator() {
ImageCodeGenerator imageCodeGenerator = new DefaultImageCodeGenerator();
return imageCodeGenerator;
}
}

验证码生成接口改为依赖验证码生成器接口来生成验证码(面向抽象编程以适应变化):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@RestController
@RequestMapping("/verifyCode")
public class VerifyCodeController {

private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

public static final String SESSION_KEY = "SESSION_KEY_IMAGE_CODE";

@Autowired
private SecurityProperties securityProperties;

@Autowired
private ImageCodeGenerator imageCodeGenerator;

/**
* 1.生成图形验证码
* 2.将验证码存到session中
* 3.将图形响应给前端
*/
@GetMapping("/image")
public void imageCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
// 首先读取URL参数中的width/height,如果没有则使用配置文件中的
int width = ServletRequestUtils.getIntParameter(request, "width", securityProperties.getCode().getImage().getWidth());
int height = ServletRequestUtils.getIntParameter(request, "height", securityProperties.getCode().getImage().getHeight());

ImageCode imageCode = imageCodeGenerator.generateImageCode(width, height,
securityProperties.getCode().getImage().getStrLength(),
securityProperties.getCode().getImage().getDurationSeconds());
// Session读写工具类, 第一个参数写法固定
sessionStrategy.setAttribute(new ServletWebRequest(request), SESSION_KEY, imageCode);
ImageIO.write(imageCode.getImage(), "JPEG", response.getOutputStream());
}

}

重启服务并登录以确保重构后并未改变代码的功能性

最后,我们在security-demo中新增一个自定义的图形验证码生成器来替换默认的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package top.zhenganwen.securitydemo.security;

import top.zhenganwen.security.core.verifycode.ImageCodeGenerator;
import top.zhenganwen.security.core.verifycode.dto.ImageCode;

/**
* @author zhenganwen
* @date 2019/8/25
* @desc CustomImageCodeGenerator
*/
@Component("imageCodeGenerator")
public class CustomImageCodeGenerator implements ImageCodeGenerator {
@Override
public ImageCode generateImageCode(int width, int height, int strLength, int durationSeconds) {
System.out.println("调用自定义的代码生成器");
return null;
}
}

这里我们简单的打印一下日志返回一个null,这样login.html调用图形验证码生成器接口生成图形验证码时如果走的是我们这个自定义的图形验证码生成器就会抛出异常。注意@Componentvalue属性要和@ConditionOnMissingBeanname属性一致才能实现替换

实现记住我功能

需求

有时用户希望在填写登录表单时勾选一个“记住我”选框,在登陆后的一段时间内可以无需登录即可访问受保护的URL

securityrememberMe.gif

实现

本节,我们就来实现以下该功能:

  1. 首先页面需要一个“记住我”选框,选框的name属性需为remember-me(可自定义配置),value属性为true

    1
    2
    3
    4
    5
    6
    7
    <form action="/auth/login" method="post">
    用户名: <input type="text" name="username">
    密码: <input type="password" name="password">
    验证码:<input type="text" name="verifyCode"><img src="/verifyCode/image?width=200" alt="">
    <input type="checkbox" name="remember-me" value="true">记住我
    <button type="submit">提交</button>
    </form>
  2. 在数据源对应的数据库中创建一张表persistent_logins,表创建语句在JdbcTokenRepositoryImpl的变量CREATE_TABLE_SQL

    1
    2
    create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, "
    + "token varchar(64) not null, last_used timestamp not null)
  3. seurity配置类中增加“记住我”的相关配置,这里因为Cookie受限于浏览器,所有我们配在security-browser模块中,如下rememberMe()部分

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    @Autowired
    private DataSource dataSource;

    @Autowired
    private UserDetailsService userDetailsService;

    @Bean
    public PersistentTokenRepository persistentTokenRepository() {
    JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
    jdbcTokenRepository.setDataSource(dataSource);
    return jdbcTokenRepository;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
    http
    .addFilterBefore(verifyCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
    .formLogin()
    .loginPage("/auth/require")
    .loginProcessingUrl("/auth/login")
    .successHandler(customAuthenticationSuccessHandler)
    .failureHandler(customAuthenticationFailureHandler)
    .and()
    .rememberMe()
    .tokenRepository(persistentTokenRepository())
    .tokenValiditySeconds(3600)
    .userDetailsService(userDetailsService)
    // 可配置页面选框的name属性
    // .rememberMeParameter()
    .and()
    .authorizeRequests()
    .antMatchers(
    "/auth/require",
    securityProperties.getBrowser().getLoginPage(),
    "/verifyCode/image").permitAll()
    .anyRequest().authenticated()
    .and()
    .csrf().disable();
    }
  4. 测试

    未登录访问/user提示需要登录,登录/login.html后访问/user可访问成功,查看数据库表persistent_logins,发现新增了一条记录。关闭服务模拟Session关闭(因为Session是保存服务端的,关闭服务端比关闭浏览器更能保证Session关闭)。重启服务,未登录访问受保护的/user,发现可以直接访问

源码分析

首次登陆序列图

上图是开启了“记住我”功能后,用户首次登录的序列图,在AbstractAuthenticationProcessingFilter中校验用户名密码成功之后在方法的末尾会调用successfulAuthentication,查看其源码(部分省略):

1
2
3
4
5
6
7
8
9
10
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, Authentication authResult)
throws IOException, ServletException {

SecurityContextHolder.getContext().setAuthentication(authResult);

rememberMeServices.loginSuccess(request, response, authResult);

successHandler.onAuthenticationSuccess(request, response, authResult);
}

发现在successHandler.onAuthenticationSuccess()调用认证成功处理器之前,还执行了rememberMeServices.loginSuccess,这个方法就是用来向数据库插入一条username-token记录并将token写入Cookie的,具体逻辑在PersistentTokenBasedRememberMeServices#onLoginSuccess()

1
2
3
4
5
6
7
8
9
10
11
12
13
protected void onLoginSuccess(HttpServletRequest request,
HttpServletResponse response, Authentication successfulAuthentication) {
String username = successfulAuthentication.getName();

PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(
username, generateSeriesData(), generateTokenData(), new Date());
try {
tokenRepository.createNewToken(persistentToken);
addCookie(persistentToken, request, response);
}catch (Exception e) {
logger.error("Failed to save persistent token ", e);
}
}

在我们设置的tokenValiditySeconds期间,若用户未登录但从同一浏览器访问受保护服务,RememberMeAuthenticationFilter会拦截到请求:

1
2
3
4
5
6
7
8
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {

if (SecurityContextHolder.getContext().getAuthentication() == null) {
Authentication rememberMeAuth = rememberMeServices.autoLogin(request,
response);
...
}

会调用autoLogin()尝试从Cookie中读取token并从持久层查询username-token,如果查到了再根据username调用UserDetailsService查找用户,查找到了生成新的认证成功的Authentication保存到当前线程保险箱中:

AbstractRememberMeServices#autoLogin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public final Authentication autoLogin(HttpServletRequest request,
HttpServletResponse response) {
String rememberMeCookie = extractRememberMeCookie(request);

if (rememberMeCookie == null) {
return null;
}

if (rememberMeCookie.length() == 0) {
logger.debug("Cookie was empty");
cancelCookie(request, response);
return null;
}

UserDetails user = null;

try {
String[] cookieTokens = decodeCookie(rememberMeCookie);
user = processAutoLoginCookie(cookieTokens, request, response);
userDetailsChecker.check(user);

return createSuccessfulAuthentication(request, user);
}
...
}

PersistentTokenBasedRememberMeServices

1
2
3
4
5
6
7
8
9
10
11
protected UserDetails processAutoLoginCookie(String[] cookieTokens,
HttpServletRequest request, HttpServletResponse response) {

final String presentedSeries = cookieTokens[0];
final String presentedToken = cookieTokens[1];

PersistentRememberMeToken token = tokenRepository
.getTokenForSeries(presentedSeries);

return getUserDetailsService().loadUserByUsername(token.getUsername());
}

短信验证码登录

之前我们使用的都是传统的用户名密码的登录方式,随着短信验证码登录、第三方应用如QQ登录的流行,传统的登录方式已无法满足我们的需求了

用户名密码认证流程是已经固化在security框架中了,我们只能编写一些实现接口扩展部分细节,而对于大体的流程是无法改变的。因此要想实现短信验证码登录,我们需要自定义一套登录流程

短信验证码发送接口

要想实现短信验证码功能首先我们需要提供此接口,前端可以通过调用此接口传入手机号进行短信验证码的发送。如下,在浏览器的登录页通过点击事件发送验证码,本来应该通过AJAX异步调用发送接口,这里为了方便演示使用超链接进行同步调用,也是为了方便演示这里将手机号写死了而没有通过js动态获取用户输入的手机号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<form action="/auth/login" method="post">
用户名: <input type="text" name="username">
密码: <input type="password" name="password">
验证码:<input type="text" name="verifyCode"><img src="/verifyCode/image?width=200" alt="">
<input type="checkbox" name="remember-me" value="true">记住我
<button type="submit">提交</button>
</form>
<hr/>
<form action="/auth/sms" method="post">
手机号: <input type="text" name="phoneNumber" value="12345678912">
验证码: <input type="text"><a href="/verifyCode/sms?phoneNumber=12345678912">点击发送</a>
<input type="checkbox" name="remember-me" value="true">记住我
<button type="submit">提交</button>
</form>

重构PO

后端security-core首先要新建一个类封装短信验证码的相关属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package top.zhenganwen.security.core.verifycode.dto;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class SmsCode {
protected String code;
protected LocalDateTime expireTime;
public boolean isExpired() {
return LocalDateTime.now().isAfter(expireTime);
}
}

这里由于之前的ImageCode也有这两个属性,因此将SmsCode重命名为VerifyCodeImageCode继承以复用代码

1
2
3
4
5
6
7
8
9
10
@Data
@AllArgsConstructor
@NoArgsConstructor
public class VerifyCode {
protected String code;
protected LocalDateTime expireTime;
public boolean isExpired() {
return LocalDateTime.now().isAfter(expireTime);
}
}
1
2
3
4
5
6
7
8
9
10
11
@Data
public class ImageCode extends VerifyCode{
private BufferedImage image;
public ImageCode(String code, BufferedImage image, LocalDateTime expireTime) {
super(code,expireTime);
this.image = image;
}
public ImageCode(String code, BufferedImage image, int durationSeconds) {
this(code, image, LocalDateTime.now().plusSeconds(durationSeconds));
}
}

重构验证码生成器

接下来我们需要一个短信验证码生成器,不像图形验证码生成器那样复杂。前者的生成逻辑就是生成一串随机的纯数字串,不像后者那样有图形长宽、颜色、背景、边框等,因此前者可以直接标注为@Component而无需考虑ConditionOnMissingBean,重构验证码生成器类结构:

image.png

1
2
3
4
5
6
7
8
9
10
11
12
package top.zhenganwen.security.core.verifycode.generator;

import top.zhenganwen.security.core.verifycode.dto.VerifyCode;

public interface VerifyCodeGenerator<T extends VerifyCode> {

/**
* 生成验证码
* @return
*/
T generateVerifyCode();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package top.zhenganwen.security.core.verifycode.generator;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.ServletRequestUtils;
import top.zhenganwen.security.core.properties.SecurityProperties;
import top.zhenganwen.security.core.verifycode.dto.ImageCode;

import javax.servlet.http.HttpServletRequest;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.util.Random;

public class DefaultImageCodeGenerator implements VerifyCodeGenerator<ImageCode> {

@Autowired
private SecurityProperties securityProperties;

@Autowired
HttpServletRequest request;

@Override
public ImageCode generateVerifyCode() {

int width = ServletRequestUtils.getIntParameter(request, "width", securityProperties.getCode().getImage().getWidth());
int height = ServletRequestUtils.getIntParameter(request, "height", securityProperties.getCode().getImage().getHeight());
int strLength = securityProperties.getCode().getImage().getStrLength();
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics g = image.getGraphics();
Random random = new Random();

g.setColor(getRandColor(200, 250));
g.fillRect(0, 0, width, height);
g.setFont(new Font("Times New Roman", Font.ITALIC, 20));
g.setColor(getRandColor(160, 200));
for (int i = 0; i < 155; i++) {
int x = random.nextInt(width);
int y = random.nextInt(height);
int xl = random.nextInt(12);
int yl = random.nextInt(12);
g.drawLine(x, y, x + xl, y + yl);
}

String sRand = "";
for (int i = 0; i < strLength; i++) {
String rand = String.valueOf(random.nextInt(10));
sRand += rand;
g.setColor(new Color(20 + random.nextInt(110), 20 + random.nextInt(110), 20 + random.nextInt(110)));
g.drawString(rand, 13 * i + 6, 16);
}

g.dispose();

return new ImageCode(sRand, image, securityProperties.getCode().getImage().getDurationSeconds());
}

...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
package top.zhenganwen.securitydemo.security;

import top.zhenganwen.security.core.verifycode.generator.VerifyCodeGenerator;
import top.zhenganwen.security.core.verifycode.dto.ImageCode;

//@Component
public class CustomImageCodeGenerator implements VerifyCodeGenerator<ImageCode> {
@Override
public ImageCode generateVerifyCode() {
System.out.println("调用自定义的代码生成器");
return null;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package top.zhenganwen.security.core.verifycode.generator;

import org.apache.commons.lang.RandomStringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import top.zhenganwen.security.core.properties.SecurityProperties;
import top.zhenganwen.security.core.verifycode.dto.VerifyCode;

import java.time.LocalDateTime;


@Component("smsCodeGenerator")
public class SmsCodeGenerator implements VerifyCodeGenerator<VerifyCode> {

@Autowired
private SecurityProperties securityProperties;

@Override
public VerifyCode generateVerifyCode() {
// 随机生成一串纯数字字符串,数字个数为 strLength
String randomCode = RandomStringUtils.randomNumeric(securityProperties.getCode().getSms().getStrLength());
return new VerifyCode(randomCode, LocalDateTime.now().plusSeconds(securityProperties.getCode().getSms().getDurationSeconds()));
}

}

短信验证码发送器

生成短信验证码之后我们需要将其保存在Session中并调用短信服务提供商的接口将短信发送出去,由于将来依赖我们的应用可能会配置不同的短信服务提供商接口,为了保证代码的可扩展性我们需要将短信发送这一行为抽象成接口并提供一个默认可被覆盖的实现,这样依赖我们的应用就可以通过注入一个新的实现来启用它们的短信发送逻辑

1
2
3
4
5
6
7
8
9
10
package top.zhenganwen.security.core.verifycode;

public interface SmsCodeSender {
/**
* 根据手机号发送短信验证码
* @param smsCode
* @param phoneNumber
*/
void send(String smsCode, String phoneNumber);
}
1
2
3
4
5
6
7
8
9
package top.zhenganwen.security.core.verifycode;

public class DefaultSmsCodeSender implements SmsCodeSender {
@Override
public void send(String smsCode, String phoneNumber) {
// 这里只是简单的打印一下,实际应该调用短信服务提供商向手机号发送短信验证码
System.out.printf("向手机号%s发送短信验证码%s", phoneNumber, smsCode);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package top.zhenganwen.security.core;

import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import top.zhenganwen.security.core.properties.SecurityProperties;
import top.zhenganwen.security.core.verifycode.DefaultImageCodeGenerator;
import top.zhenganwen.security.core.verifycode.DefaultSmsCodeSender;
import top.zhenganwen.security.core.verifycode.ImageCodeGenerator;
import top.zhenganwen.security.core.verifycode.SmsCodeSender;

@Configuration
@EnableConfigurationProperties(SecurityProperties.class)
public class SecurityCoreConfig {

@Bean
@ConditionalOnMissingBean(name = "imageCodeGenerator")
public ImageCodeGenerator imageCodeGenerator() {
ImageCodeGenerator imageCodeGenerator = new DefaultImageCodeGenerator();
return imageCodeGenerator;
}

@Bean
@ConditionalOnMissingBean(name = "smsCodeSender")
public SmsCodeSender smsCodeSender() {
return new DefaultSmsCodeSender();
}
}

重构配置类

1
2
3
4
5
6
7
8
9
10
11
package top.zhenganwen.security.core.properties;

import lombok.Data;

@Data
public class SmsCodeProperties {
// 短信验证码数字个数,默认4个数字
private int strLength = 4;
// 有效时间,默认60秒
private int durationSeconds = 60;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package top.zhenganwen.security.core.properties;

import lombok.Data;

@Data
public class ImageCodeProperties extends SmsCodeProperties{
private int width=67;
private int height=23;
private String uriPatterns;

public ImageCodeProperties() {
// 图形验证码默认显示6个字符
this.setStrLength(6);
// 图形验证码过期时间默认为3分钟
this.setDurationSeconds(180);
}
}
1
2
3
4
5
6
7
8
9
package top.zhenganwen.security.core.properties;

import lombok.Data;

@Data
public class VerifyCodeProperties {
private ImageCodeProperties image = new ImageCodeProperties();
private SmsCodeProperties sms = new SmsCodeProperties();
}

发送短信验证码接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@RestController
@RequestMapping("/verifyCode")
public class VerifyCodeController {

private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

public static final String IMAGE_CODE_SESSION_KEY = "SESSION_KEY_IMAGE_CODE";

public static final String SMS_CODE_SESSION_KEY = "SESSION_KEY_SMS_CODE";

@Autowired
private VerifyCodeGenerator<ImageCode> imageCodeGenerator;

@Autowired
private VerifyCodeGenerator<VerifyCode> smsCodeGenerator;

@Autowired
private SmsCodeSender smsCodeSender;

/**
* 1.生成图形验证码
* 2.将验证码存到session中
* 3.将图形响应给前端
*/
@GetMapping("/image")
public void imageCode(HttpServletRequest request, HttpServletResponse response) throws IOException {

ImageCode imageCode = imageCodeGenerator.generateVerifyCode();
sessionStrategy.setAttribute(new ServletWebRequest(request), IMAGE_CODE_SESSION_KEY, imageCode);
ImageIO.write(imageCode.getImage(), "JPEG", response.getOutputStream());
}

/**
* 1.生成短信验证码
* 2.将验证码存到session中
* 3.调用短信验证码发送器发送短信
*/
@GetMapping("/sms")
public void smsCode(HttpServletRequest request, HttpServletResponse response) throws ServletRequestBindingException {
long phoneNumber = ServletRequestUtils.getRequiredLongParameter(request, "phoneNumber");
VerifyCode verifyCode = smsCodeGenerator.generateVerifyCode();
sessionStrategy.setAttribute(new ServletWebRequest(request), SMS_CODE_SESSION_KEY, verifyCode);
smsCodeSender.send(verifyCode.getCode(), String.valueOf(phoneNumber));
}

}

测试

security-browser中,我们将新增的接口/verifyCode/sms的访问权限放开:

1
2
3
4
5
6
.authorizeRequests()
.antMatchers(
"/auth/require",
securityProperties.getBrowser().getLoginPage(),
"/verifyCode/**").permitAll()
.anyRequest().authenticated()

访问/login.html,点击点击发送超链接,后台输出如下:

1
向手机号12345678912发送短信验证码1220

重构——模板方法 & 依赖查找

现在我们的VerifyCodeController中的两个方法imageCodesmsCode的主干流程是一致的:

  1. 生成验证码
  2. 保存验证码,如保存到Session中、redis中等等
  3. 发送验证码给用户

这种情况下,我们可以应用模板方法设计模式(可看考我的另一篇文章《图解设计模式》),重构后的类图如下所示:

image.png

image.png

常量类

1
2
3
4
5
6
7
8
9
10
11
public class VerifyCodeConstant {
public static final String IMAGE_CODE_SESSION_KEY = "SESSION_KEY_IMAGE_CODE";

public static final String SMS_CODE_SESSION_KEY = "SESSION_KEY_SMS_CODE";

public static final String VERIFY_CODE_PROCESSOR_IMPL_SUFFIX = "CodeProcessorImpl";

public static final String VERIFY_CODE_Generator_IMPL_SUFFIX = "CodeGenerator";

public static final String PHONE_NUMBER_PARAMETER_NAME = "phoneNumber";
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public enum VerifyCodeTypeEnum {

IMAGE("image"),SMS("sms");

private String type;

public String getType() {
return type;
}

VerifyCodeTypeEnum(String type) {
this.type = type;
}
}

验证码发送处理器——模板方法 & 接口隔离 & 依赖查找

1
2
3
4
5
6
7
8
9
10
public interface VerifyCodeProcessor {
/**
* 发送验证码逻辑
* 1. 生成验证码
* 2. 保存验证码
* 3. 发送验证码
* @param request 封装request和response的工具类,用它我们就不用每次传{@link javax.servlet.http.HttpServletRequest}和{@link javax.servlet.http.HttpServletResponse}了
*/
void sendVerifyCode(ServletWebRequest request);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public abstract class AbstractVerifyCodeProcessor<T extends VerifyCode> implements VerifyCodeProcessor {

@Override
public void sendVerifyCode(ServletWebRequest request) {
T verifyCode = generateVerifyCode(request);
save(request, verifyCode);
send(request, verifyCode);
}

/**
* 生成验证码
*
* @param request
* @return
*/
public abstract T generateVerifyCode(ServletWebRequest request);

/**
* 保存验证码
*
* @param request
* @param verifyCode
*/
public abstract void save(ServletWebRequest request, T verifyCode);

/**
* 发送验证码
*
* @param request
* @param verifyCode
*/
public abstract void send(ServletWebRequest request, T verifyCode);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Component
public class ImageCodeProcessorImpl extends AbstractVerifyCodeProcessor<ImageCode> {

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

/**
* Spring高级特性
* Spring会查找容器中所有{@link VerifyCodeGenerator}的实例并以 key=beanId,value=bean的形式注入到该map中
*/
@Autowired
private Map<String, VerifyCodeGenerator> verifyCodeGeneratorMap = new HashMap<>();

private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

@Override
public ImageCode generateVerifyCode(ServletWebRequest request) {
VerifyCodeGenerator<ImageCode> verifyCodeGenerator = verifyCodeGeneratorMap.get(IMAGE.getType() + VERIFY_CODE_Generator_IMPL_SUFFIX);
return verifyCodeGenerator.generateVerifyCode();
}

@Override
public void save(ServletWebRequest request, ImageCode imageCode) {
sessionStrategy.setAttribute(request,IMAGE_CODE_SESSION_KEY, imageCode);
}

@Override
public void send(ServletWebRequest request, ImageCode imageCode) {
HttpServletResponse response = request.getResponse();
try {
ImageIO.write(imageCode.getImage(), "JPEG", response.getOutputStream());
} catch (IOException e) {
logger.error("输出图形验证码:{}", e.getMessage());
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Component
public class SmsCodeProcessorImpl extends AbstractVerifyCodeProcessor<VerifyCode> {

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

@Autowired
private Map<String, VerifyCodeGenerator> verifyCodeGeneratorMap = new HashMap<>();

@Autowired
private SmsCodeSender smsCodeSender;

private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

@Override
public VerifyCode generateVerifyCode(ServletWebRequest request) {
VerifyCodeGenerator verifyCodeGenerator = verifyCodeGeneratorMap.get(SMS.getType() + VERIFY_CODE_Generator_IMPL_SUFFIX);
return verifyCodeGenerator.generateVerifyCode();
}

@Override
public void save(ServletWebRequest request, VerifyCode verifyCode) {
sessionStrategy.setAttribute(request, SMS_CODE_SESSION_KEY, verifyCode);
}

@Override
public void send(ServletWebRequest request, VerifyCode verifyCode) {
try {
long phoneNumber = ServletRequestUtils.getRequiredLongParameter(request.getRequest(),PHONE_NUMBER_PARAMETER_NAME);
smsCodeSender.send(verifyCode.getCode(),String.valueOf(phoneNumber));
} catch (ServletRequestBindingException e) {
throw new RuntimeException("手机号码不能为空");
}
}
}

验证码生成器

1
2
3
4
5
6
7
8
public interface VerifyCodeGenerator<T extends VerifyCode> {

/**
* 生成验证码
* @return
*/
T generateVerifyCode();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public class DefaultImageCodeGenerator implements VerifyCodeGenerator<ImageCode> {

@Autowired
private SecurityProperties securityProperties;

@Autowired
HttpServletRequest request;

@Override
public ImageCode generateVerifyCode() {

int width = ServletRequestUtils.getIntParameter(request, "width", securityProperties.getCode().getImage().getWidth());
int height = ServletRequestUtils.getIntParameter(request, "height", securityProperties.getCode().getImage().getHeight());
int strLength = securityProperties.getCode().getImage().getStrLength();
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics g = image.getGraphics();
Random random = new Random();

g.setColor(getRandColor(200, 250));
g.fillRect(0, 0, width, height);
g.setFont(new Font("Times New Roman", Font.ITALIC, 20));
g.setColor(getRandColor(160, 200));
for (int i = 0; i < 155; i++) {
int x = random.nextInt(width);
int y = random.nextInt(height);
int xl = random.nextInt(12);
int yl = random.nextInt(12);
g.drawLine(x, y, x + xl, y + yl);
}

String sRand = "";
for (int i = 0; i < strLength; i++) {
String rand = String.valueOf(random.nextInt(10));
sRand += rand;
g.setColor(new Color(20 + random.nextInt(110), 20 + random.nextInt(110), 20 + random.nextInt(110)));
g.drawString(rand, 13 * i + 6, 16);
}

g.dispose();

return new ImageCode(sRand, image, securityProperties.getCode().getImage().getDurationSeconds());
}

/**
* 生成随机背景条纹
*
* @param fc
* @param bc
* @return
*/
private Color getRandColor(int fc, int bc) {
Random random = new Random();
if (fc > 255) {
fc = 255;
}
if (bc > 255) {
bc = 255;
}
int r = fc + random.nextInt(bc - fc);
int g = fc + random.nextInt(bc - fc);
int b = fc + random.nextInt(bc - fc);
return new Color(r, g, b);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component("smsCodeGenerator")
public class SmsCodeGenerator implements VerifyCodeGenerator<VerifyCode> {

@Autowired
private SecurityProperties securityProperties;

@Override
public VerifyCode generateVerifyCode() {
// 随机生成一串纯数字字符串,数字个数为 strLength
String randomCode = RandomStringUtils.randomNumeric(securityProperties.getCode().getSms().getStrLength());
return new VerifyCode(randomCode, LocalDateTime.now().plusSeconds(securityProperties.getCode().getSms().getDurationSeconds()));
}

}

验证码发送器

1
2
3
4
5
6
7
8
public interface SmsCodeSender {
/**
* 根据手机号发送短信验证码
* @param smsCode
* @param phoneNumber
*/
void send(String smsCode, String phoneNumber);
}
1
2
3
4
5
6
public class DefaultSmsCodeSender implements SmsCodeSender {
@Override
public void send(String smsCode, String phoneNumber) {
System.out.printf("向手机号%s发送短信验证码%s", phoneNumber, smsCode);
}
}

验证码发送接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@RestController
@RequestMapping("/verifyCode")
public class VerifyCodeController {

/* private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

@Autowired
private VerifyCodeGenerator<ImageCode> imageCodeGenerator;

@Autowired
private VerifyCodeGenerator<VerifyCode> smsCodeGenerator;

@Autowired
private SmsCodeSender smsCodeSender;

*//**
* 1.生成图形验证码
* 2.将验证码存到session中
* 3.将图形响应给前端
*//*
@GetMapping("/image")
public void imageCode(HttpServletRequest request, HttpServletResponse response) throws IOException {

ImageCode imageCode = imageCodeGenerator.generateVerifyCode();
sessionStrategy.setAttribute(new ServletWebRequest(request), IMAGE_CODE_SESSION_KEY, imageCode);
ImageIO.write(imageCode.getImage(), "JPEG", response.getOutputStream());
}

*//**
* 1.生成短信验证码
* 2.将验证码存到session中
* 3.调用短信验证码发送器发送短信
*//*
@GetMapping("/sms")
public void smsCode(HttpServletRequest request, HttpServletResponse response) throws ServletRequestBindingException {
long phoneNumber = ServletRequestUtils.getRequiredLongParameter(request, "phoneNumber");
VerifyCode verifyCode = smsCodeGenerator.generateVerifyCode();
sessionStrategy.setAttribute(new ServletWebRequest(request), SMS_CODE_SESSION_KEY, verifyCode);
smsCodeSender.send(verifyCode.getCode(), String.valueOf(phoneNumber));
}*/

@Autowired
private Map<String, VerifyCodeProcessor> verifyCodeProcessorMap = new HashMap<>();

@GetMapping("/{type}")
public void sendVerifyCode(@PathVariable String type, HttpServletRequest request, HttpServletResponse response) {
if (Objects.equals(type, IMAGE.getType()) == false && Objects.equals(type, SMS.getType()) == false) {
throw new IllegalArgumentException("不支持的验证码类型");
}
VerifyCodeProcessor verifyCodeProcessor = verifyCodeProcessorMap.get(type + VERIFY_CODE_PROCESSOR_IMPL_SUFFIX);
verifyCodeProcessor.sendVerifyCode(new ServletWebRequest(request, response));
}
}

配置类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package top.zhenganwen.security.core;

import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import top.zhenganwen.security.core.properties.SecurityProperties;
import top.zhenganwen.security.core.verifycode.generator.DefaultImageCodeGenerator;
import top.zhenganwen.security.core.verifycode.sender.DefaultSmsCodeSender;
import top.zhenganwen.security.core.verifycode.generator.VerifyCodeGenerator;
import top.zhenganwen.security.core.verifycode.sender.SmsCodeSender;

/**
* @author zhenganwen
* @date 2019/8/23
* @desc SecurityCoreConfig
*/
@Configuration
@EnableConfigurationProperties(SecurityProperties.class)
public class SecurityCoreConfig {

@Bean
@ConditionalOnMissingBean(name = "imageCodeGenerator")
public VerifyCodeGenerator imageCodeGenerator() {
VerifyCodeGenerator imageCodeGenerator = new DefaultImageCodeGenerator();
return imageCodeGenerator;
}

@Bean
@ConditionalOnMissingBean(name = "smsCodeSender")
public SmsCodeSender smsCodeSender() {
return new DefaultSmsCodeSender();
}
}

测试

要知道重构只是提高代码质量和增加代码可读性,因此每次小步重构之后一定要记得测试原有功能是否收到影响

  • 访问/login.html进行用户名密码登录,登陆后访问受保护服务/user

  • 访问/login.html点击点击发送,查看控制台是否打印发送日志

  • 修改/login.html,将图形验证码宽度设置为600

    1
    验证码:<input type="text" name="verifyCode"><img src="/verifyCode/image?width=600" alt="">

测试通过,重构成功!

短信验证码登录

要想实现短信验证码登录流程,我们可以借鉴已有的用户名密码登录流程,分析有哪些组件是需要我们自己来实现的:

image.png

首先我们需要一个SmsAuthenticationFilter拦截短信登录请求进行认证,期间它会将登录信息封装成一个Authentication请求AuthenticationManager进行认证

AuthenticationManager会遍历所有的AuthenticationProvider找到其中支持认证该Authentication并调用authenticate进行实际的认证,因此我们需要实现自己的Authentication(SmsAuthenticationToken)和认证该AuthenticationAuthenticationProviderSmsAuthenticationProvider),并将SmsAuthenticationProvider添加到SpringSecurtyAuthenticationProvider集合中,以使AuthenticationManager遍历该集合时能找到我们自定义的SmsAuthenticationProvider

SmsAuthenticationProvider在进行认证时,需要调用UserDetailsService根据手机号查询存储的用户信息(loadUserByUsername),因此我们还需要自定义的SmsUserDetailsService

下面我们来一一实现下(其实就是依葫芦画瓢,把对应用户名密码登录流程对应组件的代码COPY过来改一改)

SmsAuthenticationToken

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
package top.zhenganwen.security.core.verifycode.sms;

import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.SpringSecurityCoreVersion;

import java.util.Collection;

/**
* @author zhenganwen
* @date 2019/8/30
* @desc SmsAuthenticationToken
*/
public class SmsAuthenticationToken extends AbstractAuthenticationToken {

private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

// ~ Instance fields
// ================================================================================================
// 认证前保存的是用户输入的手机号,认证成功后保存的是后端存储的用户详情
private final Object principal;

// ~ Constructors
// ===================================================================================================

/**
* 认证前时调用该方法封装请求参数成一个未认证的token => authRequest
*
* @param phoneNumber 手机号
*/
public SmsAuthenticationToken(Object phoneNumber) {
super(null);
this.principal = phoneNumber;
setAuthenticated(false);
}

/**
* 认证成功后需要调用该方法封装用户信息成一个已认证的token => successToken
*
* @param principal 用户详情
* @param authorities 权限信息
*/
public SmsAuthenticationToken(Object principal, Object credentials,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
super.setAuthenticated(true); // must use super, as we override
}

// ~ Methods
// ========================================================================================================

// 用户名密码登录的凭证是密码,验证码登录不传密码
@Override
public Object getCredentials() {
return null;
}

@Override
public Object getPrincipal() {
return this.principal;
}

}

SmsAuthenticationFilter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
package top.zhenganwen.security.core.verifycode.sms;

import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.util.Assert;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
* @author zhenganwen
* @date 2019/8/30
* @desc SmsAuthenticationFilter
*/
public class SmsAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

// ~ Static fields/initializers
// =====================================================================================

public static final String SPRING_SECURITY_FORM_PHONE_NUMBER_KEY = "phoneNumber";

private String phoneNumberParameter = SPRING_SECURITY_FORM_PHONE_NUMBER_KEY;
private boolean postOnly = true;

// ~ Constructors
// ===================================================================================================

public SmsAuthenticationFilter() {
super(new AntPathRequestMatcher("/auth/sms", "POST"));
}

// ~ Methods
// ========================================================================================================

@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
if (postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}

String phoneNumber = obtainPhoneNumber(request);

if (phoneNumber == null) {
phoneNumber = "";
}

phoneNumber = phoneNumber.trim();

SmsAuthenticationToken authRequest = new SmsAuthenticationToken(phoneNumber);

return this.getAuthenticationManager().authenticate(authRequest);
}

/**
* Enables subclasses to override the composition of the phoneNumber, such as by
* including additional values and a separator.
*
* @param request so that request attributes can be retrieved
*
* @return the phoneNumber that will be presented in the <code>Authentication</code>
* request token to the <code>AuthenticationManager</code>
*/
protected String obtainPhoneNumber(HttpServletRequest request) {
return request.getParameter(phoneNumberParameter);
}

/**
* Sets the parameter name which will be used to obtain the phoneNumber from the login
* request.
*
* @param phoneNumberParameter the parameter name. Defaults to "phoneNumber".
*/
public void setPhoneNumberParameter(String phoneNumberParameter) {
Assert.hasText(phoneNumberParameter, "phoneNumber parameter must not be empty or null");
this.phoneNumberParameter = phoneNumberParameter;
}

/**
* Defines whether only HTTP POST requests will be allowed by this filter. If set to
* true, and an authentication request is received which is not a POST request, an
* exception will be raised immediately and authentication will not be attempted. The
* <tt>unsuccessfulAuthentication()</tt> method will be called as if handling a failed
* authentication.
* <p>
* Defaults to <tt>true</tt> but may be overridden by subclasses.
*/
public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}

public final String getPhoneNumberParameter() {
return phoneNumberParameter;
}

}

SmsAuthenticationProvider

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
package top.zhenganwen.security.core.verifycode.sms;

import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;

/**
* @author zhenganwen
* @date 2019/8/30
* @desc SmsAuthenticationProvider
*/
public class SmsAuthenticationProvider implements AuthenticationProvider {

private UserDetailsService userDetailsService;

public SmsAuthenticationProvider() {

}

/**
* 该方法会被 AuthenticationManager调用,对authentication进行验证,并返回一个认证通过的{@link Authentication}
* @param authentication
* @return
*/
@Override
public Authentication authenticate(Authentication authentication){
// 用户名密码登录方式需要在这里校验前端传入的密码和后端存储的密码是否一致
// 但如果将短信验证码的校验放在这里的话就无法复用了,例如用户登录后访问“我的钱包”服务可能也需要发送短信验证码并进行验证
// 因此短信验证码的校验逻辑单独抽取到一个过滤器里(留到后面实现), 这里直接返回一个认证成功的authentication
if (authentication instanceof SmsAuthenticationToken == false) {
throw new IllegalArgumentException("仅支持对SmsAuthenticationToken的认证");
}

SmsAuthenticationToken authRequest = (SmsAuthenticationToken) authentication;
UserDetails userDetails = getUserDetailsService().loadUserByUsername((String) authentication.getPrincipal());
SmsAuthenticationToken successfulAuthentication = new SmsAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities());
return successfulAuthentication;
}

/**
* Authentication的authenticate方法在遍历所有AuthenticationProvider时会调用该方法判断当前AuthenticationProvider是否对
* 某个具体Authentication的校验
*
* 重写此方法以支持对 {@link SmsAuthenticationToken} 的认证校验
* @param clazz 支持的token类型
* @return
*/
@Override
public boolean supports(Class<?> clazz) {
// 如果传入的类是否是SmsAuthenticationToken或其子类
return SmsAuthenticationToken.class.isAssignableFrom(clazz);
}

public UserDetailsService getUserDetailsService() {
return userDetailsService;
}

/**
* 提供对UserDetailsService的动态注入
* @return
*/
public void setUserDetailsService(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
}

SmsDetailsService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package top.zhenganwen.security.core.verifycode.sms;

import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Service;

import java.util.Objects;

/**
* @author zhenganwen
* @date 2019/8/30
* @desc SmsUserDetailsService
*/
@Service
public class SmsUserDetailsService implements UserDetailsService {

/**
* 根据登录名查询用户,这里登录名是手机号
*
* @param phoneNumber
* @return
* @throws PhoneNumberNotFoundException
*/
@Override
public UserDetails loadUserByUsername(String phoneNumber) throws PhoneNumberNotFoundException {
// 实际上应该调用DAO根据手机号查询用户
if (Objects.equals(phoneNumber, "12345678912") == false) {
// 未查到
throw new PhoneNumberNotFoundException();
}
// 查到了
// 使用security提供的UserDetails的实现模拟查出来的用户,在你的项目中可以使用User实体类实现UserDetails接口,这样就可以直接返回查出的User实体对象
return new User("anwen","123456", AuthorityUtils.createAuthorityList("admin","super_admin"));
}
}

这里要注意一下,添加了该类后,容器中就有两个UserDetails组建了,之前@Autowire userDetails的地方要换成@Autowire customDetailsService,否则会报错

SmsLoginConfig

各个环节的组件我们都实现了,现在我们需要写一个配置类将这些组件串起来,告诉security这些自定义组件的存在。由于短信登录方式在PC端和移动端都用得上,因此我们将其定义在security-core

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package top.zhenganwen.security.core.verifycode.sms;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.stereotype.Component;

/**
* @author zhenganwen
* @date 2019/8/30
* @desc SmsSecurityConfig
*/
@Component
public class SmsLoginConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

@Autowired
AuthenticationSuccessHandler customAuthenticationSuccessHandler;

@Autowired
AuthenticationFailureHandler customAuthenticationFailureHandler;

@Autowired
UserDetailsService smsUserDetailsService;

@Override
public void configure(HttpSecurity http) throws Exception {
SmsAuthenticationFilter smsAuthenticationFilter = new SmsAuthenticationFilter();
// 认证过滤器会请求AuthenticationManager认证authRequest,因此我们需要为其注入AuthenticatonManager,但是该实例是由Security管理的,我们需要通过getSharedObject来获取
smsAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
// 认证成功/失败处理器还是使用之前的
smsAuthenticationFilter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler);
smsAuthenticationFilter.setAuthenticationFailureHandler(customAuthenticationFailureHandler);

SmsAuthenticationProvider smsAuthenticationProvider = new SmsAuthenticationProvider();
// 将SmsUserDetailsService注入到SmsAuthenticationProvider中
smsAuthenticationProvider.setUserDetailsService(smsUserDetailsService);

// 将SmsAuthenticationProvider加入到Security管理的AuthenticationProvider集合中
http.authenticationProvider(smsAuthenticationProvider)
// 注意要添加到UsernamePasswordAuthenticationFilter之后,自定义的认证过滤器都应该添加到其之后,自定义的验证码等过滤器都应该添加到其之前
.addFilterAfter(smsAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
}

测试

访问/login.html,点击点击发送,查看控制台输出的短信验证码,再访问/login.html进行登录,登录成功!

但是,进行用户名密码登录却失败了!提示Bad Credentials,说密码错误,于是我在校验密码的地方进行断点调试:

DaoAuthenticationProvider#additionalAuthenticationChecks

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
Object salt = null;

if (this.saltSource != null) {
salt = this.saltSource.getSalt(userDetails);
}

if (authentication.getCredentials() == null) {
logger.debug("Authentication failed: no credentials provided");

throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}

String presentedPassword = authentication.getCredentials().toString();

if (!passwordEncoder.isPasswordValid(userDetails.getPassword(),
presentedPassword, salt)) {
logger.debug("Authentication failed: password does not match stored value");

throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
}

发现passwordEncoder居然是PlaintextPasswordEncoder而不是我们注入的BCryptPasswordEncoder,这是为什么呢?

我们需要追本溯源查看该passwordEncoder是什么时候被赋值的,Alt + F7在该文件中查看该类的setPasswordEncoder(Object passwordEncoder)方法的调用时机,发现在构造方法中就会被初始化为PlaintextPasswordEncoder;但这并不是我们想要的,我们想看为什么在添加短信验证码登录功能之前注入的加密器BCryptPasswordEncoder就能生效,于是Ctrl + Alt + F7在整个项目和类库中查找setPasswordEncoder(Object passwordEncoder)的调用时机,发现如下线索:

InitializeUserDetailsManagerConfigurer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
if (auth.isConfigured()) {
return;
}
UserDetailsService userDetailsService = getBeanOrNull(
UserDetailsService.class);
if (userDetailsService == null) {
return;
}

PasswordEncoder passwordEncoder = getBeanOrNull(PasswordEncoder.class);

DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
if (passwordEncoder != null) {
provider.setPasswordEncoder(passwordEncoder);
}

auth.authenticationProvider(provider);
}

/**
* @return
*/
private <T> T getBeanOrNull(Class<T> type) {
String[] userDetailsBeanNames = InitializeUserDetailsBeanManagerConfigurer.this.context
.getBeanNamesForType(type);
if (userDetailsBeanNames.length != 1) {
return null;
}

return InitializeUserDetailsBeanManagerConfigurer.this.context
.getBean(userDetailsBeanNames[0], type);
}

原来,在查找我们是否注入其它PasswordEncoder实例并试图向DaoAuthenticationProvider注入我们配置的BCryptPasswordEncoder之前,会从容器中获取UserDetails实例,如果容器中没有或者实例个数大于1,那么就返回了。

原来,是我们在实现短信验证码登录功能时,在SmsUserDetailsService标注的@Component导致容器中存在了smsUserDetailsService和之前的customUserDetailsService两个UserDetailsService实例,以至于上述代码12之后的代码都未执行,也就是说我们的CustomUserDetailsServiceBCryptPasswordEncoder都没有注入到DaoAuthenticationProvider中去。

至于为什么校验密码之前,DaoAuthenticationProvider中的this.getUserDetailsService().loadUserByUsername(username)仍能调用CustomUserDetailsService以及为什么是CustomUserDetailsService被注入到了DaoAuthenticationProvider中而不是SmsUserDetialsService,还有待分析

既然找到了问题所在(容器中存在两个UserDetailsService实例),简单的解决办法就是去掉SmsUserDetailsService@Component,在配置短信登录串联组件时自己new一个就好了

1
2
//@Component
public class SmsUserDetailsService implements UserDetailsService {
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Component
public class SmsLoginConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

@Autowired
AuthenticationSuccessHandler customAuthenticationSuccessHandler;

@Autowired
AuthenticationFailureHandler customAuthenticationFailureHandler;

// @Autowired
// SmsUserDetailsService smsUserDetailsService;

@Override
public void configure(HttpSecurity http) throws Exception {

SmsAuthenticationFilter smsAuthenticationFilter = new SmsAuthenticationFilter();
smsAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
smsAuthenticationFilter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler);
smsAuthenticationFilter.setAuthenticationFailureHandler(customAuthenticationFailureHandler);

SmsAuthenticationProvider smsAuthenticationProvider = new SmsAuthenticationProvider();
// 自己new一下
SmsUserDetailsService smsUserDetailsService = new SmsUserDetailsService();
smsAuthenticationProvider.setUserDetailsService(smsUserDetailsService);

http.authenticationProvider(smsAuthenticationProvider)
.addFilterAfter(smsAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
}

重新测试两种登录方式,均能通过!

短信验证码过滤器

上节说道,为了复用,我们应该将短信验证码的验证逻辑单独放到一个过滤器中,这里我们可以参考之前写的图形验证码过滤器,复制一份改一改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
package top.zhenganwen.security.core.verifycode.filter;

import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.social.connect.web.HttpSessionSessionStrategy;
import org.springframework.social.connect.web.SessionStrategy;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.filter.OncePerRequestFilter;
import top.zhenganwen.security.core.properties.SecurityProperties;
import top.zhenganwen.security.core.verifycode.exception.VerifyCodeException;
import top.zhenganwen.security.core.verifycode.po.VerifyCode;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;

import static top.zhenganwen.security.core.verifycode.constont.VerifyCodeConstant.SMS_CODE_SESSION_KEY;

/**
* @author zhenganwen
* @date 2019/8/24
* @desc VerifyCodeAuthenticationFilter
*/
@Component
public class SmsCodeAuthenticationFilter extends OncePerRequestFilter implements InitializingBean {

@Autowired
private AuthenticationFailureHandler customAuthenticationFailureHandler;

private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

@Autowired
private SecurityProperties securityProperties;

private Set<String> uriPatternSet = new HashSet<>();

// uri匹配工具类,帮我们做类似/user/1到/user/*的匹配
private AntPathMatcher antPathMatcher = new AntPathMatcher();

@Override
public void afterPropertiesSet() throws ServletException {
super.afterPropertiesSet();
String uriPatterns = securityProperties.getCode().getSms().getUriPatterns();
if (StringUtils.isNotBlank(uriPatterns)) {
String[] strings = StringUtils.splitByWholeSeparatorPreserveAllTokens(uriPatterns, ",");
uriPatternSet.addAll(Arrays.asList(strings));
}
uriPatternSet.add("/auth/sms");
}

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException,
IOException {
for (String uriPattern : uriPatternSet) {
// 有一个匹配就需要拦截 校验验证码
if (antPathMatcher.match(uriPattern, request.getRequestURI())) {
try {
this.validateVerifyCode(new ServletWebRequest(request));
} catch (VerifyCodeException e) {
customAuthenticationFailureHandler.onAuthenticationFailure(request, response, e);
return;
}
break;
}
}
filterChain.doFilter(request, response);
}

// 拦截用户登录的请求,从Session中读取保存的短信验证码和用户提交的验证码进行比对
private void validateVerifyCode(ServletWebRequest request){
String smsCode = (String) request.getParameter("smsCode");
if (StringUtils.isBlank(smsCode)) {
throw new VerifyCodeException("验证码不能为空");
}
VerifyCode verifyCode = (VerifyCode) sessionStrategy.getAttribute(request, SMS_CODE_SESSION_KEY);
if (verifyCode == null) {
throw new VerifyCodeException("验证码不存在");
}
if (verifyCode.isExpired()) {
throw new VerifyCodeException("验证码已过期,请刷新页面");
}
if (StringUtils.equals(smsCode,verifyCode.getCode()) == false) {
throw new VerifyCodeException("验证码错误");
}
sessionStrategy.removeAttribute(request, SMS_CODE_SESSION_KEY);
}
}

然后记得将其添加到security的过滤器链中,并且只能添加到所有认证过滤器之前:

SecurityBrowserConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Override
protected void configure(HttpSecurity http) throws Exception {

http
.addFilterBefore(smsCodeAuthenticationFilter,UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(verifyCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.formLogin()
.loginPage("/auth/require")
.loginProcessingUrl("/auth/login")
.successHandler(customAuthenticationSuccessHandler)
.failureHandler(customAuthenticationFailureHandler)
.and()
.rememberMe()
.tokenRepository(persistentTokenRepository())
.tokenValiditySeconds(3600)
.userDetailsService(customUserDetailsService)
// 可配置页面选框的name属性
// .rememberMeParameter()
.and()
.authorizeRequests()
.antMatchers(
"/auth/require",
securityProperties.getBrowser().getLoginPage(),
"/verifyCode/**").permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable()
.apply(smsLoginConfig);
}

最后在login.html中修改登录URL/auth/sms以及短信验证码参数名smsCode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<form action="/auth/login" method="post">
用户名: <input type="text" name="username" value="admin">
密码: <input type="password" name="password" value="123">
验证码:<input type="text" name="verifyCode"><img src="/verifyCode/image?width=600" alt="">
<input type="checkbox" name="remember-me" value="true">记住我
<button type="submit">提交</button>
</form>
<hr/>
<form action="/auth/sms" method="post">
手机号: <input type="text" name="phoneNumber" value="12345678912">
验证码: <input type="text" name="smsCode"><a href="/verifyCode/sms?phoneNumber=12345678912">点击发送</a>
<input type="checkbox" name="remember-me" value="true">记住我
<button type="submit">提交</button>
</form>

重构——消除重复代码

之前我们将图形验证码过滤器的代码COPY一份改了改就成了短信验证码过滤器,这两个类的主流程是相同的,只是具体实现稍有不同(从Session中读写不同的key对应的验证码对象),这可以使用模板方法进行抽取

我们代码中还存在很多字面量魔法值,我们也应该尽量消除他们,将它们提取成常量或配置属性,在需要用到的地方统一进行引用,这样就不会导致后续需要更改时忘记了某处的魔法值而导致异常。例如,如果仅仅将.loginPage("/auth/require")改为.loginPage("/authentication/require"),而没有通过更改BrowserSecurityController中的@RequestMapping("/auth/require"),就会导致程序功能出现问题

我们可以将系统配置相关的代码分模块封装成对应的配置类放在security-core中,security-browsersecurity-app中只留自身特有的配置(例如将token写到cookie中的remember-me方式应该放在security-browser中,而security-app中对应放移动端remember-me的配置方式),最后security-browsersecurity-app都可以通过http.apply的方式引用security-core中的通用配置,以实现代码的复用

只要你的项目中出现了两处以上相同的代码,你敏锐的嗅觉就应该发现这些最不起眼但也是最需要注意的代码坏味道,应该想办法及时重构而不要等到系统庞大后想动却牵一发而动全身

魔法值重构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package top.zhenganwen.security.core.verifycode.filter;

public enum VerifyCodeType {

SMS{
@Override
public String getVerifyCodeParameterName() {
return SecurityConstants.DEFAULT_SMS_CODE_PARAMETER_NAME;
}
},

IMAGE{
@Override
public String getVerifyCodeParameterName() {
return SecurityConstants.DEFAULT_IMAGE_CODE_PARAMETER_NAME;
}
};

public abstract String getVerifyCodeParameterName();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package top.zhenganwen.security.core;

public interface SecurityConstants {

/**
* 表单密码登录URL
*/
String DEFAULT_FORM_LOGIN_URL = "/auth/login";

/**
* 短信登录URL
*/
String DEFAULT_SMS_LOGIN_URL = "/auth/sms";

/**
* 前端图形验证码参数名
*/
String DEFAULT_IMAGE_CODE_PARAMETER_NAME = "imageCode";

/**
* 前端短信验证码参数名
*/
String DEFAULT_SMS_CODE_PARAMETER_NAME = "smsCode";

/**
* 图形验证码缓存在Session中的key
*/
String IMAGE_CODE_SESSION_KEY = "IMAGE_CODE_SESSION_KEY";

/**
* 短信验证码缓存在Session中的key
*/
String SMS_CODE_SESSION_KEY = "SMS_CODE_SESSION_KEY";

/**
* 验证码校验器bean名称的后缀
*/
String VERIFY_CODE_VALIDATOR_NAME_SUFFIX = "CodeValidator";

/**
* 未登录访问受保护URL则跳转路径到 此
*/
String FORWARD_TO_LOGIN_PAGE_URL = "/auth/require";

/**
* 用户点击发送验证码调用的服务
*/
String VERIFY_CODE_SEND_URL = "/verifyCode/**";
}

验证码过滤器重构

image.png

  • VerifyCodeValidatorFilter,责任是拦截需要进行验证码校验的请求
  • VerifyCodeValidator,使用模板方法,抽象验证码的校验逻辑
  • VerifyCodeValidatorHolder,利用Spring的依赖查找,聚集容器中所有的VerifyCodeValidator实现类(各种验证码的具体验证逻辑),对外提供根据验证码类型获取对应验证码校验bean的方法

login.html,将其中图形验证码参数改成了imageCode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<form action="/auth/login" method="post">
用户名: <input type="text" name="username" value="admin">
密码: <input type="password" name="password" value="123">
验证码:<input type="text" name="imageCode"><img src="/verifyCode/image?width=600" alt="">
<input type="checkbox" name="remember-me" value="true">记住我
<button type="submit">提交</button>
</form>
<hr/>
<form action="/auth/sms" method="post">
手机号: <input type="text" name="phoneNumber" value="12345678912">
验证码: <input type="text" name="smsCode"><a href="/verifyCode/sms?phoneNumber=12345678912">点击发送</a>
<input type="checkbox" name="remember-me" value="true">记住我
<button type="submit">提交</button>
</form>

VerifyCodeValidateFilter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
package top.zhenganwen.security.core.verifycode.filter;

import static top.zhenganwen.security.core.SecurityConstants.DEFAULT_SMS_LOGIN_URL;

@Component
public class VerifyCodeValidateFilter extends OncePerRequestFilter implements InitializingBean {

// 认证失败处理器
@Autowired
private AuthenticationFailureHandler authenticationFailureHandler;

// session读写工具
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

// 映射 需要校验验证码的 uri 和 校验码类型,如 /auth/login -> 图形验证码 /auth/sms -> 短信验证码
private Map<String, VerifyCodeType> uriMap = new HashMap<>();

@Autowired
private SecurityProperties securityProperties;

private AntPathMatcher antPathMatcher = new AntPathMatcher();

@Autowired
private VerifyCodeValidatorHolder verifyCodeValidatorHolder;

@Override
public void afterPropertiesSet() throws ServletException {
super.afterPropertiesSet();

uriMap.put(SecurityConstants.DEFAULT_FORM_LOGIN_URL, VerifyCodeType.IMAGE);
putUriPatterns(uriMap, securityProperties.getCode().getImage().getUriPatterns(), VerifyCodeType.IMAGE);

uriMap.put(SecurityConstants.DEFAULT_SMS_LOGIN_URL, VerifyCodeType.SMS);
putUriPatterns(uriMap, securityProperties.getCode().getSms().getUriPatterns(), VerifyCodeType.SMS);
}

private void putUriPatterns(Map<String, VerifyCodeType> urlMap, String uriPatterns, VerifyCodeType verifyCodeType) {
if (StringUtils.isNotBlank(uriPatterns)) {
String[] strings = StringUtils.splitByWholeSeparatorPreserveAllTokens(uriPatterns, ",");
for (String string : strings) {
urlMap.put(string, verifyCodeType);
}
}
}

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException
, IOException {
try {
checkVerifyCodeIfNeed(request, uriMap);
} catch (VerifyCodeException e) {
authenticationFailureHandler.onAuthenticationFailure(request, response, e);
return;
}
filterChain.doFilter(request, response);
}

private void checkVerifyCodeIfNeed(HttpServletRequest request, Map<String, VerifyCodeType> uriMap) {
String requestUri = request.getRequestURI();
Set<String> uriPatterns = uriMap.keySet();
for (String uriPattern : uriPatterns) {
if (antPathMatcher.match(uriPattern, requestUri)) {
VerifyCodeType verifyCodeType = uriMap.get(uriPattern);
verifyCodeValidatorHolder.getVerifyCodeValidator(verifyCodeType).validateVerifyCode(new ServletWebRequest(request), verifyCodeType);
break;
}
}
}

}

VerifyCodeValidator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
package top.zhenganwen.security.core.verifycode.filter;

import java.util.Objects;

public abstract class VerifyCodeValidator {

protected SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

@Autowired
private VerifyCodeValidatorHolder verifyCodeValidatorHolder;

/**
* 校验验证码
* 1.从请求中获取传入的验证码
* 2.从服务端获取存储的验证码
* 3.校验验证码
* 4.校验成功移除服务端验证码,校验失败抛出异常信息
*
* @param request
* @param verifyCodeType
* @throws VerifyCodeException
*/
public void validateVerifyCode(ServletWebRequest request, VerifyCodeType verifyCodeType) throws VerifyCodeException {
String requestCode = getVerifyCodeFromRequest(request, verifyCodeType);

VerifyCodeValidator codeValidator = verifyCodeValidatorHolder.getVerifyCodeValidator(verifyCodeType);
if (Objects.isNull(codeValidator)) {
throw new VerifyCodeException("不支持的验证码校验类型: " + verifyCodeType);
}
VerifyCode storedVerifyCode = codeValidator.getStoredVerifyCode(request);

codeValidator.validate(requestCode, storedVerifyCode);

codeValidator.removeStoredVerifyCode(request);
}

/**
* 校验验证码是否过期,默认进行简单的文本比对,子类可重写以校验传入的明文验证码和后端存储的密文验证码
*
* @param requestCode
* @param storedVerifyCode
*/
private void validate(String requestCode, VerifyCode storedVerifyCode) {
if (Objects.isNull(storedVerifyCode) || storedVerifyCode.isExpired()) {
throw new VerifyCodeException("验证码已失效,请重新生成");
}
if (StringUtils.isBlank(requestCode)) {
throw new VerifyCodeException("验证码不能为空");
}
if (StringUtils.equalsIgnoreCase(requestCode, storedVerifyCode.getCode()) == false) {
throw new VerifyCodeException("验证码错误");
}
}

/**
* 是从Session中还是从其他缓存方式移除验证码由子类自己决定
*
* @param request
*/
protected abstract void removeStoredVerifyCode(ServletWebRequest request);

/**
* 是从Session中还是从其他缓存方式读取验证码由子类自己决定
*
* @param request
* @return
*/
protected abstract VerifyCode getStoredVerifyCode(ServletWebRequest request);


/**
* 默认从请求中获取验证码参数,可被子类重写
*
* @param request
* @param verifyCodeType
* @return
*/
private String getVerifyCodeFromRequest(ServletWebRequest request, VerifyCodeType verifyCodeType) {
try {
return ServletRequestUtils.getStringParameter(request.getRequest(), verifyCodeType.getVerifyCodeParameterName());
} catch (ServletRequestBindingException e) {
throw new VerifyCodeException("非法请求,请附带验证码参数");
}
}

}

ImageCodeValidator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package top.zhenganwen.security.core.verifycode.filter;

@Component
public class ImageCodeValidator extends VerifyCodeValidator {

@Override
protected void removeStoredVerifyCode(ServletWebRequest request) {
sessionStrategy.removeAttribute(request, SecurityConstants.IMAGE_CODE_SESSION_KEY);
}

@Override
protected VerifyCode getStoredVerifyCode(ServletWebRequest request) {
return (VerifyCode) sessionStrategy.getAttribute(request, SecurityConstants.IMAGE_CODE_SESSION_KEY);
}
}

SmsCodeValidator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package top.zhenganwen.security.core.verifycode.filter;

@Component
public class SmsCodeValidator extends VerifyCodeValidator {

@Override
protected void removeStoredVerifyCode(ServletWebRequest request) {
sessionStrategy.removeAttribute(request, SecurityConstants.SMS_CODE_SESSION_KEY);
}

@Override
protected VerifyCode getStoredVerifyCode(ServletWebRequest request) {
return (VerifyCode) sessionStrategy.getAttribute(request,SecurityConstants.SMS_CODE_SESSION_KEY);
}
}

VerifyCodeValidatorHolder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package top.zhenganwen.security.core.verifycode.filter;

@Component
public class VerifyCodeValidatorHolder {

@Autowired
private Map<String, VerifyCodeValidator> verifyCodeValidatorMap = new HashMap<>();

public VerifyCodeValidator getVerifyCodeValidator(VerifyCodeType verifyCodeType) {
VerifyCodeValidator verifyCodeValidator =
verifyCodeValidatorMap.get(verifyCodeType.toString().toLowerCase() + SecurityConstants.VERIFY_CODE_VALIDATOR_NAME_SUFFIX);
if (Objects.isNull(verifyCodeType)) {
throw new VerifyCodeException("不支持的验证码类型:" + verifyCodeType);
}
return verifyCodeValidator;
}

}

SecurityBrowserConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Autowire
VerifyCodeValidatorFilter verifyCodeValidatorFilter;

http
// .addFilterBefore(smsCodeAuthenticationFilter,UsernamePasswordAuthenticationFilter.class)
// .addFilterBefore(verifyCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(verifyCodeValidateFilter, UsernamePasswordAuthenticationFilter.class)
.formLogin()
.loginPage(SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL)
.loginProcessingUrl(SecurityConstants.DEFAULT_FORM_LOGIN_URL)
.successHandler(customAuthenticationSuccessHandler)
.failureHandler(customAuthenticationFailureHandler)
.and()
.rememberMe()
.tokenRepository(persistentTokenRepository())
.tokenValiditySeconds(3600)
.userDetailsService(customUserDetailsService)
.and()
.authorizeRequests()
.antMatchers(
SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL,
securityProperties.getBrowser().getLoginPage(),
SecurityConstants.VERIFY_CODE_SEND_URL).permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable()
.apply(smsLoginConfig);

系统配置重构

image.png

security-core

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package top.zhenganwen.security.core.config;

@Component
public class SmsLoginConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

@Autowired
AuthenticationSuccessHandler customAuthenticationSuccessHandler;

@Autowired
AuthenticationFailureHandler customAuthenticationFailureHandler;

@Override
public void configure(HttpSecurity http) throws Exception {

SmsAuthenticationFilter smsAuthenticationFilter = new SmsAuthenticationFilter();
smsAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
smsAuthenticationFilter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler);
smsAuthenticationFilter.setAuthenticationFailureHandler(customAuthenticationFailureHandler);

SmsAuthenticationProvider smsAuthenticationProvider = new SmsAuthenticationProvider();
SmsUserDetailsService smsUserDetailsService = new SmsUserDetailsService();
smsAuthenticationProvider.setUserDetailsService(smsUserDetailsService);

http.authenticationProvider(smsAuthenticationProvider)
.addFilterAfter(smsAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
package top.zhenganwen.security.core.config;

@Component
public class VerifyCodeValidatorConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

@Autowired
private VerifyCodeValidateFilter verifyCodeValidateFilter;

@Override
public void configure(HttpSecurity builder) throws Exception {
builder.addFilterBefore(verifyCodeValidateFilter, UsernamePasswordAuthenticationFilter.class);
}
}

security-browser

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
package top.zhenganwen.securitydemo.browser;

@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;

@Autowired
private DataSource dataSource;

@Autowired
private UserDetailsService customUserDetailsService;

@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
jdbcTokenRepository.setDataSource(dataSource);
return jdbcTokenRepository;
}

@Autowired
SmsLoginConfig smsLoginConfig;

@Autowired
private VerifyCodeValidatorConfig verifyCodeValidatorConfig;

@Override
protected void configure(HttpSecurity http) throws Exception {

// 启用验证码校验过滤器
http.apply(verifyCodeValidatorConfig);
// 启用短信登录过滤器
http.apply(smsLoginConfig);

http
// 启用表单密码登录过滤器
.formLogin()
.loginPage(SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL)
.loginProcessingUrl(SecurityConstants.DEFAULT_FORM_LOGIN_URL)
.successHandler(customAuthenticationSuccessHandler)
.failureHandler(customAuthenticationFailureHandler)
.and()
// 浏览器应用特有的配置,将登录后生成的token保存在cookie中
.rememberMe()
.tokenRepository(persistentTokenRepository())
.tokenValiditySeconds(3600)
.userDetailsService(customUserDetailsService)
.and()
// 浏览器应用特有的配置
.authorizeRequests()
.antMatchers(
SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL,
securityProperties.getBrowser().getLoginPage(),
SecurityConstants.VERIFY_CODE_SEND_URL).permitAll()
.anyRequest().authenticated().and()
.csrf().disable();
}
}

使用Spring Social开发第三方登录

OAuth协议简介

产生背景

有时应用与应用之间会进行合作,已达到共赢的目的。例如时下较火的微信公众号、微信小程序。一方面,公众号、小程序开发者能够以丰富的内容吸引微信用户为微信提高用户留存率;另一方面,公众号、小程序能够借助微信强大的用户基础为自己的服务引流

这时问题来了,如果使用最传统的方式,小程序要想取得用户信息而向用户申请索取账号密码(例如美颜小程序需要读取用户的微信相册进行美化),且不说用户给不给,就算用户给了,那么还是会存在以下几个问题(以美颜小程序为例)

  • 访问权限

    无法控制小程序的访问权限,说是只读取微信相册,谁知道他拿了账号密码后会不会查看微信好友、使用微信钱包呢

  • 授权时效

    一旦小程序获取到用户的账号密码,用户便无法控制此次授权后,该小程序日后还不会使用该账号密码进行非法登录,用户只有每次授权后更改密码才行

  • 可靠性

    如果用户采用此种方式对多个小程序进行授权,一旦小程序泄露用户密码,那么用户面临被盗号的危险

OAuth解决方案

用户同意授权给第三方应用(如微信小程序相对于微信用户)时,只会给第三方应用一个token令牌(第三方应用可以通过这个token访问用户的特定数据资源),这个令牌就是为了解决上述问题而生:

  • 令牌是有时限的,只在规定的时间内有效,解决了 授权时效 的问题
  • 令牌只能访问用户授予访问的特定资源,解决了 访问权限 的问题
  • 令牌是一串短期有效,过期则没有任何意义的随机字符串 ,解决了 可靠性 问题

OAuth协议运行流程

首先介绍一下涉及到的几个角色及其职责:

  • Provider,服务提供商,如微信、QQ,拥有大量的用户数据
    • Authorization Server,认证服务器,用户同意授权后,由认证服务器来生成token传给第三方应用
    • Resource Server,存储了第三方应用所需的资源,确认token无误则开放相应资源给第三方应用
  • Resource Owner,资源所有者,如微信用户就是微信相册的资源所有者,相片是微信用户拍的,只不过存储在了微信服务器上
  • Client,第三方应用,需要依赖具有强大用户基础的服务提供商进行引流的应用

image.png

上述第二步还涉及到几种授权模式:

  • 授权码模式(authorization code)
  • 密码模式(resource owner password credentials)
  • 客户卡模式(client credentials)
  • 简化模式(implicit)

本章和下一章(app)将分别详细介绍前两种模式,现在互联网上几乎大部分社交平台如QQ、微博、淘宝等服务提供商都是采用的授权码模式

授权码模式授权流程

以我们平常访问某社交网站时不想注册该网站用户而直接使用QQ登录这一场景为例,如图是该社交网站作为第三方应用使用OAuth协议开发QQ联合登录的大致时序图

image.png

授权码模式之所以被广泛使用,其原因有如下两点:

  • 用户同意授权这一行为是在认证服务器上进行确认的,相比较其他3种模式在第三方应用客户端上确认(客户端可伪造用户同意授权)而言,更加透明
  • 认证服务器不是直接返回token,而是先返回授权码。像有的静态网站可能会使用implicit模式让认证服务器直接返回token从而再在页面上使用AJAX调用资源服务器接口。前者是认证服务器对接第三方应用服务器(认证服务器返回token是通过回调与第三方应用事先约定好的第三方应用接口并传入token,因此所有token都是存放在服务端的);而后者是认证服务器对接浏览器等第三方应用的客户端,token直接传给客户端存在安全风险

这也是为什么现在主流的服务提供商都采用授权码模式,因为其授权流程更完备、更安全。

Spring Social基本原理

Spring Social其实就是将上述时序图所描述的授权流程封装到了特定的类和接口中了。OAuth协议有两个版本,国外很早就用了所以流行OAuth1,而国内用得比较晚因此基本都是OAuth2,本章也是基于OAuth2来集成QQ、微信登录功能。

image.png

如图是Spring Social的主要组件,各功能如下:

  • OAuth2Operations,封装从请求用户授权到认证服务向我们返回token的整个流程。OAuth2Template是为我们提供的默认实现,这个流程基本上是固定的,无需我们介入
  • Api,封装拿到token后我们调用资源服务器接口获取用户信息的过程,这个需要我们自己定义,毕竟框架也不知道我们要接入哪个开放平台,但它也为我们提供了一个抽象AbstractOAuth2ApiBinding
  • AbstractOAuth2ServiceProvider,集成OAuth2OperationApi,串起获取token和拿token访问用户资源两个过程
  • Connection,统一用户视图,由于各服务提供商返回的用户信息数据结构是不一致的,我们需要通过适配器ApiAdapter将其统一适配到Connection这个数据结构上,可以看做用户在服务提供商中的实体
  • OAuth2ConnectionFactory,集成AbstractOAuth2ServiceProviderApiAdapter,完成整个用户授权以及获取用户信息实体的流程
  • UsersConnectionRepository,我们的系统中一般都有自己的用户表,如何将接入系统的用户实体Connection和我们自己的用户实体User进行对应就靠它来完成,用来完成我们userIdConnection的映射

开发QQ登录功能

准备工作:申请appId和appSecret,详见准备工作_oauth2-0

回调域:http://www.zhenganwen.top/socialLogin/qq

要开发一个第三方接入功能其实就是对上图一套组件逐个进行实现一下,本节我们将开发QQ登录功能,首先从上图的左半部分开始实现。

ServiceProvider

Api,声明一个对应OpenAPI的方法,用来调用该API并将响应结果转成POJO返回,对应授权码模式时序图中的第7步

1
2
3
4
5
6
7
8
9
10
11
12
13
package top.zhenganwen.security.core.social.qq.api;

import top.zhenganwen.security.core.social.qq.QQUserInfo;

/**
* @author zhenganwen
* @date 2019/9/4
* @desc QQApi 封装对QQ开放平台接口的调用
*/
public interface QQApi {

QQUserInfo getUserInfo();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package top.zhenganwen.security.core.social.qq.api;

import lombok.Data;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.social.oauth2.AbstractOAuth2ApiBinding;
import org.springframework.social.oauth2.TokenStrategy;
import top.zhenganwen.security.core.social.qq.QQUserInfo;

/**
* @author zhenganwen
* @date 2019/9/3
* @desc QQApiImpl 拿token调用开放接口获取用户信息
* 1.首先要根据 https://graph.qq.com/oauth2.0/me/{token} 获取用户在社交平台上的id => {@code openId}
* 2.调用QQ OpenAPI https://graph.qq.com/user/get_user_info?access_token=YOUR_ACCESS_TOKEN&oauth_consumer_key=YOUR_APP_ID&openid=YOUR_OPENID
* 获取用户在社交平台上的信息 => {@link QQApiImpl#getUserInfo()}
* <p>
* {@link AbstractOAuth2ApiBinding}
* 帮我们完成了调用OpenAPI时附带{@code token}参数, 见其成员变量{@code accessToken}
* 帮我们完成了HTTP调用, 见其成员变量{@code restTemplate}
* <p>
* 注意:该组件应是多例的,因为每个用户对应有不同的OpenAPI,每次不同的用户进行QQ联合登录都应该创建一个新的 {@link QQApiImpl}
*/
@Data
public class QQApiImpl extends AbstractOAuth2ApiBinding implements QQApi {

private static final String URL_TO_GET_OPEN_ID = "https://graph.qq.com/oauth2.0/me?access_token=%s";

// 因为父类会帮我们附带token参数,因此这里URL忽略了token参数
private static final String URL_TO_GET_USER_INFO = "https://graph.qq.com/user/get_user_info?oauth_consumer_key=%s&openid=%s";

private String openId;

private String appId;

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

public QQApiImpl(String accessToken,String appId) {
// 调用OpenAPI时将需要传递的参数附在URL路径上
super(accessToken, TokenStrategy.ACCESS_TOKEN_PARAMETER);
this.appId = appId;

// 获取用户openId, 响应结果格式:callback( {"client_id":"YOUR_APPID","openid":"YOUR_OPENID"} );
String responseForGetOpenId = getRestTemplate().getForObject(String.format(URL_TO_GET_OPEN_ID, accessToken), String.class);
logger.info("获取用户对应的openId:{}", responseForGetOpenId);

this.openId = StringUtils.substringBetween(responseForGetOpenId, "\"openid\":\"", "\"}");
}

@Override
public QQUserInfo getUserInfo() {
QQUserInfo qqUserInfo = getRestTemplate().getForObject(String.format(URL_TO_GET_USER_INFO, appId, openId), QQUserInfo.class);
logger.info("调用QQ OpenAPI获取用户信息: {}", qqUserInfo);
return qqUserInfo;
}
}

然后是OAuth2Operations,用来封装将用户导入授权页面、获取用户授权后传入的授权码、获取访问OpenAPI的token,对应授权码模式时序图中的第2~6步。由于这几步模式是固定的,所以Spring Social帮我们做了强封装,即OAuth2Template,因此无需我们自己实现,后面直接使用该组件即可

ServiceProvider,集成OAuth2OperationsApi,使用前者来完成授权获取token,使用后者携带token调用OpenAPI获取用户信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package top.zhenganwen.security.core.social.qq.connect;

import org.springframework.social.oauth2.AbstractOAuth2ServiceProvider;
import org.springframework.social.oauth2.OAuth2Operations;
import top.zhenganwen.security.core.social.qq.api.QQApiImpl;

/**
* @author zhenganwen
* @date 2019/9/4
* @desc QQServiceProvider 对接服务提供商,封装一整套授权登录流程, 从用户点击第三方登录按钮到掉第三方应用OpenAPI获取Connection(用户信息)
* 委托 {@link OAuth2Operations} 和 {@link org.springframework.social.oauth2.AbstractOAuth2ApiBinding}来完成整个流程
*/
public class QQServiceProvider extends AbstractOAuth2ServiceProvider<QQApiImpl> {

/**
* 当前应用在服务提供商注册的应用id
*/
private String appId;

/**
* @param oauth2Operations 封装逻辑: 跳转到认证服务器、用户授权、获取授权码、获取token
* @param appId 当前应用的appId
*/
public QQServiceProvider(OAuth2Operations oauth2Operations, String appId) {
super(oauth2Operations);
this.appId = appId;
}

@Override
public QQApiImpl getApi(String accessToken) {
return new QQApiImpl(accessToken,appId);
}
}

ConnectionFactory

UserInfo,封装OpenAPI返回的用户信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
package top.zhenganwen.security.core.social.qq;

import lombok.Data;

import java.io.Serializable;

/**
* @author zhenganwen
* @date 2019/9/4
* @desc QQUserInfo 用户在QQ应用注册的信息
*/
@Data
public class QQUserInfo implements Serializable {
/**
* 返回码
*/
private String ret;
/**
* 如果ret<0,会有相应的错误信息提示,返回数据全部用UTF-8编码。
*/
private String msg;
/**
*
*/
private String openId;
/**
* 不知道什么东西,文档上没写,但是实际api返回里有。
*/
private String is_lost;
/**
* 省(直辖市)
*/
private String province;
/**
* 市(直辖市区)
*/
private String city;
/**
* 出生年月
*/
private String year;
/**
* 用户在QQ空间的昵称。
*/
private String nickname;
/**
* 大小为30×30像素的QQ空间头像URL。
*/
private String figureurl;
/**
* 大小为50×50像素的QQ空间头像URL。
*/
private String figureurl_1;
/**
* 大小为100×100像素的QQ空间头像URL。
*/
private String figureurl_2;
/**
* 大小为40×40像素的QQ头像URL。
*/
private String figureurl_qq_1;
/**
* 大小为100×100像素的QQ头像URL。需要注意,不是所有的用户都拥有QQ的100×100的头像,但40×40像素则是一定会有。
*/
private String figureurl_qq_2;
/**
* 性别。 如果获取不到则默认返回”男”
*/
private String gender;
/**
* 标识用户是否为黄钻用户(0:不是;1:是)。
*/
private String is_yellow_vip;
/**
* 标识用户是否为黄钻用户(0:不是;1:是)
*/
private String vip;
/**
* 黄钻等级
*/
private String yellow_vip_level;
/**
* 黄钻等级
*/
private String level;
/**
* 标识是否为年费黄钻用户(0:不是; 1:是)
*/
private String is_yellow_year_vip;
}

ApiAdapter,将不同的第三方应用返回的不同用户信息数据格式转换成统一的用户视图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package top.zhenganwen.security.core.social.qq.connect;

import org.springframework.social.connect.ApiAdapter;
import org.springframework.social.connect.ConnectionValues;
import org.springframework.social.connect.UserProfile;
import org.springframework.stereotype.Component;
import top.zhenganwen.security.core.social.qq.QQUserInfo;
import top.zhenganwen.security.core.social.qq.api.QQApiImpl;

/**
* @author zhenganwen
* @date 2019/9/4
* @desc QQConnectionAdapter 从不同第三方应用返回的不同用户信息到统一用户视图{@link org.springframework.social.connect.Connection}的适配
*/
@Component
public class QQConnectionAdapter implements ApiAdapter<QQApiImpl> {

// 测试OpenAPI接口是否可用
@Override
public boolean test(QQApiImpl api) {
return true;
}

/**
* 调用OpenAPI获取用户信息并适配成{@link org.springframework.social.connect.Connection}
* 注意: 不是所有的社交应用都对应有{@link org.springframework.social.connect.Connection}中的属性,例如QQ就不像微博那样有个人主页
* @param api
* @param values
*/
@Override
public void setConnectionValues(QQApiImpl api, ConnectionValues values) {
QQUserInfo userInfo = api.getUserInfo();
// 用户昵称
values.setDisplayName(userInfo.getNickname());
// 用户头像
values.setImageUrl(userInfo.getFigureurl_2());
// 用户个人主页
values.setProfileUrl(null);
// 用户在社交平台上的id
values.setProviderUserId(userInfo.getOpenId());
}

// 此方法作用和 setConnectionValues 类似,在后续开发社交账号绑定、解绑时再说
@Override
public UserProfile fetchUserProfile(QQApiImpl api) {
return null;
}

/**
* 调用OpenAPI更新用户动态
* 由于QQ OpenAPI没有此功能,因此不用管(如果接入微博则可能需要重写此方法)
* @param api
* @param message
*/
@Override
public void updateStatus(QQApiImpl api, String message) {

}
}

ConnectionFactory

1
2
3
4
5
6
7
8
9
10
11
12
13
package top.zhenganwen.security.core.social.qq.connect;

import org.springframework.social.connect.ApiAdapter;
import org.springframework.social.connect.support.OAuth2ConnectionFactory;
import org.springframework.social.oauth2.OAuth2ServiceProvider;
import top.zhenganwen.security.core.social.qq.api.QQApiImpl;

public class QQConnectionFactory extends OAuth2ConnectionFactory<QQApiImpl> {

public QQConnectionFactory(String providerId,OAuth2ServiceProvider<QQApiImpl> serviceProvider, ApiAdapter<QQApiImpl> apiAdapter) {
super(providerId, serviceProvider, apiAdapter);
}
}

createConnectionFactory

我们需要重写SocialAutoConfigurerAdapter中的createConnectionFactory方法注入我们自定义的ConnectionFacory,SpringSoical将使用它来完成授权码模式的第2~7步

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package top.zhenganwen.security.core.social.qq.connect;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.social.SocialAutoConfigurerAdapter;
import org.springframework.context.annotation.Bean;
import org.springframework.social.connect.ConnectionFactory;
import org.springframework.social.oauth2.OAuth2Operations;
import org.springframework.social.oauth2.OAuth2Template;
import org.springframework.stereotype.Component;
import top.zhenganwen.security.core.properties.SecurityProperties;

@Component
@ConditionalOnProperty(prefix = "demo.security.qq",name = "appId")
public class QQLoginAutoConfig extends SocialAutoConfigurerAdapter {

public static final String URL_TO_GET_AUTHORIZATION_CODE = "https://graph.qq.com/oauth2.0/authorize";

public static final String URL_TO_GET_TOKEN = "https://graph.qq.com/oauth2.0/token";

@Autowired
private SecurityProperties securityProperties;

@Autowired
private QQConnectionAdapter qqConnectionAdapter;

@Override
protected ConnectionFactory<?> createConnectionFactory() {
return new QQConnectionFactory(
securityProperties.getQq().getProviderId(),
new QQServiceProvider(oAuth2Operations(), securityProperties.getQq().getAppId()),
qqConnectionAdapter);
}

@Bean
public OAuth2Operations oAuth2Operations() {
return new OAuth2Template(
securityProperties.getQq().getAppId(),
securityProperties.getQq().getAppSecret(),
URL_TO_GET_AUTHORIZATION_CODE,
URL_TO_GET_TOKEN);
}

}

QQSecurityProperties,QQ登录相关配置项

1
2
3
4
5
6
7
8
9
10
package top.zhenganwen.security.core.social.qq.connect;

import lombok.Data;

@Data
public class QQSecurityPropertie {
private String appId;
private String appSecret;
private String providerId = "qq";
}
1
2
3
4
5
6
7
8
9
package top.zhenganwen.security.core.properties;

@Data
@ConfigurationProperties(prefix = "demo.security")
public class SecurityProperties {
private BrowserProperties browser = new BrowserProperties();
private VerifyCodeProperties code = new VerifyCodeProperties();
private QQSecurityPropertie qq = new QQSecurityPropertie();
}

UsersConnectionRepository

我们需要一张表来维护当前系统用户表与用户在第三方应用注册的信息之间的对应关系,SpringSocial为我们提供了该表(在JdbcUsersConnectionRepository.java文件同一目录下)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
CREATE TABLE UserConnection (
userId VARCHAR (255) NOT NULL,
providerId VARCHAR (255) NOT NULL,
providerUserId VARCHAR (255),
rank INT NOT NULL,
displayName VARCHAR (255),
profileUrl VARCHAR (512),
imageUrl VARCHAR (512),
accessToken VARCHAR (512) NOT NULL,
secret VARCHAR (512),
refreshToken VARCHAR (512),
expireTime BIGINT,
PRIMARY KEY (
userId,
providerId,
providerUserId
)
);

CREATE UNIQUE INDEX UserConnectionRank ON UserConnection (userId, providerId, rank);

其中userId为当前系统用户的唯一标识(不一定是用户表主键,也可以是用户名,只要是用户表中能唯一标识用户的字段就行),providerId用来标识第三方应用,providerUserId是用户在该第三方应用中的用户标识。这三个字段能够标识第三方应用(providerId)用户(providerUserId)在当前系统中对应的用户(userId)。我们将此SQL在Datasource对应的数据库中执行以下。

SpringSocial为我们提供了JdbcUsersConnectionRepository作为该张表的DAO,我们需要将当前系统的数据源注入给它,并继承SocialConfigurerAdapter和添加@EnableSocial来启用SpringSocial的一些自动化配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package top.zhenganwen.security.core.social.qq;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.encrypt.Encryptors;
import org.springframework.social.config.annotation.EnableSocial;
import org.springframework.social.config.annotation.SocialConfigurerAdapter;
import org.springframework.social.connect.ConnectionFactoryLocator;
import org.springframework.social.connect.jdbc.JdbcUsersConnectionRepository;
import org.springframework.social.security.SpringSocialConfigurer;

import javax.sql.DataSource;

@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {

@Autowired
private DataSource dataSource;

@Bean
@Primary // 父类会默认使用InMemoryUsersConnectionRepository作为实现,我们要使用@Primary告诉容器只使用我们这个
@Override
public JdbcUsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
// 使用第三个参数可以对 token 进行加密存储
return new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());
}

}

SocialAuthenticationFilter

万变不离其中,使用第三方登录的流程和用户名密码的认证流程其实是一样的。只不过后者是根据用户输入的用户名到用户表中查找用户;而前者是先走OAtuh流程拿到用户在第三方应用中的providerUserId,再根据providerIdproviderUserIdUserConnection表中查询对应的userId,最后根据userId到用户表中查询用户

image.png

因此我们还需要启用SocialAuthenticationFilter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package top.zhenganwen.security.core.social.qq;

@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {

@Autowired
private DataSource dataSource;

@Override
public JdbcUsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
// 使用第三个参数可以对 token 进行加密存储
return new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());
}

// 该bean是联合登录配置类,和我们之前所写的SmsLoginConfig和VerifyCodeValidatorConfig的
// 的作用是一样的,只不过它是增加一个SocialAuthenticationFilter到过滤器链中
@Bean
public SpringSocialConfigurer springSocialConfigurer() {
return new SpringSocialConfigurer();
}
}

SecurityBrowserConfig

1
2
3
4
5
6
7
8
9
10
@Override
protected void configure(HttpSecurity http) throws Exception {

// 启用验证码校验过滤器
http.apply(verifyCodeValidatorConfig);
// 启用短信登录过滤器
http.apply(smsLoginConfig);
// 启用QQ登录(将SocialAuthenticationFilter加入到Security过滤器链中)
http.apply(springSocialConfigurer);
...

appId & appSecret & providerId

由于每个系统申请的appIdappSecret都不同,所以我们将其抽取到了配置文件中

1
2
3
demo.security.qq.appId=YOUR_APP_ID #替换成你的appId
demo.security.qq.appSecret=YOUR_APP_SECRET #替换成你的appSecret
demo.security.qq.providerId=qq

联合登录URL设置规则

我们需要在登录页提供一个QQ联合登录的链接,请求为/auth/qq

1
<a href="/auth/qq">qq登录</a>

第一个路径/auth是应为SocialAuthenticationFilter默认拦截/auth开头的请求

SocialAuthenticationFilter

1
private static final String DEFAULT_FILTER_PROCESSES_URL = "/auth";

第二个路径需要和providerId保持一致,而我们配置的demo.security.qq.provider-idqq

SocialAuthenticationFilter

1
2
3
4
5
6
7
8
9
@Deprecated
protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) {
String providerId = getRequestedProviderId(request);
if (providerId != null){
Set<String> authProviders = authServiceLocator.registeredAuthenticationProviderIds();
return authProviders.contains(providerId);
}
return false;
}

联合登录URL需和回调域保持一致

现在SpringSocial的各个组件我们算是实现了,但是能否串起来走通整个流程,我们可以来试一下,并在逐步排错的过程中进一步理解Social认证的流程

访问/login.html,点击qq登录后响应如下

image.png

提示我们回调地址是非法的,我们可以看一下地址栏中的redirect_url参数

image.png

转码后其实就是http://localhost:8080/auth/qq,也就是说如果用户同意授权那么浏览器将会重定向到联合登录的URL上。

而我在QQ互联中申请时填写的回调域是www.zhenganwen.top/socialLogin/qq(如下图),QQ联合登录要求用户同意授权之后重定向到的URL必须和申请appId时填写的回调域保持一致,也就是说页面上联合登录的URL必须和回调域保持一致。

image.png

首先域名和端口需要保持一致:

由于是本地服务器,因此我们需要修改本地hosts文件,让浏览器解析www.zhenganwen.top时解析到172.0.0.1

1
127.0.0.1 www.zhenganwen.top

并且将服务端口改为80

1
server.port=80

这样域名和端口能对应上了,能够通过www.zhenganwen.top/login.html访问登录页。

其次,还需要将联合登录URI和我们在设置的回调域对应上,/auth改为/socialLogin,需要自定义SocialAuthenticationFilterfilterProcessesUrl属性值:

新增SocialProperties

1
2
3
4
5
6
7
8
9
10
11
package top.zhenganwen.security.core.properties;

import lombok.Data;
import top.zhenganwen.security.core.social.qq.connect.QQSecurityPropertie;

@Data
public class SocialProperties {
public static final String DEFAULT_FILTER_PROCESSING_URL = "/socialLogin";
private QQSecurityPropertie qq = new QQSecurityPropertie();
private String filterProcessingUrl = DEFAULT_FILTER_PROCESSING_URL;
}

修改SecurityProperties

1
2
3
4
5
6
7
8
@Data
@ConfigurationProperties(prefix = "demo.security")
public class SecurityProperties {
private BrowserProperties browser = new BrowserProperties();
private VerifyCodeProperties code = new VerifyCodeProperties();
// private QQSecurityPropertie qq = new QQSecurityPropertie();
private SocialProperties social = new SocialProperties();
}

application.properties同步修改:

1
2
3
4
5
6
#demo.security.qq.appId=***
#demo.security.qq.appSecret=***
#demo.security.qq.providerId=qq
demo.security.social.qq.appId=***
demo.security.social.qq.appSecret=***
demo.security.social.qq.providerId=qq

QQLoginAutoConfig同步修改

1
2
3
4
@Component
//@ConditionalOnProperty(prefix = "demo.security.qq",name = "appId")
@ConditionalOnProperty(prefix = "demo.security.social.qq",name = "appId")
public class QQLoginAutoConfig extends SocialAutoConfigurerAdapter {

扩展SpringSocialConfigurer,通过钩子函数postProcess来实现对SocialAuthenticationFilter的一些自定义配置,如filterProcessingUrl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package top.zhenganwen.security.core.social.qq.connect;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.social.security.SocialAuthenticationFilter;
import org.springframework.social.security.SpringSocialConfigurer;
import top.zhenganwen.security.core.properties.SecurityProperties;

public class QQSpringSocialConfigurer extends SpringSocialConfigurer {

@Autowired
private SecurityProperties securityProperties;

@Override
protected <T> T postProcess(T object) {
SocialAuthenticationFilter filter = (SocialAuthenticationFilter) object;
filter.setFilterProcessesUrl(securityProperties.getSocial().getFilterProcessingUrl());
return (T) filter;
}
}

SocialConfig注入扩展后的SpringSocialConfigurer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {

@Autowired
private DataSource dataSource;

@Override
public JdbcUsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
// 使用第三个参数可以对 token 进行加密存储
return new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());
}

// @Bean
// public SpringSocialConfigurer springSocialConfigurer() {
// return new SpringSocialConfigurer();
// }

@Bean
public SpringSocialConfigurer qqSpringSocialConfigurer() {
QQSpringSocialConfigurer qqSpringSocialConfigurer = new QQSpringSocialConfigurer();
return qqSpringSocialConfigurer;
}
}

这样做的原因是postProcess()是一个钩子函数,在SecurityConfigurerAdapterconfig方法中,在将SocialAuthenticationFilter加入到过滤器链中时会调用postProcess,允许子类重写该方法从而对SocialAuthenticationFilter进行一些自定义配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class SpringSocialConfigurer extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
@Override
public void configure(HttpSecurity http) throws Exception {
ApplicationContext applicationContext = http.getSharedObject(ApplicationContext.class);
UsersConnectionRepository usersConnectionRepository = getDependency(applicationContext, UsersConnectionRepository.class);
SocialAuthenticationServiceLocator authServiceLocator = getDependency(applicationContext, SocialAuthenticationServiceLocator.class);
SocialUserDetailsService socialUsersDetailsService = getDependency(applicationContext, SocialUserDetailsService.class);

SocialAuthenticationFilter filter = new SocialAuthenticationFilter(
http.getSharedObject(AuthenticationManager.class),
userIdSource != null ? userIdSource : new AuthenticationNameUserIdSource(),
usersConnectionRepository,
authServiceLocator);

...

http.authenticationProvider(
new SocialAuthenticationProvider(usersConnectionRepository, socialUsersDetailsService))
.addFilterBefore(postProcess(filter), AbstractPreAuthenticatedProcessingFilter.class);
}

protected <T> T postProcess(T object) {
return (T) this.objectPostProcessor.postProcess(object);
}
}

同步修改登录页

1
<a href="/socialLogin/qq">qq登录</a>

同时要在联合登录配置类中将该联合登录URL的拦截放开

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class QQSpringSocialConfigurer extends SpringSocialConfigurer {

@Autowired
private SecurityProperties securityProperties;

@Override
protected <T> T postProcess(T object) {
SocialAuthenticationFilter filter = (SocialAuthenticationFilter) object;
filter.setFilterProcessesUrl(securityProperties.getSocial().getFilterProcessingUrl());
return (T) filter;
}

@Override
public void configure(HttpSecurity http) throws Exception {
super.configure(http);
http.authorizeRequests()
.mvcMatchers(securityProperties.getSocial().getFilterProcessingUrl() +
securityProperties.getSocial().getQq().getProviderId())
.permitAll();
}
}

访问www.zhenganwen.top/login.html,点击qq登录发现跳转如下

image.png

授权跳转逻辑走通!该阶段代码可参见:https://gitee.com/zhenganwen/code-demo/tree/21d708b6a45cbf2baab322470d96313f08b0c426/

阶段性小结

回调域解析

你是在本地80端口跑的服务,为什么认证服务器能够解析回调域www.zhenganwen.top/socialLogin/qq中的域名从而跳转到你的本地

注意上面授权登录页面的地址栏,URL附带了redirect_url这一参数,因此当你同意授权登陆后,跳转到redirect_url参数值这一操作是在你浏览器中进行的,而你在hosts中配置了127.0.0.1 www.zhenganwen.top,因此浏览器没有进行域名解析直接将请求/socialLogin/qq发送到了127.0.0.1:80上,也就是我们正在运行的security-demo服务

SpringSoicalConfigure的作用是什么?

直接上源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class SpringSocialConfigurer extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
@Override
public void configure(HttpSecurity http) throws Exception {
ApplicationContext applicationContext = http.getSharedObject(ApplicationContext.class);
UsersConnectionRepository usersConnectionRepository = getDependency(applicationContext, UsersConnectionRepository.class);
SocialAuthenticationServiceLocator authServiceLocator = getDependency(applicationContext, SocialAuthenticationServiceLocator.class);
SocialUserDetailsService socialUsersDetailsService = getDependency(applicationContext, SocialUserDetailsService.class);

SocialAuthenticationFilter filter = new SocialAuthenticationFilter(
http.getSharedObject(AuthenticationManager.class),
userIdSource != null ? userIdSource : new AuthenticationNameUserIdSource(),
usersConnectionRepository,
authServiceLocator);

...

http.authenticationProvider(
new SocialAuthenticationProvider(usersConnectionRepository, socialUsersDetailsService))
.addFilterBefore(postProcess(filter), AbstractPreAuthenticatedProcessingFilter.class);
}
}

如果我们想将之前所写的SpringSoical组件都应用上,那就要遵循SpringSecurity的认证机制,即添加一个新的认证方式就需要添加一个XxxAuthenticationFilter,而SpringSoical已经帮我们实现了SocialAuthenticationFilter,因此我们只需要在过滤器中添加它就行。与我们之前将短信登录封装到SmsLoginConfig中一样,SpringSocial帮我们将社交登录封装到了SpringSocialConfigure中,这样只要业务系统(即依赖SpringSocial的应用)只需调用httpSecurity.apply(springSocialConfigure)即可启用社交登录功能。

并且除了将SoicalAuthenticationFilter添加到过滤器链中之外,SpringSocialConfigure还会将容器中的UsersConnectionRepositorySocialAuthenticationServiceLocator关联到SoicalAuthenticationFilter中,SoicalAuthenticationFilter通过前者能够根据OAuth流程获取的社交信息(providerIdproviderUserId)查询到userId,通过后者能够根据providerId获取对应的SocialAuthenticationService并从中获取到ConnectionFactory进行获取授权码、获取accessToken、获取用户社交信息等操作

1
2
3
public interface UsersConnectionRepository {
List<String> findUserIdsWithConnection(Connection<?> connection);
}
1
2
3
public interface SocialAuthenticationServiceLocator extends ConnectionFactoryLocator {
SocialAuthenticationService<?> getAuthenticationService(String providerId);
}
1
2
3
4
public interface SocialAuthenticationService<S> {
ConnectionFactory<S> getConnectionFactory();
SocialAuthenticationToken getAuthToken(HttpServletRequest request, HttpServletResponse response) throws SocialAuthenticationRedirectException;
}

为什么要有SocialAuthenticationService,是在什么时候产生的?

SocialAuthenticationService是对ConnectionFactory的一个封装,对SocialAuthenticationFilter隐藏OAuth以及OpenAPI调用细节

因为我们在SocialConfig中添加了@EnableSocial,所以在系统启动时会根据SocialAutoConfigurerAdapter实现类中的createConnectionFactory创建对应不同社交系统的ConnectionFactory并将其包装成SocialAuthenticationService,然后将所有的SocialAuthenticationServiceproviderIdkey缓存在SocialAuthenticationLocator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Component
//@ConditionalOnProperty(prefix = "demo.security.qq",name = "appId")
@ConditionalOnProperty(prefix = "demo.security.social.qq",name = "appId")
public class QQLoginAutoConfig extends SocialAutoConfigurerAdapter {

public static final String URL_TO_GET_AUTHORIZATION_CODE = "https://graph.qq.com/oauth2.0/authorize";

public static final String URL_TO_GET_TOKEN = "https://graph.qq.com/oauth2.0/token";

@Autowired
private SecurityProperties securityProperties;

@Autowired
private QQConnectionAdapter qqConnectionAdapter;

@Override
protected ConnectionFactory<?> createConnectionFactory() {
return new QQConnectionFactory(
securityProperties.getSocial().getQq().getProviderId(),
new QQServiceProvider(oAuth2Operations(), securityProperties.getSocial().getQq().getAppId()),
qqConnectionAdapter);
}

@Bean
public OAuth2Operations oAuth2Operations() {
return new OAuth2Template(
securityProperties.getSocial().getQq().getAppId(),
securityProperties.getSocial().getQq().getAppSecret(),
URL_TO_GET_AUTHORIZATION_CODE,
URL_TO_GET_TOKEN);
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class SecurityEnabledConnectionFactoryConfigurer implements ConnectionFactoryConfigurer {

private SocialAuthenticationServiceRegistry registry;

public SecurityEnabledConnectionFactoryConfigurer() {
registry = new SocialAuthenticationServiceRegistry();
}

public void addConnectionFactory(ConnectionFactory<?> connectionFactory) {
registry.addAuthenticationService(wrapAsSocialAuthenticationService(connectionFactory));
}

public ConnectionFactoryRegistry getConnectionFactoryLocator() {
return registry;
}

private <A> SocialAuthenticationService<A> wrapAsSocialAuthenticationService(ConnectionFactory<A> cf) {
if (cf instanceof OAuth1ConnectionFactory) {
return new OAuth1AuthenticationService<A>((OAuth1ConnectionFactory<A>) cf);
} else if (cf instanceof OAuth2ConnectionFactory) {
final OAuth2AuthenticationService<A> authService = new OAuth2AuthenticationService<A>((OAuth2ConnectionFactory<A>) cf);
authService.setDefaultScope(((OAuth2ConnectionFactory<A>) cf).getScope());
return authService;
}
throw new IllegalArgumentException("The connection factory must be one of OAuth1ConnectionFactory or OAuth2ConnectionFactory");
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class SocialAuthenticationServiceRegistry extends ConnectionFactoryRegistry implements SocialAuthenticationServiceLocator {

private Map<String, SocialAuthenticationService<?>> authenticationServices = new HashMap<String, SocialAuthenticationService<?>>();

public SocialAuthenticationService<?> getAuthenticationService(String providerId) {
SocialAuthenticationService<?> authenticationService = authenticationServices.get(providerId);
if (authenticationService == null) {
throw new IllegalArgumentException("No authentication service for service provider '" + providerId + "' is registered");
}
return authenticationService;
}

public void addAuthenticationService(SocialAuthenticationService<?> authenticationService) {
addConnectionFactory(authenticationService.getConnectionFactory());
authenticationServices.put(authenticationService.getConnectionFactory().getProviderId(), authenticationService);
}

public void setAuthenticationServices(Iterable<SocialAuthenticationService<?>> authenticationServices) {
for (SocialAuthenticationService<?> authenticationService : authenticationServices) {
addAuthenticationService(authenticationService);
}
}

public Set<String> registeredAuthenticationProviderIds() {
return authenticationServices.keySet();
}

}

所以当SocialAuthenticationFilter拦截到/{filterProcessingUrl}/{providerId}之后,会根据出URL路径中的providerIdSocialAuthenticationLocator中查找对应的SocialAuthenticationService获取authRequest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class SocialAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
@Deprecated
protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) {
String providerId = getRequestedProviderId(request);
if (providerId != null){
Set<String> authProviders = authServiceLocator.registeredAuthenticationProviderIds();
return authProviders.contains(providerId);
}
return false;
}

public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (detectRejection(request)) {
if (logger.isDebugEnabled()) {
logger.debug("A rejection was detected. Failing authentication.");
}
throw new SocialAuthenticationException("Authentication failed because user rejected authorization.");
}

Authentication auth = null;
Set<String> authProviders = authServiceLocator.registeredAuthenticationProviderIds();
String authProviderId = getRequestedProviderId(request);
if (!authProviders.isEmpty() && authProviderId != null && authProviders.contains(authProviderId)) {
SocialAuthenticationService<?> authService = authServiceLocator.getAuthenticationService(authProviderId);
auth = attemptAuthService(authService, request, response);
if (auth == null) {
throw new AuthenticationServiceException("authentication failed");
}
}
return auth;
}

}

为什么社交登录URL和回调域要保持一致

SocialAuthenticationFilter#attemptAuthService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private Authentication attemptAuthService(final SocialAuthenticationService<?> authService, final HttpServletRequest request, HttpServletResponse response) 
throws SocialAuthenticationRedirectException, AuthenticationException {

final SocialAuthenticationToken token = authService.getAuthToken(request, response);
if (token == null) return null;

Assert.notNull(token.getConnection());

Authentication auth = getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
return doAuthentication(authService, request, token);
} else {
addConnection(authService, request, token, auth);
return null;
}
}

OAuth2AuthenticationService#getAuthToken

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public SocialAuthenticationToken getAuthToken(HttpServletRequest request, HttpServletResponse response) throws SocialAuthenticationRedirectException {
String code = request.getParameter("code");
if (!StringUtils.hasText(code)) {
OAuth2Parameters params = new OAuth2Parameters();
params.setRedirectUri(buildReturnToUrl(request));
setScope(request, params);
params.add("state", generateState(connectionFactory, request));
addCustomParameters(params);
throw new SocialAuthenticationRedirectException(getConnectionFactory().getOAuthOperations().buildAuthenticateUrl(params));
} else if (StringUtils.hasText(code)) {
try {
String returnToUrl = buildReturnToUrl(request);
AccessGrant accessGrant = getConnectionFactory().getOAuthOperations().exchangeForAccess(code, returnToUrl, null);
// TODO avoid API call if possible (auth using token would be fine)
Connection<S> connection = getConnectionFactory().createConnection(accessGrant);
return new SocialAuthenticationToken(connection, null);
} catch (RestClientException e) {
logger.debug("failed to exchange for access", e);
return null;
}
} else {
return null;
}
}

可以发现,用户在登录也上点击qq登录时被SocialAuthenticationFilter拦截,进入到上述的getAuthToken方法,请求参数是不带授权码的,因此第9行会抛出异常,该异常会被认证失败处理器截获并将用户导向社交系统认证服务器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class SocialAuthenticationFailureHandler implements AuthenticationFailureHandler {
private AuthenticationFailureHandler delegate;

public SocialAuthenticationFailureHandler(AuthenticationFailureHandler delegate) {
this.delegate = delegate;
}

public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
if (failed instanceof SocialAuthenticationRedirectException) {
response.sendRedirect(((SocialAuthenticationRedirectException)failed).getRedirectUrl());
} else {
this.delegate.onAuthenticationFailure(request, response, failed);
}
}
}

在用户同意授权后,认证服务器跳转到回调域并带入授权码,这时就会进入getAuthToken的第11行,拿授权码获取accessTokenAccessGrant)、调用OpenAPI获取用户信息并适配成Connection

为什么同意授权后响应如下

image.png

我们扫描二维码同意授权,浏览器重定向到/socialLogin/qq之后,发生了什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public SocialAuthenticationToken getAuthToken(HttpServletRequest request, HttpServletResponse response) throws SocialAuthenticationRedirectException {
String code = request.getParameter("code");
if (!StringUtils.hasText(code)) {
OAuth2Parameters params = new OAuth2Parameters();
params.setRedirectUri(buildReturnToUrl(request));
setScope(request, params);
params.add("state", generateState(connectionFactory, request));
addCustomParameters(params);
throw new SocialAuthenticationRedirectException(getConnectionFactory().getOAuthOperations().buildAuthenticateUrl(params));
} else if (StringUtils.hasText(code)) {
try {
String returnToUrl = buildReturnToUrl(request);
AccessGrant accessGrant = getConnectionFactory().getOAuthOperations().exchangeForAccess(code, returnToUrl, null);
// TODO avoid API call if possible (auth using token would be fine)
Connection<S> connection = getConnectionFactory().createConnection(accessGrant);
return new SocialAuthenticationToken(connection, null);
} catch (RestClientException e) {
logger.debug("failed to exchange for access", e);
return null;
}
} else {
return null;
}
}

在上述带啊的第12行打断点进行跟踪一下,发现执行13行时抛出异常跳转到了18行,异常信息如下:

1
org.springframework.web.client.RestClientException: Could not extract response: no suitable HttpMessageConverter found for response type [interface java.util.Map] and content type [text/html]

说明是在调用我们的OAuth2TemplateexchangeForAccess拿授权码获取accessToken时报错了,错误原因是在转换响应结果为AccessGrant时没有处理text/html的转换器。

首先我们看一下响应结果是什么:

image.png

发现响应结果是一个字符串,以&分割三个键值对,而OAuth2Template默认提供的转换器如下:

OAuth2Template

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
protected RestTemplate createRestTemplate() {
ClientHttpRequestFactory requestFactory = ClientHttpRequestFactorySelector.getRequestFactory();
RestTemplate restTemplate = new RestTemplate(requestFactory);
List<HttpMessageConverter<?>> converters = new ArrayList<HttpMessageConverter<?>>(2);
converters.add(new FormHttpMessageConverter());
converters.add(new FormMapHttpMessageConverter());
converters.add(new MappingJackson2HttpMessageConverter());
restTemplate.setMessageConverters(converters);
restTemplate.setErrorHandler(new LoggingErrorHandler());
if (!useParametersForClientAuthentication) {
List<ClientHttpRequestInterceptor> interceptors = restTemplate.getInterceptors();
if (interceptors == null) { // defensively initialize list if it is null. (See SOCIAL-430)
interceptors = new ArrayList<ClientHttpRequestInterceptor>();
restTemplate.setInterceptors(interceptors);
}
interceptors.add(new PreemptiveBasicAuthClientHttpRequestInterceptor(clientId, clientSecret));
}
return restTemplate;
}

查看上述5~7行的3个转换器,FormHttpMessageConverterFormMapHttpMessageConverterMappingJackson2HttpMessageConverter分别对应解析Content-Typeapplication/x-www-form-urlencodedmultipart/form-dataapplication/json的响应体,因此报错提示

1
no suitable HttpMessageConverter found for response type [interface java.util.Map] and content type [text/html]

这时我们需要在原有的OAuth2Template的基础上在增加一个处理text/html的转换器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class QQOAuth2Template extends OAuth2Template {
public QQOAuth2Template(String clientId, String clientSecret, String authorizeUrl, String accessTokenUrl) {
super(clientId, clientSecret, authorizeUrl, accessTokenUrl);
setUseParametersForClientAuthentication(true);
}

/**
* 添加消息转换器以使能够解析 Content-Type 为 text/html 的响应体
* StringHttpMessageConverter 可解析任何 Content-Type的响应体,见其构造函数
* @return
*/
@Override
protected RestTemplate createRestTemplate() {
RestTemplate restTemplate = super.createRestTemplate();
restTemplate.getMessageConverters().add(new StringHttpMessageConverter(Charset.forName("UTF-8")));
return restTemplate;
}

/**
* 如果响应体是json,OAuth2Template会帮我们构建, 但QQ互联的OpenAPI返回包都是 text/html 字符串
* 响应体 : "access_token=FE04***********CCE2&expires_in=7776000&refresh_token=88E4********BE14"
* 使用 StringHttpMessageConverter 将请求的响应体转成 String ,并手动构建 AccessGrant
* @param accessTokenUrl 拿授权码获取accessToken的URL
* @param parameters 请求 accessToken 需要附带的参数
* @return
*/
@Override
protected AccessGrant postForAccessGrant(String accessTokenUrl, MultiValueMap<String, String> parameters) {
String responseStr = getRestTemplate().postForObject(accessTokenUrl, parameters,String.class);
if (StringUtils.isEmpty(responseStr)) {
return null;
}
// 0 -> access_token=FE04***********CCE
// 1 -> expires_in=7776000
// 2 -> refresh_token=88E4********BE14
String[] strings = StringUtils.splitByWholeSeparatorPreserveAllTokens(responseStr, "&");
// accessToken scope refreshToken expiresIn
AccessGrant accessGrant = new AccessGrant(
StringUtils.substringAfterLast(strings[0], "="),
null,
StringUtils.substringAfterLast(strings[2], "="),
Long.valueOf(StringUtils.substringAfterLast(strings[1], "=")));
return accessGrant;
}
}

使用该QQOAuth2Template替换之前注入的OAuth2Template

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Component
//@ConditionalOnProperty(prefix = "demo.security.qq",name = "appId")
@ConditionalOnProperty(prefix = "demo.security.social.qq",name = "appId")
public class QQLoginAutoConfig extends SocialAutoConfigurerAdapter {

public static final String URL_TO_GET_AUTHORIZATION_CODE = "https://graph.qq.com/oauth2.0/authorize";

public static final String URL_TO_GET_TOKEN = "https://graph.qq.com/oauth2.0/token";

@Autowired
private SecurityProperties securityProperties;

@Autowired
private QQConnectionAdapter qqConnectionAdapter;

@Override
protected ConnectionFactory<?> createConnectionFactory() {
return new QQConnectionFactory(
securityProperties.getSocial().getQq().getProviderId(),
new QQServiceProvider(oAuth2Operations(), securityProperties.getSocial().getQq().getAppId()),
qqConnectionAdapter);
}

// @Bean
// public OAuth2Operations oAuth2Operations() {
// return new OAuth2Template(
// securityProperties.getSocial().getQq().getAppId(),
// securityProperties.getSocial().getQq().getAppSecret(),
// URL_TO_GET_AUTHORIZATION_CODE,
// URL_TO_GET_TOKEN);
// }

@Bean
public OAuth2Operations oAuth2Operations() {
return new QQOAuth2Template(
securityProperties.getSocial().getQq().getAppId(),
securityProperties.getSocial().getQq().getAppSecret(),
URL_TO_GET_AUTHORIZATION_CODE,
URL_TO_GET_TOKEN);
}

}

现在我们能够拿到封装accessTokenAccessGrant了,再继续端点调试Connection的获取(下述第15行)

OAuth2AuthenticationService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public SocialAuthenticationToken getAuthToken(HttpServletRequest request, HttpServletResponse response) throws SocialAuthenticationRedirectException {
String code = request.getParameter("code");
if (!StringUtils.hasText(code)) {
OAuth2Parameters params = new OAuth2Parameters();
params.setRedirectUri(buildReturnToUrl(request));
setScope(request, params);
params.add("state", generateState(connectionFactory, request));
addCustomParameters(params);
throw new SocialAuthenticationRedirectException(getConnectionFactory().getOAuthOperations().buildAuthenticateUrl(params));
} else if (StringUtils.hasText(code)) {
try {
String returnToUrl = buildReturnToUrl(request);
AccessGrant accessGrant = getConnectionFactory().getOAuthOperations().exchangeForAccess(code, returnToUrl, null);
// TODO avoid API call if possible (auth using token would be fine)
Connection<S> connection = getConnectionFactory().createConnection(accessGrant);
return new SocialAuthenticationToken(connection, null);
} catch (RestClientException e) {
logger.debug("failed to exchange for access", e);
return null;
}
} else {
return null;
}
}

发现QQApiImplgetUserInfo存在同一的问题,调用QQ互联API响应类型都是text/html,因此我们不能直接转成POJO,而要先获取响应串,在通过JSON转换工具类ObjectMapper来转换:

QQApiImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public QQUserInfo getUserInfo() {
// QQ互联的响应 Content-Type 都是 text/html,因此不能直接转为 QQUserInfo
// QQUserInfo qqUserInfo = getRestTemplate().getForObject(String.format(URL_TO_GET_USER_INFO, appId, openId), QQUserInfo.class);
String responseStr = getRestTemplate().getForObject(String.format(URL_TO_GET_USER_INFO, appId, openId), String.class);
logger.info("调用QQ OpenAPI获取用户信息: {}", responseStr);
try {
QQUserInfo qqUserInfo = objectMapper.readValue(responseStr, QQUserInfo.class);
qqUserInfo.setOpenId(openId);
return qqUserInfo;
} catch (Exception e) {
logger.error("获取用户信息转成 QQUserInfo 失败,响应信息:{}", responseStr);
return null;
}
}

再次扫码登录进行断点调试,发现Connection也能成功拿到了,并且封装成SocialAuthenticationToken返回,于是getAuthToken终于成功返回了,走到了doAuthentication

SocialAuthenticationFilter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
private Authentication attemptAuthService(final SocialAuthenticationService<?> authService, final HttpServletRequest request, HttpServletResponse response) 
throws SocialAuthenticationRedirectException, AuthenticationException {

final SocialAuthenticationToken token = authService.getAuthToken(request, response);
if (token == null) return null;

Assert.notNull(token.getConnection());

Authentication auth = getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
return doAuthentication(authService, request, token);
} else {
addConnection(authService, request, token, auth);
return null;
}
}

private Authentication doAuthentication(SocialAuthenticationService<?> authService, HttpServletRequest request, SocialAuthenticationToken token) {
try {
if (!authService.getConnectionCardinality().isAuthenticatePossible()) return null;
token.setDetails(authenticationDetailsSource.buildDetails(request));
Authentication success = getAuthenticationManager().authenticate(token);
Assert.isInstanceOf(SocialUserDetails.class, success.getPrincipal(), "unexpected principal type");
updateConnections(authService, token, success);
return success;
} catch (BadCredentialsException e) {
// connection unknown, register new user?
if (signupUrl != null) {
// store ConnectionData in session and redirect to register page
sessionStrategy.setAttribute(new ServletWebRequest(request), ProviderSignInAttempt.SESSION_ATTRIBUTE, new ProviderSignInAttempt(token.getConnection()));
throw new SocialAuthenticationRedirectException(buildSignupUrl(request));
}
throw e;
}
}

这时会调用ProviderManagerauthenticateSocialAuthenticationToken进行校验,ProviderManager又会委托SocialAuthenticationProvider

SocialAuthenticationProvider会调用我们注入的JdbcUsersConnectionRepositoryUserConnection表中根据ConnectionproviderIdproviderUserId查找userId

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.isInstanceOf(SocialAuthenticationToken.class, authentication, "unsupported authentication type");
Assert.isTrue(!authentication.isAuthenticated(), "already authenticated");
SocialAuthenticationToken authToken = (SocialAuthenticationToken) authentication;
String providerId = authToken.getProviderId();
Connection<?> connection = authToken.getConnection();

String userId = toUserId(connection);
if (userId == null) {
throw new BadCredentialsException("Unknown access token");
}

UserDetails userDetails = userDetailsService.loadUserByUserId(userId);
if (userDetails == null) {
throw new UsernameNotFoundException("Unknown connected account id");
}

return new SocialAuthenticationToken(connection, userDetails, authToken.getProviderAccountData(), getAuthorities(providerId, userDetails));
}

protected String toUserId(Connection<?> connection) {
List<String> userIds = usersConnectionRepository.findUserIdsWithConnection(connection);
// only if a single userId is connected to this providerUserId
return (userIds.size() == 1) ? userIds.iterator().next() : null;
}

JdbcUsersConnectionRepository

1
2
3
4
5
6
7
8
9
10
11
12
13
public List<String> findUserIdsWithConnection(Connection<?> connection) {
ConnectionKey key = connection.getKey();
List<String> localUserIds = jdbcTemplate.queryForList("select userId from " + tablePrefix + "UserConnection where providerId = ? and providerUserId = ?", String.class, key.getProviderId(), key.getProviderUserId());
if (localUserIds.size() == 0 && connectionSignUp != null) {
String newUserId = connectionSignUp.execute(connection);
if (newUserId != null)
{
createConnectionRepository(newUserId).addConnection(connection);
return Arrays.asList(newUserId);
}
}
return localUserIds;
}

由于找不到(因为这时我们的UserConnection表压根就没数据),toUserId会返回null,接着抛出BadCredentialsException("Unknown access token"),该异常会被SocialAuthenticationFilter捕获,并根据其signupUrl属性进行重定向(SpringSocial认为该用户在本系统没有注册,或者注册了但没有将本地用户和QQ登录关联,因此跳转到注册页)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private Authentication doAuthentication(SocialAuthenticationService<?> authService, HttpServletRequest request, SocialAuthenticationToken token) {
try {
if (!authService.getConnectionCardinality().isAuthenticatePossible()) return null;
token.setDetails(authenticationDetailsSource.buildDetails(request));
Authentication success = getAuthenticationManager().authenticate(token);
Assert.isInstanceOf(SocialUserDetails.class, success.getPrincipal(), "unexpected principal type");
updateConnections(authService, token, success);
return success;
} catch (BadCredentialsException e) {
// connection unknown, register new user?
if (signupUrl != null) {
// store ConnectionData in session and redirect to register page
sessionStrategy.setAttribute(new ServletWebRequest(request), ProviderSignInAttempt.SESSION_ATTRIBUTE, new ProviderSignInAttempt(token.getConnection()));
throw new SocialAuthenticationRedirectException(buildSignupUrl(request));
}
throw e;
}
}

SocialAuthenticationFiltersignupUrl默认为/signup

1
2
3
public class SocialAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private String signupUrl = "/signup";
}

跳转到/signup时,被SpringSecurity拦截,并重定向到loginPage(),最后到了BrowserSecurityController

SecurityBrowserConfig

1
2
.formLogin()
.loginPage(SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL)

SecurityConstants

1
2
3
4
/**
* 未登录访问受保护URL则跳转路径到 此
*/
String FORWARD_TO_LOGIN_PAGE_URL = "/auth/require";

BrowserSecurityController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@RestController
public class BrowserSecurityController {

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

// security会将跳转前的请求存储在session中
private RequestCache requestCache = new HttpSessionRequestCache();

private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

@Autowired
SecurityProperties securityProperties;

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

SavedRequest savedRequest = requestCache.getRequest(request, response);
if (savedRequest != null) {
String redirectUrl = savedRequest.getRedirectUrl();
logger.info("引发跳转到/auth/login的请求是: {}", redirectUrl);
if (StringUtils.endsWithIgnoreCase(redirectUrl, ".html")) {
// 如果用户是访问html页面被FilterSecurityInterceptor拦截从而跳转到了/auth/login,那么就重定向到登录页面
redirectStrategy.sendRedirect(request, response, securityProperties.getBrowser().getLoginPage());
}
}

// 如果不是访问html而被拦截跳转到了/auth/login,则返回JSON提示
return <